jira-agent/docs/superpowers/plans/2026-10-13-bridge-reliability-agent.md
Andrey Epifancev b045aed8b6 docs(plan): план агента bridge-reliability (#9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:38:17 +04:00

16 KiB
Raw Blame History

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

  1. Журнал со временем записи в будущем (часы виртуалки сдвинулись назад после записи) — не ставить паузу больше положенной и не падать: оценка игнорируется — тест в Task 1.
  2. Журнал с битой строкой или записью без at — восстановление темпа берёт годные записи и не падает — тест в Task 1.
  3. Тесты, где last_call_at задан вручную до loop(), — восстановление из журнала его не перетирает — тест в Task 1.
  4. Восстановление команды, выданной до остановки, при пустом журнале кроме этой записи — пауза отсчитывается от claimed/started этой записи, а не обнуляется — тест в Task 2.
  5. Обычная выдача (не восстановление) по-прежнему пишет 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:

  1. 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()], [])
  1. Новый тест 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)])
  1. test_stop_during_pause_then_restart_executes_once — последнюю проверку заменить на self.assertAlmostEqual(sum(self.sleeps), 5, delta=1) # пауза из хаба от записи claimed (M-9).
  2. 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:
  1. AGENT_VERSION = 3.
  2. 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
        # дальше без изменений
  1. 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>"