From 058a78b0d61d4e5a8b81d11a7e588cf6e0a75554 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 23:11:02 +0400 Subject: [PATCH] =?UTF-8?q?fix(agent):=20=D0=BE=D0=B4=D0=B8=D0=BD=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=86=D0=B5=D1=81=D1=81=20=D0=BD=D0=B0=20=D0=B6?= =?UTF-8?q?=D1=83=D1=80=D0=BD=D0=B0=D0=BB=20=E2=80=94=20=D0=B1=D0=BB=D0=BE?= =?UTF-8?q?=D0=BA=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20journal.jsonl.lock?= =?UTF-8?q?;=20README:=20=D0=BE=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=81=D1=82=D0=B0=D1=80=D1=8B=D0=B9=20=D0=B0?= =?UTF-8?q?=D0=B3=D0=B5=D0=BD=D1=82=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=BE=D0=BC=20(I-1,=20#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + README.md | 5 ++++- src/agent.py | 24 ++++++++++++++++++++++++ tests/test_py39.py | 2 +- tests/test_runner.py | 16 ++++++++++++++++ 5 files changed, 46 insertions(+), 2 deletions(-) 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()