fix(agent): один процесс на журнал — блокировка journal.jsonl.lock; README: остановить старый агент перед запуском (I-1, #9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a95f7d936c
commit
058a78b0d6
1
.gitignore
vendored
1
.gitignore
vendored
@ -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
|
||||||
|
|||||||
@ -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` не трогать: журнал переживает обновление, а неподтверждённые отчёты досылаются при старте.
|
||||||
|
|
||||||
## Известные ограничения / эксплуатация
|
## Известные ограничения / эксплуатация
|
||||||
|
|||||||
24
src/agent.py
24
src/agent.py
@ -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):
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user