From 8b321793cac2b25b494b6b6fd0115189720f79e8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 22:22:20 +0400 Subject: [PATCH 1/9] =?UTF-8?q?docs(openspec):=20=D1=81=D0=BF=D0=B5=D0=BA?= =?UTF-8?q?=D0=B0=20bridge-reliability=20=E2=80=94=20=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=BD=D0=B0=D1=8F=20=D0=B2=D1=8B=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B0,=20=D1=84=D0=B0=D0=BA=D1=82=D1=8B=20=D0=B0=D0=B3?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=20=D0=BE=20Jira,=20=D1=82=D0=B5=D0=BC?= =?UTF-8?q?=D0=BF=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=B0=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/bridge-reliability/.openspec.yaml | 2 + openspec/changes/bridge-reliability/design.md | 110 ++++++++++++++++++ .../changes/bridge-reliability/proposal.md | 59 ++++++++++ .../bridge-reliability/specs/agent/spec.md | 70 +++++++++++ .../bridge-reliability/specs/commands/spec.md | 101 ++++++++++++++++ .../specs/data-model/spec.md | 52 +++++++++ .../bridge-reliability/specs/hub-api/spec.md | 79 +++++++++++++ .../bridge-reliability/specs/mcp/spec.md | 31 +++++ 8 files changed, 504 insertions(+) create mode 100644 openspec/changes/bridge-reliability/.openspec.yaml create mode 100644 openspec/changes/bridge-reliability/design.md create mode 100644 openspec/changes/bridge-reliability/proposal.md create mode 100644 openspec/changes/bridge-reliability/specs/agent/spec.md create mode 100644 openspec/changes/bridge-reliability/specs/commands/spec.md create mode 100644 openspec/changes/bridge-reliability/specs/data-model/spec.md create mode 100644 openspec/changes/bridge-reliability/specs/hub-api/spec.md create mode 100644 openspec/changes/bridge-reliability/specs/mcp/spec.md diff --git a/openspec/changes/bridge-reliability/.openspec.yaml b/openspec/changes/bridge-reliability/.openspec.yaml new file mode 100644 index 0000000..5fcc9ec --- /dev/null +++ b/openspec/changes/bridge-reliability/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-13 diff --git a/openspec/changes/bridge-reliability/design.md b/openspec/changes/bridge-reliability/design.md new file mode 100644 index 0000000..cbd1bf9 --- /dev/null +++ b/openspec/changes/bridge-reliability/design.md @@ -0,0 +1,110 @@ +## 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` + не проверяется. +- `check_confirmed(…, reporter)` для `ensure_issue` при `reporter is None` не подтверждает; `reporter` берётся из настройки, + которая никогда не задаётся. Для `ensure_link` входящая связь любого типа из `link_types` — конфликт. +- Агент: `JiraClient.last_call_at` (monotonic) только в памяти; `Runner.wait_pause` без `last_call_at` не ждёт; + `recover` ставит `cmd["pause_seconds"] = 0` и вызывает `execute`, который пишет `claimed` ещё раз. + Агент приходит за новой командой (`claim`) только когда своей незавершённой нет: каждый путь `execute` кончается + подтверждённым отчётом или остановкой процесса, `recover` выполняется до первого `claim`. +- `symmetric_types()` агента уже определяет симметричные типы связей по `GET /issueLinkType` (`inward == outward`). +- `db.migrate` только создаёт таблицы (`CREATE TABLE IF NOT EXISTS`) и пишет `schema_version = 1`; миграций по версии нет. + +## Goals / Non-Goals + +Goals: ни одна команда не застревает в `claimed` без пути восстановления, кроме явного предела выдач; `ensure_issue` и +`ensure_link` подтверждаются по состоянию; созданная задача не теряется при отмене; после перезапуска агента темп Jira +соблюдается; живая база мигрирует без потери данных; агент v2 продолжает работать с новым хабом. +Non-goals — см. proposal.md. + +## Decisions + +1. **Повторная выдача (решение владельца, вариант A).** Если при `claim` есть команды в `claimed`, хаб выдаёт самую + старую из них (по `claimed_at`, затем `id`) снова: тот же `id`, те же параметры, `pause_seconds` по текущей политике + темпа, новый `claimed_at`, `deliveries + 1`. Основание — инвариант агента «новый `claim` только без незавершённой + команды»: раз агент пришёл, выданную он не получил или потерял. Все команды идемпотентны («проверь, потом сделай»), + поэтому довыдача уже выполненной команды даёт `done` без записи. Это не повтор в смысле правила «хаб не повторяет + команды сам»: новая команда не создаётся, решение о повторе после `failed` по-прежнему за человеком. +2. **Предел выдач — 3.** Если у команды в `claimed` уже `deliveries >= 3` и агент снова пришёл за командой, хаб переводит + её в `failed` с `error = "агент не отчитался после 3 выдач"`, `finished_at = now`, и выбирает следующую по обычным + правилам (довыдача другой `claimed` или `_next`). Защищает очередь от команды, на которой агент падает. Число 3 — + константа кода, не настройка. +3. **Удаляются `_fresh_claimed_exists` и R-13.** При довыдаче `claimed` команда не может «зависнуть» без агента: если + агента нет, очередь всё равно стоит. Обход пакета с зависшим определителем (`_definer_stale`, `blocked_batches`) больше + не нужен — определитель довыдаётся. `maybe_auto_refresh` вместо «свежей claimed» проверяет «есть любая claimed». + `agent_view.stuck` заменяется на `redelivered` — команды в `claimed` с `deliveries > 1`. +4. **Миграция схемы v1 → v2.** `SCHEMA_VERSION = 2`; `migrate` по номеру из `setting.schema_version`: для v1 — `ALTER TABLE + command ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE agent_status ADD COLUMN jira_user TEXT`, + `ALTER TABLE agent_status ADD COLUMN symmetric_link_types TEXT` (JSON-список), затем `schema_version = 2`, всё в одной + транзакции. Существующие `claimed` команды получают `deliveries = 1` (их уже выдали один раз). Новая база создаётся сразу + в v2. Перед миграцией существующей базы хаб копирует файл в `backups/<дата-время>-schema-v1.sqlite`. +5. **Отчёты.** `report_result` по команде в `queued` — `ValueError`-подобная ошибка, API отвечает 409 «команда не выдана». + Агент на 409 уже считает команду закрытой (`send_result`), правка агента не нужна. По команде в `cancelled` типа + `ensure_issue` с `status = done` и `result.key`: результат и `finished_at` записываются, статус остаётся `cancelled`, + заметка `new:N` переименовывается на ключ. Для остальных терминальных статусов — без изменений, как сейчас. +6. **Дубль темы в пакете — 422.** `validate` пакета отвергает две и более `ensure_issue` с одинаковой `summary` (после + обрезки пробелов по краям), перечисляя темы. Регистр не нормализуется: поиск дубля агентом — точное сравнение. +7. **Факты агента о Jira — в claim.** Тело `claim` получает необязательные `jira_user` (строка, `name` из `GET /myself` — то же + поле, что агент кладёт в `reporter` зеркала) и `symmetric_link_types` (список имён типов Jira с `inward == outward`). + Хаб пишет непустые значения в `agent_status` (`COALESCE`, как остальные поля claim). Это сведения о среде агента, не + настройки: требование data-model «логин Jira в настройках MUST NOT» не затрагивается. Заготовка настройки `reporter` + удаляется из кода. +8. **Подтверждение по состоянию.** `ensure_issue` — задача с той же `summary`, `reporter = agent_status.jira_user`, созданная + не раньше 7 дней назад; при `jira_user IS NULL` — не подтверждается (как сейчас). `ensure_link` — связь есть, если в + зеркале есть `source→target` нужного типа **или** тип симметричен и есть `target→source` этого типа; конфликт — + связь другого типа из `link_types` в любом направлении или обратная связь несимметричного типа. Отчёт импорта + (`missing_links`) использует то же правило «есть» (общая функция в `commands`). +9. **Агент: сбор фактов.** После первой загрузки настроек и до первого `claim` агент вызывает `GET /myself` и + `GET /issueLinkType` (вызовы Jira, обновляют `last_call_at`; пауза следующей команды отсчитывается от них). Ошибка любого + из вызовов не останавливает агента: поле не передаётся, агент повторяет сбор перед следующим `claim` не чаще раза в + 10 минут, пока не получит значение. `symmetric_types()` переиспользует тот же кэш. +10. **Агент: темп после перезапуска.** При старте `Runner` берёт максимальное `at` среди записей журнала (любого вида) и, + если оно не в будущем, ставит `last_call_at = monotonic() − (now − at)`. Запись `finished` пишется сразу после + последнего вызова Jira команды, поэтому оценка не раньше настоящего вызова — ждём не меньше нужного. Пустой журнал — + `last_call_at` пуст, как сейчас. `recover` не обнуляет `pause_seconds`: берёт значение из ответа `GET /agent/commands/{id}`. +11. **Агент: `claimed` при восстановлении не пишется повторно.** `execute(cmd, journal_claimed=True)`; `recover` вызывает с + `False` — запись `claimed`/`started` для этого id уже есть. +12. **Версия агента 3**, `min_agent_version` не поднимается. Хаб принимает claim без новых полей (агент v2). +13. **Признак `online`.** Спека data-model меняется под код: `agent_status` отдаёт `online: false`, если `last_seen_at` + старше 5 минут (решение без обсуждения, см. ниже). + +## Risks / Trade-offs + +- Нарушение инварианта агента (например, будущая параллельность) превратит довыдачу в двойное выполнение — безопасно + из-за идемпотентности, но тратит вызовы Jira. Инвариант фиксируется в спеке agent. +- Довыдача после долгого простоя агента выполнит старую команду: параметры не устаревают сами — человек мог за это время + поменять задачу вручную, команда «вернёт» желаемое значение. Так же вела бы себя команда в очереди; для отмены есть + `cancel force`. +- Миграция живой базы — первая в проекте; страхуется копией перед миграцией и тестом на базе v1. +- Оценка паузы из журнала по `at` старше настоящего последнего вызова, если агент упал во время `refresh` между страницами + (последний вызов Jira позже последней записи). Пауза `refresh` — 2 с, ошибка мала. + +## Migration Plan + +1. Хаб: merge → перезапуск launchd (`launchctl kickstart -k`) → в логе миграция v1→v2 и копия базы; агент v2 продолжает + работу. +2. Агент v3: файл через общую папку на виртуалку, `check`, перезапуск `run`; `agent_status` показывает логин. +3. Проверка: повторный `jira-hub import admin/jira-sync` — `missing_links: 0`. +Откат: остановить хаб, вернуть копию `backups/*-schema-v1.sqlite`, предыдущий код хаба; агент v3 работает с хабом v1 +(лишние поля claim игнорируются — проверить тестом API). + +## Decisions made without discussion + +| Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---| +| 2026-10-13 | Спека data-model приводится к коду: признак `online` (bool), а не `offline` | порог 5 мин совпадает, `online` уже читают MCP и автообновление; правка кода дала бы лишнюю смену контракта | нет: клиентов с `offline` нет | +| 2026-10-13 | Предел довыдач — 3, константа кода | одной недоставки на сбой папки хватает с запасом; «ядовитая» команда не держит очередь | если сбоев папки больше 2 подряд на одной команде — ложный `failed`, лечится `command_retry` | +| 2026-10-13 | Миграция v1→v2 с копией базы перед ней; существующим `claimed` — `deliveries = 1` | первая миграция живой базы; честный счёт выдач | нет | +| 2026-10-13 | Дубль темы в пакете — после `strip`, без нормализации регистра | агент ищет дубль точным сравнением `summary` | темы, различающиеся только регистром, пройдут — так же их различит агент | +| 2026-10-13 | Повторный сбор фактов агента не чаще раза в 10 минут | Jira банка — лимиты; факты меняются редко | логин придёт до 10 минут позже после восстановления Jira | +| 2026-10-13 | Оценка последнего вызова Jira после перезапуска — максимальное `at` журнала | `finished` пишется сразу после последнего вызова; без новой записи на каждый вызов Jira | см. Risks: `refresh` между страницами, ошибка ≤ 2 с | +| 2026-10-13 | Поздний отчёт по отменённой `ensure_issue`: результат пишется, статус `cancelled` сохраняется | зависимые команды уже `skipped`, смена статуса задним числом запутала бы пакет; ключ виден в результате и в заметке | нет | +| 2026-10-13 | `report_result` по `queued` — 409 «команда не выдана» | агент уже трактует 409 как «закрыто», правка агента не нужна | нет | diff --git a/openspec/changes/bridge-reliability/proposal.md b/openspec/changes/bridge-reliability/proposal.md new file mode 100644 index 0000000..32c0e30 --- /dev/null +++ b/openspec/changes/bridge-reliability/proposal.md @@ -0,0 +1,59 @@ +## Why + +Мост работает в ежедневном режиме с 13.10.2026 (агент на виртуалке за VPN «Континент», обмен через общую папку UTM). +Отложенные находки `bridge-core` (#1) и `folder-transport` (#5) содержат несколько мест, где живая работа может привести +к дублю задачи в Jira, к команде, зависшей в `claimed` без пути восстановления, или к лишним запросам к Jira банка: + +- **R-11 / O-9** — хаб не знает логин Jira агента, поэтому `ensure_issue` никогда не подтверждается по состоянию; каждый + потерянный отчёт о создании задачи требует ручного действия. +- **O-3** — связь симметричного типа («Связанные»), стоящая в Jira в обратную сторону, считается конфликтом в + `check_confirmed` и «отсутствующей» в отчёте импорта (13.10.2026 импорт показал 3 такие ложные `missing_links`). +- **M-2** — ответ на `claim` потерян (сбой папки, перезапуск агента во время долгого запроса): команда навсегда `claimed`, + в журнале агента её нет; очередь стоит 10 минут, затем команда требует force-cancel или повтора. +- **M-5** — поздний отчёт по force-отменённой `ensure_issue` теряет ключ созданной задачи. +- **M-11** — отчёт по команде в `queued` принимается; две `ensure_issue` с одной темой в пакете дают одну задачу на два + временных ключа. +- **M-9 / O-2** — после перезапуска агент забывает время последнего вызова Jira и сразу выполняет команду без паузы темпа; + при восстановлении по журналу пауза обнуляется явно. +- **FM-4** — восстановление по журналу повторно пишет `claimed`. +- Расхождение со спекой: `agent_status` отдаёт признак `online`, спека говорит `offline`. + +Решения владельца 13.10.2026 (сессия brainstorming): пакет A follow-ups — первым; вариант A для M-2 — хаб довыдаёт +команду, выданную агенту и оставшуюся без отчёта (принцип «хаб не повторяет команды» уточняется: довыдача +неподтверждённой выдачи — не повтор); дизайн частей 1–3 (повторная выдача и отчёты; факты агента о Jira в claim; темп +после перезапуска, `online`, тесты и выкладка) одобрен. + +## What Changes + +- **jira-hub**: + - повторная выдача: `claim` при наличии команды в `claimed` выдаёт её снова (самую старую), счётчик `deliveries`; + после 3 выдач без отчёта — `failed` «агент не отчитался после 3 выдач»; логика «свежей claimed» (10 минут простоя) и + обход пакета с зависшим определителем (R-13) удаляются; + - отчёт по `queued` — 409; пакет с двумя `ensure_issue` одной темы — 422; + - поздний отчёт по отменённой `ensure_issue` с ключом сохраняет результат и переносит заметку `new:N` → ключ, статус + остаётся `cancelled`; + - `agent_status` хранит `jira_user` и `symmetric_link_types` из claim; `check_confirmed` подтверждает `ensure_issue` + задачей от `jira_user` и не считает обратную связь симметричного типа конфликтом; отчёт импорта так же; + - заготовка настройки `reporter` удаляется; MCP `agent_status` показывает логин агента и довыданные команды. +- **jira-agent** (версия 3): + - при старте `GET /myself` и `GET /issueLinkType`; `jira_user` и `symmetric_link_types` в каждом claim; + - время последнего вызова Jira восстанавливается при старте из журнала; при восстановлении пауза команды не обнуляется; + - при восстановлении `claimed` повторно не пишется. +- Спеки: `hub-api`, `data-model`, `commands`, `agent`, `mcp` — MODIFIED; признак `online` закрепляется в спеке вместо + `offline`. + +## Non-goals + +- Остальные находки follow-ups (`T-n`, `M-n` вне перечня, расхождения второго порядка) — пакеты C и D. +- Проверки поведения на реальной Jira банка (CRLF, Epic Link, постраничность комментариев, поле спринта) — пакет B. +- Подъём `min_agent_version` до 3: агент v2 продолжает работать, новые поля claim необязательны. +- Дубли `ensure_issue` между разными пакетами: поиск дубля по теме за 7 дней уже сводит их к одной задаче. + +## Impact + +- `jira-hub`: `queue.py` (claim, report_result, agent_view), `commands.py` (validate, check_confirmed), `importer.py`, + `db.py` (миграция: `command.deliveries`, `agent_status.jira_user`, `agent_status.symmetric_link_types`), `api.py` + (409 на отчёт по queued), `settings.py`, `mcp_server.py`; `CLAUDE.md` и `ARCHITECTURE.md` — уточнённый принцип повторов. +- `jira-agent`: `src/agent.py` (Runner, Journal, HubClient.claim), `README.md`, версия 3. +- Контракт: тело `POST /agent/claim` получает два необязательных поля; ответы не меняются. +- Issues: aep/jira-hub#9, aep/jira-agent#9. diff --git a/openspec/changes/bridge-reliability/specs/agent/spec.md b/openspec/changes/bridge-reliability/specs/agent/spec.md new file mode 100644 index 0000000..b40b663 --- /dev/null +++ b/openspec/changes/bridge-reliability/specs/agent/spec.md @@ -0,0 +1,70 @@ +## MODIFIED Requirements + +### Requirement: Цикл выполнения +Агент SHALL работать одним процессом: `claim` к хабу (долгий запрос) → при получении команды: досыпать до +`pause_seconds` от последнего вызова Jira → запись «started» в журнал → выполнение по контракту `commands` → запись +«finished» с результатом → отчёт хабу, повторяемый до подтверждения. Одна команда в полёте: новый `claim` агент SHALL +отправлять, только когда у него нет выданной, но не завершённой и не отчитанной команды (на этом хаб строит повторную +выдачу, см. `commands`). При изменении `settings_version` в ответе `claim` агент SHALL перечитать настройки до выполнения +команды. + +Факты о Jira: после первой загрузки настроек и до первого `claim` агент SHALL вызвать `GET /myself` и +`GET /issueLinkType` и передавать в каждом `claim` `jira_user` (`name` из `/myself`) и `symmetric_link_types` (имена типов +с `inward == outward`). Если вызов не удался, агент SHALL продолжить работу без этого поля и повторять сбор перед +`claim` не чаще раза в 10 минут, пока не получит значение. + +#### Scenario: Остановка между командами +- **WHEN** получен Ctrl-C во время выполнения команды +- **THEN** текущая команда доделывается и отчёт отправляется, затем процесс завершается + +#### Scenario: Факты о Jira в claim +- **WHEN** агент стартовал, `/myself` вернул `name = "bridge-user"`, в `/issueLinkType` у «ASUPR_Связанные» + `inward == outward` +- **THEN** каждый `claim` содержит `jira_user = "bridge-user"` и `symmetric_link_types`, включающий «ASUPR_Связанные» + +#### Scenario: Jira недоступна при старте +- **WHEN** `/myself` при старте отвечает ошибкой соединения +- **THEN** агент работает, `claim` без `jira_user`; через 10 минут агент повторяет `/myself` + +### 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 — с фактами о Jira в `claim` и восстановлением темпа) и в каждом +отчёте; хаб предупреждает в `agent_status`, если версия ниже минимальной из настроек. + +#### Scenario: Устаревший агент +- **WHEN** агент версии 1 выходит на связь, а в настройках `min_agent_version = 2` +- **THEN** `agent_status.warning` = «обновите агента», команды выдаются по-прежнему diff --git a/openspec/changes/bridge-reliability/specs/commands/spec.md b/openspec/changes/bridge-reliability/specs/commands/spec.md new file mode 100644 index 0000000..4dd27a2 --- /dev/null +++ b/openspec/changes/bridge-reliability/specs/commands/spec.md @@ -0,0 +1,101 @@ +## MODIFIED Requirements + +### Requirement: Команда — желаемый результат на одной задаче +Каждая команда SHALL описывать желаемое состояние одной задачи Jira и выполняться агентом как «прочитать → сравнить → +записать только разницу». Повторное выполнение любой команды с тем же результатом в Jira SHALL завершаться `done` +без записи. Типы команд и их контракт: + +| Тип | Параметры | Что читает | Что пишет | +|---|---|---|---| +| `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | поиск `project = AND reporter = currentUser() AND created >= -7d`, точное сравнение `summary` | при отсутствии — `POST /issue` (для эпика — с `epic_name_field_id`); результат `key` | +| `ensure_fields` | `key`, любое подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee` | `GET /issue/{key}` | `PUT /issue/{key}` только с отличающимися полями; эпик — по `epic_link.mode` | +| `ensure_comment` | `key`, `marker`, `body` | `GET /issue/{key}/comment` | комментарий с `marker` в начале: `PUT` если текст отличается, `POST` если нет | +| `ensure_status` | `key`, `target` (имя статуса или список-путь) | `GET /issue/{key}` (статус), `GET /issue/{key}/transitions` на каждом шаге | `POST /transitions` по шагам пути; при отсутствии перехода — `failed` с перечнем доступных | +| `ensure_link` | `source`, `target`, `link_type` (логическое имя из `link_types`), `replace` (bool) | `GET /issue/{source}` (`issuelinks`), `GET /issueLinkType` (симметричность) | нужная связь есть — ничего; нет нужной и нет конфликта — `POST /issueLink`; конфликт (другой тип из `link_types` в любом направлении или обратное направление несимметричного типа) и `replace` — `DELETE` лишней, затем `POST`; конфликт без `replace` — `failed` с описанием | +| `ensure_sprint` | `key`, `sprint` (имя или `backlog`) | `GET /issue/{key}` (поле спринта), список спринтов доски из `board_id` (активные и будущие) | `POST /rest/agile/1.0/sprint/{id}/issue` или `POST /rest/agile/1.0/backlog/issue` | +| `refresh` | `keys` (список) или `jql` | `search` с полями зеркала, по 100 на страницу; плюс дочитывание соседей по `issuelinks` | ничего в Jira; состояние уходит в хаб | + +Семантика `ensure_link`: `source` блокирует/ссылается на `target`; агент в `POST /issueLink` SHALL ставить `source` в +`inwardIssue`, `target` в `outwardIssue` (проверено на Jira банка 11.10.2026). Тип связи симметричен, если в Jira у него +`inward == outward`; для симметричного типа связь `target→source` равнозначна `source→target` («нужная связь есть»). + +#### Scenario: Повтор уже выполненной команды +- **WHEN** `ensure_fields {key: AURORA-1533, duedate: 2026-10-23}` выполняется, а в Jira срок уже 23.10 +- **THEN** агент не делает `PUT`, отчёт `done`, `jira_calls = 1` + +#### Scenario: Замена связи +- **WHEN** `ensure_link {source: AURORA-1537, target: AURORA-1538, link_type: "связанные", replace: true}` и в Jira между + ними «блокирует начало» 1537→1538 +- **THEN** `DELETE` старой связи, пауза по политике, `POST` новой; отчёт `done` с обеими операциями + +#### Scenario: Связь-конфликт без replace +- **WHEN** та же команда с `replace: false` +- **THEN** `failed`, в `error` найденная связь, Jira не изменена + +#### Scenario: Симметричная связь в обратную сторону +- **WHEN** `ensure_link {source: AURORA-1445, target: AURORA-1446, link_type: "связанные"}`, тип симметричен, в Jira + есть «Связанные» 1446→1445 +- **THEN** агент ничего не пишет, отчёт `done` с `existed: true` + +#### Scenario: Переход по пути +- **WHEN** `ensure_status {key: X, target: ["Разработка", "Готово"]}` и X в «Ожидание разработки» +- **THEN** два `POST /transitions` с паузой между ними; если X уже «Готово» — `done` без записи + +#### Scenario: Создание с проверкой дубля +- **WHEN** `ensure_issue` повторяется после падения агента, а задача с той же темой от нас уже создана час назад +- **THEN** агент не создаёт вторую, отчёт `done` с ключом найденной + +### 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` + +### 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`). Довыдача — доставка той же выдачи, а не повтор команды. + +**Подтверждение по состоянию.** Если после любого `refresh` желаемый результат команды в `claimed` виден в зеркале, хаб SHALL +перевести её в `confirmed_by_state`: +- `ensure_issue` — задача с той же `summary`, `reporter = agent_status.jira_user`, созданная не раньше 7 дней назад; пока + `jira_user` неизвестен — не подтверждается; +- `ensure_fields` — все поля совпадают; `ensure_status` — статус равен последнему в пути; `ensure_sprint` — спринт совпадает; +- `ensure_link` — нужная связь есть (`source→target` нужного типа или, для типа из `agent_status.symmetric_link_types`, + `target→source` того же типа) и нет конфликта (связи другого типа из `link_types` между теми же задачами в любом + направлении, обратной связи несимметричного типа). +Для `ensure_comment` и `refresh` подтверждения по состоянию нет: такая команда ждёт отчёта агента, довыдачи или отмены +человеком. Новых команд хаб MUST NOT создавать сам: повтор после `failed` — новая команда с теми же параметрами по запросу +интерфейса. + +#### Scenario: Отчёт потерян, факт на месте +- **WHEN** `ensure_sprint {key: X, sprint: "Спринт 2"}` в `claimed` 20 минут, агент перезапущен, `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 {summary: "Шахматка: комментарии"}` в `claimed`, `agent_status.jira_user = "bridge-user"`, + `refresh` загрузил задачу с этой темой, `reporter = "bridge-user"`, созданную сегодня +- **THEN** команда `confirmed_by_state` с ключом задачи; заметка `new:N` перенесена на ключ + +#### Scenario: Связь симметричного типа в обратную сторону +- **WHEN** `ensure_link {source: A, target: B, link_type: "связанные"}` в `claimed`, тип в `symmetric_link_types`, в + зеркале есть только «Связанные» B→A +- **THEN** команда `confirmed_by_state` diff --git a/openspec/changes/bridge-reliability/specs/data-model/spec.md b/openspec/changes/bridge-reliability/specs/data-model/spec.md new file mode 100644 index 0000000..d82c847 --- /dev/null +++ b/openspec/changes/bridge-reliability/specs/data-model/spec.md @@ -0,0 +1,52 @@ +## 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), + `agent_status.jira_user`, `agent_status.symmetric_link_types`; данные на месте; `schema_version = 2` + +### Requirement: Служебные заметки +Таблица `issue_note` SHALL хранить поля, которых нет в Jira: `key` (PK; ключ Jira или временный `new:`), +`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`, `jira_user` +(логин Jira агента, присланный в `claim`), `symmetric_link_types` (JSON-список имён симметричных типов связей Jira, +присланный в `claim`). `jira_user` — сведения о среде агента, а не настройка. Через 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` diff --git a/openspec/changes/bridge-reliability/specs/hub-api/spec.md b/openspec/changes/bridge-reliability/specs/hub-api/spec.md new file mode 100644 index 0000000..902779c --- /dev/null +++ b/openspec/changes/bridge-reliability/specs/hub-api/spec.md @@ -0,0 +1,79 @@ +## MODIFIED Requirements + +### Requirement: Маршруты агента +Хаб SHALL предоставлять агенту следующие маршруты (все под агентским токеном): +- `POST /agent/claim` `{agent_version, settings_version, last_jira_call_at, jira_user?, symmetric_link_types?}` — долгий + запрос до 30 с; ответ `{command: {id, type, params, pause_seconds}, settings_version}` или `{command: null, + settings_version}`; обновляет `agent_status` (непустые `jira_user` и `symmetric_link_types` записываются, отсутствующие + не стирают прежние значения). Если есть команда в `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 «команда не выдана». +- `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, команда осталась `queued` + +#### Scenario: Факты агента в claim +- **WHEN** агент v3 прислал `claim` с `jira_user = "bridge-user"` и `symmetric_link_types = ["ASUPR_Связанные"]`, затем + агент v2 прислал `claim` без этих полей +- **THEN** `agent_status.jira_user` и `symmetric_link_types` сохранили значения от первого `claim` + +### 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` diff --git a/openspec/changes/bridge-reliability/specs/mcp/spec.md b/openspec/changes/bridge-reliability/specs/mcp/spec.md new file mode 100644 index 0000000..fc275cc --- /dev/null +++ b/openspec/changes/bridge-reliability/specs/mcp/spec.md @@ -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` | — | онлайн/офлайн, версия, логин Jira агента, текущая команда, довыданные команды (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** агент v3 на связи, команда 40 выдана дважды и ещё без отчёта +- **THEN** `agent_status` — «агент онлайн (версия 3, Jira: bridge-user, …)» и строка о команде 40 с числом выдач 2 -- 2.47.2 From 84cce848e7f6d67f57035493c683598bcfb85a9e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 22:30:25 +0400 Subject: [PATCH 2/9] =?UTF-8?q?docs(openspec):=20bridge-reliability=20?= =?UTF-8?q?=D1=83=D0=BF=D1=80=D0=BE=D1=89=D0=B5=D0=BD=D0=B0=20=E2=80=94=20?= =?UTF-8?q?R-11=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=B4=D0=BE=D0=B2=D1=8B=D0=B4=D0=B0=D1=87=D0=B5?= =?UTF-8?q?=D0=B9,=20O-3=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20reversed=5Flin?= =?UTF-8?q?ks=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/bridge-reliability/design.md | 138 +++++++++--------- .../changes/bridge-reliability/proposal.md | 47 +++--- .../bridge-reliability/specs/agent/spec.md | 18 +-- .../bridge-reliability/specs/commands/spec.md | 85 +++-------- .../specs/data-model/spec.md | 16 +- .../bridge-reliability/specs/hub-api/spec.md | 20 +-- .../bridge-reliability/specs/mcp/spec.md | 6 +- .../specs/migration/spec.md | 19 +++ 8 files changed, 153 insertions(+), 196 deletions(-) create mode 100644 openspec/changes/bridge-reliability/specs/migration/spec.md diff --git a/openspec/changes/bridge-reliability/design.md b/openspec/changes/bridge-reliability/design.md index cbd1bf9..9701f94 100644 --- a/openspec/changes/bridge-reliability/design.md +++ b/openspec/changes/bridge-reliability/design.md @@ -9,102 +9,106 @@ `_next` обходит пакет, чей определитель `new:N` висит в `claimed` дольше 10 минут (R-13). - `report_result` для терминального статуса возвращает команду без изменений (результат отбрасывается); статус `queued` не проверяется. -- `check_confirmed(…, reporter)` для `ensure_issue` при `reporter is None` не подтверждает; `reporter` берётся из настройки, - которая никогда не задаётся. Для `ensure_link` входящая связь любого типа из `link_types` — конфликт. +- `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`. -- `symmetric_types()` агента уже определяет симметричные типы связей по `GET /issueLinkType` (`inward == outward`). + `recover` ставит `cmd["pause_seconds"] = 0` и вызывает `execute`, который пишет `claimed` ещё раз. Агент приходит за + новой командой (`claim`) только когда своей незавершённой нет: каждый путь `execute` кончается подтверждённым отчётом + или остановкой процесса, `recover` выполняется до первого `claim`. Единственное место, где агент сам теряет выданную + команду, — `reload_settings_before` при недоступном хабе без остановки (команда не пишется в журнал, цикл идёт на + новый `claim`); довыдача закрывает и его. - `db.migrate` только создаёт таблицы (`CREATE TABLE IF NOT EXISTS`) и пишет `schema_version = 1`; миграций по версии нет. + `db.connect` вызывается на каждый запрос API и при старте. ## Goals / Non-Goals -Goals: ни одна команда не застревает в `claimed` без пути восстановления, кроме явного предела выдач; `ensure_issue` и -`ensure_link` подтверждаются по состоянию; созданная задача не теряется при отмене; после перезапуска агента темп Jira -соблюдается; живая база мигрирует без потери данных; агент v2 продолжает работать с новым хабом. +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`. Основание — инвариант агента «новый `claim` только без незавершённой - команды»: раз агент пришёл, выданную он не получил или потерял. Все команды идемпотентны («проверь, потом сделай»), - поэтому довыдача уже выполненной команды даёт `done` без записи. Это не повтор в смысле правила «хаб не повторяет - команды сам»: новая команда не создаётся, решение о повторе после `failed` по-прежнему за человеком. -2. **Предел выдач — 3.** Если у команды в `claimed` уже `deliveries >= 3` и агент снова пришёл за командой, хаб переводит - её в `failed` с `error = "агент не отчитался после 3 выдач"`, `finished_at = now`, и выбирает следующую по обычным - правилам (довыдача другой `claimed` или `_next`). Защищает очередь от команды, на которой агент падает. Число 3 — - константа кода, не настройка. -3. **Удаляются `_fresh_claimed_exists` и R-13.** При довыдаче `claimed` команда не может «зависнуть» без агента: если - агента нет, очередь всё равно стоит. Обход пакета с зависшим определителем (`_definer_stale`, `blocked_batches`) больше - не нужен — определитель довыдаётся. `maybe_auto_refresh` вместо «свежей claimed» проверяет «есть любая claimed». - `agent_view.stuck` заменяется на `redelivered` — команды в `claimed` с `deliveries > 1`. -4. **Миграция схемы v1 → v2.** `SCHEMA_VERSION = 2`; `migrate` по номеру из `setting.schema_version`: для v1 — `ALTER TABLE - command ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE agent_status ADD COLUMN jira_user TEXT`, - `ALTER TABLE agent_status ADD COLUMN symmetric_link_types TEXT` (JSON-список), затем `schema_version = 2`, всё в одной - транзакции. Существующие `claimed` команды получают `deliveries = 1` (их уже выдали один раз). Новая база создаётся сразу - в v2. Перед миграцией существующей базы хаб копирует файл в `backups/<дата-время>-schema-v1.sqlite`. -5. **Отчёты.** `report_result` по команде в `queued` — `ValueError`-подобная ошибка, API отвечает 409 «команда не выдана». - Агент на 409 уже считает команду закрытой (`send_result`), правка агента не нужна. По команде в `cancelled` типа - `ensure_issue` с `status = done` и `result.key`: результат и `finished_at` записываются, статус остаётся `cancelled`, - заметка `new:N` переименовывается на ключ. Для остальных терминальных статусов — без изменений, как сейчас. -6. **Дубль темы в пакете — 422.** `validate` пакета отвергает две и более `ensure_issue` с одинаковой `summary` (после - обрезки пробелов по краям), перечисляя темы. Регистр не нормализуется: поиск дубля агентом — точное сравнение. -7. **Факты агента о Jira — в claim.** Тело `claim` получает необязательные `jira_user` (строка, `name` из `GET /myself` — то же - поле, что агент кладёт в `reporter` зеркала) и `symmetric_link_types` (список имён типов Jira с `inward == outward`). - Хаб пишет непустые значения в `agent_status` (`COALESCE`, как остальные поля claim). Это сведения о среде агента, не - настройки: требование data-model «логин Jira в настройках MUST NOT» не затрагивается. Заготовка настройки `reporter` - удаляется из кода. -8. **Подтверждение по состоянию.** `ensure_issue` — задача с той же `summary`, `reporter = agent_status.jira_user`, созданная - не раньше 7 дней назад; при `jira_user IS NULL` — не подтверждается (как сейчас). `ensure_link` — связь есть, если в - зеркале есть `source→target` нужного типа **или** тип симметричен и есть `target→source` этого типа; конфликт — - связь другого типа из `link_types` в любом направлении или обратная связь несимметричного типа. Отчёт импорта - (`missing_links`) использует то же правило «есть» (общая функция в `commands`). -9. **Агент: сбор фактов.** После первой загрузки настроек и до первого `claim` агент вызывает `GET /myself` и - `GET /issueLinkType` (вызовы Jira, обновляют `last_call_at`; пауза следующей команды отсчитывается от них). Ошибка любого - из вызовов не останавливает агента: поле не передаётся, агент повторяет сбор перед следующим `claim` не чаще раза в - 10 минут, пока не получит значение. `symmetric_types()` переиспользует тот же кэш. -10. **Агент: темп после перезапуска.** При старте `Runner` берёт максимальное `at` среди записей журнала (любого вида) и, - если оно не в будущем, ставит `last_call_at = monotonic() − (now − at)`. Запись `finished` пишется сразу после - последнего вызова Jira команды, поэтому оценка не раньше настоящего вызова — ждём не меньше нужного. Пустой журнал — - `last_call_at` пуст, как сейчас. `recover` не обнуляет `pause_seconds`: берёт значение из ответа `GET /agent/commands/{id}`. -11. **Агент: `claimed` при восстановлении не пишется повторно.** `execute(cmd, journal_claimed=True)`; `recover` вызывает с + старую из них (по `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 уже есть. -12. **Версия агента 3**, `min_agent_version` не поднимается. Хаб принимает claim без новых полей (агент v2). -13. **Признак `online`.** Спека data-model меняется под код: `agent_status` отдаёт `online: false`, если `last_seen_at` +11. **Версия агента 3**, `min_agent_version` не поднимается. Тела запросов агента не меняются. +12. **Признак `online`.** Спека data-model меняется под код: `agent_status` отдаёт `online: false`, если `last_seen_at` старше 5 минут (решение без обсуждения, см. ниже). ## Risks / Trade-offs - Нарушение инварианта агента (например, будущая параллельность) превратит довыдачу в двойное выполнение — безопасно из-за идемпотентности, но тратит вызовы Jira. Инвариант фиксируется в спеке agent. -- Довыдача после долгого простоя агента выполнит старую команду: параметры не устаревают сами — человек мог за это время - поменять задачу вручную, команда «вернёт» желаемое значение. Так же вела бы себя команда в очереди; для отмены есть - `cancel force`. +- Довыдача после долгого простоя агента выполнит старую команду: если человек за это время поменял задачу вручную, + команда «вернёт» желаемое значение. Так же вела бы себя команда в очереди; для отмены есть `cancel force`. +- Гонка двух `claim` через папку (старый долгий запрос ещё ждёт в хабе, агент прислал новый): старый берёт команду, ответ + никто не читает, новый её довыдаёт — `deliveries = 2` без реального сбоя. Предел 3 оставляет запас на один такой + случай плюс одну настоящую недоставку. - Миграция живой базы — первая в проекте; страхуется копией перед миграцией и тестом на базе v1. - Оценка паузы из журнала по `at` старше настоящего последнего вызова, если агент упал во время `refresh` между страницами (последний вызов Jira позже последней записи). Пауза `refresh` — 2 с, ошибка мала. ## Migration Plan -1. Хаб: merge → перезапуск launchd (`launchctl kickstart -k`) → в логе миграция v1→v2 и копия базы; агент v2 продолжает - работу. -2. Агент v3: файл через общую папку на виртуалку, `check`, перезапуск `run`; `agent_status` показывает логин. -3. Проверка: повторный `jira-hub import admin/jira-sync` — `missing_links: 0`. -Откат: остановить хаб, вернуть копию `backups/*-schema-v1.sqlite`, предыдущий код хаба; агент v3 работает с хабом v1 -(лишние поля claim игнорируются — проверить тестом API). +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`, перезапуск `run`. +3. Проверка: повторный `jira-hub import admin/jira-sync` — три связи в `reversed_links`, `missing_links: 0`. +Откат: остановить хаб, вернуть копию `backups/*-schema-v1.sqlite` на место `hub.sqlite`, предыдущий код хаба; агент v3 +работает с хабом v1 (тела запросов не менялись). ## Decisions made without discussion | Дата | Решение | Обоснование | Цена ошибки | |---|---|---|---| | 2026-10-13 | Спека data-model приводится к коду: признак `online` (bool), а не `offline` | порог 5 мин совпадает, `online` уже читают MCP и автообновление; правка кода дала бы лишнюю смену контракта | нет: клиентов с `offline` нет | -| 2026-10-13 | Предел довыдач — 3, константа кода | одной недоставки на сбой папки хватает с запасом; «ядовитая» команда не держит очередь | если сбоев папки больше 2 подряд на одной команде — ложный `failed`, лечится `command_retry` | +| 2026-10-13 | Предел довыдач — 3, константа кода | одной недоставки на сбой папки хватает с запасом; «ядовитая» команда не держит очередь | при 3 сбоях подряд на одной команде — ложный `failed`, лечится `command_retry` | | 2026-10-13 | Миграция v1→v2 с копией базы перед ней; существующим `claimed` — `deliveries = 1` | первая миграция живой базы; честный счёт выдач | нет | | 2026-10-13 | Дубль темы в пакете — после `strip`, без нормализации регистра | агент ищет дубль точным сравнением `summary` | темы, различающиеся только регистром, пройдут — так же их различит агент | -| 2026-10-13 | Повторный сбор фактов агента не чаще раза в 10 минут | Jira банка — лимиты; факты меняются редко | логин придёт до 10 минут позже после восстановления Jira | | 2026-10-13 | Оценка последнего вызова Jira после перезапуска — максимальное `at` журнала | `finished` пишется сразу после последнего вызова; без новой записи на каждый вызов Jira | см. Risks: `refresh` между страницами, ошибка ≤ 2 с | -| 2026-10-13 | Поздний отчёт по отменённой `ensure_issue`: результат пишется, статус `cancelled` сохраняется | зависимые команды уже `skipped`, смена статуса задним числом запутала бы пакет; ключ виден в результате и в заметке | нет | -| 2026-10-13 | `report_result` по `queued` — 409 «команда не выдана» | агент уже трактует 409 как «закрыто», правка агента не нужна | нет | +| 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 | Довыдача не делает резервную копию базы | копия перед первой командой пакета уже сделана при первой выдаче | нет | diff --git a/openspec/changes/bridge-reliability/proposal.md b/openspec/changes/bridge-reliability/proposal.md index 32c0e30..33da10c 100644 --- a/openspec/changes/bridge-reliability/proposal.md +++ b/openspec/changes/bridge-reliability/proposal.md @@ -4,24 +4,28 @@ Отложенные находки `bridge-core` (#1) и `folder-transport` (#5) содержат несколько мест, где живая работа может привести к дублю задачи в Jira, к команде, зависшей в `claimed` без пути восстановления, или к лишним запросам к Jira банка: -- **R-11 / O-9** — хаб не знает логин Jira агента, поэтому `ensure_issue` никогда не подтверждается по состоянию; каждый - потерянный отчёт о создании задачи требует ручного действия. -- **O-3** — связь симметричного типа («Связанные»), стоящая в Jira в обратную сторону, считается конфликтом в - `check_confirmed` и «отсутствующей» в отчёте импорта (13.10.2026 импорт показал 3 такие ложные `missing_links`). - **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 — первым; вариант A для M-2 — хаб довыдаёт -команду, выданную агенту и оставшуюся без отчёта (принцип «хаб не повторяет команды» уточняется: довыдача -неподтверждённой выдачи — не повтор); дизайн частей 1–3 (повторная выдача и отчёты; факты агента о Jira в claim; темп -после перезапуска, `online`, тесты и выкладка) одобрен. +Решения владельца 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 @@ -29,31 +33,34 @@ - повторная выдача: `claim` при наличии команды в `claimed` выдаёт её снова (самую старую), счётчик `deliveries`; после 3 выдач без отчёта — `failed` «агент не отчитался после 3 выдач»; логика «свежей claimed» (10 минут простоя) и обход пакета с зависшим определителем (R-13) удаляются; + - миграция схемы v1 → v2 (`command.deliveries`) с копией базы перед ней — первая миграция живой базы; - отчёт по `queued` — 409; пакет с двумя `ensure_issue` одной темы — 422; - поздний отчёт по отменённой `ensure_issue` с ключом сохраняет результат и переносит заметку `new:N` → ключ, статус остаётся `cancelled`; - - `agent_status` хранит `jira_user` и `symmetric_link_types` из claim; `check_confirmed` подтверждает `ensure_issue` - задачей от `jira_user` и не считает обратную связь симметричного типа конфликтом; отчёт импорта так же; - - заготовка настройки `reporter` удаляется; MCP `agent_status` показывает логин агента и довыданные команды. + - отчёт импорта: связи из `links.csv`, которые в зеркале есть того же типа в обратную сторону, — в `reversed_links`, + а не в `missing_links`; + - заготовка настройки `reporter` удаляется; `agent_status` — `redelivered` вместо `stuck`; MCP показывает довыданные + команды; принцип повторов уточняется в `CLAUDE.md`, `ARCHITECTURE.md`, docstring `queue`. - **jira-agent** (версия 3): - - при старте `GET /myself` и `GET /issueLinkType`; `jira_user` и `symmetric_link_types` в каждом claim; - время последнего вызова Jira восстанавливается при старте из журнала; при восстановлении пауза команды не обнуляется; - при восстановлении `claimed` повторно не пишется. -- Спеки: `hub-api`, `data-model`, `commands`, `agent`, `mcp` — MODIFIED; признак `online` закрепляется в спеке вместо - `offline`. +- Спеки: `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 продолжает работать, новые поля claim необязательны. +- Подъём `min_agent_version` до 3: агент v2 продолжает работать. - Дубли `ensure_issue` между разными пакетами: поиск дубля по теме за 7 дней уже сводит их к одной задаче. ## Impact -- `jira-hub`: `queue.py` (claim, report_result, agent_view), `commands.py` (validate, check_confirmed), `importer.py`, - `db.py` (миграция: `command.deliveries`, `agent_status.jira_user`, `agent_status.symmetric_link_types`), `api.py` - (409 на отчёт по queued), `settings.py`, `mcp_server.py`; `CLAUDE.md` и `ARCHITECTURE.md` — уточнённый принцип повторов. -- `jira-agent`: `src/agent.py` (Runner, Journal, HubClient.claim), `README.md`, версия 3. -- Контракт: тело `POST /agent/claim` получает два необязательных поля; ответы не меняются. +- `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. diff --git a/openspec/changes/bridge-reliability/specs/agent/spec.md b/openspec/changes/bridge-reliability/specs/agent/spec.md index b40b663..029fd04 100644 --- a/openspec/changes/bridge-reliability/specs/agent/spec.md +++ b/openspec/changes/bridge-reliability/specs/agent/spec.md @@ -8,24 +8,10 @@ выдачу, см. `commands`). При изменении `settings_version` в ответе `claim` агент SHALL перечитать настройки до выполнения команды. -Факты о Jira: после первой загрузки настроек и до первого `claim` агент SHALL вызвать `GET /myself` и -`GET /issueLinkType` и передавать в каждом `claim` `jira_user` (`name` из `/myself`) и `symmetric_link_types` (имена типов -с `inward == outward`). Если вызов не удался, агент SHALL продолжить работу без этого поля и повторять сбор перед -`claim` не чаще раза в 10 минут, пока не получит значение. - #### Scenario: Остановка между командами - **WHEN** получен Ctrl-C во время выполнения команды - **THEN** текущая команда доделывается и отчёт отправляется, затем процесс завершается -#### Scenario: Факты о Jira в claim -- **WHEN** агент стартовал, `/myself` вернул `name = "bridge-user"`, в `/issueLinkType` у «ASUPR_Связанные» - `inward == outward` -- **THEN** каждый `claim` содержит `jira_user = "bridge-user"` и `symmetric_link_types`, включающий «ASUPR_Связанные» - -#### Scenario: Jira недоступна при старте -- **WHEN** `/myself` при старте отвечает ошибкой соединения -- **THEN** агент работает, `claim` без `jira_user`; через 10 минут агент повторяет `/myself` - ### Requirement: Журнал на диске и восстановление Агент SHALL вести `journal.jsonl` (только дозапись) рядом с собой: строки `claimed {command_id, at}` (сразу после получения команды, до паузы), `started {command_id, at}`, `finished {command_id, status, result, error, jira_calls, @@ -62,8 +48,8 @@ rate_limited, at}`, `acked {command_id, at}`. При старте агент SHA ### Requirement: Доставка и запуск README агента SHALL описывать: копирование `agent.py` и `agent.example.json` через общую папку, заполнение `agent.json` (включая выбор транспорта в `hub_url`), `check`, запуск в терминале и через `nohup` с логом, опциональный launchd-plist, -`prune`. Версия агента SHALL быть в константе файла (3 — с фактами о Jira в `claim` и восстановлением темпа) и в каждом -отчёте; хаб предупреждает в `agent_status`, если версия ниже минимальной из настроек. +`prune`. Версия агента SHALL быть в константе файла (3 — с восстановлением темпа после перезапуска) и в каждом отчёте; +хаб предупреждает в `agent_status`, если версия ниже минимальной из настроек. #### Scenario: Устаревший агент - **WHEN** агент версии 1 выходит на связь, а в настройках `min_agent_version = 2` diff --git a/openspec/changes/bridge-reliability/specs/commands/spec.md b/openspec/changes/bridge-reliability/specs/commands/spec.md index 4dd27a2..110b93c 100644 --- a/openspec/changes/bridge-reliability/specs/commands/spec.md +++ b/openspec/changes/bridge-reliability/specs/commands/spec.md @@ -1,50 +1,5 @@ ## MODIFIED Requirements -### Requirement: Команда — желаемый результат на одной задаче -Каждая команда SHALL описывать желаемое состояние одной задачи Jira и выполняться агентом как «прочитать → сравнить → -записать только разницу». Повторное выполнение любой команды с тем же результатом в Jira SHALL завершаться `done` -без записи. Типы команд и их контракт: - -| Тип | Параметры | Что читает | Что пишет | -|---|---|---|---| -| `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | поиск `project = AND reporter = currentUser() AND created >= -7d`, точное сравнение `summary` | при отсутствии — `POST /issue` (для эпика — с `epic_name_field_id`); результат `key` | -| `ensure_fields` | `key`, любое подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee` | `GET /issue/{key}` | `PUT /issue/{key}` только с отличающимися полями; эпик — по `epic_link.mode` | -| `ensure_comment` | `key`, `marker`, `body` | `GET /issue/{key}/comment` | комментарий с `marker` в начале: `PUT` если текст отличается, `POST` если нет | -| `ensure_status` | `key`, `target` (имя статуса или список-путь) | `GET /issue/{key}` (статус), `GET /issue/{key}/transitions` на каждом шаге | `POST /transitions` по шагам пути; при отсутствии перехода — `failed` с перечнем доступных | -| `ensure_link` | `source`, `target`, `link_type` (логическое имя из `link_types`), `replace` (bool) | `GET /issue/{source}` (`issuelinks`), `GET /issueLinkType` (симметричность) | нужная связь есть — ничего; нет нужной и нет конфликта — `POST /issueLink`; конфликт (другой тип из `link_types` в любом направлении или обратное направление несимметричного типа) и `replace` — `DELETE` лишней, затем `POST`; конфликт без `replace` — `failed` с описанием | -| `ensure_sprint` | `key`, `sprint` (имя или `backlog`) | `GET /issue/{key}` (поле спринта), список спринтов доски из `board_id` (активные и будущие) | `POST /rest/agile/1.0/sprint/{id}/issue` или `POST /rest/agile/1.0/backlog/issue` | -| `refresh` | `keys` (список) или `jql` | `search` с полями зеркала, по 100 на страницу; плюс дочитывание соседей по `issuelinks` | ничего в Jira; состояние уходит в хаб | - -Семантика `ensure_link`: `source` блокирует/ссылается на `target`; агент в `POST /issueLink` SHALL ставить `source` в -`inwardIssue`, `target` в `outwardIssue` (проверено на Jira банка 11.10.2026). Тип связи симметричен, если в Jira у него -`inward == outward`; для симметричного типа связь `target→source` равнозначна `source→target` («нужная связь есть»). - -#### Scenario: Повтор уже выполненной команды -- **WHEN** `ensure_fields {key: AURORA-1533, duedate: 2026-10-23}` выполняется, а в Jira срок уже 23.10 -- **THEN** агент не делает `PUT`, отчёт `done`, `jira_calls = 1` - -#### Scenario: Замена связи -- **WHEN** `ensure_link {source: AURORA-1537, target: AURORA-1538, link_type: "связанные", replace: true}` и в Jira между - ними «блокирует начало» 1537→1538 -- **THEN** `DELETE` старой связи, пауза по политике, `POST` новой; отчёт `done` с обеими операциями - -#### Scenario: Связь-конфликт без replace -- **WHEN** та же команда с `replace: false` -- **THEN** `failed`, в `error` найденная связь, Jira не изменена - -#### Scenario: Симметричная связь в обратную сторону -- **WHEN** `ensure_link {source: AURORA-1445, target: AURORA-1446, link_type: "связанные"}`, тип симметричен, в Jira - есть «Связанные» 1446→1445 -- **THEN** агент ничего не пишет, отчёт `done` с `existed: true` - -#### Scenario: Переход по пути -- **WHEN** `ensure_status {key: X, target: ["Разработка", "Готово"]}` и X в «Ожидание разработки» -- **THEN** два `POST /transitions` с паузой между ними; если X уже «Готово» — `done` без записи - -#### Scenario: Создание с проверкой дубля -- **WHEN** `ensure_issue` повторяется после падения агента, а задача с той же темой от нас уже создана час назад -- **THEN** агент не создаёт вторую, отчёт `done` с ключом найденной - ### Requirement: Пакет и порядок выполнения Пакет SHALL создаваться одним вызовом с автором, заголовком и списком команд; хаб SHALL назначить `seq` по порядку списка и добавить в хвост `refresh` по всем ключам пакета (кроме пакетов, состоящих только из `refresh`). Пакет с двумя и более @@ -56,6 +11,10 @@ - **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 символов), пакет продолжается. @@ -64,23 +23,18 @@ сначала выдать снова самую старую (по `claimed_at`, затем `id`) команду в `claimed`: тот же `id` и параметры, `pause_seconds` по текущей политике темпа, новый `claimed_at`, `deliveries + 1`. Если у неё уже `deliveries >= 3`, хаб SHALL перевести её в `failed` с `error = "агент не отчитался после 3 выдач"` и перейти к следующей (довыдача другой `claimed` -или следующая `queued`). Довыдача — доставка той же выдачи, а не повтор команды. +или следующая `queued`). Довыдача — доставка той же выдачи, а не повтор команды: новых команд хаб MUST NOT создавать +сам, повтор после `failed` — новая команда с теми же параметрами по запросу интерфейса. -**Подтверждение по состоянию.** Если после любого `refresh` желаемый результат команды в `claimed` виден в зеркале, хаб SHALL -перевести её в `confirmed_by_state`: -- `ensure_issue` — задача с той же `summary`, `reporter = agent_status.jira_user`, созданная не раньше 7 дней назад; пока - `jira_user` неизвестен — не подтверждается; -- `ensure_fields` — все поля совпадают; `ensure_status` — статус равен последнему в пути; `ensure_sprint` — спринт совпадает; -- `ensure_link` — нужная связь есть (`source→target` нужного типа или, для типа из `agent_status.symmetric_link_types`, - `target→source` того же типа) и нет конфликта (связи другого типа из `link_types` между теми же задачами в любом - направлении, обратной связи несимметричного типа). -Для `ensure_comment` и `refresh` подтверждения по состоянию нет: такая команда ждёт отчёта агента, довыдачи или отмены -человеком. Новых команд хаб 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` 20 минут, агент перезапущен, `refresh` показал X в «Спринт 2» -- **THEN** команда `confirmed_by_state`, следующая команда очереди выдаётся +- **WHEN** `ensure_sprint {key: X, sprint: "Спринт 2"}` в `claimed`, после выполненного `refresh` зеркало показывает X в «Спринт 2» +- **THEN** команда `confirmed_by_state` #### Scenario: Ответ на claim потерян - **WHEN** команда 40 выдана (`deliveries = 1`), ответ до агента не дошёл, агент снова прислал `claim` @@ -90,12 +44,7 @@ - **WHEN** команда 41 в `claimed` с `deliveries = 3`, агент снова прислал `claim`, в очереди есть команда 42 - **THEN** 41 — `failed` «агент не отчитался после 3 выдач»; `claim` отдаёт 42 -#### Scenario: Создание подтверждается по состоянию -- **WHEN** `ensure_issue {summary: "Шахматка: комментарии"}` в `claimed`, `agent_status.jira_user = "bridge-user"`, - `refresh` загрузил задачу с этой темой, `reporter = "bridge-user"`, созданную сегодня -- **THEN** команда `confirmed_by_state` с ключом задачи; заметка `new:N` перенесена на ключ - -#### Scenario: Связь симметричного типа в обратную сторону -- **WHEN** `ensure_link {source: A, target: B, link_type: "связанные"}` в `claimed`, тип в `symmetric_link_types`, в - зеркале есть только «Связанные» B→A -- **THEN** команда `confirmed_by_state` +#### Scenario: Потерянный отчёт о создании задачи +- **WHEN** `ensure_issue {temp_key: new:5, summary: "Отчёт"}` выполнена агентом, отчёт не дошёл, агент прислал `claim` +- **THEN** команда выдаётся снова; агент находит созданную задачу поиском дубля и отчитывается `done` с её ключом; второй + задачи нет, заметка `new:5` перенесена на ключ diff --git a/openspec/changes/bridge-reliability/specs/data-model/spec.md b/openspec/changes/bridge-reliability/specs/data-model/spec.md index d82c847..849b860 100644 --- a/openspec/changes/bridge-reliability/specs/data-model/spec.md +++ b/openspec/changes/bridge-reliability/specs/data-model/spec.md @@ -3,8 +3,8 @@ ### 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`. Денежных и дробных +в `setting.schema_version` (текущая — 2). Миграция существующей базы SHALL выполняться в одной транзакции; перед ней +хаб SHALL скопировать файл базы в `backups/<дата-время>-schema-v<старая>.sqlite` рядом с базой. Денежных и дробных полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени). #### Scenario: Первый запуск @@ -13,8 +13,8 @@ #### Scenario: Миграция базы v1 - **WHEN** хаб стартует с базой `schema_version = 1`, в которой есть задачи, заметки и команда в `claimed` -- **THEN** в `backups/` копия базы v1; добавлены `command.deliveries` (у `claimed` — 1, у остальных — 0), - `agent_status.jira_user`, `agent_status.symmetric_link_types`; данные на месте; `schema_version = 2` +- **THEN** в `backups/` копия базы v1; добавлена `command.deliveries` (у `claimed` — 1, у остальных — 0); данные на месте; + `schema_version = 2` ### Requirement: Служебные заметки Таблица `issue_note` SHALL хранить поля, которых нет в Jira: `key` (PK; ключ Jira или временный `new:`), @@ -41,11 +41,9 @@ SHALL вычисляться из команд, не храниться. ### 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`, `jira_user` -(логин Jira агента, присланный в `claim`), `symmetric_link_types` (JSON-список имён симметричных типов связей Jira, -присланный в `claim`). `jira_user` — сведения о среде агента, а не настройка. Через API и MCP `agent_status` SHALL -отдаваться с признаком `online` (истина, если `last_seen_at` моложе 5 минут) и списком `redelivered` — команд в -`claimed`, выданных больше одного раза (`id`, `type`, `claimed_at`, `deliveries`). +хранить `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 минут diff --git a/openspec/changes/bridge-reliability/specs/hub-api/spec.md b/openspec/changes/bridge-reliability/specs/hub-api/spec.md index 902779c..6658598 100644 --- a/openspec/changes/bridge-reliability/specs/hub-api/spec.md +++ b/openspec/changes/bridge-reliability/specs/hub-api/spec.md @@ -2,17 +2,16 @@ ### Requirement: Маршруты агента Хаб SHALL предоставлять агенту следующие маршруты (все под агентским токеном): -- `POST /agent/claim` `{agent_version, settings_version, last_jira_call_at, jira_user?, symmetric_link_types?}` — долгий - запрос до 30 с; ответ `{command: {id, type, params, pause_seconds}, settings_version}` или `{command: null, - settings_version}`; обновляет `agent_status` (непустые `jira_user` и `symmetric_link_types` записываются, отсутствующие - не стирают прежние значения). Если есть команда в `claimed`, `claim` SHALL выдать её снова (повторная выдача, см. - `commands`), а не следующую из очереди. +- `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 «команда не выдана». + отменённой `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` — ключи зеркала (для определения соседей). @@ -27,12 +26,7 @@ #### Scenario: Отчёт по невыданной команде - **WHEN** `POST /agent/commands/16/result` с `status = done`, команда 16 в `queued` -- **THEN** 409, команда осталась `queued` - -#### Scenario: Факты агента в claim -- **WHEN** агент v3 прислал `claim` с `jira_user = "bridge-user"` и `symmetric_link_types = ["ASUPR_Связанные"]`, затем - агент v2 прислал `claim` без этих полей -- **THEN** `agent_status.jira_user` и `symmetric_link_types` сохранили значения от первого `claim` +- **THEN** 409 `not_claimed`, команда осталась `queued` ### Requirement: Маршруты интерфейсов Хаб SHALL предоставлять интерфейсам (MCP, позже просмотрщик) следующие маршруты под клиентским токеном: diff --git a/openspec/changes/bridge-reliability/specs/mcp/spec.md b/openspec/changes/bridge-reliability/specs/mcp/spec.md index fc275cc..4ab13bb 100644 --- a/openspec/changes/bridge-reliability/specs/mcp/spec.md +++ b/openspec/changes/bridge-reliability/specs/mcp/spec.md @@ -12,7 +12,7 @@ | `batches` | `limit` | последние пакеты со статусом | | `command_retry`, `batch_cancel` | `id` | новый пакет / результат отмены | | `refresh` | `keys` или `full`, `wait` (bool) | `sync_run` или id пакета | -| `agent_status` | — | онлайн/офлайн, версия, логин Jira агента, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек | +| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек | | `sync_log` | `limit` | последние `sync_run` | | `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` | @@ -27,5 +27,5 @@ - **THEN** ответ через 6 минут со статусами всех команд #### Scenario: Статус агента с довыдачей -- **WHEN** агент v3 на связи, команда 40 выдана дважды и ещё без отчёта -- **THEN** `agent_status` — «агент онлайн (версия 3, Jira: bridge-user, …)» и строка о команде 40 с числом выдач 2 +- **WHEN** агент на связи, команда 40 выдана дважды и ещё без отчёта +- **THEN** ответ `agent_status` содержит «агент онлайн» и строку о команде 40 с числом выдач 2 diff --git a/openspec/changes/bridge-reliability/specs/migration/spec.md b/openspec/changes/bridge-reliability/specs/migration/spec.md new file mode 100644 index 0000000..9551943 --- /dev/null +++ b/openspec/changes/bridge-reliability/specs/migration/spec.md @@ -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` её нет -- 2.47.2 From 234ea75d4f9040f0b8e075a76d0d7ca6e8a7d890 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 22:36:33 +0400 Subject: [PATCH 3/9] =?UTF-8?q?docs(plan):=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=85=D0=B0=D0=B1=D0=B0=20bridge-reliability=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../2026-10-13-bridge-reliability-hub.md | 777 ++++++++++++++++++ 1 file changed, 777 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-13-bridge-reliability-hub.md diff --git a/docs/superpowers/plans/2026-10-13-bridge-reliability-hub.md b/docs/superpowers/plans/2026-10-13-bridge-reliability-hub.md new file mode 100644 index 0000000..42c18c9 --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-bridge-reliability-hub.md @@ -0,0 +1,777 @@ +# bridge-reliability — хаб: план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** хаб довыдаёт команду, оставшуюся без отчёта (с пределом 3 выдачи), мигрирует живую базу на схему v2, отвергает +отчёт по невыданной команде и пакет с дублем темы, сохраняет ключ из позднего отчёта по отменённой `ensure_issue`, +показывает связи «в обратную сторону» в отчёте импорта отдельно. + +**Architecture:** вся логика выдачи и отчётов — в `src/jira_hub/queue.py` (одна транзакция на `claim`/`report_result`); +миграция — в `db.migrate` по `setting.schema_version`; валидация пакета — `commands.validate`; отчёт импорта — +`importer._import`. HTTP-слой (`api.py`) только переводит исключения в коды. + +**Tech Stack:** Python 3.13, uv, pytest, SQLite (stdlib), stdlib `http.server`. + +**Spec:** `openspec/changes/bridge-reliability/` (proposal.md, design.md, specs/{hub-api,data-model,commands,mcp,migration,agent}). + +## Global Constraints + +- Комментарии, docstring, сообщения ошибок и коммитов — по-русски; идентификаторы — английские. +- `uv run pytest -q` — зелёным считается только весь набор (до начала: 159 passed). +- Новых зависимостей нет. Контракт тел запросов агента не меняется. +- Предел выдач — константа `MAX_DELIVERIES = 3`; текст ошибки ровно `агент не отчитался после 3 выдач`. +- `SCHEMA_VERSION = 2`; копия базы перед миграцией — `<каталог базы>/backups/<дата-время>-schema-v1.sqlite`, без ротации. +- Каждая задача заканчивается коммитом `git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/reliability …` + с сообщением на русском и строкой `Co-Authored-By: Claude Opus 5.5 `; суффикс `(#9)` в заголовке. +- Решения, принятые исполнителем без обсуждения, — в `openspec/changes/bridge-reliability/design.md`, раздел + `## Decisions made without discussion`, с маркером `R-n`. + +## Review Focus + +1. Живая база v1 с командой в `claimed`: после старта нового хаба команда довыдаётся (`deliveries` 1 → 2), а не теряется и + не падает миграция — тест в Task 1 (миграция) и Task 2 (довыдача команды, мигрированной с `deliveries = 1`). +2. Два параллельных `claim` (старый долгий запрос реле и новый): одна строка в `claimed`, `deliveries` = числу выдач, + обе выдачи — один и тот же `id` — тест `test_claim_is_exclusive` в Task 2. +3. Довыдача `refresh`: второй `sync_run` не создаётся (иначе счётчики синхронизаций двоятся) — тест в Task 2. +4. Довыдача команды из отменённого пакета (`cancel_batch` оставляет `claimed`): выдаётся снова, не теряется — тест в Task 2. +5. Поздний отчёт по отменённой `ensure_issue` приходит дважды или после того, как результат уже записан: второй раз + ничего не меняет (идемпотентность) — тест в Task 3. + +--- + +### Task 1: Миграция схемы v1 → v2 (`command.deliveries`) + +**Files:** +- Modify: `src/jira_hub/db.py` (`SCHEMA_VERSION`, таблица `command` в `SCHEMA`, `connect`, `migrate`) +- Test: `tests/test_db.py` + +**Interfaces:** +- Consumes: `backup.make_backup(conn, backups_dir, label, keep)` из `src/jira_hub/backup.py` (без изменений; `keep=0` — + без ротации). +- Produces: колонка `command.deliveries INTEGER NOT NULL DEFAULT 0`; `db.SCHEMA_VERSION == 2`; + `db.migrate(conn, path=None)`. + +- [ ] **Step 1: Write the failing tests** — в `tests/test_db.py` заменить проверку версии и добавить тесты миграции: + +```python +import sqlite3 + +from jira_hub import db + + +def test_connect_creates_schema(): + conn = db.connect(":memory:") + names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} + assert {"setting", "issue", "issue_link", "issue_note", "batch", "command", "sync_run", "agent_status"} <= names + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2" + assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 + cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")} + assert "deliveries" in cols + + +def _v1_database(path): + """База в состоянии схемы v1: без command.deliveries, schema_version = 1, с живыми данными.""" + conn = db.connect(path) + conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('x', 'claude', '2026-10-13T00:00:00+00:00')") + for status in ("claimed", "done", "queued"): + conn.execute("INSERT INTO command(batch_id, seq, type, params, status, claimed_at) VALUES (1, 1, 'refresh', '{}', ?, ?)", + (status, "2026-10-13T00:00:00+00:00" if status == "claimed" else None)) + conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')") + conn.execute("ALTER TABLE command DROP COLUMN deliveries") + conn.execute("UPDATE setting SET value = '1' WHERE key = 'schema_version'") + conn.close() + + +def test_migrates_v1_with_backup(tmp_path): + path = tmp_path / "hub.sqlite" + _v1_database(path) + conn = db.connect(path) + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2" + rows = conn.execute("SELECT status, deliveries FROM command ORDER BY id").fetchall() + assert [(r["status"], r["deliveries"]) for r in rows] == [("claimed", 1), ("done", 0), ("queued", 0)] + assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе" + copies = list((tmp_path / "backups").glob("*-schema-v1.sqlite")) + assert len(copies) == 1 + old = sqlite3.connect(copies[0]) # не db.connect — он бы мигрировал копию + assert "deliveries" not in {r[1] for r in old.execute("PRAGMA table_info(command)")} + assert old.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()[0] == "1" + old.close() + conn.close() + db.connect(path).close() # повторный connect не мигрирует и не копирует + assert len(list((tmp_path / "backups").glob("*.sqlite"))) == 1 + + +def test_new_database_makes_no_backup(tmp_path): + db.connect(tmp_path / "hub.sqlite").close() + assert not (tmp_path / "backups").exists() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_db.py -q` +Expected: FAIL — `schema_version` равен `"1"`, колонки `deliveries` нет. + +- [ ] **Step 3: Implement** — в `src/jira_hub/db.py`: + +1. Импорт рядом с остальными: `from . import backup` (модуль `backup` не импортирует `db`, цикла нет). +2. `SCHEMA_VERSION = 2`. +3. В `CREATE TABLE IF NOT EXISTS command` после строки `claimed_at TEXT,` добавить + ` deliveries INTEGER NOT NULL DEFAULT 0,`. +4. После `SCHEMA` добавить шаги миграций: + +```python +# Шаги миграции: версия → SQL, переводящий схему в версию + 1 (design.md bridge-reliability, решение 5) +MIGRATIONS = { + 1: ["ALTER TABLE command ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0", + "UPDATE command SET deliveries = 1 WHERE status = 'claimed'"], # уже выданные — одна выдача +} +``` + +5. В `connect` заменить `migrate(conn)` на `migrate(conn, None if is_memory else path)`. +6. Заменить `migrate`: + +```python +def _schema_version(conn) -> int | None: + row = conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone() + return None if row is None else int(json.loads(row["value"])) + + +def migrate(conn: sqlite3.Connection, path=None) -> None: + """Создать недостающие таблицы и поднять схему существующей базы до SCHEMA_VERSION. Перед миграцией файловой базы — + копия в <каталог базы>/backups (без ротации). Параллельный connect безопасен: версия перечитывается в транзакции.""" + conn.executescript(SCHEMA) + version = _schema_version(conn) + if version is None: + conn.execute("INSERT INTO setting(key, value) VALUES ('schema_version', ?)", (json.dumps(SCHEMA_VERSION),)) + elif version < SCHEMA_VERSION: + if path is not None: + backup.make_backup(conn, Path(path).parent / "backups", f"schema-v{version}", keep=0) + with transaction(conn): + current = _schema_version(conn) + while current < SCHEMA_VERSION: + for sql in MIGRATIONS[current]: + conn.execute(sql) + current += 1 + conn.execute("UPDATE setting SET value = ? WHERE key = 'schema_version'", (json.dumps(current),)) + conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)") +``` + +Функция `transaction` определена в том же модуле ниже `migrate` — вызов во время выполнения, порядок определения не +важен. + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest tests/test_db.py -q` — PASS. Затем `uv run pytest -q` — весь набор зелёный (другие тесты колонку +не используют). + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/reliability add src/jira_hub/db.py tests/test_db.py +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/reliability commit -m "feat(db): миграция схемы v1→v2 — command.deliveries, копия базы перед миграцией (#9) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Повторная выдача команды без отчёта, предел 3 выдачи + +**Files:** +- Modify: `src/jira_hub/queue.py` (docstring модуля, константы, `_definer_stale`, `_next`, `_fresh_claimed_exists`, + `claim`, `agent_view`, `maybe_auto_refresh`) +- Modify: `src/jira_hub/mcp_server.py` (`fmt_agent`) +- Modify: `CLAUDE.md` (строка «Хаб не повторяет команды сам»), `ARCHITECTURE.md` (строка про `queue` в «Границы»), + `README.md` (раздел «Эксплуатация», пункт про «нет ответа от агента») +- Test: `tests/test_queue.py`, `tests/test_api.py`, `tests/test_e2e_hub.py`, `tests/test_mcp.py` + +**Interfaces:** +- Consumes: `command.deliveries` (Task 1). +- Produces: `queue.MAX_DELIVERIES = 3`; `queue.claim(...)` с прежней сигнатурой и формой ответа + `{id, type, params, pause_seconds, batch_id}`; `queue.agent_view(conn)["redelivered"]` — список + `{id, type, claimed_at, deliveries}` (ключа `stuck` больше нет). + +- [ ] **Step 1: Write the failing tests** — в `tests/test_queue.py`: + +1. В `test_claim_order_pause_and_report` заменить строку + `assert queue.claim(conn) is None # одна команда в полёте` на + +```python + again = queue.claim(conn) # ответ потерян — та же команда снова + assert again["id"] == c["id"] and again["pause_seconds"] == 120 + assert queue.get_command(conn, c["id"])["deliveries"] == 2 +``` + +2. В `test_claim_is_exclusive` заменить последние две проверки на + +```python + got = [r for r in results if r is not None] + assert got and len({r["id"] for r in got}) == 1 # один и тот же id при любом порядке потоков + row = setup.execute("SELECT COUNT(*) AS n, MAX(deliveries) AS d FROM command WHERE status = 'claimed'").fetchone() + assert row["n"] == 1 and row["d"] == len(got) +``` + +3. Удалить `test_stale_claimed_does_not_block` и `test_stale_definer_does_not_block_other_batches`. +4. В `test_cancel_and_retry` заменить `assert queue.claim(conn) is None` на + `assert queue.claim(conn)["id"] == c["id"] # взятая из отменённого пакета довыдаётся`. +5. В `test_fresh_definer_still_blocks_queue` заменить `assert queue.claim(conn) is None # одна в полёте` + на `assert queue.claim(conn)["id"] == first["id"] # довыдача определителя, зависимые ждут`. +6. `test_reconcile_confirms_by_state` переписать (две `claimed` одновременно бывают только в старых данных — моделируем SQL): + +```python +def test_reconcile_confirms_by_state(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}]) + c = queue.claim(conn) + b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + r = queue.batch_view(conn, b["id"])["commands"][0] + conn.execute("UPDATE command SET status = 'claimed', claimed_at = ?, deliveries = 1 WHERE id = ?", (db.now(), r["id"])) + mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": []}], []) + queue.report_result(conn, r["id"], "done") + assert queue.get_command(conn, c["id"])["status"] == "confirmed_by_state" + assert queue.batch_view(conn, b["id"])["status"] == "done" +``` + +7. В `test_auto_refresh_conditions` в конец добавить: + +```python + conn.execute("UPDATE sync_run SET started_at = '2020-01-01T00:00:00+00:00'") # интервал прошёл + queue.create_batch(conn, "y", "claude", [fields("A-3")]) + queue.claim(conn) # выдана, отчёта нет, queued нет + conn.execute("DELETE FROM command WHERE status = 'queued'") # хвостовой refresh не мешает проверке + conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE status = 'claimed'") # давно без отчёта + assert queue.maybe_auto_refresh(conn) is None # claimed без отчёта — очередь занята +``` + +8. Добавить новые тесты: + +```python +def test_redelivery_of_lost_claim_before_queue(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) + c = queue.claim(conn) + again = queue.claim(conn) + assert again["id"] == c["id"] and again["params"] == c["params"] + assert queue.agent_view(conn)["current_command_id"] == c["id"] + view = queue.agent_view(conn)["redelivered"] + assert [(x["id"], x["deliveries"]) for x in view] == [(c["id"], 2)] + queue.report_result(conn, c["id"], "done") + assert queue.claim(conn)["params"]["key"] == "A-2" + assert queue.agent_view(conn)["redelivered"] == [] + + +def test_redelivery_limit_fails_command(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")]) + c = queue.claim(conn) + queue.claim(conn) + queue.claim(conn) # третья выдача + nxt = queue.claim(conn) # четвёртой нет — failed и дальше по очереди + assert nxt["params"]["key"] == "A-2" + failed = queue.get_command(conn, c["id"]) + assert failed["status"] == "failed" and failed["error"] == "агент не отчитался после 3 выдач" and failed["finished_at"] + assert queue.MAX_DELIVERIES == 3 + + +def test_redelivery_oldest_first(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1")]) + queue.create_batch(conn, "y", "claude", [fields("A-2")]) + rows = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields' ORDER BY id").fetchall() + conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2026-10-13T10:00:00+00:00', deliveries = 1 WHERE id = ?", (rows[1]["id"],)) + conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2026-10-13T11:00:00+00:00', deliveries = 1 WHERE id = ?", (rows[0]["id"],)) + assert queue.claim(conn)["id"] == rows[1]["id"] + + +def test_redelivered_refresh_keeps_one_sync_run(): + conn = fresh() + queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + r = queue.claim(conn) + assert queue.claim(conn)["id"] == r["id"] + assert conn.execute("SELECT COUNT(*) AS n FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["n"] == 1 + + +def test_redelivery_does_not_repeat_backup(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1")]) + labels = [] + queue.claim(conn, backup=labels.append) + queue.claim(conn, backup=labels.append) + assert len(labels) == 1 + + +def test_migrated_claimed_is_redelivered(): + """Команда, взятая до миграции (deliveries = 1 после неё), довыдаётся со счётчиком 2.""" + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1")]) + cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"] + conn.execute("UPDATE command SET status = 'claimed', claimed_at = '2026-10-12T00:00:00+00:00', deliveries = 1 WHERE id = ?", (cid,)) + assert queue.claim(conn)["id"] == cid + assert queue.get_command(conn, cid)["deliveries"] == 2 +``` + +9. В `tests/test_api.py::test_agent_flow` заменить + `assert a.post("/agent/claim", {})["command"] is None # одна в полёте, долгий запрос выключен` на + `assert a.post("/agent/claim", {})["command"]["id"] == cmd["id"] # ответ потерян — довыдача той же команды`. +10. В `tests/test_e2e_hub.py` заменить + `assert agent.post("/agent/claim", {"agent_version": 1, "settings_version": 1})["command"] is None` на + `assert agent.post("/agent/claim", {"agent_version": 1, "settings_version": 1})["command"]["id"] == cmd["id"]` + и комментарий над ней — на `# одна команда в полёте: повторный claim довыдаёт ensure_issue, зависимая ensure_link не выдаётся`. +11. В `tests/test_mcp.py::test_formatters` заменить `"stuck": []` на `"redelivered": []` и добавить: + +```python + text = mcp_server.fmt_agent({"online": True, "agent_version": "2", "current_command_id": 40, "rate_limited_24h": 0, + "redelivered": [{"id": 40, "type": "ensure_fields", "claimed_at": "2026-10-13T10:00:00+00:00", + "deliveries": 2}], + "warning": None, "last_seen_at": "2026-10-13T10:00:00+00:00", "settings_version": 3}) + assert "онлайн" in text and "#40" in text and "выдач 2" in text +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_queue.py tests/test_api.py tests/test_e2e_hub.py tests/test_mcp.py -q` +Expected: FAIL — повторный `claim` даёт `None`, нет `MAX_DELIVERIES`, нет `redelivered`. + +- [ ] **Step 3: Implement** — `src/jira_hub/queue.py`: + +1. Docstring модуля: + +```python +"""Очередь команд: пакеты, выдача агенту по одной, отчёты, отмена, повтор, сверка с зеркалом, автообновление. +Новых команд хаб сам не создаёт: повтор после failed — новый пакет по запросу человека. Команду, выданную агенту и +оставшуюся без отчёта, хаб довыдаёт при следующем claim — это доставка той же выдачи (bridge-reliability, решение 1).""" +``` + +2. Константы: удалить `STALE_MINUTES`; добавить + `MAX_DELIVERIES = 3 # выдач без отчёта, после которых команда — failed (bridge-reliability, решение 2)`. +3. Удалить функции `_definer_stale` и `_fresh_claimed_exists`. +4. В `_next` удалить `blocked_batches` и обход зависшего определителя; блок `if pending:` становится + `return None # создание ещё не завершено — ждём (одна в полёте)`: + +```python +def _next(conn) -> dict | None: + rows = conn.execute("SELECT c.* FROM command c JOIN batch b ON b.id = c.batch_id " + "WHERE c.status = 'queued' AND b.cancelled_at IS NULL ORDER BY b.created_at, b.id, c.seq").fetchall() + for row in rows: + cmd = _cmd(row) + needed = C.temp_keys_used(cmd) + if needed: + mapping = _mapping(conn, cmd["batch_id"]) + unresolved = [k for k in needed if k not in mapping] + if any(_definer_status(conn, cmd["batch_id"], k) not in TERMINAL for k in unresolved): + return None # создание ещё не завершено — ждём (одна в полёте) + cmd["params"] = C.substitute(cmd["type"], cmd["params"], mapping) + if cmd["type"] == "refresh": + cmd["params"]["keys"] = [k for k in cmd["params"].get("keys", []) if not C.is_temp(k)] + unresolved = [] if cmd["params"]["keys"] else unresolved + if unresolved: + conn.execute("UPDATE command SET status = 'skipped', finished_at = ?, error = ? WHERE id = ?", + (db.now(), f"задача {unresolved[0]} не создана", cmd["id"])) + continue + return cmd + return None +``` + +5. Новая функция перед `claim`: + +```python +def _redeliver(conn, now) -> dict | None: + """Самая старая взятая команда без отчёта — выдать снова. Агент приходит за новой командой, только когда своей + незавершённой нет, значит выданную он не получил или потерял. После MAX_DELIVERIES выдач — failed.""" + while True: + row = conn.execute("SELECT * FROM command WHERE status = 'claimed' ORDER BY claimed_at, id LIMIT 1").fetchone() + if row is None: + return None + cmd = _cmd(row) + if cmd["deliveries"] < MAX_DELIVERIES: + return cmd + conn.execute("UPDATE command SET status = 'failed', finished_at = ?, error = ? WHERE id = ?", + (now, f"агент не отчитался после {MAX_DELIVERIES} выдач", cmd["id"])) + conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1 AND current_command_id = ?", (cmd["id"],)) + log.warning("команда %s: агент не отчитался после %s выдач — failed", cmd["id"], MAX_DELIVERIES) +``` + +6. `claim` — тело транзакции: + +```python +def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None) -> dict | None: + now = db.now() + to_backup = None + with db.transaction(conn): + conn.execute("UPDATE agent_status SET last_seen_at = ?, agent_version = COALESCE(?, agent_version), " + "settings_version = COALESCE(?, settings_version), last_jira_call_at = COALESCE(?, last_jira_call_at) WHERE id = 1", + (now, None if agent_version is None else str(agent_version), settings_version, last_jira_call_at)) + cmd = _redeliver(conn, now) + if cmd is not None: + pause = int(settings.pacing(conn).get(cmd["type"], 0)) + conn.execute("UPDATE command SET claimed_at = ?, pause_seconds = ?, deliveries = deliveries + 1 WHERE id = ?", + (now, pause, cmd["id"])) + conn.execute("UPDATE agent_status SET current_command_id = ? WHERE id = 1", (cmd["id"],)) + log.info("команда %s выдана снова (выдача %s)", cmd["id"], cmd["deliveries"] + 1) + else: + cmd = _next(conn) + if cmd is None: + conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1") + return None + pause = int(settings.pacing(conn).get(cmd["type"], 0)) + first = conn.execute("SELECT COUNT(*) AS n FROM command WHERE batch_id = ? AND status != 'queued'", + (cmd["batch_id"],)).fetchone()["n"] == 0 + has_writes = conn.execute("SELECT 1 FROM command WHERE batch_id = ? AND type != 'refresh' LIMIT 1", + (cmd["batch_id"],)).fetchone() is not None + conn.execute("UPDATE command SET status = 'claimed', claimed_at = ?, pause_seconds = ?, params = ?, deliveries = 1 " + "WHERE id = ?", (now, pause, _dump(cmd["params"]), cmd["id"])) + conn.execute("UPDATE agent_status SET current_command_id = ? WHERE id = 1", (cmd["id"],)) + if cmd["type"] == "refresh": + p = cmd["params"] + conn.execute("INSERT INTO sync_run(started_at, kind, query, command_id) VALUES (?, ?, ?, ?)", + (now, "jql" if p.get("jql") else "keys", p.get("jql") or ",".join(p.get("keys", [])), cmd["id"])) + if first and has_writes: + to_backup = f"batch-{cmd['batch_id']}" + # дальше без изменений: резервная копия вне транзакции и return +``` + + Хвост функции (`if to_backup and backup: …` и `return {...}`) оставить как есть. +7. `agent_view` — вместо блока `cutoff`/`stuck`: + +```python + d["redelivered"] = [dict(r) for r in conn.execute( + "SELECT id, type, claimed_at, deliveries FROM command WHERE status = 'claimed' AND deliveries > 1 ORDER BY id")] +``` + +8. `maybe_auto_refresh` — условие занятой очереди: + +```python + if conn.execute("SELECT 1 FROM command WHERE status IN ('queued', 'claimed') LIMIT 1").fetchone(): + return None +``` + +9. `src/jira_hub/mcp_server.py::fmt_agent` — цикл по `stuck` заменить на + +```python + for s in a.get("redelivered", []): + out.append(f"выдана повторно: #{s['id']} {s['type']}, выдач {s['deliveries']}, последняя {s['claimed_at']}") +``` + +10. Документы: + - `CLAUDE.md`, строка «Хаб не повторяет команды сам…» → `- Хаб не создаёт команды сам: повтор после failed — новая + команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при + следующем claim (до 3 выдач, затем failed).` + - `ARCHITECTURE.md`, строка «`queue` не повторяет команды…» → `- \`queue\` не создаёт команд сам: повтор после + failed — новый пакет. Выданная без отчёта команда довыдаётся при следующем \`claim\` (до 3 выдач, затем failed).` + - `README.md`, раздел «Эксплуатация», пункт «Команда «нет ответа от агента»…» заменить на: + `- Довыдача: если агент пришёл за новой командой, а выданная ему осталась без отчёта (ответ потерян, агент + перезапущен), хаб выдаёт её снова; \`agent_status\` показывает такие команды с числом выдач. После 3 выдач без + отчёта команда становится \`failed\` («агент не отчитался после 3 выдач»), очередь идёт дальше; повтор — + \`command_retry\`. Отменить взятую команду: \`POST /commands/{id}/cancel\` с \`{"force": true}\`.` + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest -q` — весь набор зелёный. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/reliability add -A src tests CLAUDE.md ARCHITECTURE.md README.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/reliability commit -m "feat(queue): довыдача команды без отчёта, предел 3 выдачи; R-13 и «свежая claimed» удалены (#9) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Отчёты и пакеты — 409 по невыданной, ключ из позднего отчёта, дубль темы + +**Files:** +- Modify: `src/jira_hub/queue.py` (`NotClaimedError`, `report_result`) +- Modify: `src/jira_hub/api.py` (`h_agent_result`) +- Modify: `src/jira_hub/commands.py` (`validate`) +- Test: `tests/test_queue.py`, `tests/test_api.py`, `tests/test_commands.py` + +**Interfaces:** +- Consumes: `queue.claim` (Task 2). +- Produces: `queue.NotClaimedError(Exception)`; API `POST /agent/commands/{id}/result` → 409 `{"error": "not_claimed"}` + для `queued`; `CommandError` с текстом `повторяется тема ensure_issue: «…»`. + +- [ ] **Step 1: Write the failing tests** + +`tests/test_queue.py`: + +```python +def test_report_for_queued_command_rejected(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [fields("A-1")]) + cid = conn.execute("SELECT id FROM command WHERE type = 'ensure_fields'").fetchone()["id"] + with pytest.raises(queue.NotClaimedError): + queue.report_result(conn, cid, "done") + assert queue.get_command(conn, cid)["status"] == "queued" + + +def test_late_report_after_force_cancel_keeps_issue_key(): + conn = fresh() + mirror.set_note(conn, "new:7", {"fact_status": "В работе"}, "import") + queue.create_batch(conn, "x", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}]) + c = queue.claim(conn) + cancelled = queue.cancel_command(conn, c["id"], force=True) + late = queue.report_result(conn, c["id"], "done", result={"key": "A-20", "created": True}, jira_calls=3) + assert late["status"] == "cancelled" and late["result"] == {"key": "A-20", "created": True} and late["jira_calls"] == 3 + assert late["finished_at"] == cancelled["finished_at"] + assert mirror.get_note(conn, "A-20")["fact_status"] == "В работе" and mirror.get_note(conn, "new:7") is None + again = queue.report_result(conn, c["id"], "done", result={"key": "A-21"}) + assert again["result"] == {"key": "A-20", "created": True} # второй поздний отчёт ничего не меняет + + +def test_late_failed_report_after_force_cancel_changes_nothing(): + conn = fresh() + queue.create_batch(conn, "x", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}]) + c = queue.claim(conn) + queue.cancel_command(conn, c["id"], force=True) + late = queue.report_result(conn, c["id"], "failed", error="HTTP 400") + assert late["status"] == "cancelled" and late["result"] is None and late["error"] is None +``` + +`tests/test_commands.py` — в параметры `test_validate_errors` (список `bad, msg`) добавить случай; если тест +параметризован другим способом, добавить отдельный тест: + +```python +def test_validate_rejects_duplicate_issue_summary(): + cmds = [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Отчёт"}}, + {"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": " Отчёт "}}, + {"type": "ensure_issue", "params": {"temp_key": "new:3", "issue_type": "task", "summary": "отчёт"}}] + with pytest.raises(commands.CommandError) as e: + commands.validate(cmds) + assert "повторяется тема ensure_issue: «Отчёт»" in str(e.value) + commands.validate([cmds[0], cmds[2]]) # регистр различается — пакет допустим +``` + +(`import pytest` и `from jira_hub import commands` в файле уже есть; если нет — добавить.) + +`tests/test_api.py`: + +```python +def test_result_for_queued_command_is_409(hub): + b = hub["client"].post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]}) + cid = hub["client"].get(f"/batches/{b['id']}")["commands"][0]["id"] + with pytest.raises(HubError) as e: + hub["agent"].post(f"/agent/commands/{cid}/result", {"status": "done"}) + assert e.value.status == 409 and e.value.error == "not_claimed" + + +def test_duplicate_summary_batch_is_422(hub): + with pytest.raises(HubError) as e: + hub["client"].post("/batches", {"title": "x", "commands": [ + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Шахматка: комментарии"}}, + {"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": "Шахматка: комментарии"}}]}) + assert e.value.status == 422 and "Шахматка: комментарии" in e.value.detail +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_queue.py tests/test_commands.py tests/test_api.py -q` +Expected: FAIL — нет `NotClaimedError`, отчёт по `queued` принят, поздний ключ потерян, дубль темы проходит. + +- [ ] **Step 3: Implement** + +`src/jira_hub/queue.py` — рядом с `ClaimedError`: + +```python +class NotClaimedError(Exception): + """Отчёт по команде, которую агенту не выдавали (status = queued) — API отвечает 409.""" +``` + +В `report_result` после `if cmd is None: raise KeyError(command_id)`: + +```python + if cmd["status"] == "queued": + raise NotClaimedError(f"команда {command_id} не выдана") + if cmd["status"] in TERMINAL: + # поздний отчёт о созданной задаче по отменённой команде: ключ не теряем, статус не трогаем (M-5) + if (cmd["status"] == "cancelled" and cmd["type"] == "ensure_issue" and status == "done" + and result and result.get("key") and not cmd["result"]): + conn.execute("UPDATE command SET result = ?, jira_calls = ?, rate_limited = ? WHERE id = ?", + (_dump(result), int(jira_calls or 0), int(rate_limited or 0), command_id)) + mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"]) + log.warning("команда %s отменена, но агент создал %s — ключ записан в результат", command_id, result["key"]) + return get_command(conn, command_id) + return cmd # идемпотентно; cancelled не затирается +``` + +(прежняя строка `if cmd["status"] in TERMINAL: return cmd` заменяется этим блоком.) + +`src/jira_hub/api.py::h_agent_result` — добавить ветку до `except ValueError`: + +```python + except queue.NotClaimedError as e: + raise ApiError(409, "not_claimed", str(e)) +``` + +`src/jira_hub/commands.py::validate` — перед `return out`: + +```python + summaries = [c["params"]["summary"].strip() for c in out if c["type"] == "ensure_issue"] + repeated = sorted({s for s in summaries if summaries.count(s) > 1}) + if repeated: + raise CommandError("повторяется тема ensure_issue: " + ", ".join(f"«{s}»" for s in repeated) + + " — в Jira получится одна задача на два временных ключа") +``` + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest -q` — весь набор зелёный. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/reliability add -A src tests +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/reliability commit -m "feat(queue): 409 на отчёт по невыданной команде, ключ из позднего отчёта по отменённой ensure_issue, 422 на дубль темы (#9) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: `ensure_issue` не подтверждается по состоянию; `reversed_links` в отчёте импорта + +**Files:** +- Modify: `src/jira_hub/commands.py` (`check_confirmed`) +- Modify: `src/jira_hub/queue.py` (`reconcile`) +- Modify: `src/jira_hub/importer.py` (`_import`, docstring модуля при необходимости) +- Modify: `README.md` (описание отчёта `import`) +- Test: `tests/test_commands.py`, `tests/test_queue.py`, `tests/test_importer.py` + +**Interfaces:** +- Produces: `commands.check_confirmed(conn, cmd, link_types) -> tuple[bool, dict | None]` (без `reporter`); отчёт + импорта `{notes, expected_links, missing_keys, missing_links, reversed_links}`. + +- [ ] **Step 1: Write the failing tests** + +`tests/test_commands.py`: удалить `test_check_confirmed_issue_by_summary`, `test_ensure_issue_without_reporter_never_confirmed`, +`test_ensure_issue_older_than_7_days_not_confirmed`, `test_ensure_issue_reporter_filter`, +`test_ensure_issue_two_matches_takes_newest`; добавить: + +```python +def test_ensure_issue_never_confirmed_by_state(): + """bridge-reliability, решение 4: потерянный отчёт ensure_issue закрывается довыдачей, не сверкой с зеркалом.""" + conn = _mirror() + conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'") + assert commands.check_confirmed(conn, _ensure_issue("Тема"), LT) == (False, None) +``` + +Проверить, что в параметрах `test_check_confirmed` нет случаев `ensure_issue` с ожиданием `True`; если есть — поменять +ожидание на `False`. + +`tests/test_queue.py`: удалить `test_reconcile_respects_reporter_setting`; в +`test_reconcile_does_not_confirm_ensure_issue_without_reporter` убрать из docstring упоминание настройки (`"""ensure_issue +по состоянию не подтверждается: совпадение темы не закрывает команду, заметка new:N не переезжает."""`); +`test_reconcile_does_not_overwrite_finished_command` переписать на `ensure_sprint`: + +```python +def test_reconcile_does_not_overwrite_finished_command(monkeypatch): + """M-1: отчёт агента/отмена между check_confirmed и UPDATE не перезаписываются.""" + conn = fresh() + queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}]) + c = queue.claim(conn) + mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": []}], []) + real = queue.C.check_confirmed + + def racing(*a, **kw): + out = real(*a, **kw) + queue.cancel_command(conn, c["id"], force=True) # гонка: отмена после проверки + return out + + monkeypatch.setattr(queue.C, "check_confirmed", racing) + assert queue.reconcile(conn) == [] + assert queue.get_command(conn, c["id"])["status"] == "cancelled" +``` + +`tests/test_importer.py`: + +```python +def test_reversed_link_reported_separately(tmp_path): + make_package(tmp_path) + write_csv(tmp_path / "links.csv", ["Откуда", "Тип", "Куда", "Пояснение (внутр.)"], + [["AURORA-1533", "связанные", "AURORA-1586", "в Jira стоит наоборот"], + ["AURORA-1533", "блокирует начало", "AURORA-1416", "нет совсем"]]) + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"}) + mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}], + [{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}]) + rep = importer.import_package(conn, tmp_path) + assert rep["reversed_links"] == [{"source": "AURORA-1533", "target": "AURORA-1586", "link_type": "ASUPR_Связанные"}] + assert rep["missing_links"] == [{"source": "AURORA-1533", "target": "AURORA-1416", "link_type": "ASUPR_Блокер_Начала"}] +``` + +В `test_import_notes_and_report` добавить `assert rep["reversed_links"] == []`; в `test_missing_csv_files_give_empty_report` +ожидаемый словарь дополнить `"reversed_links": []`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_commands.py tests/test_queue.py tests/test_importer.py -q` +Expected: FAIL в `test_reversed_link_reported_separately` и проверках `reversed_links` (ключа нет). +`test_ensure_issue_never_confirmed_by_state` может пройти уже сейчас (без `reporter` подтверждения и так нет) — это +закрепляющий тест, он должен остаться зелёным после удаления ветки. + +- [ ] **Step 3: Implement** + +`src/jira_hub/commands.py::check_confirmed`: + +```python +def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]: + """Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment и refresh по состоянию не подтверждаются: + потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля (bridge-reliability, + решение 4); совпадение темы с задачей неизвестного автора не доказательство.""" + ctype, p = cmd["type"], cmd["params"] + if ctype in ("ensure_issue", "ensure_comment", "refresh"): + return False, None + issue = mirror.get_issue(conn, p.get("key") or p.get("source")) + # дальше без изменений +``` + +(ветка `if ctype == "ensure_issue": …` удаляется целиком.) + +`src/jira_hub/queue.py::reconcile` — удалить строки с `reporter = settings.get(conn, "reporter")` и комментарием-продолжением; +вызов: `ok, result = C.check_confirmed(conn, cmd, link_types)`. Ветку `if cmd["type"] == "ensure_issue" and result: +mirror.rename_note(...)` удалить (ensure_issue больше не подтверждается). + +`src/jira_hub/importer.py` — в `_import` блок связей: + +```python + link_types = settings.get(conn, "link_types") or {} + expected, missing_links, reversed_links = [], [], [] + for r in _rows(folder / "links.csv"): + src = new_keys.get(_clean(r.get("Откуда")) or "", _clean(r.get("Откуда"))) + dst = new_keys.get(_clean(r.get("Куда")) or "", _clean(r.get("Куда"))) + kind = (_clean(r.get("Тип")) or "").lower() + if not src or not dst or src.startswith("new:") or dst.startswith("new:"): + continue + link = {"source": src, "target": dst, "link_type": link_types.get(kind, kind)} + expected.append(link) + issue = mirror.get_issue(conn, src) + if issue and any(l["target_key"] == dst and l["link_type"] == link["link_type"] for l in issue["links_out"]): + continue + if issue and any(l["source_key"] == dst and l["link_type"] == link["link_type"] for l in issue["links_in"]): + reversed_links.append(link) # связь есть, но в Jira стоит в обратную сторону (O-3) + else: + missing_links.append(link) +``` + +и в `return` добавить `"reversed_links": reversed_links`. + +`README.md` — в абзаце про `jira-hub import` после «`missing_links` (ожидаемые связи, которых нет)» дописать: +`, \`reversed_links\` (ожидаемые связи, которые в Jira стоят в обратную сторону — для «Связанных» это та же связь)`. + +- [ ] **Step 4: Run tests** + +Run: `uv run pytest -q` — весь набор зелёный. Затем `grep -rn "reporter" src/jira_hub/queue.py src/jira_hub/commands.py` — +пусто. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/reliability add -A src tests README.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/reliability commit -m "feat: ensure_issue закрывается довыдачей, не сверкой; reversed_links в отчёте импорта (R-11, O-3, #9) + +Co-Authored-By: Claude Opus 5.5 " +``` -- 2.47.2 From 4f375fd39c0a68a6701be033198c1667930d7fd3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 22:40:59 +0400 Subject: [PATCH 4/9] =?UTF-8?q?feat(db):=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D1=81=D1=85=D0=B5=D0=BC=D1=8B=20v1?= =?UTF-8?q?=E2=86=92v2=20=E2=80=94=20command.deliveries,=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BF=D0=B8=D1=8F=20=D0=B1=D0=B0=D0=B7=D1=8B=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B5=D0=B9=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/db.py | 36 +++++++++++++++++++++++++++++++----- tests/test_db.py | 43 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/jira_hub/db.py b/src/jira_hub/db.py index 9b16192..01d0c06 100644 --- a/src/jira_hub/db.py +++ b/src/jira_hub/db.py @@ -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)") diff --git a/tests/test_db.py b/tests/test_db.py index 0133f79..df32089 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -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): -- 2.47.2 From 958dbe956fce3aace3537f424c6e5663cd92e444 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 22:44:16 +0400 Subject: [PATCH 5/9] =?UTF-8?q?feat(queue):=20=D0=B4=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B4=D0=B0=D1=87=D0=B0=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=8B=20=D0=B1=D0=B5=D0=B7=20=D0=BE=D1=82=D1=87=D1=91=D1=82?= =?UTF-8?q?=D0=B0,=20=D0=BF=D1=80=D0=B5=D0=B4=D0=B5=D0=BB=203=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B4=D0=B0=D1=87=D0=B8;=20R-13=20=D0=B8=20=C2=AB=D1=81?= =?UTF-8?q?=D0=B2=D0=B5=D0=B6=D0=B0=D1=8F=20claimed=C2=BB=20=D1=83=D0=B4?= =?UTF-8?q?=D0=B0=D0=BB=D0=B5=D0=BD=D1=8B=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 4 +- CLAUDE.md | 6 +- README.md | 7 ++- src/jira_hub/mcp_server.py | 4 +- src/jira_hub/queue.py | 92 +++++++++++++++-------------- tests/test_api.py | 2 +- tests/test_e2e_hub.py | 4 +- tests/test_mcp.py | 7 ++- tests/test_queue.py | 117 +++++++++++++++++++++++++++---------- 9 files changed, 153 insertions(+), 90 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 07ca460..a006dab 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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). diff --git a/CLAUDE.md b/CLAUDE.md index 2faa398..1406bbc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. ## Команды diff --git a/README.md b/README.md index 1e5ddef..00a766c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index 43106a0..3e66cb4 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -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) diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index 6544caa..95bba99 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -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 @@ -108,31 +109,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 +132,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 +155,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) @@ -296,9 +299,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 +308,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): diff --git a/tests/test_api.py b/tests/test_api.py index 652468a..a06a168 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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" diff --git a/tests/test_e2e_hub.py b/tests/test_e2e_hub.py index 9782309..f6b03aa 100644 --- a/tests/test_e2e_hub.py +++ b/tests/test_e2e_hub.py @@ -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: diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 6ca2fc3..6c460f8 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -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): diff --git a/tests/test_queue.py b/tests/test_queue.py index b3b6399..c492055 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -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(): @@ -277,21 +269,84 @@ 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 -- 2.47.2 From bf73299732b38e6ef077b2e6c0db48d191f58d3a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 22:46:41 +0400 Subject: [PATCH 6/9] =?UTF-8?q?feat(queue):=20409=20=D0=BD=D0=B0=20=D0=BE?= =?UTF-8?q?=D1=82=D1=87=D1=91=D1=82=20=D0=BF=D0=BE=20=D0=BD=D0=B5=D0=B2?= =?UTF-8?q?=D1=8B=D0=B4=D0=B0=D0=BD=D0=BD=D0=BE=D0=B9=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD=D0=B4=D0=B5,=20=D0=BA=D0=BB=D1=8E=D1=87=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=BF=D0=BE=D0=B7=D0=B4=D0=BD=D0=B5=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0=20=D0=BF=D0=BE=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BC=D0=B5=D0=BD=D1=91=D0=BD=D0=BD=D0=BE=D0=B9=20ensure?= =?UTF-8?q?=5Fissue,=20422=20=D0=BD=D0=B0=20=D0=B4=D1=83=D0=B1=D0=BB=D1=8C?= =?UTF-8?q?=20=D1=82=D0=B5=D0=BC=D1=8B=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/api.py | 2 ++ src/jira_hub/commands.py | 5 +++++ src/jira_hub/queue.py | 14 ++++++++++++++ tests/test_api.py | 16 ++++++++++++++++ tests/test_commands.py | 10 ++++++++++ tests/test_queue.py | 34 ++++++++++++++++++++++++++++++++++ 6 files changed, 81 insertions(+) diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 6251d20..163aad5 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -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 diff --git a/src/jira_hub/commands.py b/src/jira_hub/commands.py index d48ac57..320f6b3 100644 --- a/src/jira_hub/commands.py +++ b/src/jira_hub/commands.py @@ -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 diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index 95bba99..3b265f4 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -22,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"]) @@ -212,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 = ?", diff --git a/tests/test_api.py b/tests/test_api.py index a06a168..d48517a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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 diff --git a/tests/test_commands.py b/tests/test_commands.py index c42aa58..b087f17 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -149,3 +149,13 @@ def test_ensure_issue_two_matches_takes_newest(): 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]]) # регистр различается — пакет допустим diff --git a/tests/test_queue.py b/tests/test_queue.py index c492055..5f2d0df 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -350,3 +350,37 @@ def test_migrated_claimed_is_redelivered(): 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 -- 2.47.2 From 817c761fb26395ff012ba3016c26bee3cb98378b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 22:48:57 +0400 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20ensure=5Fissue=20=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D0=B4=D0=B0=D1=87=D0=B5=D0=B9,=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=81=D0=B2=D0=B5=D1=80=D0=BA=D0=BE=D0=B9;=20reversed=5Flinks?= =?UTF-8?q?=20=D0=B2=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B5=20=D0=B8=D0=BC?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82=D0=B0=20(R-11,=20O-3,=20#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/jira_hub/commands.py | 18 +++++------------- src/jira_hub/importer.py | 12 ++++++++---- src/jira_hub/queue.py | 6 +----- tests/test_commands.py | 40 ++-------------------------------------- tests/test_importer.py | 18 +++++++++++++++++- tests/test_queue.py | 25 ++++--------------------- 7 files changed, 38 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index 00a766c..99bc5b2 100644 --- a/README.md +++ b/README.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 diff --git a/src/jira_hub/commands.py b/src/jira_hub/commands.py index 320f6b3..f44a249 100644 --- a/src/jira_hub/commands.py +++ b/src/jira_hub/commands.py @@ -111,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 diff --git a/src/jira_hub/importer.py b/src/jira_hub/importer.py index 6f269cb..7e99b50 100644 --- a/src/jira_hub/importer.py +++ b/src/jira_hub/importer.py @@ -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} diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index 3b265f4..2f8a6b6 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -251,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): @@ -265,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 diff --git a/tests/test_commands.py b/tests/test_commands.py index b087f17..9e42b2f 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -108,47 +108,11 @@ 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(): diff --git a/tests/test_importer.py b/tests/test_importer.py index 7e77125..6339981 100644 --- a/tests/test_importer.py +++ b/tests/test_importer.py @@ -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_Блокер_Начала"}] diff --git a/tests/test_queue.py b/tests/test_queue.py index 5f2d0df..0f4f40f 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -205,20 +205,6 @@ 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 _claimed_ensure_issue(conn, summary="Тема"): queue.create_batch(conn, "x", "claude", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}}]) c = queue.claim(conn) @@ -227,7 +213,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") @@ -241,11 +227,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): @@ -256,7 +240,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): -- 2.47.2 From 1fc27055ba8a4ac3599c6fe35120dbec46b334c7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 23:11:32 +0400 Subject: [PATCH 8/9] =?UTF-8?q?fix(backup):=20=D0=BA=D0=BE=D0=BF=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20=D0=BC=D0=B8=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=B5=D0=B9=20=D1=81=D1=85=D0=B5=D0=BC?= =?UTF-8?q?=D1=8B=20=D0=BD=D0=B5=20=D1=80=D0=BE=D1=82=D0=B8=D1=80=D1=83?= =?UTF-8?q?=D0=B5=D1=82=D1=81=D1=8F;=20design:=20=D0=BE=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D1=86=D0=B5=D1=81=D1=81=20=D0=B0=D0=B3?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0,=20=D0=BE=D1=82=D0=BA=D0=B0=D1=82=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=B2=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=BF?= =?UTF-8?q?=D0=B8=D0=B8=20(M-1,=20I-1,=20#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/bridge-reliability/design.md | 32 ++++++++++++++----- .../bridge-reliability/specs/agent/spec.md | 9 ++++-- src/jira_hub/backup.py | 3 +- tests/test_queue.py | 12 +++++++ 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/openspec/changes/bridge-reliability/design.md b/openspec/changes/bridge-reliability/design.md index 9701f94..798a287 100644 --- a/openspec/changes/bridge-reliability/design.md +++ b/openspec/changes/bridge-reliability/design.md @@ -15,9 +15,8 @@ - Агент: `JiraClient.last_call_at` (monotonic) только в памяти; `Runner.wait_pause` без `last_call_at` не ждёт; `recover` ставит `cmd["pause_seconds"] = 0` и вызывает `execute`, который пишет `claimed` ещё раз. Агент приходит за новой командой (`claim`) только когда своей незавершённой нет: каждый путь `execute` кончается подтверждённым отчётом - или остановкой процесса, `recover` выполняется до первого `claim`. Единственное место, где агент сам теряет выданную - команду, — `reload_settings_before` при недоступном хабе без остановки (команда не пишется в журнал, цикл идёт на - новый `claim`); довыдача закрывает и его. + или остановкой процесса, `recover` выполняется до первого `claim`. Инвариант держится только для одного процесса + агента на журнал — второй процесс получил бы довыданную команду одновременно с первым (решение 13). - `db.migrate` только создаёт таблицы (`CREATE TABLE IF NOT EXISTS`) и пишет `schema_version = 1`; миграций по версии нет. `db.connect` вызывается на каждый запрос API и при старте. @@ -74,13 +73,21 @@ Non-goals — см. proposal.md. 10. **Агент: `claimed` при восстановлении не пишется повторно.** `execute(cmd, journal_claimed=True)`; `recover` вызывает с `False` — запись `claimed`/`started` для этого id уже есть. 11. **Версия агента 3**, `min_agent_version` не поднимается. Тела запросов агента не меняются. +13. **Один процесс агента на журнал (финальное ревью, I-1).** `agent.py run` берёт исключительную блокировку + `<журнал>.lock` (`fcntl.flock`, `LOCK_NB`); занята — сообщение «агент уже запущен…» и код 1. Без неё два процесса + (например, при перезапуске через `nohup`/launchd без остановки старого) выполняли бы довыданную команду одновременно: + идемпотентность «проверь, потом сделай» от одновременного выполнения не защищает (две `ensure_issue` могут не найти + дубль друг друга). Агент v2 блокировки не имеет — при выкатке хаба убедиться, что процесс агента один. 12. **Признак `online`.** Спека data-model меняется под код: `agent_status` отдаёт `online: false`, если `last_seen_at` старше 5 минут (решение без обсуждения, см. ниже). ## Risks / Trade-offs -- Нарушение инварианта агента (например, будущая параллельность) превратит довыдачу в двойное выполнение — безопасно - из-за идемпотентности, но тратит вызовы Jira. Инвариант фиксируется в спеке agent. +- Нарушение инварианта агента (второй процесс с тем же журналом, будущая параллельность) превращает довыдачу в + одновременное выполнение одной команды двумя исполнителями — для `ensure_issue` это возможный дубль задачи, для + `ensure_comment` — два комментария. Закрыто блокировкой процесса в агенте v3 (решение 13); инвариант зафиксирован в + спеке agent. Предел выдач (решение 2) защищает от потери доставки, но не от падения процесса агента на команде: + восстановление по журналу берёт команду через `GET /agent/commands/{id}` и выдач не добавляет. - Довыдача после долгого простоя агента выполнит старую команду: если человек за это время поменял задачу вручную, команда «вернёт» желаемое значение. Так же вела бы себя команда в очереди; для отмены есть `cancel force`. - Гонка двух `claim` через папку (старый долгий запрос ещё ждёт в хабе, агент прислал новый): старый берёт команду, ответ @@ -92,12 +99,16 @@ Non-goals — см. proposal.md. ## 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`, перезапуск `run`. +2. Агент v3: файл через общую папку на виртуалку, `check`, остановить v2 (дождаться «остановлен»), запустить v3. 3. Проверка: повторный `jira-hub import admin/jira-sync` — три связи в `reversed_links`, `missing_links: 0`. -Откат: остановить хаб, вернуть копию `backups/*-schema-v1.sqlite` на место `hub.sqlite`, предыдущий код хаба; агент v3 -работает с хабом v1 (тела запросов не менялись). +Откат: предыдущий код хаба работает и на базе v2 (лишняя колонка `deliveries` с `DEFAULT 0` ему не мешает), поэтому +достаточно вернуть код и перезапустить хаб. Копия `backups/*-schema-v1.sqlite` (ротации не подлежит) — только на случай +повреждения базы: восстановление из неё теряет всё, записанное после миграции. Агент v3 работает с хабом v1 (тела +запросов не менялись). ## Decisions made without discussion @@ -112,3 +123,8 @@ Non-goals — см. proposal.md. | 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) | довыдача отдаёт команду любому пришедшему процессу | нет: второй процесс просто не стартует | + diff --git a/openspec/changes/bridge-reliability/specs/agent/spec.md b/openspec/changes/bridge-reliability/specs/agent/spec.md index 029fd04..01367cb 100644 --- a/openspec/changes/bridge-reliability/specs/agent/spec.md +++ b/openspec/changes/bridge-reliability/specs/agent/spec.md @@ -5,13 +5,18 @@ `pause_seconds` от последнего вызова Jira → запись «started» в журнал → выполнение по контракту `commands` → запись «finished» с результатом → отчёт хабу, повторяемый до подтверждения. Одна команда в полёте: новый `claim` агент SHALL отправлять, только когда у него нет выданной, но не завершённой и не отчитанной команды (на этом хаб строит повторную -выдачу, см. `commands`). При изменении `settings_version` в ответе `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, @@ -48,7 +53,7 @@ rate_limited, at}`, `acked {command_id, at}`. При старте агент SHA ### Requirement: Доставка и запуск README агента SHALL описывать: копирование `agent.py` и `agent.example.json` через общую папку, заполнение `agent.json` (включая выбор транспорта в `hub_url`), `check`, запуск в терминале и через `nohup` с логом, опциональный launchd-plist, -`prune`. Версия агента SHALL быть в константе файла (3 — с восстановлением темпа после перезапуска) и в каждом отчёте; +`prune`. Версия агента SHALL быть в константе файла (3 — с восстановлением темпа после перезапуска и блокировкой одного процесса) и в каждом отчёте; хаб предупреждает в `agent_status`, если версия ниже минимальной из настроек. #### Scenario: Устаревший агент diff --git a/src/jira_hub/backup.py b/src/jira_hub/backup.py index 059634b..4052ee6 100644 --- a/src/jira_hub/backup.py +++ b/src/jira_hub/backup.py @@ -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() diff --git a/tests/test_queue.py b/tests/test_queue.py index 0f4f40f..053d827 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -205,6 +205,18 @@ 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_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="Тема"): queue.create_batch(conn, "x", "claude", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}}]) c = queue.claim(conn) -- 2.47.2 From eae2771525c58c872157b301925f5a8b9cd17fb5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 23:11:46 +0400 Subject: [PATCH 9/9] =?UTF-8?q?docs(openspec):=20=D0=BF=D0=BE=D1=80=D1=8F?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=B2=20design.md=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/bridge-reliability/design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openspec/changes/bridge-reliability/design.md b/openspec/changes/bridge-reliability/design.md index 798a287..d633bd8 100644 --- a/openspec/changes/bridge-reliability/design.md +++ b/openspec/changes/bridge-reliability/design.md @@ -73,13 +73,13 @@ Non-goals — см. proposal.md. 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 блокировки не имеет — при выкатке хаба убедиться, что процесс агента один. -12. **Признак `online`.** Спека data-model меняется под код: `agent_status` отдаёт `online: false`, если `last_seen_at` - старше 5 минут (решение без обсуждения, см. ниже). ## Risks / Trade-offs -- 2.47.2