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

19 KiB
Raw Blame History

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:

    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>"