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

266 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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