fix(agent): один процесс на журнал — блокировка journal.jsonl.lock; README: остановить старый агент перед запуском (I-1, #9)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-13 23:11:02 +04:00
parent a95f7d936c
commit 058a78b0d6
5 changed files with 46 additions and 2 deletions

1
.gitignore vendored
View File

@ -7,3 +7,4 @@ data.json
tests/fixtures/*.m4a tests/fixtures/*.m4a
tests/fixtures/*.webm tests/fixtures/*.webm
.DS_Store .DS_Store
journal.jsonl.lock

View File

@ -170,7 +170,10 @@ python3 agent.py prune
## Обновление агента ## Обновление агента
Скопировать новый `agent.py` поверх старого и перезапустить процесс (или `launchctl unload` + `load`). Остановить старый процесс (Ctrl-C или `kill <pid>`, дождаться «остановлен»), скопировать новый `agent.py` поверх
старого и запустить снова (или `launchctl unload` + `load`). Два процесса с одним журналом работать не могут: хаб
довыдаёт команду без отчёта, и второй процесс выполнял бы её одновременно с первым. Начиная с версии 3 второй процесс
не стартует («агент уже запущен…», код 1) — блокировка `journal.jsonl.lock` рядом с журналом.
`agent.json` и `journal.jsonl` не трогать: журнал переживает обновление, а неподтверждённые отчёты досылаются при старте. `agent.json` и `journal.jsonl` не трогать: журнал переживает обновление, а неподтверждённые отчёты досылаются при старте.
## Известные ограничения / эксплуатация ## Известные ограничения / эксплуатация

View File

@ -12,6 +12,7 @@ Python 3.9. Спека: jira-hub/openspec/specs/agent/spec.md.
""" """
import argparse import argparse
import base64 import base64
import fcntl
import http.client import http.client
import json import json
import os import os
@ -1118,7 +1119,30 @@ class Runner:
self.sleep(self.poll) 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: 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)) runner = Runner(cfg, HubClient(cfg), JiraClient(cfg), Journal(journal_path))
def on_signal(signum, frame): def on_signal(signum, frame):

View File

@ -17,7 +17,7 @@ class Py39Test(unittest.TestCase):
def test_no_third_party_imports(self): def test_no_third_party_imports(self):
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f: with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
tree = ast.parse(f.read()) 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): for node in ast.walk(tree):
if isinstance(node, ast.Import): if isinstance(node, ast.Import):
for a in node.names: for a in node.names:

View File

@ -428,5 +428,21 @@ class RunnerTest(unittest.TestCase):
self.assertEqual(seen["code"], 130) 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__": if __name__ == "__main__":
unittest.main() unittest.main()