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

778 lines
46 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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