From 8b321793cac2b25b494b6b6fd0115189720f79e8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 22:22:20 +0400 Subject: [PATCH] =?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