feat(agent): журнал на диске, восстановление по нему, prune

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
Andrey Epifancev 2026-10-11 23:33:51 +04:00
parent 24cfdcf25c
commit 7fb592e6de
2 changed files with 160 additions and 0 deletions

View File

@ -199,6 +199,85 @@ class HubClient(Http):
return self.call("GET", "/agent/known-keys")["keys"]
# ---------------------------------------------------------------------------
# Журнал: только дозапись; started → finished → acked по id команды
# ---------------------------------------------------------------------------
class Journal:
def __init__(self, path: str):
self.path = path
def append(self, record: dict) -> None:
record = dict(record)
record.setdefault("at", now_iso())
with open(self.path, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
def records(self) -> List[dict]:
if not os.path.exists(self.path):
return []
out = []
with open(self.path, encoding="utf-8") as f:
for n, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except ValueError:
log("журнал: строка %d не читается, пропускаю" % n)
return out
def started(self, command_id) -> None:
self.append({"kind": "started", "command_id": int(command_id)})
def finished(self, command_id, status, result, error, jira_calls, rate_limited) -> None:
self.append({"kind": "finished", "command_id": int(command_id), "status": status, "result": result,
"error": error, "jira_calls": int(jira_calls or 0), "rate_limited": int(rate_limited or 0)})
def acked(self, command_id) -> None:
self.append({"kind": "acked", "command_id": int(command_id)})
def unacked_finished(self) -> List[dict]:
last, acked = {}, set()
for r in self.records():
if r.get("kind") == "finished":
last[r["command_id"]] = r
acked.discard(r["command_id"])
elif r.get("kind") == "acked":
acked.add(r["command_id"])
return [r for cid, r in last.items() if cid not in acked]
def unfinished_started(self) -> List[int]:
started, finished = [], set()
for r in self.records():
if r.get("kind") == "started":
started.append(r["command_id"])
elif r.get("kind") in ("finished", "acked"):
finished.add(r["command_id"])
return [cid for cid in started if cid not in finished]
def prune(self, days: int = 30) -> int:
"""Удалить записи команд, подтверждённых (acked) раньше, чем days дней назад. Неподтверждённые — остаются."""
records = self.records()
cutoff = datetime.now(timezone.utc).timestamp() - days * 86400
old = set()
for r in records:
if r.get("kind") == "acked":
try:
ts = datetime.fromisoformat(r.get("at", "")).timestamp()
except ValueError:
continue
if ts < cutoff:
old.add(r["command_id"])
keep = [r for r in records if r.get("command_id") not in old]
tmp = self.path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
for r in keep:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
os.replace(tmp, self.path)
return len(records) - len(keep)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
@ -228,6 +307,10 @@ def main(argv=None) -> int:
cfg = load_config(args.config)
if args.cmd == "check":
return cmd_check(cfg)
if args.cmd == "prune":
removed = Journal(args.journal).prune(30)
log("журнал: удалено записей %d" % removed)
return 0
log("команда %s ещё не реализована" % args.cmd)
return 2

77
tests/test_journal.py Normal file
View File

@ -0,0 +1,77 @@
import contextlib
import io
import json
import os
import tempfile
import unittest
from _common import agent
class JournalTest(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.path = os.path.join(self.dir.name, "journal.jsonl")
self.j = agent.Journal(self.path)
def tearDown(self):
self.dir.cleanup()
def test_empty(self):
self.assertEqual(self.j.records(), [])
self.assertEqual(self.j.unacked_finished(), [])
self.assertEqual(self.j.unfinished_started(), [])
def test_lifecycle(self):
self.j.started(1)
self.assertEqual(self.j.unfinished_started(), [1])
self.j.finished(1, "done", {"key": "A-1"}, None, 2, 0)
self.assertEqual(self.j.unfinished_started(), [])
pending = self.j.unacked_finished()
self.assertEqual(len(pending), 1)
self.assertEqual(pending[0]["command_id"], 1)
self.assertEqual(pending[0]["result"], {"key": "A-1"})
self.assertEqual(pending[0]["jira_calls"], 2)
self.j.acked(1)
self.assertEqual(self.j.unacked_finished(), [])
self.j.started(2)
self.j.started(3)
self.j.finished(3, "failed", None, "HTTP 400", 1, 0)
self.assertEqual(self.j.unfinished_started(), [2])
self.assertEqual([r["command_id"] for r in self.j.unacked_finished()], [3])
def test_broken_line_is_skipped(self):
self.j.started(1)
with open(self.path, "a", encoding="utf-8") as f:
f.write("{не json\n")
self.j.finished(1, "done", None, None, 1, 0)
out = io.StringIO()
with contextlib.redirect_stdout(out):
records = self.j.records()
self.assertEqual(len(records), 2)
self.assertIn("строка 2 не читается", out.getvalue())
def test_prune_keeps_unacked_and_recent(self):
self.j.started(1)
self.j.finished(1, "done", None, None, 1, 0)
self.j.append({"kind": "acked", "command_id": 1, "at": "2020-01-01T00:00:00+00:00"})
self.j.started(2)
self.j.finished(2, "done", None, None, 1, 0)
self.j.acked(2)
self.j.started(3)
removed = self.j.prune(days=30)
self.assertEqual(removed, 3)
ids = [(r["kind"], r["command_id"]) for r in self.j.records()]
self.assertEqual(ids, [("started", 2), ("finished", 2), ("acked", 2), ("started", 3)])
self.assertEqual(self.j.prune(days=30), 0)
def test_no_secrets_in_records(self):
self.j.finished(1, "done", {"key": "A-1"}, None, 1, 0)
with open(self.path, encoding="utf-8") as f:
text = f.read()
self.assertNotIn("password", text)
self.assertEqual(json.loads(text.splitlines()[0])["kind"], "finished")
if __name__ == "__main__":
unittest.main()