From 0bb43431b8e12da6aa0ecf8d022dbcb1b7aaa742 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 01:33:07 +0400 Subject: [PATCH] =?UTF-8?q?docs(openspec):=20hub-hygiene-d=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F,?= =?UTF-8?q?=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20=D0=B0=D0=B3=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0,=20CRLF,=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20?= =?UTF-8?q?(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/hub-hygiene-d/.openspec.yaml | 2 + openspec/changes/hub-hygiene-d/design.md | 105 ++++++++++++++++++ openspec/changes/hub-hygiene-d/proposal.md | 57 ++++++++++ .../hub-hygiene-d/specs/commands/spec.md | 62 +++++++++++ .../hub-hygiene-d/specs/hub-api/spec.md | 99 +++++++++++++++++ .../changes/hub-hygiene-d/specs/mcp/spec.md | 49 ++++++++ 6 files changed, 374 insertions(+) create mode 100644 openspec/changes/hub-hygiene-d/.openspec.yaml create mode 100644 openspec/changes/hub-hygiene-d/design.md create mode 100644 openspec/changes/hub-hygiene-d/proposal.md create mode 100644 openspec/changes/hub-hygiene-d/specs/commands/spec.md create mode 100644 openspec/changes/hub-hygiene-d/specs/hub-api/spec.md create mode 100644 openspec/changes/hub-hygiene-d/specs/mcp/spec.md diff --git a/openspec/changes/hub-hygiene-d/.openspec.yaml b/openspec/changes/hub-hygiene-d/.openspec.yaml new file mode 100644 index 0000000..a216797 --- /dev/null +++ b/openspec/changes/hub-hygiene-d/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-15 diff --git a/openspec/changes/hub-hygiene-d/design.md b/openspec/changes/hub-hygiene-d/design.md new file mode 100644 index 0000000..7e5191b --- /dev/null +++ b/openspec/changes/hub-hygiene-d/design.md @@ -0,0 +1,105 @@ +## Context + +Хаб `main` 8229bba (web-app и agent-v9-hardening слиты). Issue aep/jira-hub#32. Источники: #25 (M-1…M-3, M-5…M-7), +#16 (T3.1, T3.2, T5.2, M-8, ре-ревью minor-1, minor-3, O-9, O-10, O-11), #4 (M-9/O-4, M-10). Сверка с кодом — 15.10.2026. + +Найдено при чтении кода: +- `release._write_atomic` — `tmp.write_bytes` + `os.replace`, без `fsync` и без очистки `.tmp` при ошибке; `publish` + пишет `agent.py`, затем манифест. `cli` ловит только `PublishError`. Если манифест не записался, новый `agent.py` + не сходится со старым манифестом по sha → `load` → `None` → 404. +- `release.current`: `int(m["version"])` (6.9 → 6, `true` → 1), `str(m["sha256"])`; `log.warning` на каждый вызов + (каждый `claim` и `GET /agent`). +- `release.load`: `data.decode("utf-8")` после сверки sha, `UnicodeDecodeError` не ловится → 500. +- `Hub.agent_update = {"update_state": None, "launcher_version": None}` — в памяти; после перезапуска хаба до первого + `claim` `launcher_version = None` неотличимо от «агент без запускателя» (`release.hint`, `mcp_server.fmt_agent:115`). +- `h_settings_put` отвечает `{"settings_version": version}`. +- `queue._next`: при неразрешённом временном ключе — `skipped` «задача new:N не создана», независимо от того, + `failed` или `cancelled` команда-создатель (`_definer_status`). +- `commands.check_confirmed`: `(issue.get(field) or None) != (p[field] or None)` — `\r\n` в зеркале против `\n` в + параметре даёт «не совпало». +- `relay._cleaned: set[Path]`, проверка `self.root not in self._cleaned` — по написанию пути. +- Тесты: 249 passed; 87 `ResourceWarning: unclosed database` (test_queue, test_relay, test_commands, test_importer). + +## Goals / Non-Goals + +**Goals:** публикация не оставляет хаб без годной версии при сбое записи и не роняет команду трейсбеком; статус агента +не утверждает неизвестное; подтверждение по состоянию работает на задачах с CRLF; ответы и тексты называют то, что +произошло; тесты закрывают найденные пробелы и чисты от `ResourceWarning`. + +**Non-Goals:** см. proposal. + +## Decisions + +### Публикация агента + +1. **Запись файла** (`_write_atomic`): временный файл `.<имя>.tmp` в том же каталоге, `write` + `flush` + `os.fsync`, + `os.replace`; при `OSError` временный файл удаляется (ошибки удаления игнорируются), исключение пробрасывается. +2. **Порядок публикации:** `agent.py` → `agent.py.prev` (`os.replace`, если `agent.py` есть); новый файл через + `_write_atomic` в `agent.py`; манифест через `_write_atomic`. `OSError` после переименования — `agent.py.prev` + возвращается на место `agent.py` (`os.replace`; если и это не удалось — в лог), и `PublishError("не удалось записать + публикацию: <ошибка>")`. После успешной записи манифеста `agent.py.prev` удаляется. Итог: при любом сбое опубликована + прежняя версия (манифест и `agent.py` согласованы), `release/` без временных файлов. +3. **`cli`** печатает текст `PublishError` в stderr и возвращает 1 — как сейчас; новых веток нет, `OSError` до `cli` не + доходит. +4. **Годный манифест** (`current`): объект; `version` — `int` и не `bool`, ≥ 1; `sha256` — строка, `re.fullmatch(r"[0-9a-f]{64}")`. + Иначе — «публикации нет». Совпадает с проверкой агента v9 (design agent-v9-hardening 10). +5. **Предупреждение о битом манифесте** — один раз на каждый новый текст ошибки: модульная переменная с последним + текстом; успешное чтение или отсутствие манифеста её сбрасывает. Отсутствие манифеста — не ошибка, без лога. +6. **`load`**: `UnicodeDecodeError` → `log.error` «agent.py не в UTF-8 — публикации нет» и `None` (404 `no_release`). + +### Статус агента + +7. **`agent_reported`**: `Hub.agent_update` получает ключ `reported: False`; `note_agent_update` (вызывается на каждом + `claim`) ставит `True`. `GET /agent` отдаёт `agent_reported`. `release.hint(...)` получает признак и не выдаёт + «агент запущен без run.py…», пока `agent_reported = false`; подсказки об откате и отказе — по `update_state` как + сейчас (до первого `claim` он `null`). +8. **MCP `agent_status`**: строка запускателя — «запускатель: v», «без запускателя» (агент сообщил `null`) или + «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)» (`agent_reported = false`). + Если поля `agent_reported` в ответе нет (старый хаб) — прежнее поведение. + +### Настройки, очередь, подтверждение, реле + +9. **`PUT /settings`** отвечает `settings.get_all(conn)` (токены скрыты) после записи — те же поля, что `GET /settings`, + включая `settings_version`. Клиенты, читающие `settings_version` из ответа (`client`, `mcp_server.settings_set`, + web), не меняются. +10. **Пропуск зависимой команды**: если среди неразрешённых ключей есть ключ, чья `ensure_issue` в `cancelled`, текст — + «создание задачи new:N отменено» (по первому такому ключу); иначе прежний «задача new:N не создана». +11. **Сравнение строк в `check_confirmed`**: для полей `ensure_fields` со строковыми значениями сравнение после замены + `\r\n` на `\n` с обеих сторон; `None` и пустая строка по-прежнему равны. Метки и прочие поля — без изменений. +12. **Реле**: `_cleaned` хранит `Path.resolve()`; проверка — по `self.root.resolve()`. Ошибка `resolve` (папки нет) — + сравнение по исходному пути, как сейчас. + +### Тесты и чистота + +13. Новые тесты: bool в строковом параметре и `[]` в списочном (`test_commands`); `sync_run.finished_at` не ставится + при `failed` не-refresh по пределу выдач (`test_queue`); 501 на `DELETE`/`PATCH` (`test_api`); `epics.csv` без + «Ключ» (`test_importer`); «подготовлена заново» в `test_vanished_tree_keeps_work` (`test_relay`); предупреждение + «обновите агента» при `agent_version < min_agent_version`, офлайн-агент (нет контакта дольше порога) в + `GET /agent`, `tick` — проверка эффекта (`test_api`/`test_queue`); публикация — сбой записи манифеста возвращает + прежний `agent.py`, `.tmp` нет; битый манифест (`6.9`, `true`, короткий sha) — «публикации нет», предупреждение один + раз; не-UTF-8 → 404; `agent_reported` до и после `claim`; `PUT /settings` отдаёт `relay_dir`; текст «отменено»; + CRLF-подтверждение; реле с симлинком на ту же папку. +14. **`ResourceWarning`**: фикстуры и хелперы тестов закрывают соединения (`conn.close()` / `contextlib.closing`); + критерий — `uv run pytest -W error::ResourceWarning` без предупреждений `unclosed database` (pytest + `filterwarnings = error::ResourceWarning` в `pyproject.toml`, если не ломает сторонние пакеты; иначе — проверка в + плане командой). +15. **T3.1**: `_type_error` переписывается без тяжёлой строки с `and` и кортежем — отдельные ветки по типу. + +## Risks / Trade-offs + +- [`agent.py.prev` в `release/`] лишний файл живёт только между переименованием и записью манифеста; при падении + процесса посреди — остаётся; следующая публикация его перезапишет. → не влияет на `load` (читается только `agent.py`). +- [«неизвестно» до первого claim] до 30 с после перезапуска хаба статус неполный. → честнее ложного «без запускателя». +- [`PUT /settings` отдаёт больше полей] клиенты, ожидавшие ровно `{settings_version}`, получают надмножество. → поле на + месте. +- [Конфликт с #29] текстовый в `tests/test_api.py` (одна строка у #29). → кто сливается вторым, разрешает. + +## Decisions made without discussion + +| Маркер | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| R-1 (design #7) | 2026-10-15 | Признак `agent_reported` в памяти, а не `launcher_version` в БД | без записи на каждый claim; значение из БД после перезапуска могло устареть | до 30 с «неизвестно» после перезапуска хаба | +| R-2 (design #2) | 2026-10-15 | Откат публикации через `agent.py.prev` (переименования, без копии) | переименование не требует места на диске — откат работает и при полном диске | лишний файл в `release/` при падении процесса посреди публикации | +| R-3 (design #9) | 2026-10-15 | `PUT /settings` отвечает всеми настройками, а не `{settings_version, relay_dir}` | один формат с `GET /settings`; O-11 — «не видно, что записалось» относится к любой настройке | надмножество полей в ответе | +| R-4 (design #10) | 2026-10-15 | «Отменено» — только при `cancelled` создателе; при `failed` текст прежний | O-10 описывает только force-отмену; `failed` — задача действительно не создана | нет | +| R-5 (design #14) | 2026-10-15 | Цель — ноль `ResourceWarning` от sqlite в тестах, проверка `-W error::ResourceWarning` | M-9/O-4: 87 предупреждений прячут настоящие утечки | время на правку фикстур | diff --git a/openspec/changes/hub-hygiene-d/proposal.md b/openspec/changes/hub-hygiene-d/proposal.md new file mode 100644 index 0000000..d88372e --- /dev/null +++ b/openspec/changes/hub-hygiene-d/proposal.md @@ -0,0 +1,57 @@ +## Why + +У хаба накопились отложенные находки трёх change'ей: самообновления (#25), пакета C (#16) и первого change'а (#4, +«на потом»). Разбор против `main` 8229bba (15.10.2026) показал, что актуальны все пункты, кроме M-4 #25, который исправило +слияние web-app. Вред у одного пользователя небольшой, но заметный: +- при полном диске `jira-hub agent publish` падает с трейсбеком и оставляет публикацию в состоянии «404 до следующей + публикации»; +- после перезапуска хаба `agent_status` пишет «без запускателя», хотя агент под `run.py`; +- потерянный отчёт `ensure_fields` по задаче, у которой описание с `\r\n` (71 из 180 правлены в UI), закрывается + довыдачей вместо подтверждения по состоянию; +- в тестах 87 незакрытых соединений sqlite и пробелы в покрытии. + +Решения владельца 15.10.2026: всё актуальное — одним пакетом; M-4 #25 закрыть, minor-2 #16 отклонить; агентский +#26 в пакет не входит; работать параллельно с #29 (web-app follow-ups), она трогает только `h_app`. + +## What Changes + +- **Публикация агента** (`release.py`, `cli.py`): запись с `fsync`; прежний `agent.py` переименовывается в + `agent.py.prev`, при сбое записи манифеста возвращается; `OSError` → «не удалось записать публикацию: <ошибка>», + код 1, временные файлы удалены; манифест годен, только если `version` — целое (не bool) и `sha256` — 64 hex-символа; + не-UTF-8 файл при совпавшем sha — 404, а не 500; предупреждение о битом манифесте — один раз на каждую новую ошибку. +- **`GET /agent`**: поле `agent_reported` (был ли `claim` с момента запуска хаба); пока его нет — подсказки про `run.py` + нет; MCP `agent_status` пишет «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)». +- **`PUT /settings`**: ответ — все настройки, как `GET /settings` (включая `settings_version`). +- **Очередь**: зависимая команда, пропущенная из-за force-отменённой `ensure_issue`, получает + «создание задачи new:N отменено». +- **Подтверждение по состоянию**: строки `ensure_fields` сравниваются без различия `\r\n` и `\n`. +- **Реле**: обработанные папки сравниваются по `resolve()`. +- **Тесты**: bool в строковом параметре и пустой список; `finished_at` у `sync_run` только для `refresh`; 501 на прочие + методы; `epics.csv` без ключа; «подготовлена заново» в реле; предупреждение `min_agent_version`; офлайн-агент; `tick` с + проверками; ноль `ResourceWarning`; читаемый `commands._type_error`. + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `hub-api`: ответ `PUT /settings`; публикация версии агента (сбой записи, проверка манифеста, `agent_reported`). +- `mcp`: строка запускателя в `agent_status`. +- `commands`: текст `skipped` при отменённом создании; подтверждение `ensure_fields` без различия перевода строки. + +## Non-Goals + +- сохранение `launcher_version` в БД (запись на каждый claim; «неизвестно» честнее устаревшего значения); +- агент (aep/jira-agent#26) — в следующий change агента; +- minor-2 #16 (дубль предупреждения при неудачном `chmod`) — отклонено; +- раздача веб-приложения (`h_app`) — в #29. + +## Impact + +- jira-hub (issue aep/jira-hub#32): `src/jira_hub/release.py`, `cli.py`, `api.py` (`Hub`, `h_agent_get`, + `h_settings_put`), `queue.py` (`_next`), `commands.py` (`check_confirmed`, `_type_error`), `relay.py` (`_cleaned`), + `mcp_server.py` (`fmt_agent`), `tests/`, ARCHITECTURE.md при необходимости. +- Пересечение с `feat/29-web-app-followups`: `api.py` (другие функции) и одна строка `tests/test_api.py`; дельты спек + трогают разные требования (`hub-api` «Раздача веб-приложения» у #29). +- Выкатка: после merge — pull на Mac mini, перезапуск хаба (владелец), `/mcp` → reconnect в сессиях Claude. diff --git a/openspec/changes/hub-hygiene-d/specs/commands/spec.md b/openspec/changes/hub-hygiene-d/specs/commands/spec.md new file mode 100644 index 0000000..e9bc9dc --- /dev/null +++ b/openspec/changes/hub-hygiene-d/specs/commands/spec.md @@ -0,0 +1,62 @@ +## MODIFIED Requirements + +### Requirement: Временные ключи и ссылки внутри пакета +Параметр-ключ любой команды MAY быть временным `new:`. Хаб SHALL выдавать агенту такую команду только после `done` +команды `ensure_issue` с тем же `temp_key` в том же пакете, подставив настоящий ключ. Если `ensure_issue` завершилась +`failed` или `cancelled`, зависимые команды SHALL получить `skipped` с ошибкой «создание задачи new: отменено», если +`ensure_issue` отменена, иначе «задача new: не создана». Временный ключ вне пакета с его `ensure_issue` SHALL +отвергаться при создании пакета (422). + +#### Scenario: Создать и привязать +- **WHEN** пакет: `ensure_issue {temp_key: new:60, …}`, `ensure_link {source: new:60, target: AURORA-1537, …}` +- **THEN** вторая команда выдаётся агенту с `source = AURORA-1590` после завершения первой + +#### Scenario: Создание отменено +- **WHEN** пакет: `ensure_issue {temp_key: new:901, …}` (отменена с `force: true`), `ensure_comment {key: new:901, …}` +- **THEN** `ensure_comment` — `skipped` с ошибкой «создание задачи new:901 отменено» + +#### Scenario: Создание упало +- **WHEN** `ensure_issue {temp_key: new:902, …}` завершилась `failed`, зависимая `ensure_link {source: new:902, …}` +- **THEN** `ensure_link` — `skipped` с ошибкой «задача new:902 не создана» + +### Requirement: Ошибки и подтверждение по состоянию +Ошибка команды SHALL записываться в `error` (HTTP-код, текст Jira); хаб SHALL хранить не больше 1500 символов `error`, +обрезая присланное. Пакет после ошибки продолжается. + +**Повторная выдача.** Агент запрашивает новую команду, только когда у него нет незавершённой (см. `agent`), поэтому +команда в `claimed` к моменту следующего `claim` считается не доставленной или потерянной агентом. При `claim` хаб SHALL +сначала выдать снова самую старую (по `claimed_at`, затем `id`) команду в `claimed`: тот же `id` и параметры, +`pause_seconds` по текущей политике темпа, новый `claimed_at`, `deliveries + 1`. Если у неё уже `deliveries >= 3`, хаб SHALL +перевести её в `failed` с `error = "агент не отчитался после 3 выдач"` и перейти к следующей (довыдача другой `claimed` +или следующая `queued`). Довыдача — доставка той же выдачи, а не повтор команды: новых команд хаб MUST NOT создавать +сам, повтор после `failed` — новая команда с теми же параметрами по запросу интерфейса. Если агент всё же отчитался +`done` с ключом по `ensure_issue`, переведённой в `failed` по пределу, хаб SHALL записать ключ в результат и перенести +заметку, не меняя статус (как для отменённой, см. `hub-api`). + +**Подтверждение по состоянию.** Если после любого `refresh` желаемый результат команды в `claimed` виден в зеркале, хаб +SHALL перевести её в `confirmed_by_state`: `ensure_fields` — все поля совпадают (строки сравниваются без различия +`\r\n` и `\n`; `null` и пустая строка равны); `ensure_status` — статус равен последнему в пути; `ensure_link` — связь +есть и конфликтов нет; `ensure_sprint` — спринт совпадает. `ensure_issue`, `ensure_comment` и `refresh` по состоянию не +подтверждаются: потерянный отчёт по ним закрывается повторной выдачей (агент находит созданную задачу поиском дубля) +или отменой человеком. + +#### Scenario: Отчёт потерян, факт на месте +- **WHEN** `ensure_sprint {key: X, sprint: "Спринт 2"}` в `claimed`, после выполненного `refresh` зеркало показывает X в «Спринт 2» +- **THEN** команда `confirmed_by_state` + +#### Scenario: Описание с CRLF в зеркале +- **WHEN** `ensure_fields {key: X, description: "a\nb"}` в `claimed`, после `refresh` в зеркале у X `description = "a\r\nb"` +- **THEN** команда `confirmed_by_state` + +#### Scenario: Ответ на claim потерян +- **WHEN** команда 40 выдана (`deliveries = 1`), ответ до агента не дошёл, агент снова прислал `claim` +- **THEN** `claim` отдаёт команду 40, `deliveries = 2`; в очереди есть `queued` команды, но выдаётся 40 + +#### Scenario: Предел выдач +- **WHEN** команда 41 в `claimed` с `deliveries = 3`, агент снова прислал `claim`, в очереди есть команда 42 +- **THEN** 41 — `failed` «агент не отчитался после 3 выдач»; `claim` отдаёт 42 + +#### Scenario: Потерянный отчёт о создании задачи +- **WHEN** `ensure_issue {temp_key: new:5, summary: "Отчёт"}` выполнена агентом, отчёт не дошёл, агент прислал `claim` +- **THEN** команда выдаётся снова; агент находит созданную задачу поиском дубля и отчитывается `done` с её ключом; второй + задачи нет, заметка `new:5` перенесена на ключ diff --git a/openspec/changes/hub-hygiene-d/specs/hub-api/spec.md b/openspec/changes/hub-hygiene-d/specs/hub-api/spec.md new file mode 100644 index 0000000..abcb3e3 --- /dev/null +++ b/openspec/changes/hub-hygiene-d/specs/hub-api/spec.md @@ -0,0 +1,99 @@ +## MODIFIED Requirements + +### Requirement: Маршруты интерфейсов +Хаб SHALL предоставлять интерфейсам (MCP, позже просмотрщик) следующие маршруты под клиентским токеном: +- `GET /issues` с фильтрами `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, + `q` (подстрока темы), `limit`/`offset` — строки зеркала (все поля `issue`) с `fact_status` и `work_kind` из заметок; + сокращение до карточки — дело интерфейса. +- `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` — отменяет `queued` команды + пакета; ответ — вид пакета с полем `still_claimed`: список `{id, type}` команд пакета в `claimed` (они будут выполнены + агентом; остановить — отмена команды с `force`). +- `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`; + ответ — все настройки после записи, как у `GET /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 с темой «Шахматка: комментарии», пакет не создан + +#### Scenario: Тема не строка +- **WHEN** `POST /batches` с `ensure_issue {temp_key: new:1, summary: 42, …}` +- **THEN** 422 с указанием параметра `summary`, пакет не создан + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** в пакете 9 команда 30 `claimed`, команды 31 и 32 `queued`; `POST /batches/9/cancel` +- **THEN** 31 и 32 `cancelled`; в ответе `still_claimed = [{id: 30, type: …}]`; 30 остаётся `claimed` + +#### Scenario: Ответ на запись настроек +- **WHEN** `PUT /settings {relay_dir: "/Volumes/externalSSD/utm/rshb/jira-bridge"}` +- **THEN** 200; ответ содержит `relay_dir` с записанным значением, новую `settings_version` и остальные настройки; + токенов в ответе нет + +### Requirement: Публикация версии агента +`jira-hub agent publish PATH` SHALL публиковать файл агента: текст в UTF-8 не больше 1 МБ, ровно одна строка +`AGENT_VERSION = <целое>`, версия больше опубликованной. Файл SHALL записываться в `/release/agent.py` +(каталог 0700), затем `manifest.json` `{version, sha256, size, published_at}`; оба — через временный файл с `fsync` и +переименование, манифест последним. Прежний `agent.py` на время публикации SHALL переименовываться в `agent.py.prev` +и удаляться после записи манифеста; если запись нового файла или манифеста не удалась (`OSError`), хаб SHALL вернуть +`agent.py.prev` на место `agent.py`, удалить временные файлы и завершить команду сообщением «не удалось записать +публикацию: <ошибка>» и кодом 1 — опубликованной остаётся прежняя версия. Команда SHALL работать без запущенного +`serve` и печатать «опубликована v, sha256 <первые 12 символов>…, байт»; при ошибке — сообщение и код 1, +опубликованное не меняется. Хаб SHALL читать манифест при каждом `claim`, `GET /agent/release` и `GET /agent`; манифест +годен, только если это объект, `version` — целое число (не логическое) не меньше 1, `sha256` — строка из 64 символов +`[0-9a-f]`; негодный или нечитаемый манифест означает «публикации нет» и предупреждение в лог — одно на каждый новый +текст ошибки. Файл `agent.py`, не декодируемый как UTF-8 при совпавшем sha256, означает «публикации нет» +(`GET /agent/release` — 404 `no_release`). `GET /agent` SHALL дополняться полями `release` (`{version, sha256, +published_at}` или `null`), `update_state`, `launcher_version`, `agent_reported` (был ли `claim` с момента запуска хаба) +и `update_hint`: «агент запущен без run.py — обновится только вручную» (опубликована версия выше версии агента, агент +онлайн, `agent_reported = true`, `launcher_version = null`), «откат с v на v: » (`rolled_back`), +«v отвергнута: » (`rejected`), иначе `null`. + +#### Scenario: Публикация новой версии +- **WHEN** опубликована v5, `jira-hub agent publish jira-agent/src/agent.py` с `AGENT_VERSION = 6` +- **THEN** в `release/` — новый `agent.py` и манифест с `version = 6`, `agent.py.prev` нет; вывод «опубликована v6, + sha256 …»; код 0 + +#### Scenario: Версия не выше опубликованной +- **WHEN** опубликована v6, публикуется файл с `AGENT_VERSION = 6` +- **THEN** «опубликована v6, новая должна быть больше», код 1; `release/` не изменён + +#### Scenario: Нет строки версии +- **WHEN** публикуется файл без строки `AGENT_VERSION = <целое>` или с двумя такими строками +- **THEN** сообщение об ошибке, код 1; `release/` не изменён + +#### Scenario: Диск полон при записи манифеста +- **WHEN** опубликована v8, публикуется v9, запись `manifest.json` завершилась `OSError` +- **THEN** «не удалось записать публикацию: …», код 1, без трейсбека; `agent.py` и манифест — прежние v8, + `GET /agent/release` отдаёт v8; временных файлов и `agent.py.prev` в `release/` нет + +#### Scenario: Негодный манифест +- **WHEN** в `manifest.json` `version = 6.9` (или `true`, или `sha256` короче 64 символов), хаб получает 100 `claim` +- **THEN** в `claim` `agent_release = null`, `GET /agent/release` — 404; в логе одно предупреждение о манифесте + +#### Scenario: Агент без запускателя +- **WHEN** опубликована v6, агент v5 онлайн, в последнем `claim` `launcher_version = null` +- **THEN** `GET /agent` → `agent_reported = true`, `update_hint` = «агент запущен без run.py — обновится только вручную» + +#### Scenario: Хаб перезапущен, агент ещё не выходил на связь +- **WHEN** опубликована v10, агент v9 под запускателем, хаб перезапущен, `claim` после запуска ещё не было +- **THEN** `GET /agent` → `agent_reported = false`, `launcher_version = null`, `update_hint = null`; после первого + `claim` — `agent_reported = true`, `launcher_version` из `claim` diff --git a/openspec/changes/hub-hygiene-d/specs/mcp/spec.md b/openspec/changes/hub-hygiene-d/specs/mcp/spec.md new file mode 100644 index 0000000..700969b --- /dev/null +++ b/openspec/changes/hub-hygiene-d/specs/mcp/spec.md @@ -0,0 +1,49 @@ +## 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` | `id` | новый пакет | +| `batch_cancel` | `id` | вид пакета; для команд пакета, уже взятых агентом, — строка «команда #N уже у агента и будет выполнена; остановить — `POST /commands/N/cancel` с `force: true`» | +| `refresh` | `keys` или `full`, `wait` (bool) | без `wait` — строка с id поставленного пакета; с `wait` — итог пакета, как у `batch_wait` | +| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек; публикация агента («публикация: v от <дата>» или «публикации нет»), состояние обновления («обновление: v → v»), запускатель («запускатель: v», «без запускателя» или, пока `agent_reported = false`, «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)»), `update_hint`, если он есть | +| `sync_log` | `limit` | последние `sync_run` | +| `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` | + +Описание инструмента `batch_create` SHALL предупреждать, что `ensure_issue` должна нести `labels` с базовой меткой +проекта (`AWPCA`): хаб и агент метки сами не добавляют, и задача без неё выпадает из базовой JQL и из зеркала. + +Автор команд из MCP SHALL быть `claude`; из API без автора — `api`. + +#### Scenario: Пакет из чата +- **WHEN** `batch_create {title: "1538 в спринт 2", commands: [ensure_fields{…}, ensure_link{…}, ensure_sprint{…}]}` +- **THEN** пакет создан с четырьмя командами (три + `refresh`), ответ содержит оценку в минутах + +#### Scenario: Ожидание результата +- **WHEN** `batch_wait {id, timeout_minutes: 10}` и пакет завершился через 6 минут +- **THEN** ответ через 6 минут со статусами всех команд + +#### Scenario: Статус агента с довыдачей +- **WHEN** агент на связи, команда 40 выдана дважды и ещё без отчёта +- **THEN** ответ `agent_status` содержит «агент онлайн» и строку о команде 40 с числом выдач 2 + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** `batch_cancel {id: 9}`, команда 30 пакета в `claimed` +- **THEN** ответ содержит вид пакета и строку «команда #30 уже у агента и будет выполнена; остановить — …» + +#### Scenario: Статус агента после отката +- **WHEN** опубликована v7, агент v6 под запускателем v1 прислал `update_state = rolled_back` для v7 +- **THEN** ответ `agent_status` содержит «публикация: v7 от …», «обновление: rolled_back v6 → v7», «запускатель: v1» и + «откат с v7 на v6: …» + +#### Scenario: Статус агента сразу после перезапуска хаба +- **WHEN** хаб перезапущен, агент ещё не прислал `claim` +- **THEN** ответ `agent_status` содержит «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска + хаба)» и не содержит «без запускателя» и «агент запущен без run.py»