diff --git a/.gitignore b/.gitignore index ca0fe32..fb103f0 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ data.json tests/fixtures/*.m4a tests/fixtures/*.webm .DS_Store +journal.jsonl.lock diff --git a/README.md b/README.md index 49e6ad3..8d232b2 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,10 @@ python3 agent.py prune ## Обновление агента -Скопировать новый `agent.py` поверх старого и перезапустить процесс (или `launchctl unload` + `load`). +Остановить старый процесс (Ctrl-C или `kill `, дождаться «остановлен»), скопировать новый `agent.py` поверх +старого и запустить снова (или `launchctl unload` + `load`). Два процесса с одним журналом работать не могут: хаб +довыдаёт команду без отчёта, и второй процесс выполнял бы её одновременно с первым. Начиная с версии 3 второй процесс +не стартует («агент уже запущен…», код 1) — блокировка `journal.jsonl.lock` рядом с журналом. `agent.json` и `journal.jsonl` не трогать: журнал переживает обновление, а неподтверждённые отчёты досылаются при старте. ## Известные ограничения / эксплуатация diff --git a/src/agent.py b/src/agent.py index f5a47c6..79aeaae 100644 --- a/src/agent.py +++ b/src/agent.py @@ -12,6 +12,7 @@ Python 3.9. Спека: jira-hub/openspec/specs/agent/spec.md. """ import argparse import base64 +import fcntl import http.client import json import os @@ -1118,7 +1119,30 @@ class Runner: self.sleep(self.poll) +def acquire_lock(journal_path: str): + """Исключительная блокировка <журнал>.lock: один процесс агента на журнал. Хаб довыдаёт команду без отчёта при + следующем claim, поэтому второй процесс выполнял бы ту же команду одновременно с первым. None — уже занято.""" + f = open(journal_path + ".lock", "a") + try: + fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + f.close() + return None + return f + + def cmd_run(cfg: dict, journal_path: str) -> int: + lock = acquire_lock(journal_path) + if lock is None: + log("агент уже запущен с журналом %s — второй процесс не стартует" % journal_path) + return 1 + try: + return _run(cfg, journal_path) + finally: + lock.close() + + +def _run(cfg: dict, journal_path: str) -> int: runner = Runner(cfg, HubClient(cfg), JiraClient(cfg), Journal(journal_path)) def on_signal(signum, frame): diff --git a/tests/test_py39.py b/tests/test_py39.py index 3b1a0cb..1520da6 100644 --- a/tests/test_py39.py +++ b/tests/test_py39.py @@ -17,7 +17,7 @@ class Py39Test(unittest.TestCase): def test_no_third_party_imports(self): with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f: tree = ast.parse(f.read()) - allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing", "uuid"} + allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing", "uuid", "fcntl"} for node in ast.walk(tree): if isinstance(node, ast.Import): for a in node.names: diff --git a/tests/test_runner.py b/tests/test_runner.py index 3d192ff..a6e8f5f 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -428,5 +428,21 @@ class RunnerTest(unittest.TestCase): self.assertEqual(seen["code"], 130) + def test_second_process_refused(self): + """I-1 финального ревью: второй run с тем же журналом не стартует — иначе довыдача хаба отдаст ему ту же команду.""" + from unittest import mock + held = agent.acquire_lock(self.journal.path) + self.assertIsNotNone(held) + try: + self.assertIsNone(agent.acquire_lock(self.journal.path)) + cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token) + with mock.patch.object(agent.Runner, "loop", lambda r: self.fail("цикл не должен стартовать")): + self.assertEqual(agent.cmd_run(cfg, self.journal.path), 1) + finally: + held.close() + again = agent.acquire_lock(self.journal.path) + self.assertIsNotNone(again) # после выхода первого — снова можно + again.close() + if __name__ == "__main__": unittest.main()