feat: надёжность моста — довыдача команды без отчёта, миграция v2, отчёты (#9) #10
@ -7,7 +7,7 @@
|
||||
| Модуль | Ответственность | Зависит от |
|
||||
|---|---|---|
|
||||
| `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/` | stdlib |
|
||||
| `db` | схема SQLite, миграции, `connect` | stdlib |
|
||||
| `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect` | `backup` |
|
||||
| `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` |
|
||||
| `mirror` | зеркало задач и связей, `apply_state`, заметки, фильтры | `db` |
|
||||
| `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` |
|
||||
@ -30,7 +30,7 @@
|
||||
## Границы
|
||||
- Зеркало пишет только `mirror.apply_state` по запросу агента (и заметки через `PUT /issues/{key}/note`); из очереди и API задач напрямую не правят.
|
||||
- Хаб не знает пароль Jira и не обращается к Jira; `init` не переносит `auth`, `base_url`, `verify_ssl`, `ca_bundle`.
|
||||
- `queue` не повторяет команды: нет автоматических ретраев, повтор — новый пакет.
|
||||
- `queue` не создаёт команд сам: повтор после failed — новый пакет. Выданная без отчёта команда довыдаётся при следующем `claim` (до 3 выдач, затем failed).
|
||||
- `mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`.
|
||||
- `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api`, `client` и `relay` (реле — клиент своего же HTTP).
|
||||
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
# jira-hub
|
||||
|
||||
## Что это
|
||||
Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; текущего change нет (последний — `openspec/changes/archive/2026-10-13-folder-transport/`).
|
||||
Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; текущий change — `openspec/changes/bridge-reliability/`.
|
||||
|
||||
## Стек и ограничения
|
||||
- Python 3.13, uv, hatchling, pytest; HTTP на stdlib (`http.server`), БД — SQLite (stdlib), MCP — пакет `mcp` (закреплён `<2`, рулинг R-10).
|
||||
- Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента.
|
||||
- Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь.
|
||||
- Хаб не повторяет команды сам: повтор — новая команда по запросу человека/интерфейса.
|
||||
- Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed).
|
||||
- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`. Порт по умолчанию 8790.
|
||||
- Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное.
|
||||
|
||||
## Архитектура
|
||||
- `src/jira_hub/` — весь код пакета, без разделения на ядро и адаптеры; чистая логика (`commands`, `mirror`, `queue`, `settings`) принимает соединение SQLite параметром.
|
||||
- `db` (схема, миграции) ← `settings`, `mirror`, `commands` ← `queue` ← `api` (HTTP, маршруты в `ROUTES`, фоновый `tick`, реле общей папки `relay` (поток в `serve`, настройка `relay_dir`)) ← `cli`.
|
||||
- `client` (HTTP-клиент хаба) ← `mcp_server` (stdio, инструменты MCP); `importer` → `mirror`/`settings`; `backup` вызывается из `api`.
|
||||
- `client` (HTTP-клиент хаба) ← `mcp_server` (stdio, инструменты MCP); `importer` → `mirror`/`settings`; `backup` вызывается из `api` и из `db` (копия перед миграцией схемы).
|
||||
- Подробная карта модулей и границ — `ARCHITECTURE.md`.
|
||||
|
||||
## Команды
|
||||
|
||||
@ -40,7 +40,7 @@ uv run jira-hub import ../admin/jira-sync
|
||||
|
||||
Переносит из CSV заметки (статус по факту, вид работы и т. п.) и выводит отчёт JSON:
|
||||
`notes` (сколько заметок записано), `expected_links`, `missing_keys` (ключи из CSV, которых нет в зеркале) и
|
||||
`missing_links` (ожидаемые связи, которых нет). Запускать после первого `refresh` с `full: true`: пока зеркало пусто,
|
||||
`missing_links` (ожидаемые связи, которых нет), `reversed_links` (ожидаемые связи, которые в Jira стоят в обратную сторону — для «Связанных» это та же связь). Запускать после первого `refresh` с `full: true`: пока зеркало пусто,
|
||||
все ключи будут в `missing_keys`. Заметки к ещё не загруженным задачам сохраняются.
|
||||
|
||||
## MCP в Claude
|
||||
@ -165,9 +165,10 @@ curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint=
|
||||
- Автообновление: если `auto_refresh_minutes > 0` (по умолчанию 60), агент на связи и очередь пуста дольше интервала,
|
||||
хаб ставит пакет «Автообновление».
|
||||
- Настройки: `PUT /settings` или инструмент `settings_set`; защищены `settings_version`, `schema_version`, `tokens`.
|
||||
- Команда «нет ответа от агента»: команда в `claimed` без отчёта. Дождитесь агента (перезапущенный агент отчитается) или
|
||||
отмените: `POST /commands/{id}/cancel` с `{"force": true}`. Хаб сам команды не повторяет; если факт уже виден после
|
||||
`refresh`, команда становится `confirmed_by_state`. Повтор: `command_retry`.
|
||||
- Довыдача: если агент пришёл за новой командой, а выданная ему осталась без отчёта (ответ потерян, агент
|
||||
перезапущен), хаб выдаёт её снова; `agent_status` показывает такие команды с числом выдач. После 3 выдач без
|
||||
отчёта команда становится `failed` («агент не отчитался после 3 выдач»), очередь идёт дальше; повтор —
|
||||
`command_retry`. Отменить взятую команду: `POST /commands/{id}/cancel` с `{"force": true}`.
|
||||
- Автозапуск `serve` через launchd: `~/Library/LaunchAgents/ru.aepif.jira-hub.plist`
|
||||
|
||||
```xml
|
||||
|
||||
777
docs/superpowers/plans/2026-10-13-bridge-reliability-hub.md
Normal file
777
docs/superpowers/plans/2026-10-13-bridge-reliability-hub.md
Normal file
@ -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 <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>"
|
||||
```
|
||||
2
openspec/changes/bridge-reliability/.openspec.yaml
Normal file
2
openspec/changes/bridge-reliability/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-13
|
||||
130
openspec/changes/bridge-reliability/design.md
Normal file
130
openspec/changes/bridge-reliability/design.md
Normal file
@ -0,0 +1,130 @@
|
||||
## Context
|
||||
|
||||
Мост в работе с 13.10.2026: хаб на Mac mini (launchd, база `~/.local/share/jira-hub/hub.sqlite` с живыми данными:
|
||||
193 задачи, 139 заметок, пакеты #1–#3), агент v2 на виртуалке, обмен через общую папку (реле в `serve`).
|
||||
|
||||
Как устроено сейчас (по коду `main` на 799b0e2 / d2cb529):
|
||||
- `queue.claim` в одной транзакции: обновляет `agent_status`, при наличии `claimed` моложе 10 минут
|
||||
(`_fresh_claimed_exists`, `STALE_MINUTES`) отдаёт пусто; иначе `_next` выбирает `queued` команду, ставит `claimed`.
|
||||
`_next` обходит пакет, чей определитель `new:N` висит в `claimed` дольше 10 минут (R-13).
|
||||
- `report_result` для терминального статуса возвращает команду без изменений (результат отбрасывается); статус `queued`
|
||||
не проверяется.
|
||||
- `reconcile` после отчёта `refresh` подтверждает `claimed` команды по зеркалу (`check_confirmed`); для `ensure_issue`
|
||||
нужен `reporter` из настройки, которая никогда не задаётся, — не подтверждается никогда.
|
||||
- Отчёт импорта: связь из `links.csv` «есть», только если в зеркале `source→target` того же типа.
|
||||
- Агент: `JiraClient.last_call_at` (monotonic) только в памяти; `Runner.wait_pause` без `last_call_at` не ждёт;
|
||||
`recover` ставит `cmd["pause_seconds"] = 0` и вызывает `execute`, который пишет `claimed` ещё раз. Агент приходит за
|
||||
новой командой (`claim`) только когда своей незавершённой нет: каждый путь `execute` кончается подтверждённым отчётом
|
||||
или остановкой процесса, `recover` выполняется до первого `claim`. Инвариант держится только для одного процесса
|
||||
агента на журнал — второй процесс получил бы довыданную команду одновременно с первым (решение 13).
|
||||
- `db.migrate` только создаёт таблицы (`CREATE TABLE IF NOT EXISTS`) и пишет `schema_version = 1`; миграций по версии нет.
|
||||
`db.connect` вызывается на каждый запрос API и при старте.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
Goals: ни одна команда не застревает в `claimed` без пути восстановления, кроме явного предела выдач; созданная задача
|
||||
не теряется при отмене; после перезапуска агента темп Jira соблюдается; живая база мигрирует без потери данных; агент v2
|
||||
продолжает работать с новым хабом; отчёт импорта не даёт ложных `missing_links` для связей в обратную сторону.
|
||||
Non-goals — см. proposal.md.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Повторная выдача (решение владельца, вариант A).** Если при `claim` есть команды в `claimed`, хаб выдаёт самую
|
||||
старую из них (по `claimed_at`, затем `id`) снова: тот же `id`, те же (уже подставленные) параметры, `pause_seconds` по
|
||||
текущей политике темпа, новый `claimed_at`, `deliveries + 1`, `agent_status.current_command_id` = её `id`. Основание —
|
||||
инвариант агента «новый `claim` только без незавершённой команды»: раз агент пришёл, выданную он не получил или
|
||||
потерял. Все команды идемпотентны («проверь, потом сделай»), поэтому довыдача уже выполненной команды даёт `done` без
|
||||
записи; для `ensure_issue` агент находит свою задачу поиском дубля (`reporter = currentUser()`, тема, 7 дней). Это не
|
||||
повтор в смысле правила «хаб не повторяет команды сам»: новая команда не создаётся, решение о повторе после `failed`
|
||||
по-прежнему за человеком. Резервная копия перед первой командой пакета при довыдаче не делается (уже сделана).
|
||||
2. **Предел выдач — 3.** Если у самой старой `claimed` уже `deliveries >= 3`, хаб переводит её в `failed` с
|
||||
`error = "агент не отчитался после 3 выдач"`, `finished_at = now`, снимает `current_command_id` и выбирает дальше по тем
|
||||
же правилам (следующая `claimed` или `_next`). Защищает очередь от команды, на которой агент падает. Число 3 —
|
||||
константа `MAX_DELIVERIES`, не настройка.
|
||||
3. **Удаляются `_fresh_claimed_exists`, `_definer_stale`, `STALE_MINUTES` и обход R-13.** При довыдаче команда не может
|
||||
«зависнуть» без агента: если агента нет, очередь всё равно стоит. `maybe_auto_refresh` вместо «свежей claimed»
|
||||
проверяет «есть любая claimed». `agent_view.stuck` заменяется на `redelivered` — команды в `claimed` с
|
||||
`deliveries > 1` (`id`, `type`, `claimed_at`, `deliveries`).
|
||||
4. **R-11 закрывается довыдачей.** Подтверждение по состоянию остаётся (`reconcile` после `refresh`) для команд, которые
|
||||
окажутся в `claimed` одновременно с выполненным `refresh` (две `claimed` после миграции живой базы, гонка двух `claim`),
|
||||
но для `ensure_issue` оно не делается: `check_confirmed` для неё всегда «нет», параметр `reporter` и чтение настройки
|
||||
`reporter` удаляются. Совпадение темы с задачей неизвестного автора — не доказательство.
|
||||
5. **Миграция схемы v1 → v2.** `SCHEMA_VERSION = 2`; новая база создаётся сразу в v2 (`deliveries` в `CREATE TABLE`).
|
||||
Для существующей базы `migrate` читает `setting.schema_version`; если 1 — копия файла базы через
|
||||
`backup.make_backup(conn, <каталог базы>/backups, "schema-v1", keep=0)` (без ротации), затем в `transaction`: повторная
|
||||
проверка версии (параллельный `connect` мог успеть), `ALTER TABLE command ADD COLUMN deliveries INTEGER NOT NULL
|
||||
DEFAULT 0`, `UPDATE command SET deliveries = 1 WHERE status = 'claimed'`, `schema_version = 2`. Для `:memory:` копия не
|
||||
делается. `<каталог базы>/backups` совпадает с `paths.backups_dir()` при штатном `JIRA_HUB_HOME`.
|
||||
6. **Отчёты.** `report_result` по команде в `queued` — `NotClaimedError` (подкласс `ValueError` не годится: API уже
|
||||
отдаёт 422 на `ValueError`), API отвечает 409 `not_claimed`. Агент на 409 уже считает команду закрытой
|
||||
(`send_result`), правка агента не нужна. По команде в `cancelled` типа `ensure_issue` с `status = done` и
|
||||
`result.key`, если `result` у неё ещё пуст: `result`, `jira_calls`, `rate_limited` записываются, `finished_at` и статус
|
||||
не меняются, заметка `new:N` переименовывается на ключ. Для остальных терминальных статусов — без изменений, как сейчас.
|
||||
7. **Дубль темы в пакете — 422.** `validate` пакета отвергает две и более `ensure_issue` с одинаковой `summary` после
|
||||
`strip()`, перечисляя повторяющиеся темы. Регистр не нормализуется: поиск дубля агентом — точное сравнение.
|
||||
8. **Отчёт импорта: `reversed_links`.** Связь из `links.csv`, которой нет как `source→target` того же типа, но есть
|
||||
`target→source` того же типа, попадает в `reversed_links`, а не в `missing_links`. Симметричность типа хабу не нужна:
|
||||
человек видит, что связь в Jira есть, и решает сам. CLI выводит новое поле в том же JSON.
|
||||
9. **Агент: темп после перезапуска.** При старте `Runner` берёт максимальное `at` среди записей журнала (любого вида) и,
|
||||
если оно не в будущем, ставит `JiraClient.last_call_at = monotonic() − (now − at)`, `last_call_iso = at`. Запись
|
||||
`finished` пишется сразу после последнего вызова Jira команды, поэтому оценка не раньше настоящего вызова — ждём не
|
||||
меньше нужного. Пустой журнал или нечитаемое `at` — `last_call_at` пуст, как сейчас. `recover` не обнуляет
|
||||
`pause_seconds`: берёт значение из ответа `GET /agent/commands/{id}`.
|
||||
10. **Агент: `claimed` при восстановлении не пишется повторно.** `execute(cmd, journal_claimed=True)`; `recover` вызывает с
|
||||
`False` — запись `claimed`/`started` для этого id уже есть.
|
||||
11. **Версия агента 3**, `min_agent_version` не поднимается. Тела запросов агента не меняются.
|
||||
12. **Признак `online`.** Спека data-model меняется под код: `agent_status` отдаёт `online: false`, если `last_seen_at`
|
||||
старше 5 минут (решение без обсуждения, см. ниже).
|
||||
13. **Один процесс агента на журнал (финальное ревью, I-1).** `agent.py run` берёт исключительную блокировку
|
||||
`<журнал>.lock` (`fcntl.flock`, `LOCK_NB`); занята — сообщение «агент уже запущен…» и код 1. Без неё два процесса
|
||||
(например, при перезапуске через `nohup`/launchd без остановки старого) выполняли бы довыданную команду одновременно:
|
||||
идемпотентность «проверь, потом сделай» от одновременного выполнения не защищает (две `ensure_issue` могут не найти
|
||||
дубль друг друга). Агент v2 блокировки не имеет — при выкатке хаба убедиться, что процесс агента один.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- Нарушение инварианта агента (второй процесс с тем же журналом, будущая параллельность) превращает довыдачу в
|
||||
одновременное выполнение одной команды двумя исполнителями — для `ensure_issue` это возможный дубль задачи, для
|
||||
`ensure_comment` — два комментария. Закрыто блокировкой процесса в агенте v3 (решение 13); инвариант зафиксирован в
|
||||
спеке agent. Предел выдач (решение 2) защищает от потери доставки, но не от падения процесса агента на команде:
|
||||
восстановление по журналу берёт команду через `GET /agent/commands/{id}` и выдач не добавляет.
|
||||
- Довыдача после долгого простоя агента выполнит старую команду: если человек за это время поменял задачу вручную,
|
||||
команда «вернёт» желаемое значение. Так же вела бы себя команда в очереди; для отмены есть `cancel force`.
|
||||
- Гонка двух `claim` через папку (старый долгий запрос ещё ждёт в хабе, агент прислал новый): старый берёт команду, ответ
|
||||
никто не читает, новый её довыдаёт — `deliveries = 2` без реального сбоя. Предел 3 оставляет запас на один такой
|
||||
случай плюс одну настоящую недоставку.
|
||||
- Миграция живой базы — первая в проекте; страхуется копией перед миграцией и тестом на базе v1.
|
||||
- Оценка паузы из журнала по `at` старше настоящего последнего вызова, если агент упал во время `refresh` между страницами
|
||||
(последний вызов Jira позже последней записи). Пауза `refresh` — 2 с, ошибка мала.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
0. До перезапуска хаба: убедиться, что на виртуалке работает один процесс агента; посмотреть команды в `claimed`
|
||||
(`agent_status`) — все они после старта будут довыданы и выполнены через проверку; ненужные отменить с `force`.
|
||||
1. Хаб: merge → `git pull` в основном checkout → перезапуск launchd (`launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub`)
|
||||
→ в `backups/` копия `*-schema-v1.sqlite`, `schema_version = 2`; агент v2 продолжает работу.
|
||||
2. Агент v3: файл через общую папку на виртуалку, `check`, остановить v2 (дождаться «остановлен»), запустить v3.
|
||||
3. Проверка: повторный `jira-hub import admin/jira-sync` — три связи в `reversed_links`, `missing_links: 0`.
|
||||
Откат: предыдущий код хаба работает и на базе v2 (лишняя колонка `deliveries` с `DEFAULT 0` ему не мешает), поэтому
|
||||
достаточно вернуть код и перезапустить хаб. Копия `backups/*-schema-v1.sqlite` (ротации не подлежит) — только на случай
|
||||
повреждения базы: восстановление из неё теряет всё, записанное после миграции. Агент v3 работает с хабом v1 (тела
|
||||
запросов не менялись).
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
| Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|
|
||||
| 2026-10-13 | Спека data-model приводится к коду: признак `online` (bool), а не `offline` | порог 5 мин совпадает, `online` уже читают MCP и автообновление; правка кода дала бы лишнюю смену контракта | нет: клиентов с `offline` нет |
|
||||
| 2026-10-13 | Предел довыдач — 3, константа кода | одной недоставки на сбой папки хватает с запасом; «ядовитая» команда не держит очередь | при 3 сбоях подряд на одной команде — ложный `failed`, лечится `command_retry` |
|
||||
| 2026-10-13 | Миграция v1→v2 с копией базы перед ней; существующим `claimed` — `deliveries = 1` | первая миграция живой базы; честный счёт выдач | нет |
|
||||
| 2026-10-13 | Дубль темы в пакете — после `strip`, без нормализации регистра | агент ищет дубль точным сравнением `summary` | темы, различающиеся только регистром, пройдут — так же их различит агент |
|
||||
| 2026-10-13 | Оценка последнего вызова Jira после перезапуска — максимальное `at` журнала | `finished` пишется сразу после последнего вызова; без новой записи на каждый вызов Jira | см. Risks: `refresh` между страницами, ошибка ≤ 2 с |
|
||||
| 2026-10-13 | Поздний отчёт по отменённой `ensure_issue`: результат пишется, статус `cancelled` и `finished_at` сохраняются | зависимые команды уже `skipped`, смена статуса задним числом запутала бы пакет; ключ виден в результате и в заметке | нет |
|
||||
| 2026-10-13 | `report_result` по `queued` — 409 `not_claimed` | агент уже трактует 409 как «закрыто», правка агента не нужна | нет |
|
||||
| 2026-10-13 | Связи «в обратную сторону» в отчёте импорта — отдельный список `reversed_links` без учёта симметричности типа | хаб не знает симметричность; человек видит, что связь есть | несимметричная связь, поставленная наоборот, не попадёт в `missing_links` — видна в `reversed_links` |
|
||||
| 2026-10-13 | Довыдача не делает резервную копию базы | копия перед первой командой пакета уже сделана при первой выдаче | нет |
|
||||
| 2026-10-13 | Шаг плана «автообновление при claimed без отчёта» вынесен из `test_auto_refresh_conditions` в отдельный тест (R-1) | в конце исходного теста `auto_refresh_minutes = 0`, проверка прошла бы и без правки | нет |
|
||||
| 2026-10-13 | В `CLAUDE.md` и `ARCHITECTURE.md` хаба отмечено, что `db` вызывает `backup` (копия перед миграцией), и указан текущий change (R-2) | иначе документы противоречат коду | нет |
|
||||
| 2026-10-13 | Копии `*-schema-v*.sqlite` исключены из ротации резервных копий (R-3, финальное ревью M-1) | ротация копий пакетов (`keep = 30`) удалила бы копию перед миграцией первой | копии перед миграциями копятся без ограничения — по одной на миграцию |
|
||||
| 2026-10-13 | Один процесс агента на журнал — блокировка `<журнал>.lock` в агенте v3 (R-4, финальное ревью I-1; подробно — решение 13) | довыдача отдаёт команду любому пришедшему процессу | нет: второй процесс просто не стартует |
|
||||
|
||||
66
openspec/changes/bridge-reliability/proposal.md
Normal file
66
openspec/changes/bridge-reliability/proposal.md
Normal file
@ -0,0 +1,66 @@
|
||||
## Why
|
||||
|
||||
Мост работает в ежедневном режиме с 13.10.2026 (агент на виртуалке за VPN «Континент», обмен через общую папку UTM).
|
||||
Отложенные находки `bridge-core` (#1) и `folder-transport` (#5) содержат несколько мест, где живая работа может привести
|
||||
к дублю задачи в Jira, к команде, зависшей в `claimed` без пути восстановления, или к лишним запросам к Jira банка:
|
||||
|
||||
- **M-2** — ответ на `claim` потерян (сбой папки, перезапуск агента во время долгого запроса): команда навсегда `claimed`,
|
||||
в журнале агента её нет; очередь стоит 10 минут, затем команда требует force-cancel или повтора.
|
||||
- **R-11 / O-9** — потерянный отчёт `ensure_issue` не закрывается подтверждением по состоянию (хаб не знает логин Jira
|
||||
агента); каждый такой случай требует ручного действия.
|
||||
- **M-5** — поздний отчёт по force-отменённой `ensure_issue` теряет ключ созданной задачи.
|
||||
- **M-11** — отчёт по команде в `queued` принимается; две `ensure_issue` с одной темой в пакете дают одну задачу на два
|
||||
временных ключа.
|
||||
- **O-3** — связь симметричного типа («Связанные»), стоящая в Jira в обратную сторону, попадает в `missing_links` отчёта
|
||||
импорта (13.10.2026 — 3 ложные тревоги).
|
||||
- **M-9 / O-2** — после перезапуска агент забывает время последнего вызова Jira и сразу выполняет команду без паузы темпа;
|
||||
при восстановлении по журналу пауза обнуляется явно.
|
||||
- **FM-4** — восстановление по журналу повторно пишет `claimed`.
|
||||
- Расхождение со спекой: `agent_status` отдаёт признак `online`, спека говорит `offline`.
|
||||
|
||||
Решения владельца 13.10.2026 (сессия brainstorming):
|
||||
- пакет A follow-ups — первым;
|
||||
- M-2, вариант A: хаб довыдаёт команду, выданную агенту и оставшуюся без отчёта (принцип «хаб не повторяет команды»
|
||||
уточняется: новых команд хаб не создаёт, довыдача неподтверждённой выдачи — доставка, не повтор);
|
||||
- после написания спеки, при планировании: довыдача закрывает и сценарий R-11 (потерянная `ensure_issue` выдаётся снова,
|
||||
агент находит свою задачу по `reporter = currentUser()`), а подтверждение по состоянию при довыдаче почти недостижимо
|
||||
(`refresh` не выдаётся, пока есть `claimed`). Поэтому передача логина агента и симметричных типов связей в `claim`
|
||||
из change убрана; R-11 закрывается довыдачей; O-3 — отдельным списком связей «в обратную сторону» в отчёте импорта.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **jira-hub**:
|
||||
- повторная выдача: `claim` при наличии команды в `claimed` выдаёт её снова (самую старую), счётчик `deliveries`;
|
||||
после 3 выдач без отчёта — `failed` «агент не отчитался после 3 выдач»; логика «свежей claimed» (10 минут простоя) и
|
||||
обход пакета с зависшим определителем (R-13) удаляются;
|
||||
- миграция схемы v1 → v2 (`command.deliveries`) с копией базы перед ней — первая миграция живой базы;
|
||||
- отчёт по `queued` — 409; пакет с двумя `ensure_issue` одной темы — 422;
|
||||
- поздний отчёт по отменённой `ensure_issue` с ключом сохраняет результат и переносит заметку `new:N` → ключ, статус
|
||||
остаётся `cancelled`;
|
||||
- отчёт импорта: связи из `links.csv`, которые в зеркале есть того же типа в обратную сторону, — в `reversed_links`,
|
||||
а не в `missing_links`;
|
||||
- заготовка настройки `reporter` удаляется; `agent_status` — `redelivered` вместо `stuck`; MCP показывает довыданные
|
||||
команды; принцип повторов уточняется в `CLAUDE.md`, `ARCHITECTURE.md`, docstring `queue`.
|
||||
- **jira-agent** (версия 3):
|
||||
- время последнего вызова Jira восстанавливается при старте из журнала; при восстановлении пауза команды не обнуляется;
|
||||
- при восстановлении `claimed` повторно не пишется.
|
||||
- Спеки: `hub-api`, `data-model`, `commands`, `agent`, `mcp`, `migration` — MODIFIED; признак `online` закрепляется в
|
||||
спеке вместо `offline`.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Передача логина Jira агента и симметричных типов связей в хаб (см. Why).
|
||||
- Остальные находки follow-ups (`T-n`, `M-n` вне перечня, расхождения второго порядка) — пакеты C и D.
|
||||
- Проверки поведения на реальной Jira банка (CRLF, Epic Link, постраничность комментариев, поле спринта) — пакет B.
|
||||
- Подъём `min_agent_version` до 3: агент v2 продолжает работать.
|
||||
- Дубли `ensure_issue` между разными пакетами: поиск дубля по теме за 7 дней уже сводит их к одной задаче.
|
||||
|
||||
## Impact
|
||||
|
||||
- `jira-hub`: `queue.py` (claim, report_result, agent_view, maybe_auto_refresh, reconcile), `commands.py` (validate,
|
||||
check_confirmed), `importer.py`, `db.py` (миграция v2), `api.py` (409 на отчёт по queued), `mcp_server.py`;
|
||||
`CLAUDE.md`, `ARCHITECTURE.md`.
|
||||
- `jira-agent`: `src/agent.py` (Runner, recover, execute), `README.md`, версия 3.
|
||||
- Контракт хаб ↔ агент: тела запросов не меняются; новый ответ 409 на отчёт по `queued` (агент уже трактует 409 как
|
||||
«закрыто»).
|
||||
- Issues: aep/jira-hub#9, aep/jira-agent#9.
|
||||
61
openspec/changes/bridge-reliability/specs/agent/spec.md
Normal file
61
openspec/changes/bridge-reliability/specs/agent/spec.md
Normal file
@ -0,0 +1,61 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Цикл выполнения
|
||||
Агент SHALL работать одним процессом: `claim` к хабу (долгий запрос) → при получении команды: досыпать до
|
||||
`pause_seconds` от последнего вызова Jira → запись «started» в журнал → выполнение по контракту `commands` → запись
|
||||
«finished» с результатом → отчёт хабу, повторяемый до подтверждения. Одна команда в полёте: новый `claim` агент SHALL
|
||||
отправлять, только когда у него нет выданной, но не завершённой и не отчитанной команды (на этом хаб строит повторную
|
||||
выдачу, см. `commands`). Один процесс на журнал: `run` SHALL брать исключительную блокировку файла `<журнал>.lock` и,
|
||||
если она занята, завершаться с кодом 1 и сообщением «агент уже запущен». При изменении `settings_version` в ответе `claim` агент SHALL перечитать настройки до выполнения
|
||||
команды.
|
||||
|
||||
#### Scenario: Остановка между командами
|
||||
- **WHEN** получен Ctrl-C во время выполнения команды
|
||||
- **THEN** текущая команда доделывается и отчёт отправляется, затем процесс завершается
|
||||
|
||||
#### Scenario: Второй процесс с тем же журналом
|
||||
- **WHEN** агент работает, и запускается ещё один `agent.py run` с тем же `journal.jsonl`
|
||||
- **THEN** второй процесс пишет «агент уже запущен…» и завершается с кодом 1, не обращаясь к хабу и Jira
|
||||
|
||||
### Requirement: Журнал на диске и восстановление
|
||||
Агент SHALL вести `journal.jsonl` (только дозапись) рядом с собой: строки `claimed {command_id, at}` (сразу после
|
||||
получения команды, до паузы), `started {command_id, at}`, `finished {command_id, status, result, error, jira_calls,
|
||||
rate_limited, at}`, `acked {command_id, at}`. При старте агент SHALL: отправить хабу все `finished` без `acked`; для
|
||||
`claimed`/`started` без `finished` — запросить команду у хаба по id и, если она в хабе всё ещё `claimed`, выполнить её
|
||||
заново по правилу «проверь, потом сделай» с паузой `pause_seconds` из ответа хаба, не записывая `claimed` повторно.
|
||||
Команда `python3 agent.py prune` SHALL удалять из журнала записи с `acked` старше 30 дней.
|
||||
|
||||
#### Scenario: Падение после ответа Jira
|
||||
- **WHEN** агент упал между `POST /issue` и записью `finished`, затем перезапущен
|
||||
- **THEN** агент находит в журнале `started` без `finished`, запрашивает команду у хаба, находит созданную задачу поиском
|
||||
дубля и отчитывается `done` с её ключом; второй задачи нет
|
||||
|
||||
#### Scenario: Восстановление без лишней записи
|
||||
- **WHEN** в журнале `claimed 40` без `finished`, хаб отдаёт команду 40 в `claimed` с `pause_seconds = 30`
|
||||
- **THEN** в журнал дописываются `started 40`, `finished 40`, `acked 40`; второй строки `claimed 40` нет; перед выполнением
|
||||
агент выдерживает 30 с от последнего вызова Jira
|
||||
|
||||
### Requirement: Ограничение темпа и 429
|
||||
Агент SHALL помнить время последнего вызова Jira (любого) и не начинать команду раньше `pause_seconds` после него. При
|
||||
старте агент SHALL принять за время последнего вызова Jira самое позднее `at` в журнале (если оно не в будущем); при пустом
|
||||
журнале паузы перед первой командой нет. На HTTP 429 и 503 агент SHALL ждать `Retry-After` (иначе 60 с × номер попытки)
|
||||
и повторять тот же запрос до 5 раз, считая попытки в `rate_limited`; после 5 неудач — `failed`. Пауза между записями
|
||||
внутри одной команды — по зазору типа команды.
|
||||
|
||||
#### Scenario: 429 с Retry-After
|
||||
- **WHEN** Jira ответила 429 с `Retry-After: 90`
|
||||
- **THEN** агент ждёт 90 с, повторяет тот же запрос; в отчёте `rate_limited = 1`
|
||||
|
||||
#### Scenario: Перезапуск не сбивает темп
|
||||
- **WHEN** последняя запись журнала `finished` сделана 10 с назад, агент перезапущен и получил команду с `pause_seconds = 30`
|
||||
- **THEN** агент ждёт около 20 с перед первым вызовом Jira по команде
|
||||
|
||||
### Requirement: Доставка и запуск
|
||||
README агента SHALL описывать: копирование `agent.py` и `agent.example.json` через общую папку, заполнение `agent.json`
|
||||
(включая выбор транспорта в `hub_url`), `check`, запуск в терминале и через `nohup` с логом, опциональный launchd-plist,
|
||||
`prune`. Версия агента SHALL быть в константе файла (3 — с восстановлением темпа после перезапуска и блокировкой одного процесса) и в каждом отчёте;
|
||||
хаб предупреждает в `agent_status`, если версия ниже минимальной из настроек.
|
||||
|
||||
#### Scenario: Устаревший агент
|
||||
- **WHEN** агент версии 1 выходит на связь, а в настройках `min_agent_version = 2`
|
||||
- **THEN** `agent_status.warning` = «обновите агента», команды выдаются по-прежнему
|
||||
50
openspec/changes/bridge-reliability/specs/commands/spec.md
Normal file
50
openspec/changes/bridge-reliability/specs/commands/spec.md
Normal file
@ -0,0 +1,50 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Пакет и порядок выполнения
|
||||
Пакет SHALL создаваться одним вызовом с автором, заголовком и списком команд; хаб SHALL назначить `seq` по порядку списка
|
||||
и добавить в хвост `refresh` по всем ключам пакета (кроме пакетов, состоящих только из `refresh`). Пакет с двумя и более
|
||||
`ensure_issue` с одинаковой `summary` (после обрезки пробелов по краям) SHALL отвергаться (422). Очередь SHALL выдавать
|
||||
команды в порядке `(batch.created_at, seq)`, одну в полёте. Пакет и его `queued` команды MAY быть отменены; `claimed`
|
||||
команда MUST NOT отменяться автоматически.
|
||||
|
||||
#### Scenario: Оценка длительности
|
||||
- **WHEN** создаётся пакет из 3 `ensure_fields` и 2 `ensure_comment`
|
||||
- **THEN** `estimate_seconds` = сумма пауз по политике для последовательности типов + пауза `refresh`
|
||||
|
||||
#### Scenario: Две задачи с одной темой
|
||||
- **WHEN** пакет содержит `ensure_issue {temp_key: new:1, summary: "Отчёт"}` и `ensure_issue {temp_key: new:2, summary: " Отчёт "}`
|
||||
- **THEN** 422 с темой «Отчёт», пакет не создан
|
||||
|
||||
### Requirement: Ошибки и подтверждение по состоянию
|
||||
Ошибка команды SHALL записываться в `error` (HTTP-код, текст Jira до 1500 символов), пакет продолжается.
|
||||
|
||||
**Повторная выдача.** Агент запрашивает новую команду, только когда у него нет незавершённой (см. `agent`), поэтому
|
||||
команда в `claimed` к моменту следующего `claim` считается не доставленной или потерянной агентом. При `claim` хаб SHALL
|
||||
сначала выдать снова самую старую (по `claimed_at`, затем `id`) команду в `claimed`: тот же `id` и параметры,
|
||||
`pause_seconds` по текущей политике темпа, новый `claimed_at`, `deliveries + 1`. Если у неё уже `deliveries >= 3`, хаб SHALL
|
||||
перевести её в `failed` с `error = "агент не отчитался после 3 выдач"` и перейти к следующей (довыдача другой `claimed`
|
||||
или следующая `queued`). Довыдача — доставка той же выдачи, а не повтор команды: новых команд хаб MUST NOT создавать
|
||||
сам, повтор после `failed` — новая команда с теми же параметрами по запросу интерфейса.
|
||||
|
||||
**Подтверждение по состоянию.** Если после любого `refresh` желаемый результат команды в `claimed` виден в зеркале, хаб
|
||||
SHALL перевести её в `confirmed_by_state`: `ensure_fields` — все поля совпадают; `ensure_status` — статус равен последнему
|
||||
в пути; `ensure_link` — связь есть и конфликтов нет; `ensure_sprint` — спринт совпадает. `ensure_issue`, `ensure_comment`
|
||||
и `refresh` по состоянию не подтверждаются: потерянный отчёт по ним закрывается повторной выдачей (агент находит
|
||||
созданную задачу поиском дубля) или отменой человеком.
|
||||
|
||||
#### Scenario: Отчёт потерян, факт на месте
|
||||
- **WHEN** `ensure_sprint {key: X, sprint: "Спринт 2"}` в `claimed`, после выполненного `refresh` зеркало показывает X в «Спринт 2»
|
||||
- **THEN** команда `confirmed_by_state`
|
||||
|
||||
#### Scenario: Ответ на claim потерян
|
||||
- **WHEN** команда 40 выдана (`deliveries = 1`), ответ до агента не дошёл, агент снова прислал `claim`
|
||||
- **THEN** `claim` отдаёт команду 40, `deliveries = 2`; в очереди есть `queued` команды, но выдаётся 40
|
||||
|
||||
#### Scenario: Предел выдач
|
||||
- **WHEN** команда 41 в `claimed` с `deliveries = 3`, агент снова прислал `claim`, в очереди есть команда 42
|
||||
- **THEN** 41 — `failed` «агент не отчитался после 3 выдач»; `claim` отдаёт 42
|
||||
|
||||
#### Scenario: Потерянный отчёт о создании задачи
|
||||
- **WHEN** `ensure_issue {temp_key: new:5, summary: "Отчёт"}` выполнена агентом, отчёт не дошёл, агент прислал `claim`
|
||||
- **THEN** команда выдаётся снова; агент находит созданную задачу поиском дубля и отчитывается `done` с её ключом; второй
|
||||
задачи нет, заметка `new:5` перенесена на ключ
|
||||
50
openspec/changes/bridge-reliability/specs/data-model/spec.md
Normal file
50
openspec/changes/bridge-reliability/specs/data-model/spec.md
Normal file
@ -0,0 +1,50 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Схема базы хаба
|
||||
База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_note`, `batch`, `command`,
|
||||
`sync_run`, `agent_status`, `setting`. Схема SHALL создаваться и мигрироваться самим хабом при старте по номеру версии
|
||||
в `setting.schema_version` (текущая — 2). Миграция существующей базы SHALL выполняться в одной транзакции; перед ней
|
||||
хаб SHALL скопировать файл базы в `backups/<дата-время>-schema-v<старая>.sqlite` рядом с базой. Денежных и дробных
|
||||
полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени).
|
||||
|
||||
#### Scenario: Первый запуск
|
||||
- **WHEN** хаб стартует с несуществующим файлом базы
|
||||
- **THEN** файл создаётся, таблицы и индексы на месте, `setting.schema_version` = текущая
|
||||
|
||||
#### Scenario: Миграция базы v1
|
||||
- **WHEN** хаб стартует с базой `schema_version = 1`, в которой есть задачи, заметки и команда в `claimed`
|
||||
- **THEN** в `backups/` копия базы v1; добавлена `command.deliveries` (у `claimed` — 1, у остальных — 0); данные на месте;
|
||||
`schema_version = 2`
|
||||
|
||||
### Requirement: Служебные заметки
|
||||
Таблица `issue_note` SHALL хранить поля, которых нет в Jira: `key` (PK; ключ Jira или временный `new:<n>`),
|
||||
`fact_status`, `work_kind`, `done_text`, `rest_text`, `owner_note`, `updated_at`, `updated_by`. Обновление зеркала
|
||||
MUST NOT менять `issue_note`. При завершении `ensure_issue` с временным ключом хаб SHALL переименовать строку заметок
|
||||
на настоящий ключ — в том числе по позднему отчёту `done` с ключом о команде, отменённой до отчёта.
|
||||
|
||||
#### Scenario: Заметка переживает refresh
|
||||
- **WHEN** у задачи есть `issue_note.fact_status = "В работе"` и приходит полное обновление зеркала
|
||||
- **THEN** `fact_status` не изменился
|
||||
|
||||
### Requirement: Пакеты и команды
|
||||
Таблица `batch` SHALL хранить `id`, `title`, `author`, `created_at`, `cancelled_at`, `estimate_seconds`.
|
||||
Таблица `command` SHALL хранить `id`, `batch_id`, `seq`, `type`, `params` (JSON), `status`
|
||||
(`queued | claimed | done | failed | skipped | cancelled | confirmed_by_state`), `claimed_at` (время последней выдачи),
|
||||
`deliveries` (сколько раз команда выдана агенту), `finished_at`, `result` (JSON), `error`, `jira_calls`, `rate_limited`
|
||||
(число 429/503), `pause_seconds` (выданная пауза), `depends_on` (id команды, чей результат подставляется). Статус пакета
|
||||
SHALL вычисляться из команд, не храниться.
|
||||
|
||||
#### Scenario: Статус пакета
|
||||
- **WHEN** в пакете 3 команды: `done`, `claimed`, `queued`
|
||||
- **THEN** статус пакета «выполняется»; когда все в терминальных статусах — «завершён», с числом `failed`/`skipped`
|
||||
|
||||
### Requirement: Журнал синхронизаций и статус агента
|
||||
Таблица `sync_run` SHALL хранить каждую загрузку состояния: `started_at`, `finished_at`, `kind` (`keys | jql`), `query`,
|
||||
`issues_count`, `links_count`, `jira_calls`, `rate_limited`, `command_id`. Таблица `agent_status` (одна строка) SHALL
|
||||
хранить `last_seen_at`, `agent_version`, `settings_version`, `current_command_id`, `last_jira_call_at`. Через API и MCP
|
||||
`agent_status` SHALL отдаваться с признаком `online` (истина, если `last_seen_at` моложе 5 минут) и списком
|
||||
`redelivered` — команд в `claimed`, выданных больше одного раза (`id`, `type`, `claimed_at`, `deliveries`).
|
||||
|
||||
#### Scenario: Агент давно не выходил на связь
|
||||
- **WHEN** `last_seen_at` старше 5 минут
|
||||
- **THEN** `agent_status` через API и MCP отдаётся с `online = false`
|
||||
73
openspec/changes/bridge-reliability/specs/hub-api/spec.md
Normal file
73
openspec/changes/bridge-reliability/specs/hub-api/spec.md
Normal file
@ -0,0 +1,73 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Маршруты агента
|
||||
Хаб SHALL предоставлять агенту следующие маршруты (все под агентским токеном):
|
||||
- `POST /agent/claim` `{agent_version, settings_version, last_jira_call_at}` — долгий запрос до 30 с; ответ `{command:
|
||||
{id, type, params, pause_seconds}, settings_version}` или `{command: null, settings_version}`; обновляет `agent_status`.
|
||||
Если есть команда в `claimed`, `claim` SHALL выдать её снова (повторная выдача, см. `commands`), а не следующую из
|
||||
очереди.
|
||||
- `GET /agent/settings` — настройки для агента (без токенов).
|
||||
- `GET /agent/commands/{id}` — команда по id (для восстановления из журнала), включая `pause_seconds`.
|
||||
- `POST /agent/commands/{id}/result` `{status, result, error, jira_calls, rate_limited}` — идемпотентно: повторный
|
||||
отчёт по команде в терминальном статусе отвечает 200 без изменений, кроме позднего отчёта `done` с `result.key` по
|
||||
отменённой `ensure_issue` без результата (результат записывается, статус остаётся `cancelled`, заметка `new:N`
|
||||
переносится на ключ). Отчёт по команде в `queued` SHALL отвергаться 409 `not_claimed`.
|
||||
- `POST /agent/state` `{command_id, issues: [...], links: [...], neighbor_keys: [...], page, last_page}` — загрузка состояния
|
||||
пачками; `neighbor_keys` — ключи вне базовой JQL, дочитанные по связям; хаб обновляет зеркало и `sync_run`.
|
||||
- `GET /agent/known-keys` — ключи зеркала (для определения соседей).
|
||||
|
||||
#### Scenario: Выдача с подстановкой ключа
|
||||
- **WHEN** следующая команда ссылается на `new:60`, а `ensure_issue` для `new:60` завершилась с `AURORA-1590`
|
||||
- **THEN** `claim` отдаёт команду с `AURORA-1590` в параметрах
|
||||
|
||||
#### Scenario: Повторный отчёт
|
||||
- **WHEN** агент дважды прислал `result` для команды 15
|
||||
- **THEN** второй ответ 200, статус и результат не изменились
|
||||
|
||||
#### Scenario: Отчёт по невыданной команде
|
||||
- **WHEN** `POST /agent/commands/16/result` с `status = done`, команда 16 в `queued`
|
||||
- **THEN** 409 `not_claimed`, команда осталась `queued`
|
||||
|
||||
### Requirement: Маршруты интерфейсов
|
||||
Хаб SHALL предоставлять интерфейсам (MCP, позже просмотрщик) следующие маршруты под клиентским токеном:
|
||||
- `GET /issues` с фильтрами `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`,
|
||||
`q` (подстрока темы), `limit`/`offset` — компактные карточки (ключ, тема, статус, приоритет, срок, исполнитель, спринт,
|
||||
эпик, заметки кратко).
|
||||
- `GET /issues/{key}` — задача целиком: поля, связи в обе стороны, заметки, последние 10 команд по ней.
|
||||
- `PUT /issues/{key}/note` — заметки (частичное обновление, `updated_by` из тела).
|
||||
- `POST /batches` `{title, author, commands: [...]}` → `{id, estimate_seconds}`; 422 при неизвестном типе, неверных
|
||||
параметрах, временном ключе без `ensure_issue`, двух и более `ensure_issue` с одинаковой `summary` (сравнение после
|
||||
обрезки пробелов по краям; в ответе перечислены повторяющиеся темы).
|
||||
- `GET /batches?limit=`, `GET /batches/{id}` (с командами), `POST /batches/{id}/cancel`.
|
||||
- `POST /commands/{id}/retry` — новая команда с теми же параметрами в новом пакете «Повтор команды N».
|
||||
- `POST /commands/{id}/cancel` — только для `queued`; для `claimed` — 409, если не передан `force: true`.
|
||||
- `POST /refresh` `{keys | jql | full: true}` → пакет из одного `refresh`.
|
||||
- `GET /agent`, `GET /sync-runs?limit=`, `GET /settings`, `PUT /settings` (частично; поднимает `settings_version`).
|
||||
|
||||
#### Scenario: Фильтр по спринту
|
||||
- **WHEN** `GET /issues?sprint=Спринт 2`
|
||||
- **THEN** только задачи с таким `sprint`, отсортированы по сроку, затем по ключу
|
||||
|
||||
#### Scenario: Отмена взятой команды
|
||||
- **WHEN** `POST /commands/15/cancel` без `force`, команда `claimed`
|
||||
- **THEN** 409 с `claimed_at`; с `force: true` — `cancelled`, следующий отчёт агента по ней принимается, статус не меняется
|
||||
|
||||
#### Scenario: Поздний отчёт по отменённому созданию
|
||||
- **WHEN** `ensure_issue {temp_key: new:7, …}` отменена с `force: true`, затем агент прислал `done` с `result.key = AURORA-1620`
|
||||
- **THEN** команда `cancelled`, в `result` — `AURORA-1620`; заметка `new:7` перенесена на `AURORA-1620`
|
||||
|
||||
#### Scenario: Дубль темы в пакете
|
||||
- **WHEN** `POST /batches` с двумя `ensure_issue`, у обеих `summary = "Шахматка: комментарии"`
|
||||
- **THEN** 422 с темой «Шахматка: комментарии», пакет не создан
|
||||
|
||||
### Requirement: Автообновление зеркала
|
||||
Если `auto_refresh_minutes > 0`, агент на связи и очередь пуста дольше этого интервала, хаб SHALL поставить пакет
|
||||
«Автообновление» из `refresh {jql: base_jql}`. Очередь не пуста, если есть команды в `queued` или `claimed`.
|
||||
|
||||
#### Scenario: Очередь занята
|
||||
- **WHEN** интервал прошёл, но в очереди есть `queued` команды
|
||||
- **THEN** автообновление не ставится до опустошения очереди
|
||||
|
||||
#### Scenario: Есть выданная команда без отчёта
|
||||
- **WHEN** интервал прошёл, `queued` нет, одна команда в `claimed`
|
||||
- **THEN** автообновление не ставится; команда довыдаётся при следующем `claim`
|
||||
31
openspec/changes/bridge-reliability/specs/mcp/spec.md
Normal file
31
openspec/changes/bridge-reliability/specs/mcp/spec.md
Normal file
@ -0,0 +1,31 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Инструменты
|
||||
| Инструмент | Параметры | Результат |
|
||||
|---|---|---|
|
||||
| `issues` | фильтры как у `GET /issues`, `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт, статус по факту |
|
||||
| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды |
|
||||
| `notes_set` | `key`, подмножество полей заметок | обновлённые заметки |
|
||||
| `batch_create` | `title`, `commands` (список объектов по контракту `commands`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации |
|
||||
| `batch_status` | `id` | статусы команд, ошибки, оценка остатка |
|
||||
| `batch_wait` | `id`, `timeout_minutes` (≤ 60) | как `batch_status`, возвращается по завершении или по таймауту |
|
||||
| `batches` | `limit` | последние пакеты со статусом |
|
||||
| `command_retry`, `batch_cancel` | `id` | новый пакет / результат отмены |
|
||||
| `refresh` | `keys` или `full`, `wait` (bool) | `sync_run` или id пакета |
|
||||
| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек |
|
||||
| `sync_log` | `limit` | последние `sync_run` |
|
||||
| `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` |
|
||||
|
||||
Автор команд из MCP SHALL быть `claude`; из API без автора — `api`.
|
||||
|
||||
#### Scenario: Пакет из чата
|
||||
- **WHEN** `batch_create {title: "1538 в спринт 2", commands: [ensure_fields{…}, ensure_link{…}, ensure_sprint{…}]}`
|
||||
- **THEN** пакет создан с четырьмя командами (три + `refresh`), ответ содержит оценку в минутах
|
||||
|
||||
#### Scenario: Ожидание результата
|
||||
- **WHEN** `batch_wait {id, timeout_minutes: 10}` и пакет завершился через 6 минут
|
||||
- **THEN** ответ через 6 минут со статусами всех команд
|
||||
|
||||
#### Scenario: Статус агента с довыдачей
|
||||
- **WHEN** агент на связи, команда 40 выдана дважды и ещё без отчёта
|
||||
- **THEN** ответ `agent_status` содержит «агент онлайн» и строку о команде 40 с числом выдач 2
|
||||
19
openspec/changes/bridge-reliability/specs/migration/spec.md
Normal file
19
openspec/changes/bridge-reliability/specs/migration/spec.md
Normal file
@ -0,0 +1,19 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Импорт CSV-пакета
|
||||
`jira-hub import <папка admin/jira-sync>` SHALL прочитать `tasks.csv`, `new_tasks.csv`, `epics.csv` и заполнить
|
||||
`issue_note` (ключ Jira; для строк без ключа — `new:<номер строки>`): статус по факту, вид работы, что сделано, что
|
||||
осталось, внутренние колонки. `links.csv` SHALL импортироваться как сверочный набор в отчёт импорта (ожидаемые связи
|
||||
против зеркала после первого `refresh`), не как команды. Связь, которой в зеркале нет как `source→target` того же типа,
|
||||
но есть как `target→source` того же типа, SHALL попадать в `reversed_links`, а не в `missing_links`. Импорт идемпотентен:
|
||||
повтор обновляет заметки, не дублирует.
|
||||
|
||||
#### Scenario: Импорт и первая синхронизация
|
||||
- **WHEN** выполнены `init`, `import`, запущены хаб и агент, `refresh full` завершён
|
||||
- **THEN** число задач зеркала ≥ числа строк `tasks.csv` + созданных из `new_tasks.csv`; отчёт импорта перечисляет ключи
|
||||
из CSV, которых нет в зеркале, связи из `links.csv`, которых нет в `issue_link`, и связи, которые есть в обратную сторону
|
||||
|
||||
#### Scenario: Связь в обратную сторону
|
||||
- **WHEN** в `links.csv` строка «AURORA-1445 → AURORA-1446, связанные», а в зеркале есть только «ASUPR_Связанные»
|
||||
1446→1445
|
||||
- **THEN** связь в `reversed_links`, в `missing_links` её нет
|
||||
@ -104,6 +104,8 @@ def h_agent_result(hub, conn, m, q, body):
|
||||
body.get("jira_calls", 0), body.get("rate_limited", 0))
|
||||
except KeyError:
|
||||
raise ApiError(404, "not_found", "command")
|
||||
except queue.NotClaimedError as e:
|
||||
raise ApiError(409, "not_claimed", str(e))
|
||||
except ValueError as e:
|
||||
raise ApiError(422, "bad_status", str(e))
|
||||
return 200, cmd
|
||||
|
||||
@ -8,7 +8,8 @@ def make_backup(conn, backups_dir: Path, label: str, keep: int = 30) -> Path:
|
||||
backups_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = backups_dir / f"{datetime.now():%Y%m%d-%H%M%S-%f}-{label}.sqlite"
|
||||
conn.execute("VACUUM INTO ?", (str(target),))
|
||||
files = sorted(backups_dir.glob("*.sqlite"))
|
||||
# копии перед миграцией схемы (db.migrate) — для отката, ротации не подлежат
|
||||
files = sorted(p for p in backups_dir.glob("*.sqlite") if "-schema-v" not in p.name)
|
||||
if keep > 0:
|
||||
for old in files[:-keep]:
|
||||
old.unlink()
|
||||
|
||||
@ -85,6 +85,11 @@ def validate(commands) -> list[dict]:
|
||||
if ctype == "ensure_issue":
|
||||
defined.add(params["temp_key"])
|
||||
out.append({"type": ctype, "params": params})
|
||||
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 получится одна задача на два временных ключа")
|
||||
return out
|
||||
|
||||
|
||||
@ -106,21 +111,13 @@ def estimate(types, pacing: dict) -> int:
|
||||
return sum(int(pacing.get(t, 0)) for t in types)
|
||||
|
||||
|
||||
def check_confirmed(conn, cmd, link_types: dict, reporter: str | None = None) -> tuple[bool, dict | None]:
|
||||
"""Виден ли желаемый результат в зеркале. ensure_comment и refresh по состоянию не подтверждаются.
|
||||
reporter: логин Jira, под которым агент заводит задачи. ensure_issue подтверждается ТОЛЬКО задачей этого
|
||||
автора; пока reporter неизвестен (None), по состоянию не подтверждается (R-11): совпадение темы с чужой
|
||||
задачей не доказательство, а запись в чужую задачу недопустима."""
|
||||
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_comment", "refresh"):
|
||||
if ctype in ("ensure_issue", "ensure_comment", "refresh"):
|
||||
return False, None
|
||||
if ctype == "ensure_issue":
|
||||
if reporter is None:
|
||||
return False, None
|
||||
row = conn.execute("SELECT key FROM issue WHERE summary = ? AND reporter = ? "
|
||||
"AND substr(created, 1, 10) >= date('now', '-7 days') ORDER BY created DESC LIMIT 1",
|
||||
(p["summary"], reporter)).fetchone()
|
||||
return (True, {"key": row["key"]}) if row else (False, None)
|
||||
issue = mirror.get_issue(conn, p.get("key") or p.get("source"))
|
||||
if issue is None:
|
||||
return False, None
|
||||
|
||||
@ -6,7 +6,9 @@ from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
from . import backup
|
||||
|
||||
SCHEMA_VERSION = 2
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS setting (
|
||||
@ -67,6 +69,7 @@ CREATE TABLE IF NOT EXISTS command (
|
||||
params TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
claimed_at TEXT,
|
||||
deliveries INTEGER NOT NULL DEFAULT 0,
|
||||
finished_at TEXT,
|
||||
result TEXT,
|
||||
error TEXT,
|
||||
@ -98,6 +101,12 @@ CREATE TABLE IF NOT EXISTS agent_status (
|
||||
);
|
||||
"""
|
||||
|
||||
# Шаги миграции: версия → 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'"], # уже выданные — одна выдача
|
||||
}
|
||||
|
||||
|
||||
def now() -> str:
|
||||
"""Текущее время UTC в ISO-8601 с секундами — единый формат дат в базе."""
|
||||
@ -115,15 +124,32 @@ def connect(path) -> sqlite3.Connection:
|
||||
if not is_memory:
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
migrate(conn)
|
||||
migrate(conn, None if is_memory else path)
|
||||
return conn
|
||||
|
||||
|
||||
def migrate(conn: sqlite3.Connection) -> None:
|
||||
conn.executescript(SCHEMA)
|
||||
def _schema_version(conn) -> int | None:
|
||||
row = conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()
|
||||
if row is None:
|
||||
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)")
|
||||
|
||||
|
||||
|
||||
@ -69,7 +69,7 @@ def _import(conn, folder: Path) -> dict:
|
||||
csv_keys.append(key)
|
||||
|
||||
link_types = settings.get(conn, "link_types") or {}
|
||||
expected, missing_links = [], []
|
||||
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("Куда")))
|
||||
@ -79,10 +79,14 @@ def _import(conn, folder: Path) -> dict:
|
||||
link = {"source": src, "target": dst, "link_type": link_types.get(kind, kind)}
|
||||
expected.append(link)
|
||||
issue = mirror.get_issue(conn, src)
|
||||
present = issue and any(l["target_key"] == dst and l["link_type"] == link["link_type"] for l in issue["links_out"])
|
||||
if not present:
|
||||
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)
|
||||
|
||||
known = set(mirror.known_keys(conn))
|
||||
missing_keys = sorted({k for k in csv_keys if k not in known})
|
||||
return {"notes": notes, "expected_links": len(expected), "missing_keys": missing_keys, "missing_links": missing_links}
|
||||
return {"notes": notes, "expected_links": len(expected), "missing_keys": missing_keys, "missing_links": missing_links,
|
||||
"reversed_links": reversed_links}
|
||||
|
||||
@ -101,8 +101,8 @@ def fmt_agent(a) -> str:
|
||||
f"429/503 за сутки: {a.get('rate_limited_24h', 0)}"]
|
||||
if a.get("warning"):
|
||||
out.append("внимание: " + a["warning"])
|
||||
for s in a.get("stuck", []):
|
||||
out.append(f"нет ответа от агента: #{s['id']} {s['type']} с {s['claimed_at']}")
|
||||
for s in a.get("redelivered", []):
|
||||
out.append(f"выдана повторно: #{s['id']} {s['type']}, выдач {s['deliveries']}, последняя {s['claimed_at']}")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"""Очередь команд: пакеты, выдача агенту по одной, отчёты, отмена, повтор, сверка с зеркалом, автообновление.
|
||||
Хаб никогда не повторяет команды сам (design.md, решение 4)."""
|
||||
Новых команд хаб сам не создаёт: повтор после failed — новый пакет по запросу человека. Команду, выданную агенту и
|
||||
оставшуюся без отчёта, хаб довыдаёт при следующем claim — это доставка той же выдачи (bridge-reliability, решение 1)."""
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
@ -11,7 +12,7 @@ from . import db, mirror, settings
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
TERMINAL = {"done", "failed", "skipped", "cancelled", "confirmed_by_state"}
|
||||
STALE_MINUTES = 10 # взятая команда старше — не блокирует выдачу, показывается как «нет ответа»
|
||||
MAX_DELIVERIES = 3 # выдач без отчёта, после которых команда — failed (bridge-reliability, решение 2)
|
||||
ONLINE_MINUTES = 5
|
||||
|
||||
|
||||
@ -21,6 +22,10 @@ class ClaimedError(Exception):
|
||||
self.claimed_at = claimed_at
|
||||
|
||||
|
||||
class NotClaimedError(Exception):
|
||||
"""Отчёт по команде, которую агенту не выдавали (status = queued) — API отвечает 409."""
|
||||
|
||||
|
||||
def _cmd(row) -> dict:
|
||||
d = dict(row)
|
||||
d["params"] = json.loads(d["params"])
|
||||
@ -108,31 +113,16 @@ def _definer_status(conn, batch_id, temp_key):
|
||||
return r["status"] if r else "failed"
|
||||
|
||||
|
||||
def _definer_stale(conn, batch_id, temp_key) -> bool:
|
||||
"""Определитель взят агентом и давно не отвечает (старше STALE_MINUTES)."""
|
||||
cutoff = (db.utcnow() - timedelta(minutes=STALE_MINUTES)).isoformat()
|
||||
return conn.execute("SELECT 1 FROM command WHERE batch_id = ? AND type = 'ensure_issue' AND status = 'claimed' "
|
||||
"AND claimed_at <= ? AND json_extract(params, '$.temp_key') = ?",
|
||||
(batch_id, cutoff, temp_key)).fetchone() is not 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()
|
||||
blocked_batches = set() # пакеты, ждущие зависшего определителя (R-13)
|
||||
for row in rows:
|
||||
if row["batch_id"] in blocked_batches:
|
||||
continue
|
||||
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]
|
||||
pending = [k for k in unresolved if _definer_status(conn, cmd["batch_id"], k) not in TERMINAL]
|
||||
if pending:
|
||||
if all(_definer_stale(conn, cmd["batch_id"], k) for k in pending):
|
||||
blocked_batches.add(cmd["batch_id"]) # определитель завис: остаток пакета ждёт человека,
|
||||
continue # очередь идёт к следующему пакету
|
||||
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":
|
||||
@ -146,9 +136,20 @@ def _next(conn) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def _fresh_claimed_exists(conn) -> bool:
|
||||
cutoff = (db.utcnow() - timedelta(minutes=STALE_MINUTES)).isoformat()
|
||||
return conn.execute("SELECT 1 FROM command WHERE status = 'claimed' AND claimed_at > ? LIMIT 1", (cutoff,)).fetchone() is not None
|
||||
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)
|
||||
|
||||
|
||||
def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None) -> dict | None:
|
||||
@ -158,26 +159,32 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non
|
||||
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))
|
||||
if _fresh_claimed_exists(conn):
|
||||
return None
|
||||
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 = ? 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']}"
|
||||
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']}"
|
||||
if to_backup and backup:
|
||||
try:
|
||||
backup(to_backup)
|
||||
@ -209,7 +216,17 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls=
|
||||
cmd = get_command(conn, command_id)
|
||||
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 не затирается
|
||||
now = db.now()
|
||||
conn.execute("UPDATE command SET status = ?, finished_at = ?, result = ?, error = ?, jira_calls = ?, rate_limited = ? WHERE id = ?",
|
||||
@ -234,12 +251,10 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls=
|
||||
def reconcile(conn) -> list[int]:
|
||||
"""Взятые команды без ответа, чей результат уже виден в зеркале → confirmed_by_state."""
|
||||
link_types = settings.get(conn, "link_types") or {}
|
||||
reporter = settings.get(conn, "reporter") # задел: сейчас не задаётся, ensure_issue по состоянию не подтверждается (R-11);
|
||||
# позже сюда придёт jira_user от агента
|
||||
changed = []
|
||||
for row in conn.execute("SELECT * FROM command WHERE status = 'claimed'").fetchall():
|
||||
cmd = _cmd(row)
|
||||
ok, result = C.check_confirmed(conn, cmd, link_types, reporter)
|
||||
ok, result = C.check_confirmed(conn, cmd, link_types)
|
||||
if not ok:
|
||||
continue
|
||||
with db.transaction(conn):
|
||||
@ -248,8 +263,6 @@ def reconcile(conn) -> list[int]:
|
||||
(db.now(), _dump(result) if result else None, cmd["id"]))
|
||||
if cur.rowcount == 0: # отчёт агента или отмена опередили сверку
|
||||
continue
|
||||
if cmd["type"] == "ensure_issue" and result:
|
||||
mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"])
|
||||
changed.append(cmd["id"])
|
||||
return changed
|
||||
|
||||
@ -296,9 +309,8 @@ def agent_view(conn) -> dict:
|
||||
d["warning"] = "обновите агента" if d["agent_version"] and int(d["agent_version"]) < min_version else None
|
||||
d["rate_limited_24h"] = conn.execute("SELECT COALESCE(SUM(rate_limited), 0) AS n FROM command WHERE finished_at >= ?",
|
||||
((now - timedelta(days=1)).isoformat(),)).fetchone()["n"]
|
||||
cutoff = (now - timedelta(minutes=STALE_MINUTES)).isoformat()
|
||||
d["stuck"] = [dict(r) for r in conn.execute(
|
||||
"SELECT id, type, claimed_at FROM command WHERE status = 'claimed' AND claimed_at <= ? ORDER BY id", (cutoff,))]
|
||||
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")]
|
||||
return d
|
||||
|
||||
|
||||
@ -306,7 +318,7 @@ def maybe_auto_refresh(conn) -> dict | None:
|
||||
minutes = int(settings.get(conn, "auto_refresh_minutes") or 0)
|
||||
if minutes <= 0 or not agent_view(conn)["online"]:
|
||||
return None
|
||||
if conn.execute("SELECT 1 FROM command WHERE status = 'queued' LIMIT 1").fetchone() or _fresh_claimed_exists(conn):
|
||||
if conn.execute("SELECT 1 FROM command WHERE status IN ('queued', 'claimed') LIMIT 1").fetchone():
|
||||
return None
|
||||
last = conn.execute("SELECT MAX(started_at) AS t FROM sync_run").fetchone()["t"]
|
||||
if last and db.utcnow() - db.parse(last) < timedelta(minutes=minutes):
|
||||
|
||||
@ -51,7 +51,7 @@ def test_agent_flow(hub):
|
||||
assert cmd["type"] == "ensure_fields" and cmd["pause_seconds"] == 120 and claim["settings_version"] >= 1
|
||||
assert a.get(f"/agent/commands/{cmd['id']}")["id"] == cmd["id"]
|
||||
assert a.get("/agent/settings")["link_types"]["связанные"] == "ASUPR_Связанные"
|
||||
assert a.post("/agent/claim", {})["command"] is None # одна в полёте, долгий запрос выключен
|
||||
assert a.post("/agent/claim", {})["command"]["id"] == cmd["id"] # ответ потерян — довыдача той же команды
|
||||
r = a.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"changed": ["duedate"]}, "jira_calls": 2})
|
||||
assert r["status"] == "done"
|
||||
assert a.post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "x"})["status"] == "done"
|
||||
@ -313,3 +313,19 @@ def test_relay_dir_put_validation(hub):
|
||||
assert c.put("/settings", {"relay_dir": "/tmp/jira-bridge"})["settings_version"] == before + 1
|
||||
assert c.put("/settings", {"relay_dir": None})["settings_version"] == before + 2
|
||||
assert "relay_dir" not in hub["agent"].get("/agent/settings")
|
||||
|
||||
|
||||
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
|
||||
|
||||
@ -108,44 +108,18 @@ def _ensure_issue(summary):
|
||||
return {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}}
|
||||
|
||||
|
||||
def test_check_confirmed_issue_by_summary():
|
||||
conn = _mirror()
|
||||
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'")
|
||||
ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov")
|
||||
assert ok and result == {"key": "A-1"}
|
||||
ok, _ = commands.check_confirmed(conn, _ensure_issue("Другая"), LT, reporter="petrov")
|
||||
assert not ok
|
||||
|
||||
|
||||
def test_ensure_issue_without_reporter_never_confirmed():
|
||||
"""R-11: пока автор неизвестен, совпадение темы не подтверждает ensure_issue."""
|
||||
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)
|
||||
assert commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter=None) == (False, None)
|
||||
|
||||
|
||||
def test_ensure_issue_older_than_7_days_not_confirmed():
|
||||
conn = _mirror()
|
||||
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'")
|
||||
conn.execute(f"UPDATE issue SET created = strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now', '-30 days') WHERE key = 'A-1'")
|
||||
ok, _ = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov")
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_ensure_issue_reporter_filter():
|
||||
conn = _mirror()
|
||||
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'")
|
||||
ok, _ = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="ivanov")
|
||||
assert ok is False
|
||||
ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov")
|
||||
assert ok and result == {"key": "A-1"}
|
||||
|
||||
|
||||
def test_ensure_issue_two_matches_takes_newest():
|
||||
conn = _mirror()
|
||||
mirror.apply_state(conn, [{"key": "A-3", "summary": "Тема", "reporter": "petrov"}], [])
|
||||
conn.execute(f"UPDATE issue SET created = strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now', '-1 day') WHERE key = 'A-1'")
|
||||
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-3'")
|
||||
ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov")
|
||||
assert ok and result == {"key": "A-3"}
|
||||
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]]) # регистр различается — пакет допустим
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import sqlite3
|
||||
|
||||
from jira_hub import db
|
||||
|
||||
|
||||
@ -5,8 +7,47 @@ 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"] == "1"
|
||||
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()
|
||||
|
||||
|
||||
def test_connect_is_idempotent(tmp_path):
|
||||
|
||||
@ -44,8 +44,8 @@ def test_create_link_sprint_flow(live):
|
||||
view = client.get(f"/batches/{bid}")
|
||||
assert view["status"] == "running" and view["remaining_seconds"] > 0
|
||||
if cmd["type"] == "ensure_issue":
|
||||
# одна команда в полёте: зависимая ensure_link не выдаётся до done ensure_issue
|
||||
assert agent.post("/agent/claim", {"agent_version": 1, "settings_version": 1})["command"] is None
|
||||
# одна команда в полёте: повторный claim довыдаёт ensure_issue, зависимая ensure_link не выдаётся
|
||||
assert agent.post("/agent/claim", {"agent_version": 1, "settings_version": 1})["command"]["id"] == cmd["id"]
|
||||
agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"key": "A-90"}, "jira_calls": 2})
|
||||
# done ensure_issue не пишет в зеркало; заметка переехала с new:60 на A-90
|
||||
with pytest.raises(HubError) as e:
|
||||
|
||||
@ -48,6 +48,7 @@ def test_import_notes_and_report(tmp_path):
|
||||
assert rep["missing_keys"] == ["AURORA-1350", "AURORA-1416", "AURORA-1529"]
|
||||
assert rep["expected_links"] == 2 # new:3 без ключа — не сверяется
|
||||
assert rep["missing_links"] == [{"source": "AURORA-1533", "target": "AURORA-1416", "link_type": "ASUPR_Блокер_Начала"}]
|
||||
assert rep["reversed_links"] == []
|
||||
assert importer.import_package(conn, tmp_path)["notes"] == 6
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM issue_note").fetchone()["n"] == 6
|
||||
|
||||
@ -98,4 +99,19 @@ def test_missing_csv_files_give_empty_report(tmp_path):
|
||||
conn = db.connect(":memory:")
|
||||
settings.ensure_defaults(conn)
|
||||
rep = importer.import_package(conn, tmp_path)
|
||||
assert rep == {"notes": 0, "expected_links": 0, "missing_keys": [], "missing_links": []}
|
||||
assert rep == {"notes": 0, "expected_links": 0, "missing_keys": [], "missing_links": [], "reversed_links": []}
|
||||
|
||||
|
||||
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_Блокер_Начала"}]
|
||||
|
||||
@ -70,8 +70,13 @@ def test_formatters():
|
||||
"sprint": None, "fact_status": None}])
|
||||
assert "x" * 79 + "…" in text and "x" * 80 not in text and "—" in text
|
||||
assert "очередь" in mcp_server.fmt_agent({"online": False, "agent_version": None, "current_command_id": None,
|
||||
"rate_limited_24h": 0, "stuck": [], "warning": None, "last_seen_at": None,
|
||||
"rate_limited_24h": 0, "redelivered": [], "warning": None, "last_seen_at": None,
|
||||
"settings_version": None})
|
||||
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
|
||||
|
||||
|
||||
def test_settings_set_bad_json(live):
|
||||
|
||||
@ -42,7 +42,9 @@ def test_claim_order_pause_and_report():
|
||||
b2 = queue.create_batch(conn, "второй", "claude", [fields("A-2")])
|
||||
c = queue.claim(conn, agent_version=1, settings_version=1)
|
||||
assert c["type"] == "ensure_fields" and c["params"]["key"] == "A-1" and c["pause_seconds"] == 120 and c["batch_id"] == b1["id"]
|
||||
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
|
||||
assert queue.agent_view(conn)["current_command_id"] == c["id"]
|
||||
done = queue.report_result(conn, c["id"], "done", result={"changed": ["duedate"]}, jira_calls=2)
|
||||
assert done["status"] == "done" and done["jira_calls"] == 2
|
||||
@ -75,8 +77,10 @@ def test_claim_is_exclusive(tmp_path):
|
||||
threads = [threading.Thread(target=worker, args=(c,)) for c in conns]
|
||||
[t.start() for t in threads]
|
||||
[t.join() for t in threads]
|
||||
assert sorted(r is None for r in results) == [False, True]
|
||||
assert setup.execute("SELECT COUNT(*) AS n FROM command WHERE status = 'claimed'").fetchone()["n"] == 1
|
||||
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)
|
||||
|
||||
|
||||
def test_backup_failure_does_not_block_claim(caplog):
|
||||
@ -92,16 +96,6 @@ def test_backup_failure_does_not_block_claim(caplog):
|
||||
assert "не создана" in caplog.text
|
||||
|
||||
|
||||
def test_stale_claimed_does_not_block():
|
||||
conn = fresh()
|
||||
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
||||
c = queue.claim(conn)
|
||||
conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (c["id"],))
|
||||
nxt = queue.claim(conn)
|
||||
assert nxt["params"]["key"] == "A-2"
|
||||
assert queue.agent_view(conn)["stuck"][0]["id"] == c["id"]
|
||||
|
||||
|
||||
def test_temp_key_substitution_and_skip():
|
||||
conn = fresh()
|
||||
mirror.set_note(conn, "new:60", {"fact_status": "В работе"}, "import")
|
||||
@ -134,7 +128,7 @@ def test_cancel_and_retry():
|
||||
queue.cancel_command(conn, c["id"])
|
||||
view = queue.cancel_batch(conn, b["id"])
|
||||
assert [x["status"] for x in view["commands"]] == ["claimed", "cancelled", "cancelled"]
|
||||
assert queue.claim(conn) is None
|
||||
assert queue.claim(conn)["id"] == c["id"] # взятая из отменённого пакета довыдаётся
|
||||
forced = queue.cancel_command(conn, c["id"], force=True)
|
||||
assert forced["status"] == "cancelled"
|
||||
after = queue.report_result(conn, c["id"], "done", result={"changed": []})
|
||||
@ -157,15 +151,13 @@ 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)
|
||||
conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (c["id"],))
|
||||
b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||||
r = queue.claim(conn)
|
||||
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 r["batch_id"] == c["batch_id"] # выдался хвостовой refresh первого пакета
|
||||
assert queue.batch_view(conn, c["batch_id"])["status"] == "done"
|
||||
assert queue.batch_view(conn, b["id"])["status"] == "queued"
|
||||
assert queue.batch_view(conn, b["id"])["status"] == "done"
|
||||
|
||||
|
||||
def test_auto_refresh_conditions():
|
||||
@ -213,18 +205,16 @@ def test_make_backup_rotates(tmp_path):
|
||||
assert db.connect(tmp_path / "backups" / names[-1]).execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
|
||||
|
||||
|
||||
def test_reconcile_respects_reporter_setting():
|
||||
conn = fresh()
|
||||
settings.set_(conn, "reporter", "robot")
|
||||
queue.create_batch(conn, "x", "claude", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Тема"}}])
|
||||
c = queue.claim(conn)
|
||||
conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (c["id"],))
|
||||
created = db.utcnow().isoformat()
|
||||
mirror.apply_state(conn, [{"key": "A-7", "summary": "Тема", "reporter": "other", "created": created, "labels": []}], [])
|
||||
assert queue.reconcile(conn) == [] # чужая задача не подтверждает
|
||||
mirror.apply_state(conn, [{"key": "A-8", "summary": "Тема", "reporter": "robot", "created": created, "labels": []}], [])
|
||||
assert queue.reconcile(conn) == [c["id"]]
|
||||
assert queue.get_command(conn, c["id"])["result"] == {"key": "A-8"}
|
||||
def test_rotation_keeps_schema_migration_copy(tmp_path):
|
||||
"""Копия базы перед миграцией схемы не участвует в ротации копий пакетов (M-1 финального ревью)."""
|
||||
from jira_hub import backup
|
||||
conn = db.connect(tmp_path / "hub.sqlite")
|
||||
backup.make_backup(conn, tmp_path / "backups", "schema-v1", keep=0)
|
||||
for i in range(4):
|
||||
backup.make_backup(conn, tmp_path / "backups", f"batch-{i}", keep=2)
|
||||
names = sorted(p.name for p in (tmp_path / "backups").glob("*.sqlite"))
|
||||
assert len([n for n in names if n.endswith("-schema-v1.sqlite")]) == 1
|
||||
assert len([n for n in names if "-batch-" in n]) == 2
|
||||
|
||||
|
||||
def _claimed_ensure_issue(conn, summary="Тема"):
|
||||
@ -235,7 +225,7 @@ def _claimed_ensure_issue(conn, summary="Тема"):
|
||||
|
||||
|
||||
def test_reconcile_does_not_confirm_ensure_issue_without_reporter():
|
||||
"""R-11: настройка reporter не задана — совпадение темы не подтверждает, заметка new:N не переезжает."""
|
||||
"""ensure_issue по состоянию не подтверждается: совпадение темы не закрывает команду, заметка new:N не переезжает."""
|
||||
conn = fresh()
|
||||
c = _claimed_ensure_issue(conn)
|
||||
mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t")
|
||||
@ -249,11 +239,9 @@ def test_reconcile_does_not_confirm_ensure_issue_without_reporter():
|
||||
def test_reconcile_does_not_overwrite_finished_command(monkeypatch):
|
||||
"""M-1: отчёт агента/отмена между check_confirmed и UPDATE не перезаписываются."""
|
||||
conn = fresh()
|
||||
settings.set_(conn, "reporter", "robot")
|
||||
c = _claimed_ensure_issue(conn)
|
||||
mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t")
|
||||
mirror.apply_state(conn, [{"key": "A-8", "summary": "Тема", "reporter": "robot",
|
||||
"created": db.utcnow().isoformat(), "labels": []}], [])
|
||||
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):
|
||||
@ -264,7 +252,6 @@ def test_reconcile_does_not_overwrite_finished_command(monkeypatch):
|
||||
monkeypatch.setattr(queue.C, "check_confirmed", racing)
|
||||
assert queue.reconcile(conn) == []
|
||||
assert queue.get_command(conn, c["id"])["status"] == "cancelled"
|
||||
assert mirror.get_note(conn, "new:1") is not None # rename_note не выполнялся
|
||||
|
||||
|
||||
def _two_batches_with_dependent(conn):
|
||||
@ -277,21 +264,118 @@ def _two_batches_with_dependent(conn):
|
||||
return a, b, first
|
||||
|
||||
|
||||
def test_stale_definer_does_not_block_other_batches():
|
||||
"""R-13: зависший ensure_issue с зависимыми не держит очередь целиком."""
|
||||
conn = fresh()
|
||||
a, b, first = _two_batches_with_dependent(conn)
|
||||
conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (first["id"],))
|
||||
got = queue.claim(conn)
|
||||
assert got is not None and got["batch_id"] == b["id"] and got["type"] == "refresh"
|
||||
# остаток пакета A остаётся в очереди, не выдаётся и не пропускается
|
||||
assert [c["status"] for c in queue.batch_view(conn, a["id"])["commands"]][1:] == ["queued", "queued"]
|
||||
|
||||
|
||||
def test_fresh_definer_still_blocks_queue():
|
||||
conn = fresh()
|
||||
a, b, first = _two_batches_with_dependent(conn)
|
||||
assert queue.claim(conn) is None # одна в полёте
|
||||
assert queue.claim(conn)["id"] == first["id"] # довыдача определителя, зависимые ждут
|
||||
conn.execute("UPDATE command SET status = 'done', result = '{\"key\": \"A-9\"}' WHERE id = ?", (first["id"],))
|
||||
nxt = queue.claim(conn)
|
||||
assert nxt["batch_id"] == a["id"] and nxt["params"]["source"] == "A-9"
|
||||
|
||||
|
||||
def test_auto_refresh_waits_for_claimed_without_report():
|
||||
conn = fresh()
|
||||
queue.claim(conn, agent_version=1, settings_version=1) # агент на связи
|
||||
queue.create_batch(conn, "y", "claude", [fields("A-3")])
|
||||
queue.claim(conn) # выдана, отчёта нет
|
||||
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 без отчёта — очередь занята
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user