266 lines
16 KiB
Markdown
266 lines
16 KiB
Markdown
# 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>"
|
||
```
|