jira-agent/docs/superpowers/plans/2026-10-14-bridge-hygiene-agent.md
Andrey Epifancev 93c2b0247d docs: план агента bridge-hygiene (#15)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:28:39 +04:00

353 lines
19 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-hygiene — агент: план реализации
> **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:** агент v4: сравнение текстов без различия `\r\n`/`\n`, честное время последнего вызова Jira в `claim`, полная
пауза при времени журнала в будущем, устойчивость к ответу папки с неверными типами, `prune` под блокировкой.
**Architecture:** точечные правки в `src/agent.py` (`Executor.do_ensure_fields`, `do_ensure_comment`,
`Runner.restore_pace`, `FolderTransport`, CLI `prune`); контракт с хабом не меняется.
**Tech Stack:** Python 3.9.6 (`/usr/bin/python3`), только stdlib, `unittest`.
**Spec:** `../jira-hub/.wt/hygiene/openspec/changes/bridge-hygiene/` (design.md решения 16–21, specs/agent, specs/commands);
агентские решения — `openspec/changes/bridge-hygiene/design.md` в этом репозитории.
## Global Constraints
- Рабочий каталог: `/Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/hygiene` (ветка `feat/15-bridge-hygiene`).
- Только синтаксис и stdlib Python 3.9: никаких `X | Y` в аннотациях функций, `match`, сторонних импортов.
- Весь набор: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` (до начала: 141 OK).
- `AGENT_VERSION = 4`.
- Комментарии, docstring, логи и коммиты — по-русски; коммит с суффиксом `(#15)` и строкой
`Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Решения без обсуждения — в `openspec/changes/bridge-hygiene/design.md` этого репозитория, следующий маркер `R-12`.
## Review Focus
1. Описание в Jira `None`, а в команде `""` (или наоборот) — изменений нет, как и до правки — тест в Task 1.
2. Перезапуск с журналом, где последняя запись в будущем, и сразу Ctrl-C в паузе — пауза прерывается, команда остаётся на
восстановление — существующий `test_stop_during_pause` (Task 2 не должен его ломать).
3. Ответ папки `{"status": true, …}` (bool — подкласс int в Python) — нечитаемый ответ, а не статус 1 — тест в Task 3.
4. `prune` при работающем агенте — отказ без изменения журнала; после остановки — работает — тест в Task 4.
5. Первый `claim` после перезапуска несёт `last_jira_call_at = null`, после первого вызова Jira — настоящее время —
тест в Task 2.
---
### Task 1: Сравнение описания и комментария без различия переводов строк
**Files:**
- Modify: `src/agent.py` (новая `_nl` рядом с `_date`; `do_ensure_fields`, `do_ensure_comment`)
- Test: `tests/test_executor_fields.py`
**Interfaces:**
- Produces: `agent._nl(value) -> str` — `(value or "").replace("\r\n", "\n")`.
- [ ] **Step 1: Write the failing tests** — дописать в класс `ExecutorFieldsTest`:
```python
def test_fields_description_crlf_equals_lf(self):
self.fake.add("AURORA-1", description="а\r\nб") # так Jira хранит текст, правленный в UI
out = self.run_cmd("ensure_fields", key="AURORA-1", description="а\nб")
self.assertEqual(out["result"]["changed"], [])
out = self.run_cmd("ensure_fields", key="AURORA-1", description="а\nв")
self.assertEqual(out["result"]["changed"], ["description"])
self.assertEqual(self.fake.issues["AURORA-1"]["description"], "а\nв") # отправлен текст команды как есть
def test_fields_description_none_equals_empty(self):
self.fake.add("AURORA-1", description=None)
out = self.run_cmd("ensure_fields", key="AURORA-1", description="")
self.assertEqual(out["result"]["changed"], [])
def test_comment_crlf_equals_lf(self):
self.fake.add("AURORA-1")
self.fake.comments["AURORA-1"] = [{"id": "10", "body": "*Актуализация 10.10.2026.* строка1\r\nстрока2"}]
out = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="строка1\nстрока2")
self.assertEqual(out["result"], {"comment_id": "10", "changed": False})
```
Сверить формат элемента `fake.comments[key]` с `tests/fake_jira.py` (около строки 204) и подогнать словарь под него.
- [ ] **Step 2: Run tests to verify they fail**
Run: `/usr/bin/python3 -m unittest tests.test_executor_fields -v`
Expected: FAIL — `changed == ["description"]` для CRLF; комментарий `changed: True`.
- [ ] **Step 3: Implement** — в `src/agent.py` сразу после `_date`:
```python
def _nl(value) -> str:
"""Текст для сравнения: Jira хранит правленное в UI с \r\n, команды приходят с \n (T3.9, проверено 13.10.2026)."""
return (value or "").replace("\r\n", "\n")
```
В `do_ensure_fields` строку сравнения описания:
```python
if "description" in p and _nl(cur.get(self.fid("description"))) != _nl(p["description"]):
```
В `do_ensure_comment` строку проверки совпадения:
```python
if existing and _nl(existing.get("body")) == _nl(body):
```
- [ ] **Step 4: Run tests**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Expected: OK (включая `test_fields_description_exact_compare` — разница в завершающем `\n` по-прежнему изменение).
- [ ] **Step 5: Commit**
```bash
git add src/agent.py tests/test_executor_fields.py
git commit -m "fix(agent): описание и комментарий сравниваются без различия CRLF/LF (#15)
T3.9 (agent#4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 2: Темп после перезапуска — будущее время журнала и `last_jira_call_at`
**Files:**
- Modify: `src/agent.py` (`Runner.restore_pace`)
- Modify: `tests/fake_hub.py` (запоминать тела `claim`)
- Test: `tests/test_runner.py`
**Interfaces:**
- Consumes: `JiraClient.last_call_at` (monotonic), `JiraClient.last_call_iso` (ISO UTC, ставится в `JiraClient` при
каждом вызове Jira).
- Produces: `FakeHub.claim_bodies: list[dict]` — тела всех `POST /agent/claim` по порядку.
- [ ] **Step 1: `tests/fake_hub.py`** — в `__init__` добавить `self.claim_bodies = []`; в `route` в ветке
`if path == "/agent/claim":` первой строкой `self.claim_bodies.append(body or {})`.
- [ ] **Step 2: Write the failing tests** — в `tests/test_runner.py` заменить `test_future_journal_time_ignored` и
дописать новый тест:
```python
def test_future_journal_time_means_full_pause(self):
at = (datetime.now(timezone.utc) + timedelta(hours=2)).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.assertAlmostEqual(sum(self.sleeps), 30, delta=1.5) # часы ушли назад — пауза полная (M-6 #12)
self.assertEqual(self.hub.results[1]["status"], "done")
def test_claim_after_restart_sends_no_journal_estimate(self):
at = (datetime.now(timezone.utc) - timedelta(seconds=10)).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"})
self.runner.loop()
self.assertIsNone(self.hub.claim_bodies[0].get("last_jira_call_at")) # оценка журнала — только для паузы (M-7 #12)
self.assertIsNotNone(self.hub.claim_bodies[-1].get("last_jira_call_at")) # после вызова Jira — настоящее время
self.assertIsNotNone(self.runner.jira.last_call_at)
```
- [ ] **Step 3: Run tests to verify they fail**
Run: `/usr/bin/python3 -m unittest tests.test_runner -v`
Expected: FAIL — пауза 0 при будущем времени; первый `claim` несёт время журнала.
- [ ] **Step 4: Implement** — `Runner.restore_pace` целиком:
```python
def restore_pace(self) -> None:
"""После перезапуска время последнего вызова Jira неизвестно (живёт в памяти). Берём самое позднее время записи
журнала: finished пишется сразу после последнего вызова команды, оценка не раньше настоящего вызова (M-9).
Время в будущем (часы ушли назад) — считаем «сейчас»: пауза полная (M-6 #12). Оценка служит только паузе:
last_call_iso (уходит хабу в claim) ставит лишь настоящий вызов Jira (M-7 #12)."""
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())
ago = 0
self.jira.last_call_at = time.monotonic() - ago
log("темп: последний вызов Jira не позже %s (по журналу)"
% at.astimezone(timezone.utc).replace(microsecond=0).isoformat())
```
- [ ] **Step 5: Run tests**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Expected: OK. Если другой тест проверял `last_call_iso` после `restore_pace` — привести его к спеке (оценка в
`last_call_iso` не попадает) и записать рулинг `R-12` в design.md.
- [ ] **Step 6: Commit**
```bash
git add src/agent.py tests/fake_hub.py tests/test_runner.py
git commit -m "fix(agent): полная пауза при времени журнала в будущем, claim без оценки из журнала (#15)
M-6, M-7 (agent#12).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 3: Ответ папки с неверными типами; текст ошибки без «хаб: хаб»
**Files:**
- Modify: `src/agent.py` (`FolderTransport.call`, `FolderTransport._take`)
- Test: `tests/test_folder.py`
**Interfaces:**
- Consumes: `FolderTransport(root, authorization)`, `FolderTransport.BAD_JSON_WAIT`, `FolderTransport._take(path)`.
- [ ] **Step 1: Write the failing tests** — дописать в `FolderTransportTest`:
```python
def _bad_response(self, payload):
path = os.path.join(self.root, "resp-bad.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f)
ft = agent.FolderTransport(self.root, "Bearer x")
ft.BAD_JSON_WAIT = 0.3
return ft, path
def test_wrong_status_type_is_unreadable(self):
for payload in ({"id": "x", "status": "ok", "body": "{}"}, {"id": "x", "status": True, "body": "{}"},
{"id": "x", "status": 200, "body": 5}):
ft, path = self._bad_response(payload)
with self.assertRaises(ConnectionError) as e:
ft._take(path)
self.assertIn("нечитаемый ответ", str(e.exception))
def test_no_answer_message_has_no_hub_prefix(self):
hub = self.client(timeout=0.3)
with self.assertRaises(ConnectionError) as e:
hub.settings()
self.assertFalse(str(e.exception).startswith("хаб")) # цикл сам пишет «хаб: …» (O-3 agent#8)
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `/usr/bin/python3 -m unittest tests.test_folder -v`
Expected: FAIL — `ValueError: invalid literal for int()` / `TypeError` вместо `ConnectionError`; текст начинается с «хаб».
- [ ] **Step 3: Implement** — в `FolderTransport._take` после проверки `if not isinstance(resp, dict): raise ValueError(...)`:
```python
status, body = resp.get("status"), resp.get("body")
if type(status) is not int or not (body is None or isinstance(body, str)):
raise ValueError("неверные типы status/body") # bool — подкласс int, поэтому type(), не isinstance (M-2 agent#8)
```
В `FolderTransport.call` последнюю строку заменить на
`raise ConnectionError("не ответил через папку %s за %.0f с" % (self.root, timeout))`.
- [ ] **Step 4: Run tests**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Expected: OK (тесты с «не ответил через папку» проходят — подстрока сохранена).
- [ ] **Step 5: Commit**
```bash
git add src/agent.py tests/test_folder.py
git commit -m "fix(agent): ответ папки с неверными типами — ConnectionError; лог без повтора «хаб» (#15)
M-2, O-3 (agent#8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 4: `prune` под блокировкой, версия 4, README, `.gitignore`
**Files:**
- Modify: `src/agent.py` (`AGENT_VERSION`, новая `cmd_prune`, `main`)
- Modify: `README.md`, `.gitignore`, `CLAUDE.md`
- Test: `tests/test_runner.py`, `tests/test_folder.py`
**Interfaces:**
- Consumes: `agent.acquire_lock(journal_path) -> file | None`, `Journal(path).prune(days) -> int`.
- Produces: `agent.cmd_prune(journal_path: str) -> int` — 0 после очистки, 1 если журнал занят работающим агентом.
- [ ] **Step 1: Write the failing tests**
В `tests/test_runner.py` (класс `RunnerTest`):
```python
def test_prune_refused_while_agent_runs(self):
old = "2020-01-01T00:00:00+00:00"
self.journal.append({"kind": "finished", "command_id": 7, "status": "done", "result": None, "error": None,
"jira_calls": 1, "rate_limited": 0, "at": old})
self.journal.append({"kind": "acked", "command_id": 7, "at": old})
held = agent.acquire_lock(self.journal.path)
try:
self.assertEqual(agent.cmd_prune(self.journal.path), 1)
self.assertEqual(len(self.journal.records()), 2) # журнал не тронут (T2.3 agent#4)
finally:
held.close()
self.assertEqual(agent.cmd_prune(self.journal.path), 0)
self.assertEqual(self.journal.records(), [])
```
В `tests/test_folder.py` переименовать `test_version_is_3` в `test_version_is_4` с `self.assertEqual(agent.AGENT_VERSION, 4)`.
- [ ] **Step 2: Run tests to verify they fail**
Run: `/usr/bin/python3 -m unittest tests.test_runner tests.test_folder -v`
Expected: FAIL — `AttributeError: module 'agent' has no attribute 'cmd_prune'`; версия 3.
- [ ] **Step 3: Implement** — в `src/agent.py`: `AGENT_VERSION = 4`; после `cmd_run`:
```python
def cmd_prune(journal_path: str) -> int:
"""Удалить подтверждённые записи старше 30 дней. Под той же блокировкой, что run: prune перезаписывает файл, и запись,
добавленная работающим агентом (finished — неотправленный отчёт), пропала бы (T2.3)."""
lock = acquire_lock(journal_path)
if lock is None:
log("агент запущен с журналом %s — prune невозможен, сначала остановите агента" % journal_path)
return 1
try:
removed = Journal(journal_path).prune(30)
log("журнал: удалено записей %d" % removed)
return 0
finally:
lock.close()
```
В `main` ветку `prune` заменить на `return cmd_prune(args.journal)`.
- [ ] **Step 4: Документы**
- `.gitignore`: дописать `.wt/`.
- `README.md`:
- раздел про `prune` (около строки 121): «Не запускать при работающем агенте (ограничение 1 ниже).» →
«При работающем агенте `prune` откажет («агент запущен…», код 1): сначала остановить агента.»;
- ограничение 1 («`prune` не запускать при работающем агенте. Блокировки нет…») удалить, перенумеровать список;
- ограничение «Сравнение описания в `ensure_fields` точное…» заменить на «**Сравнение текстов без различия
переводов строк.** Описание и тело комментария сравниваются после замены `\r\n` на `\n` (Jira хранит правленное в
UI с `\r\n`); отправляется текст команды как есть. Прочие различия (пробелы, завершающий перевод строки) — изменение.»;
- «Версия»: «(сейчас 3)» → «(сейчас 4)»; добавить строку: «Версия 4 — сравнение текстов без различия `\r\n`/`\n`,
полная пауза при времени журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой.»
- `CLAUDE.md`: в «Команды» у `prune` «(не при работающем агенте)» → «(при работающем агенте откажет)».
- [ ] **Step 5: Run tests**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t . && git status --short`
Expected: OK; `.wt/` не виден в `git status`.
- [ ] **Step 6: Commit**
```bash
git add src/agent.py tests/test_runner.py tests/test_folder.py README.md .gitignore CLAUDE.md
git commit -m "feat(agent): prune под блокировкой журнала, версия 4, README (#15)
T2.3 (agent#4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```