sdlc-hub/docs/superpowers/plans/2026-10-13-bridge-reliability-hub.md
Andrey Epifancev 234ea75d4f docs(plan): план хаба bridge-reliability (#9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:36:33 +04:00

46 KiB
Raw Permalink Blame History

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 <noreply@anthropic.com>; суффикс (#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 заменить проверку версии и добавить тесты миграции:

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 добавить шаги миграций:
# Шаги миграции: версия → 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'"],   # уже выданные — одна выдача
}
  1. В connect заменить migrate(conn) на migrate(conn, None if is_memory else path).
  2. Заменить migrate:
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
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 <noreply@anthropic.com>"

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 # одна команда в полёте на
    again = queue.claim(conn)                                          # ответ потерян — та же команда снова
    assert again["id"] == c["id"] and again["pause_seconds"] == 120
    assert queue.get_command(conn, c["id"])["deliveries"] == 2
  1. В test_claim_is_exclusive заменить последние две проверки на
    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)
  1. Удалить test_stale_claimed_does_not_block и test_stale_definer_does_not_block_other_batches.
  2. В test_cancel_and_retry заменить assert queue.claim(conn) is None на assert queue.claim(conn)["id"] == c["id"] # взятая из отменённого пакета довыдаётся.
  3. В test_fresh_definer_still_blocks_queue заменить assert queue.claim(conn) is None # одна в полёте на assert queue.claim(conn)["id"] == first["id"] # довыдача определителя, зависимые ждут.
  4. test_reconcile_confirms_by_state переписать (две claimed одновременно бывают только в старых данных — моделируем SQL):
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"
  1. В test_auto_refresh_conditions в конец добавить:
    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 без отчёта — очередь занята
  1. Добавить новые тесты:
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
  1. В tests/test_api.py::test_agent_flow заменить assert a.post("/agent/claim", {})["command"] is None # одна в полёте, долгий запрос выключен на assert a.post("/agent/claim", {})["command"]["id"] == cmd["id"] # ответ потерян — довыдача той же команды.
  2. В 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 не выдаётся.
  3. В tests/test_mcp.py::test_formatters заменить "stuck": [] на "redelivered": [] и добавить:
    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 модуля:
"""Очередь команд: пакеты, выдача агенту по одной, отчёты, отмена, повтор, сверка с зеркалом, автообновление.
Новых команд хаб сам не создаёт: повтор после failed — новый пакет по запросу человека. Команду, выданную агенту и
оставшуюся без отчёта, хаб довыдаёт при следующем claim — это доставка той же выдачи (bridge-reliability, решение 1)."""
  1. Константы: удалить STALE_MINUTES; добавить MAX_DELIVERIES = 3 # выдач без отчёта, после которых команда — failed (bridge-reliability, решение 2).
  2. Удалить функции _definer_stale и _fresh_claimed_exists.
  3. В _next удалить blocked_batches и обход зависшего определителя; блок if pending: становится return None # создание ещё не завершено — ждём (одна в полёте):
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
  1. Новая функция перед claim:
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)
  1. claim — тело транзакции:
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:

    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")]
  1. maybe_auto_refresh — условие занятой очереди:
    if conn.execute("SELECT 1 FROM command WHERE status IN ('queued', 'claimed') LIMIT 1").fetchone():
        return None
  1. src/jira_hub/mcp_server.py::fmt_agent — цикл по stuck заменить на
    for s in a.get("redelivered", []):
        out.append(f"выдана повторно: #{s['id']} {s['type']}, выдач {s['deliveries']}, последняя {s['claimed_at']}")
  1. Документы:
    • 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
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 <noreply@anthropic.com>"

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:

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) добавить случай; если тест параметризован другим способом, добавить отдельный тест:

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:

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:

class NotClaimedError(Exception):
    """Отчёт по команде, которую агенту не выдавали (status = queued) — API отвечает 409."""

В report_result после if cmd is None: raise KeyError(command_id):

        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:

    except queue.NotClaimedError as e:
        raise ApiError(409, "not_claimed", str(e))

src/jira_hub/commands.py::validate — перед return out:

    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
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 <noreply@anthropic.com>"

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; добавить:

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:

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:

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:

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 блок связей:

    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
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 <noreply@anthropic.com>"