16 KiB
bridge-reliability — агент: план реализации
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: после перезапуска агент соблюдает темп Jira (время последнего вызова восстанавливается из журнала, пауза при
восстановлении не обнуляется) и не пишет claimed в журнал повторно; версия агента 3.
Architecture: всё в src/agent.py: Journal.last_at() даёт самое позднее время записи; Runner.restore_pace()
один раз в начале loop() переводит его в JiraClient.last_call_at; Runner.execute(cmd, journal_claimed=True);
Runner.recover() больше не обнуляет pause_seconds и вызывает execute(cmd, journal_claimed=False).
Tech Stack: Python 3.9, только стандартная библиотека, unittest.
Spec: ../jira-hub/openspec/changes/bridge-reliability/ (ветка feat/9-bridge-reliability в jira-hub;
specs/agent/spec.md — требования «Цикл выполнения», «Журнал на диске и восстановление», «Ограничение темпа и 429»,
«Доставка и запуск»; design.md — решения 9–11).
Global Constraints
- Python ≥ 3.9, только stdlib (
tests/test_py39.pyпроверяет синтаксис и импорты). - Тесты:
/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .из корня worktree — зелёным считается только весь набор (до начала: 133 OK). - Тела запросов к хабу не меняются.
- Комментарии, сообщения лога и коммитов — по-русски; коммиты через
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/reliability …, суффикс(#9), строкаCo-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>. - Решения без обсуждения — в
openspec/changes/bridge-reliability/design.mdэтого репозитория (создать при первом решении: заголовок# bridge-reliability — решения агента, ссылка на спеку хаба, таблица## Decisions made without discussionс колонками Дата | Решение | Обоснование | Цена ошибки), маркерR-n.
Review Focus
- Журнал со временем записи в будущем (часы виртуалки сдвинулись назад после записи) — не ставить паузу больше положенной и не падать: оценка игнорируется — тест в Task 1.
- Журнал с битой строкой или записью без
at— восстановление темпа берёт годные записи и не падает — тест в Task 1. - Тесты, где
last_call_atзадан вручную доloop(), — восстановление из журнала его не перетирает — тест в Task 1. - Восстановление команды, выданной до остановки, при пустом журнале кроме этой записи — пауза отсчитывается от
claimed/startedэтой записи, а не обнуляется — тест в Task 2. - Обычная выдача (не восстановление) по-прежнему пишет
claimedдо паузы (R-9) — существующийtest_processes_queueостаётся зелёным, Task 2.
Task 1: Время последнего вызова Jira восстанавливается из журнала
Files:
- Modify:
src/agent.py(Journal.last_at,Runner.restore_pace,Runner.loop) - Test:
tests/test_runner.py,tests/test_journal.py
Interfaces:
-
Produces:
Journal.last_at() -> Optional[datetime](aware UTC, самое позднееatсреди годных записей илиNone);Runner.restore_pace() -> None(ставитjira.last_call_at/last_call_iso, если они пусты и в журнале есть время не из будущего). -
Step 1: Write the failing tests
tests/test_journal.py (в существующий класс тестов журнала; имена self.path/self.journal взять из его setUp;
при необходимости from datetime import datetime, timedelta, timezone):
def test_last_at_is_latest_record_time(self):
self.assertIsNone(self.journal.last_at())
self.journal.append({"kind": "claimed", "command_id": 1, "at": "2026-10-13T10:00:00+00:00"})
self.journal.append({"kind": "finished", "command_id": 1, "at": "2026-10-13T10:05:00+00:00"})
self.journal.append({"kind": "acked", "command_id": 1, "at": "2026-10-13T10:04:00+00:00"})
self.assertEqual(self.journal.last_at(), datetime(2026, 10, 13, 10, 5, tzinfo=timezone.utc))
def test_last_at_skips_bad_and_missing_times(self):
self.journal.append({"kind": "claimed", "command_id": 1, "at": "2026-10-13T10:00:00+00:00"})
self.journal.append({"kind": "started", "command_id": 1, "at": "не время"})
self.journal.append({"kind": "started", "command_id": 1, "at": "2026-10-13T11:00:00"}) # без зоны — пропуск
with open(self.journal.path, "a", encoding="utf-8") as f:
f.write("битая строка\n")
self.assertEqual(self.journal.last_at(), datetime(2026, 10, 13, 10, 0, tzinfo=timezone.utc))
tests/test_runner.py (класс RunnerTest; импорт from datetime import datetime, timedelta, timezone в начало файла):
def test_restart_pace_from_old_finished(self):
at = (datetime.now(timezone.utc) - timedelta(seconds=10)).replace(microsecond=0).isoformat()
self.journal.append({"kind": "finished", "command_id": 99, "status": "done", "result": None, "error": None,
"jira_calls": 1, "rate_limited": 0, "at": at})
self.journal.append({"kind": "acked", "command_id": 99, "at": at})
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=30)
self.runner.loop()
self.assertAlmostEqual(sum(self.sleeps), 20, delta=1.5)
def test_future_journal_time_ignored(self):
at = (datetime.now(timezone.utc) + timedelta(hours=1)).replace(microsecond=0).isoformat()
self.journal.append({"kind": "acked", "command_id": 99, "at": at})
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=30)
self.runner.loop()
self.assertEqual(self.sleeps, []) # время из будущего — как пустой журнал
self.assertEqual(self.hub.results[1]["status"], "done")
def test_restore_pace_keeps_existing_last_call(self):
at = (datetime.now(timezone.utc) - timedelta(seconds=100)).replace(microsecond=0).isoformat()
self.journal.append({"kind": "acked", "command_id": 99, "at": at})
mark = time.monotonic()
self.runner.jira.last_call_at = mark
self.runner.restore_pace()
self.assertEqual(self.runner.jira.last_call_at, mark)
- Step 2: Run tests to verify they fail
Run: /usr/bin/python3 -W error::ResourceWarning -m unittest tests.test_journal tests.test_runner
Expected: FAIL — нет Journal.last_at, нет Runner.restore_pace, пауза после перезапуска 0.
- Step 3: Implement —
src/agent.py:
В класс Journal после records:
def last_at(self) -> Optional[datetime]:
"""Самое позднее время записи журнала (UTC). Записи без времени или с непонятным временем пропускаются."""
latest = None
for r in self.records():
try:
t = datetime.fromisoformat(r["at"])
except (KeyError, TypeError, ValueError):
continue
if t.tzinfo is None:
continue
if latest is None or t > latest:
latest = t
return latest
В класс Runner после load_settings:
def restore_pace(self) -> None:
"""После перезапуска время последнего вызова Jira неизвестно (живёт в памяти). Берём самое позднее время записи
журнала: finished пишется сразу после последнего вызова команды, оценка не раньше настоящего вызова (M-9)."""
if self.jira.last_call_at is not None:
return
at = self.journal.last_at()
if at is None:
return
ago = (datetime.now(timezone.utc) - at).total_seconds()
if ago < 0:
log("журнал: время последней записи в будущем (%s) — темп не восстанавливаю" % at.isoformat())
return
self.jira.last_call_at = time.monotonic() - ago
self.jira.last_call_iso = at.astimezone(timezone.utc).replace(microsecond=0).isoformat()
log("темп: последний вызов Jira не позже %s (по журналу)" % self.jira.last_call_iso)
В loop() первой строкой тела (до idle, recovered = 0, False или сразу после неё): self.restore_pace().
- Step 4: Run tests
Run: полный набор (см. Global Constraints) — OK. Существующие тесты перезапуска
(test_stop_during_pause_then_restart_executes_once, test_recover_unfinished_started) остаются зелёными: recover
до Task 2 обнуляет pause_seconds, поэтому восстановленное время последнего вызова на них не влияет.
- Step 5: Commit
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/reliability add src/agent.py tests
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/reliability commit -m "feat(agent): время последнего вызова Jira восстанавливается из журнала после перезапуска (M-9, #9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 2: Восстановление без обнуления паузы и без повторного claimed; версия 3
Files:
- Modify:
src/agent.py(AGENT_VERSION,Runner.execute,Runner.recover, docstring про спеку в начале файла, если он ссылается наchanges/bridge-core) - Modify:
README.md(строки про «выполняет через проверку (без паузы)», «Версия … (сейчас 2)», раздел про R-9) - Test:
tests/test_runner.py,tests/test_folder.py
Interfaces:
-
Consumes:
Runner.restore_pace(Task 1). -
Produces:
Runner.execute(cmd: dict, journal_claimed: bool = True) -> None;AGENT_VERSION = 3. -
Step 1: Write the failing tests —
tests/test_runner.py:
test_recover_unfinished_started— заменить проверку пауз и добавить проверку журнала:
def test_recover_unfinished_started(self):
cid = self.claim_in_flight("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-20"}, pause=99)
self.journal.started(cid)
self.runner.loop()
self.assertEqual(self.hub.results[cid]["result"], {"changed": []}) # уже так в Jira — записи нет
self.assertEqual(self.journal.unfinished_started(), [])
self.assertAlmostEqual(sum(self.sleeps), 99, delta=1) # пауза из хаба, от записи started (M-9)
self.assertEqual([r[0] for r in self.fake.writes()], [])
- Новый тест FM-4:
def test_recover_does_not_journal_claimed_twice(self):
cid = self.claim_in_flight("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-20"}, pause=0)
self.journal.claimed(cid)
self.runner.loop()
self.assertEqual(self.kinds(), [("claimed", cid), ("started", cid), ("finished", cid), ("acked", cid)])
test_stop_during_pause_then_restart_executes_once— последнюю проверку заменить наself.assertAlmostEqual(sum(self.sleeps), 5, delta=1) # пауза из хаба от записи claimed (M-9).tests/test_folder.py:test_version_is_2→test_version_is_3сself.assertEqual(agent.AGENT_VERSION, 3).
- Step 2: Run tests to verify they fail
Run: /usr/bin/python3 -W error::ResourceWarning -m unittest tests.test_runner tests.test_folder
Expected: FAIL — пауза при восстановлении 0; claimed в журнале дважды; версия 2.
- Step 3: Implement —
src/agent.py:
AGENT_VERSION = 3.execute:
def execute(self, cmd: dict, journal_claimed: bool = True) -> None:
cid = cmd["id"]
log("#%s %s %s" % (cid, cmd["type"], json.dumps(cmd.get("params"), ensure_ascii=False)[:200]))
if journal_claimed:
self.journal.claimed(cid) # сразу после claim, до паузы (R-9): остановка не теряет команду
if not self.wait_pause(cmd.get("pause_seconds")):
return
# дальше без изменений
recover— конец цикла:
log("#%s выдана до перезапуска — выполняю через проверку" % cid)
self.execute(cmd, journal_claimed=False) # claimed/started для неё уже в журнале (FM-4); пауза — из хаба (M-9)
(строка cmd["pause_seconds"] = 0 удаляется.)
4. Если docstring модуля/справка --help ссылается на jira-hub/openspec/changes/bridge-core/specs/agent/spec.md —
заменить на jira-hub/openspec/specs/agent/spec.md.
README.md:
-
«…при следующем запуске агент запрашивает её у хаба и выполняет через проверку (без паузы).» → «…выполняет через проверку, выдержав паузу темпа от последней записи журнала.»
-
«Константа
AGENT_VERSIONвagent.py(сейчас 2)» → «(сейчас 3)». -
В разделе ограничений, пункт 2 (R-9): «выполняет без паузы» → «выполняет с паузой темпа (время последнего вызова Jira берётся из журнала)».
-
В раздел про паузы (строка «Паузы. …») дописать предложение: «После перезапуска время последнего вызова Jira берётся из журнала (самая поздняя запись), поэтому первая команда тоже ждёт паузу.»
-
Если в README есть упоминание, что хаб ждёт 10 минут / «нет ответа от агента», — заменить на довыдачу: «Если ответ на
claimпотерян, хаб выдаст ту же команду снова при следующемclaim(до 3 выдач).» -
Step 4: Run tests
Run: полный набор (см. Global Constraints) — OK.
- Step 5: Commit
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/reliability add -A src tests README.md
git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/reliability commit -m "feat(agent): восстановление с паузой из хаба и без повторного claimed; версия 3 (M-9, FM-4, #9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"