From 234ea75d4f9040f0b8e075a76d0d7ca6e8a7d890 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 22:36:33 +0400 Subject: [PATCH] =?UTF-8?q?docs(plan):=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=85?= =?UTF-8?q?=D0=B0=D0=B1=D0=B0=20bridge-reliability=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../2026-10-13-bridge-reliability-hub.md | 777 ++++++++++++++++++ 1 file changed, 777 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-13-bridge-reliability-hub.md diff --git a/docs/superpowers/plans/2026-10-13-bridge-reliability-hub.md b/docs/superpowers/plans/2026-10-13-bridge-reliability-hub.md new file mode 100644 index 0000000..42c18c9 --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-bridge-reliability-hub.md @@ -0,0 +1,777 @@ +# 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:** хаб довыдаёт команду, оставшуюся без отчёта (с пределом 3 выдачи), мигрирует живую базу на схему v2, отвергает +отчёт по невыданной команде и пакет с дублем темы, сохраняет ключ из позднего отчёта по отменённой `ensure_issue`, +показывает связи «в обратную сторону» в отчёте импорта отдельно. + +**Architecture:** вся логика выдачи и отчётов — в `src/jira_hub/queue.py` (одна транзакция на `claim`/`report_result`); +миграция — в `db.migrate` по `setting.schema_version`; валидация пакета — `commands.validate`; отчёт импорта — +`importer._import`. HTTP-слой (`api.py`) только переводит исключения в коды. + +**Tech Stack:** Python 3.13, uv, pytest, SQLite (stdlib), stdlib `http.server`. + +**Spec:** `openspec/changes/bridge-reliability/` (proposal.md, design.md, specs/{hub-api,data-model,commands,mcp,migration,agent}). + +## Global Constraints + +- Комментарии, docstring, сообщения ошибок и коммитов — по-русски; идентификаторы — английские. +- `uv run pytest -q` — зелёным считается только весь набор (до начала: 159 passed). +- Новых зависимостей нет. Контракт тел запросов агента не меняется. +- Предел выдач — константа `MAX_DELIVERIES = 3`; текст ошибки ровно `агент не отчитался после 3 выдач`. +- `SCHEMA_VERSION = 2`; копия базы перед миграцией — `<каталог базы>/backups/<дата-время>-schema-v1.sqlite`, без ротации. +- Каждая задача заканчивается коммитом `git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/reliability …` + с сообщением на русском и строкой `Co-Authored-By: Claude Opus 5.5 `; суффикс `(#9)` в заголовке. +- Решения, принятые исполнителем без обсуждения, — в `openspec/changes/bridge-reliability/design.md`, раздел + `## Decisions made without discussion`, с маркером `R-n`. + +## Review Focus + +1. Живая база v1 с командой в `claimed`: после старта нового хаба команда довыдаётся (`deliveries` 1 → 2), а не теряется и + не падает миграция — тест в Task 1 (миграция) и Task 2 (довыдача команды, мигрированной с `deliveries = 1`). +2. Два параллельных `claim` (старый долгий запрос реле и новый): одна строка в `claimed`, `deliveries` = числу выдач, + обе выдачи — один и тот же `id` — тест `test_claim_is_exclusive` в Task 2. +3. Довыдача `refresh`: второй `sync_run` не создаётся (иначе счётчики синхронизаций двоятся) — тест в Task 2. +4. Довыдача команды из отменённого пакета (`cancel_batch` оставляет `claimed`): выдаётся снова, не теряется — тест в Task 2. +5. Поздний отчёт по отменённой `ensure_issue` приходит дважды или после того, как результат уже записан: второй раз + ничего не меняет (идемпотентность) — тест в Task 3. + +--- + +### Task 1: Миграция схемы v1 → v2 (`command.deliveries`) + +**Files:** +- Modify: `src/jira_hub/db.py` (`SCHEMA_VERSION`, таблица `command` в `SCHEMA`, `connect`, `migrate`) +- Test: `tests/test_db.py` + +**Interfaces:** +- Consumes: `backup.make_backup(conn, backups_dir, label, keep)` из `src/jira_hub/backup.py` (без изменений; `keep=0` — + без ротации). +- Produces: колонка `command.deliveries INTEGER NOT NULL DEFAULT 0`; `db.SCHEMA_VERSION == 2`; + `db.migrate(conn, path=None)`. + +- [ ] **Step 1: Write the failing tests** — в `tests/test_db.py` заменить проверку версии и добавить тесты миграции: + +```python +import sqlite3 + +from jira_hub import db + + +def test_connect_creates_schema(): + conn = db.connect(":memory:") + names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} + assert {"setting", "issue", "issue_link", "issue_note", "batch", "command", "sync_run", "agent_status"} <= names + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2" + assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 + cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")} + assert "deliveries" in cols + + +def _v1_database(path): + """База в состоянии схемы v1: без command.deliveries, schema_version = 1, с живыми данными.""" + conn = db.connect(path) + conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('x', 'claude', '2026-10-13T00:00:00+00:00')") + for status in ("claimed", "done", "queued"): + conn.execute("INSERT INTO command(batch_id, seq, type, params, status, claimed_at) VALUES (1, 1, 'refresh', '{}', ?, ?)", + (status, "2026-10-13T00:00:00+00:00" if status == "claimed" else None)) + conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')") + conn.execute("ALTER TABLE command DROP COLUMN deliveries") + conn.execute("UPDATE setting SET value = '1' WHERE key = 'schema_version'") + conn.close() + + +def test_migrates_v1_with_backup(tmp_path): + path = tmp_path / "hub.sqlite" + _v1_database(path) + conn = db.connect(path) + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2" + rows = conn.execute("SELECT status, deliveries FROM command ORDER BY id").fetchall() + assert [(r["status"], r["deliveries"]) for r in rows] == [("claimed", 1), ("done", 0), ("queued", 0)] + assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе" + copies = list((tmp_path / "backups").glob("*-schema-v1.sqlite")) + assert len(copies) == 1 + old = sqlite3.connect(copies[0]) # не db.connect — он бы мигрировал копию + assert "deliveries" not in {r[1] for r in old.execute("PRAGMA table_info(command)")} + assert old.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()[0] == "1" + old.close() + conn.close() + db.connect(path).close() # повторный connect не мигрирует и не копирует + assert len(list((tmp_path / "backups").glob("*.sqlite"))) == 1 + + +def test_new_database_makes_no_backup(tmp_path): + db.connect(tmp_path / "hub.sqlite").close() + assert not (tmp_path / "backups").exists() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_db.py -q` +Expected: FAIL — `schema_version` равен `"1"`, колонки `deliveries` нет. + +- [ ] **Step 3: Implement** — в `src/jira_hub/db.py`: + +1. Импорт рядом с остальными: `from . import backup` (модуль `backup` не импортирует `db`, цикла нет). +2. `SCHEMA_VERSION = 2`. +3. В `CREATE TABLE IF NOT EXISTS command` после строки `claimed_at TEXT,` добавить + ` deliveries INTEGER NOT NULL DEFAULT 0,`. +4. После `SCHEMA` добавить шаги миграций: + +```python +# Шаги миграции: версия → SQL, переводящий схему в версию + 1 (design.md bridge-reliability, решение 5) +MIGRATIONS = { + 1: ["ALTER TABLE command ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0", + "UPDATE command SET deliveries = 1 WHERE status = 'claimed'"], # уже выданные — одна выдача +} +``` + +5. В `connect` заменить `migrate(conn)` на `migrate(conn, None if is_memory else path)`. +6. Заменить `migrate`: + +```python +def _schema_version(conn) -> int | None: + row = conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone() + return None if row is None else int(json.loads(row["value"])) + + +def migrate(conn: sqlite3.Connection, path=None) -> None: + """Создать недостающие таблицы и поднять схему существующей базы до SCHEMA_VERSION. Перед миграцией файловой базы — + копия в <каталог базы>/backups (без ротации). Параллельный connect безопасен: версия перечитывается в транзакции.""" + conn.executescript(SCHEMA) + version = _schema_version(conn) + if version is None: + conn.execute("INSERT INTO setting(key, value) VALUES ('schema_version', ?)", (json.dumps(SCHEMA_VERSION),)) + elif version < SCHEMA_VERSION: + if path is not None: + backup.make_backup(conn, Path(path).parent / "backups", f"schema-v{version}", keep=0) + with transaction(conn): + current = _schema_version(conn) + while current < SCHEMA_VERSION: + for sql in MIGRATIONS[current]: + conn.execute(sql) + current += 1 + conn.execute("UPDATE setting SET value = ? WHERE key = 'schema_version'", (json.dumps(current),)) + conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)") +``` + +Функция `transaction` определена в том же модуле ниже `migrate` — вызов во время выполнения, порядок определения не +важен. + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest tests/test_db.py -q` — PASS. Затем `uv run pytest -q` — весь набор зелёный (другие тесты колонку +не используют). + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/reliability add src/jira_hub/db.py tests/test_db.py +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/reliability commit -m "feat(db): миграция схемы v1→v2 — command.deliveries, копия базы перед миграцией (#9) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Повторная выдача команды без отчёта, предел 3 выдачи + +**Files:** +- Modify: `src/jira_hub/queue.py` (docstring модуля, константы, `_definer_stale`, `_next`, `_fresh_claimed_exists`, + `claim`, `agent_view`, `maybe_auto_refresh`) +- Modify: `src/jira_hub/mcp_server.py` (`fmt_agent`) +- Modify: `CLAUDE.md` (строка «Хаб не повторяет команды сам»), `ARCHITECTURE.md` (строка про `queue` в «Границы»), + `README.md` (раздел «Эксплуатация», пункт про «нет ответа от агента») +- Test: `tests/test_queue.py`, `tests/test_api.py`, `tests/test_e2e_hub.py`, `tests/test_mcp.py` + +**Interfaces:** +- Consumes: `command.deliveries` (Task 1). +- Produces: `queue.MAX_DELIVERIES = 3`; `queue.claim(...)` с прежней сигнатурой и формой ответа + `{id, type, params, pause_seconds, batch_id}`; `queue.agent_view(conn)["redelivered"]` — список + `{id, type, claimed_at, deliveries}` (ключа `stuck` больше нет). + +- [ ] **Step 1: Write the failing tests** — в `tests/test_queue.py`: + +1. В `test_claim_order_pause_and_report` заменить строку + `assert queue.claim(conn) is None # одна команда в полёте` на + +```python + again = queue.claim(conn) # ответ потерян — та же команда снова + assert again["id"] == c["id"] and again["pause_seconds"] == 120 + assert queue.get_command(conn, c["id"])["deliveries"] == 2 +``` + +2. В `test_claim_is_exclusive` заменить последние две проверки на + +```python + got = [r for r in results if r is not None] + assert got and len({r["id"] for r in got}) == 1 # один и тот же id при любом порядке потоков + row = setup.execute("SELECT COUNT(*) AS n, MAX(deliveries) AS d FROM command WHERE status = 'claimed'").fetchone() + assert row["n"] == 1 and row["d"] == len(got) +``` + +3. Удалить `test_stale_claimed_does_not_block` и `test_stale_definer_does_not_block_other_batches`. +4. В `test_cancel_and_retry` заменить `assert queue.claim(conn) is None` на + `assert queue.claim(conn)["id"] == c["id"] # взятая из отменённого пакета довыдаётся`. +5. В `test_fresh_definer_still_blocks_queue` заменить `assert queue.claim(conn) is None # одна в полёте` + на `assert queue.claim(conn)["id"] == first["id"] # довыдача определителя, зависимые ждут`. +6. `test_reconcile_confirms_by_state` переписать (две `claimed` одновременно бывают только в старых данных — моделируем SQL): + +```python +def test_reconcile_confirms_by_state(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}]) + c = queue.claim(conn) + b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + r = queue.batch_view(conn, b["id"])["commands"][0] + conn.execute("UPDATE command SET status = 'claimed', claimed_at = ?, deliveries = 1 WHERE id = ?", (db.now(), r["id"])) + mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": []}], []) + queue.report_result(conn, r["id"], "done") + assert queue.get_command(conn, c["id"])["status"] == "confirmed_by_state" + assert queue.batch_view(conn, b["id"])["status"] == "done" +``` + +7. В `test_auto_refresh_conditions` в конец добавить: + +```python + conn.execute("UPDATE sync_run SET started_at = '2020-01-01T00:00:00+00:00'") # интервал прошёл + queue.create_batch(conn, "y", "claude", [fields("A-3")]) + queue.claim(conn) # выдана, отчёта нет, queued нет + conn.execute("DELETE FROM command WHERE status = 'queued'") # хвостовой refresh не мешает проверке + conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE status = 'claimed'") # давно без отчёта + assert queue.maybe_auto_refresh(conn) is None # claimed без отчёта — очередь занята +``` + +8. Добавить новые тесты: + +```python +def test_redelivery_of_lost_claim_before_queue(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) + c = queue.claim(conn) + again = queue.claim(conn) + assert again["id"] == c["id"] and again["params"] == c["params"] + assert queue.agent_view(conn)["current_command_id"] == c["id"] + view = queue.agent_view(conn)["redelivered"] + assert [(x["id"], x["deliveries"]) for x in view] == [(c["id"], 2)] + queue.report_result(conn, c["id"], "done") + assert queue.claim(conn)["params"]["key"] == "A-2" + assert queue.agent_view(conn)["redelivered"] == [] + + +def test_redelivery_limit_fails_command(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) + c = queue.claim(conn) + queue.claim(conn) + queue.claim(conn) # третья выдача + nxt = queue.claim(conn) # четвёртой нет — failed и дальше по очереди + assert nxt["params"]["key"] == "A-2" + failed = queue.get_command(conn, c["id"]) + assert failed["status"] == "failed" and failed["error"] == "агент не отчитался после 3 выдач" and failed["finished_at"] + assert queue.MAX_DELIVERIES == 3 + + +def test_redelivery_oldest_first(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1")]) + queue.create_batch(conn, "y", "claude", [fields("A-2")]) + rows = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields' ORDER BY id").fetchall() + conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2026-10-13T10:00:00+00:00', deliveries = 1 WHERE id = ?", (rows[1]["id"],)) + conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2026-10-13T11:00:00+00:00', deliveries = 1 WHERE id = ?", (rows[0]["id"],)) + assert queue.claim(conn)["id"] == rows[1]["id"] + + +def test_redelivered_refresh_keeps_one_sync_run(): + conn = fresh() + queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + r = queue.claim(conn) + assert queue.claim(conn)["id"] == r["id"] + assert conn.execute("SELECT COUNT(*) AS n FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["n"] == 1 + + +def test_redelivery_does_not_repeat_backup(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1")]) + labels = [] + queue.claim(conn, backup=labels.append) + queue.claim(conn, backup=labels.append) + assert len(labels) == 1 + + +def test_migrated_claimed_is_redelivered(): + """Команда, взятая до миграции (deliveries = 1 после неё), довыдаётся со счётчиком 2.""" + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1")]) + cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"] + conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2026-10-12T00:00:00+00:00', deliveries = 1 WHERE id = ?", (cid,)) + assert queue.claim(conn)["id"] == cid + assert queue.get_command(conn, cid)["deliveries"] == 2 +``` + +9. В `tests/test_api.py::test_agent_flow` заменить + `assert a.post("/agent/claim", {})["command"] is None # одна в полёте, долгий запрос выключен` на + `assert a.post("/agent/claim", {})["command"]["id"] == cmd["id"] # ответ потерян — довыдача той же команды`. +10. В `tests/test_e2e_hub.py` заменить + `assert agent.post("/agent/claim", {"agent_version": 1, "settings_version": 1})["command"] is None` на + `assert agent.post("/agent/claim", {"agent_version": 1, "settings_version": 1})["command"]["id"] == cmd["id"]` + и комментарий над ней — на `# одна команда в полёте: повторный claim довыдаёт ensure_issue, зависимая ensure_link не выдаётся`. +11. В `tests/test_mcp.py::test_formatters` заменить `"stuck": []` на `"redelivered": []` и добавить: + +```python + text = mcp_server.fmt_agent({"online": True, "agent_version": "2", "current_command_id": 40, "rate_limited_24h": 0, + "redelivered": [{"id": 40, "type": "ensure_fields", "claimed_at": "2026-10-13T10:00:00+00:00", + "deliveries": 2}], + "warning": None, "last_seen_at": "2026-10-13T10:00:00+00:00", "settings_version": 3}) + assert "онлайн" in text and "#40" in text and "выдач 2" in text +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_queue.py tests/test_api.py tests/test_e2e_hub.py tests/test_mcp.py -q` +Expected: FAIL — повторный `claim` даёт `None`, нет `MAX_DELIVERIES`, нет `redelivered`. + +- [ ] **Step 3: Implement** — `src/jira_hub/queue.py`: + +1. Docstring модуля: + +```python +"""Очередь команд: пакеты, выдача агенту по одной, отчёты, отмена, повтор, сверка с зеркалом, автообновление. +Новых команд хаб сам не создаёт: повтор после failed — новый пакет по запросу человека. Команду, выданную агенту и +оставшуюся без отчёта, хаб довыдаёт при следующем claim — это доставка той же выдачи (bridge-reliability, решение 1).""" +``` + +2. Константы: удалить `STALE_MINUTES`; добавить + `MAX_DELIVERIES = 3 # выдач без отчёта, после которых команда — failed (bridge-reliability, решение 2)`. +3. Удалить функции `_definer_stale` и `_fresh_claimed_exists`. +4. В `_next` удалить `blocked_batches` и обход зависшего определителя; блок `if pending:` становится + `return None # создание ещё не завершено — ждём (одна в полёте)`: + +```python +def _next(conn) -> dict | None: + rows = conn.execute("SELECT c.* FROM command c JOIN batch b ON b.id = c.batch_id " + "WHERE c.status = 'queued' AND b.cancelled_at IS NULL ORDER BY b.created_at, b.id, c.seq").fetchall() + for row in rows: + cmd = _cmd(row) + needed = C.temp_keys_used(cmd) + if needed: + mapping = _mapping(conn, cmd["batch_id"]) + unresolved = [k for k in needed if k not in mapping] + if any(_definer_status(conn, cmd["batch_id"], k) not in TERMINAL for k in unresolved): + return None # создание ещё не завершено — ждём (одна в полёте) + cmd["params"] = C.substitute(cmd["type"], cmd["params"], mapping) + if cmd["type"] == "refresh": + cmd["params"]["keys"] = [k for k in cmd["params"].get("keys", []) if not C.is_temp(k)] + unresolved = [] if cmd["params"]["keys"] else unresolved + if unresolved: + conn.execute("UPDATE command SET status = 'skipped', finished_at = ?, error = ? WHERE id = ?", + (db.now(), f"задача {unresolved[0]} не создана", cmd["id"])) + continue + return cmd + return None +``` + +5. Новая функция перед `claim`: + +```python +def _redeliver(conn, now) -> dict | None: + """Самая старая взятая команда без отчёта — выдать снова. Агент приходит за новой командой, только когда своей + незавершённой нет, значит выданную он не получил или потерял. После MAX_DELIVERIES выдач — failed.""" + while True: + row = conn.execute("SELECT * FROM command WHERE status = 'claimed' ORDER BY claimed_at, id LIMIT 1").fetchone() + if row is None: + return None + cmd = _cmd(row) + if cmd["deliveries"] < MAX_DELIVERIES: + return cmd + conn.execute("UPDATE command SET status = 'failed', finished_at = ?, error = ? WHERE id = ?", + (now, f"агент не отчитался после {MAX_DELIVERIES} выдач", cmd["id"])) + conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1 AND current_command_id = ?", (cmd["id"],)) + log.warning("команда %s: агент не отчитался после %s выдач — failed", cmd["id"], MAX_DELIVERIES) +``` + +6. `claim` — тело транзакции: + +```python +def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None) -> dict | None: + now = db.now() + to_backup = None + with db.transaction(conn): + conn.execute("UPDATE agent_status SET last_seen_at = ?, agent_version = COALESCE(?, agent_version), " + "settings_version = COALESCE(?, settings_version), last_jira_call_at = COALESCE(?, last_jira_call_at) WHERE id = 1", + (now, None if agent_version is None else str(agent_version), settings_version, last_jira_call_at)) + cmd = _redeliver(conn, now) + if cmd is not None: + pause = int(settings.pacing(conn).get(cmd["type"], 0)) + conn.execute("UPDATE command SET claimed_at = ?, pause_seconds = ?, deliveries = deliveries + 1 WHERE id = ?", + (now, pause, cmd["id"])) + conn.execute("UPDATE agent_status SET current_command_id = ? WHERE id = 1", (cmd["id"],)) + log.info("команда %s выдана снова (выдача %s)", cmd["id"], cmd["deliveries"] + 1) + else: + cmd = _next(conn) + if cmd is None: + conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1") + return None + pause = int(settings.pacing(conn).get(cmd["type"], 0)) + first = conn.execute("SELECT COUNT(*) AS n FROM command WHERE batch_id = ? AND status != 'queued'", + (cmd["batch_id"],)).fetchone()["n"] == 0 + has_writes = conn.execute("SELECT 1 FROM command WHERE batch_id = ? AND type != 'refresh' LIMIT 1", + (cmd["batch_id"],)).fetchone() is not None + conn.execute("UPDATE command SET status = 'claimed', claimed_at = ?, pause_seconds = ?, params = ?, deliveries = 1 " + "WHERE id = ?", (now, pause, _dump(cmd["params"]), cmd["id"])) + conn.execute("UPDATE agent_status SET current_command_id = ? WHERE id = 1", (cmd["id"],)) + if cmd["type"] == "refresh": + p = cmd["params"] + conn.execute("INSERT INTO sync_run(started_at, kind, query, command_id) VALUES (?, ?, ?, ?)", + (now, "jql" if p.get("jql") else "keys", p.get("jql") or ",".join(p.get("keys", [])), cmd["id"])) + if first and has_writes: + to_backup = f"batch-{cmd['batch_id']}" + # дальше без изменений: резервная копия вне транзакции и return +``` + + Хвост функции (`if to_backup and backup: …` и `return {...}`) оставить как есть. +7. `agent_view` — вместо блока `cutoff`/`stuck`: + +```python + d["redelivered"] = [dict(r) for r in conn.execute( + "SELECT id, type, claimed_at, deliveries FROM command WHERE status = 'claimed' AND deliveries > 1 ORDER BY id")] +``` + +8. `maybe_auto_refresh` — условие занятой очереди: + +```python + if conn.execute("SELECT 1 FROM command WHERE status IN ('queued', 'claimed') LIMIT 1").fetchone(): + return None +``` + +9. `src/jira_hub/mcp_server.py::fmt_agent` — цикл по `stuck` заменить на + +```python + for s in a.get("redelivered", []): + out.append(f"выдана повторно: #{s['id']} {s['type']}, выдач {s['deliveries']}, последняя {s['claimed_at']}") +``` + +10. Документы: + - `CLAUDE.md`, строка «Хаб не повторяет команды сам…» → `- Хаб не создаёт команды сам: повтор после failed — новая + команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при + следующем claim (до 3 выдач, затем failed).` + - `ARCHITECTURE.md`, строка «`queue` не повторяет команды…» → `- \`queue\` не создаёт команд сам: повтор после + failed — новый пакет. Выданная без отчёта команда довыдаётся при следующем \`claim\` (до 3 выдач, затем failed).` + - `README.md`, раздел «Эксплуатация», пункт «Команда «нет ответа от агента»…» заменить на: + `- Довыдача: если агент пришёл за новой командой, а выданная ему осталась без отчёта (ответ потерян, агент + перезапущен), хаб выдаёт её снова; \`agent_status\` показывает такие команды с числом выдач. После 3 выдач без + отчёта команда становится \`failed\` («агент не отчитался после 3 выдач»), очередь идёт дальше; повтор — + \`command_retry\`. Отменить взятую команду: \`POST /commands/{id}/cancel\` с \`{"force": true}\`.` + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest -q` — весь набор зелёный. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/reliability add -A src tests CLAUDE.md ARCHITECTURE.md README.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/reliability commit -m "feat(queue): довыдача команды без отчёта, предел 3 выдачи; R-13 и «свежая claimed» удалены (#9) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Отчёты и пакеты — 409 по невыданной, ключ из позднего отчёта, дубль темы + +**Files:** +- Modify: `src/jira_hub/queue.py` (`NotClaimedError`, `report_result`) +- Modify: `src/jira_hub/api.py` (`h_agent_result`) +- Modify: `src/jira_hub/commands.py` (`validate`) +- Test: `tests/test_queue.py`, `tests/test_api.py`, `tests/test_commands.py` + +**Interfaces:** +- Consumes: `queue.claim` (Task 2). +- Produces: `queue.NotClaimedError(Exception)`; API `POST /agent/commands/{id}/result` → 409 `{"error": "not_claimed"}` + для `queued`; `CommandError` с текстом `повторяется тема ensure_issue: «…»`. + +- [ ] **Step 1: Write the failing tests** + +`tests/test_queue.py`: + +```python +def test_report_for_queued_command_rejected(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1")]) + cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"] + with pytest.raises(queue.NotClaimedError): + queue.report_result(conn, cid, "done") + assert queue.get_command(conn, cid)["status"] == "queued" + + +def test_late_report_after_force_cancel_keeps_issue_key(): + conn = fresh() + mirror.set_note(conn, "new:7", {"fact_status": "В работе"}, "import") + queue.create_batch(conn, "x", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}]) + c = queue.claim(conn) + cancelled = queue.cancel_command(conn, c["id"], force=True) + late = queue.report_result(conn, c["id"], "done", result={"key": "A-20", "created": True}, jira_calls=3) + assert late["status"] == "cancelled" and late["result"] == {"key": "A-20", "created": True} and late["jira_calls"] == 3 + assert late["finished_at"] == cancelled["finished_at"] + assert mirror.get_note(conn, "A-20")["fact_status"] == "В работе" and mirror.get_note(conn, "new:7") is None + again = queue.report_result(conn, c["id"], "done", result={"key": "A-21"}) + assert again["result"] == {"key": "A-20", "created": True} # второй поздний отчёт ничего не меняет + + +def test_late_failed_report_after_force_cancel_changes_nothing(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}]) + c = queue.claim(conn) + queue.cancel_command(conn, c["id"], force=True) + late = queue.report_result(conn, c["id"], "failed", error="HTTP 400") + assert late["status"] == "cancelled" and late["result"] is None and late["error"] is None +``` + +`tests/test_commands.py` — в параметры `test_validate_errors` (список `bad, msg`) добавить случай; если тест +параметризован другим способом, добавить отдельный тест: + +```python +def test_validate_rejects_duplicate_issue_summary(): + cmds = [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Отчёт"}}, + {"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": " Отчёт "}}, + {"type": "ensure_issue", "params": {"temp_key": "new:3", "issue_type": "task", "summary": "отчёт"}}] + with pytest.raises(commands.CommandError) as e: + commands.validate(cmds) + assert "повторяется тема ensure_issue: «Отчёт»" in str(e.value) + commands.validate([cmds[0], cmds[2]]) # регистр различается — пакет допустим +``` + +(`import pytest` и `from jira_hub import commands` в файле уже есть; если нет — добавить.) + +`tests/test_api.py`: + +```python +def test_result_for_queued_command_is_409(hub): + b = hub["client"].post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]}) + cid = hub["client"].get(f"/batches/{b['id']}")["commands"][0]["id"] + with pytest.raises(HubError) as e: + hub["agent"].post(f"/agent/commands/{cid}/result", {"status": "done"}) + assert e.value.status == 409 and e.value.error == "not_claimed" + + +def test_duplicate_summary_batch_is_422(hub): + with pytest.raises(HubError) as e: + hub["client"].post("/batches", {"title": "x", "commands": [ + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Шахматка: комментарии"}}, + {"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": "Шахматка: комментарии"}}]}) + assert e.value.status == 422 and "Шахматка: комментарии" in e.value.detail +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_queue.py tests/test_commands.py tests/test_api.py -q` +Expected: FAIL — нет `NotClaimedError`, отчёт по `queued` принят, поздний ключ потерян, дубль темы проходит. + +- [ ] **Step 3: Implement** + +`src/jira_hub/queue.py` — рядом с `ClaimedError`: + +```python +class NotClaimedError(Exception): + """Отчёт по команде, которую агенту не выдавали (status = queued) — API отвечает 409.""" +``` + +В `report_result` после `if cmd is None: raise KeyError(command_id)`: + +```python + if cmd["status"] == "queued": + raise NotClaimedError(f"команда {command_id} не выдана") + if cmd["status"] in TERMINAL: + # поздний отчёт о созданной задаче по отменённой команде: ключ не теряем, статус не трогаем (M-5) + if (cmd["status"] == "cancelled" and cmd["type"] == "ensure_issue" and status == "done" + and result and result.get("key") and not cmd["result"]): + conn.execute("UPDATE command SET result = ?, jira_calls = ?, rate_limited = ? WHERE id = ?", + (_dump(result), int(jira_calls or 0), int(rate_limited or 0), command_id)) + mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"]) + log.warning("команда %s отменена, но агент создал %s — ключ записан в результат", command_id, result["key"]) + return get_command(conn, command_id) + return cmd # идемпотентно; cancelled не затирается +``` + +(прежняя строка `if cmd["status"] in TERMINAL: return cmd` заменяется этим блоком.) + +`src/jira_hub/api.py::h_agent_result` — добавить ветку до `except ValueError`: + +```python + except queue.NotClaimedError as e: + raise ApiError(409, "not_claimed", str(e)) +``` + +`src/jira_hub/commands.py::validate` — перед `return out`: + +```python + summaries = [c["params"]["summary"].strip() for c in out if c["type"] == "ensure_issue"] + repeated = sorted({s for s in summaries if summaries.count(s) > 1}) + if repeated: + raise CommandError("повторяется тема ensure_issue: " + ", ".join(f"«{s}»" for s in repeated) + + " — в Jira получится одна задача на два временных ключа") +``` + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest -q` — весь набор зелёный. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/reliability add -A src tests +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/reliability commit -m "feat(queue): 409 на отчёт по невыданной команде, ключ из позднего отчёта по отменённой ensure_issue, 422 на дубль темы (#9) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: `ensure_issue` не подтверждается по состоянию; `reversed_links` в отчёте импорта + +**Files:** +- Modify: `src/jira_hub/commands.py` (`check_confirmed`) +- Modify: `src/jira_hub/queue.py` (`reconcile`) +- Modify: `src/jira_hub/importer.py` (`_import`, docstring модуля при необходимости) +- Modify: `README.md` (описание отчёта `import`) +- Test: `tests/test_commands.py`, `tests/test_queue.py`, `tests/test_importer.py` + +**Interfaces:** +- Produces: `commands.check_confirmed(conn, cmd, link_types) -> tuple[bool, dict | None]` (без `reporter`); отчёт + импорта `{notes, expected_links, missing_keys, missing_links, reversed_links}`. + +- [ ] **Step 1: Write the failing tests** + +`tests/test_commands.py`: удалить `test_check_confirmed_issue_by_summary`, `test_ensure_issue_without_reporter_never_confirmed`, +`test_ensure_issue_older_than_7_days_not_confirmed`, `test_ensure_issue_reporter_filter`, +`test_ensure_issue_two_matches_takes_newest`; добавить: + +```python +def test_ensure_issue_never_confirmed_by_state(): + """bridge-reliability, решение 4: потерянный отчёт ensure_issue закрывается довыдачей, не сверкой с зеркалом.""" + conn = _mirror() + conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'") + assert commands.check_confirmed(conn, _ensure_issue("Тема"), LT) == (False, None) +``` + +Проверить, что в параметрах `test_check_confirmed` нет случаев `ensure_issue` с ожиданием `True`; если есть — поменять +ожидание на `False`. + +`tests/test_queue.py`: удалить `test_reconcile_respects_reporter_setting`; в +`test_reconcile_does_not_confirm_ensure_issue_without_reporter` убрать из docstring упоминание настройки (`"""ensure_issue +по состоянию не подтверждается: совпадение темы не закрывает команду, заметка new:N не переезжает."""`); +`test_reconcile_does_not_overwrite_finished_command` переписать на `ensure_sprint`: + +```python +def test_reconcile_does_not_overwrite_finished_command(monkeypatch): + """M-1: отчёт агента/отмена между check_confirmed и UPDATE не перезаписываются.""" + conn = fresh() + queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}]) + c = queue.claim(conn) + mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": []}], []) + real = queue.C.check_confirmed + + def racing(*a, **kw): + out = real(*a, **kw) + queue.cancel_command(conn, c["id"], force=True) # гонка: отмена после проверки + return out + + monkeypatch.setattr(queue.C, "check_confirmed", racing) + assert queue.reconcile(conn) == [] + assert queue.get_command(conn, c["id"])["status"] == "cancelled" +``` + +`tests/test_importer.py`: + +```python +def test_reversed_link_reported_separately(tmp_path): + make_package(tmp_path) + write_csv(tmp_path / "links.csv", ["Откуда", "Тип", "Куда", "Пояснение (внутр.)"], + [["AURORA-1533", "связанные", "AURORA-1586", "в Jira стоит наоборот"], + ["AURORA-1533", "блокирует начало", "AURORA-1416", "нет совсем"]]) + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"}) + mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}], + [{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}]) + rep = importer.import_package(conn, tmp_path) + assert rep["reversed_links"] == [{"source": "AURORA-1533", "target": "AURORA-1586", "link_type": "ASUPR_Связанные"}] + assert rep["missing_links"] == [{"source": "AURORA-1533", "target": "AURORA-1416", "link_type": "ASUPR_Блокер_Начала"}] +``` + +В `test_import_notes_and_report` добавить `assert rep["reversed_links"] == []`; в `test_missing_csv_files_give_empty_report` +ожидаемый словарь дополнить `"reversed_links": []`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_commands.py tests/test_queue.py tests/test_importer.py -q` +Expected: FAIL в `test_reversed_link_reported_separately` и проверках `reversed_links` (ключа нет). +`test_ensure_issue_never_confirmed_by_state` может пройти уже сейчас (без `reporter` подтверждения и так нет) — это +закрепляющий тест, он должен остаться зелёным после удаления ветки. + +- [ ] **Step 3: Implement** + +`src/jira_hub/commands.py::check_confirmed`: + +```python +def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]: + """Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment и refresh по состоянию не подтверждаются: + потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля (bridge-reliability, + решение 4); совпадение темы с задачей неизвестного автора не доказательство.""" + ctype, p = cmd["type"], cmd["params"] + if ctype in ("ensure_issue", "ensure_comment", "refresh"): + return False, None + issue = mirror.get_issue(conn, p.get("key") or p.get("source")) + # дальше без изменений +``` + +(ветка `if ctype == "ensure_issue": …` удаляется целиком.) + +`src/jira_hub/queue.py::reconcile` — удалить строки с `reporter = settings.get(conn, "reporter")` и комментарием-продолжением; +вызов: `ok, result = C.check_confirmed(conn, cmd, link_types)`. Ветку `if cmd["type"] == "ensure_issue" and result: +mirror.rename_note(...)` удалить (ensure_issue больше не подтверждается). + +`src/jira_hub/importer.py` — в `_import` блок связей: + +```python + link_types = settings.get(conn, "link_types") or {} + expected, missing_links, reversed_links = [], [], [] + for r in _rows(folder / "links.csv"): + src = new_keys.get(_clean(r.get("Откуда")) or "", _clean(r.get("Откуда"))) + dst = new_keys.get(_clean(r.get("Куда")) or "", _clean(r.get("Куда"))) + kind = (_clean(r.get("Тип")) or "").lower() + if not src or not dst or src.startswith("new:") or dst.startswith("new:"): + continue + link = {"source": src, "target": dst, "link_type": link_types.get(kind, kind)} + expected.append(link) + issue = mirror.get_issue(conn, src) + if issue and any(l["target_key"] == dst and l["link_type"] == link["link_type"] for l in issue["links_out"]): + continue + if issue and any(l["source_key"] == dst and l["link_type"] == link["link_type"] for l in issue["links_in"]): + reversed_links.append(link) # связь есть, но в Jira стоит в обратную сторону (O-3) + else: + missing_links.append(link) +``` + +и в `return` добавить `"reversed_links": reversed_links`. + +`README.md` — в абзаце про `jira-hub import` после «`missing_links` (ожидаемые связи, которых нет)» дописать: +`, \`reversed_links\` (ожидаемые связи, которые в Jira стоят в обратную сторону — для «Связанных» это та же связь)`. + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest -q` — весь набор зелёный. Затем `grep -rn "reporter" src/jira_hub/queue.py src/jira_hub/commands.py` — +пусто. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/reliability add -A src tests README.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/reliability commit -m "feat: ensure_issue закрывается довыдачей, не сверкой; reversed_links в отчёте импорта (R-11, O-3, #9) + +Co-Authored-By: Claude Opus 5.5 " +```