19 KiB
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
- Описание в Jira
None, а в команде""(или наоборот) — изменений нет, как и до правки — тест в Task 1. - Перезапуск с журналом, где последняя запись в будущем, и сразу Ctrl-C в паузе — пауза прерывается, команда остаётся на
восстановление — существующий
test_stop_during_pause(Task 2 не должен его ломать). - Ответ папки
{"status": true, …}(bool — подкласс int в Python) — нечитаемый ответ, а не статус 1 — тест в Task 3. pruneпри работающем агенте — отказ без изменения журнала; после остановки — работает — тест в Task 4.- Первый
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:
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:
def _nl(value) -> str:
"""Текст для сравнения: Jira хранит правленное в UI с \r\n, команды приходят с \n (T3.9, проверено 13.10.2026)."""
return (value or "").replace("\r\n", "\n")
В do_ensure_fields строку сравнения описания:
if "description" in p and _nl(cur.get(self.fid("description"))) != _nl(p["description"]):
В do_ensure_comment строку проверки совпадения:
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
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и дописать новый тест:
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целиком:
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
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:
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(...):
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
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):
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:
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
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>"