diff --git a/CLAUDE.md b/CLAUDE.md index eec637d..b19e55f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # jira-hub ## Что это -Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; текущий change — `openspec/changes/agent-self-update/` (самообновление агента). +Хаб моста 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-14-agent-self-update/`). ## Стек и ограничения - Python 3.13, uv, hatchling, pytest; HTTP на stdlib (`http.server`), БД — SQLite (stdlib), MCP — пакет `mcp` (закреплён `<2`, рулинг R-10). diff --git a/openspec/changes/agent-self-update/.openspec.yaml b/openspec/changes/archive/2026-10-14-agent-self-update/.openspec.yaml similarity index 100% rename from openspec/changes/agent-self-update/.openspec.yaml rename to openspec/changes/archive/2026-10-14-agent-self-update/.openspec.yaml diff --git a/openspec/changes/agent-self-update/design.md b/openspec/changes/archive/2026-10-14-agent-self-update/design.md similarity index 100% rename from openspec/changes/agent-self-update/design.md rename to openspec/changes/archive/2026-10-14-agent-self-update/design.md diff --git a/openspec/changes/agent-self-update/proposal.md b/openspec/changes/archive/2026-10-14-agent-self-update/proposal.md similarity index 100% rename from openspec/changes/agent-self-update/proposal.md rename to openspec/changes/archive/2026-10-14-agent-self-update/proposal.md diff --git a/openspec/changes/agent-self-update/specs/agent/spec.md b/openspec/changes/archive/2026-10-14-agent-self-update/specs/agent/spec.md similarity index 100% rename from openspec/changes/agent-self-update/specs/agent/spec.md rename to openspec/changes/archive/2026-10-14-agent-self-update/specs/agent/spec.md diff --git a/openspec/changes/agent-self-update/specs/hub-api/spec.md b/openspec/changes/archive/2026-10-14-agent-self-update/specs/hub-api/spec.md similarity index 100% rename from openspec/changes/agent-self-update/specs/hub-api/spec.md rename to openspec/changes/archive/2026-10-14-agent-self-update/specs/hub-api/spec.md diff --git a/openspec/changes/agent-self-update/specs/mcp/spec.md b/openspec/changes/archive/2026-10-14-agent-self-update/specs/mcp/spec.md similarity index 100% rename from openspec/changes/agent-self-update/specs/mcp/spec.md rename to openspec/changes/archive/2026-10-14-agent-self-update/specs/mcp/spec.md diff --git a/openspec/specs/agent/spec.md b/openspec/specs/agent/spec.md index c79fc01..4d33e84 100644 --- a/openspec/specs/agent/spec.md +++ b/openspec/specs/agent/spec.md @@ -6,12 +6,14 @@ ## Requirements ### Requirement: Состав и настройки агента -Агент SHALL состоять из одного файла `agent.py` (Python ≥ 3.9, только стандартная библиотека) и файла `agent.json`: -`hub_url`, `hub_token`, `jira.base_url`, `jira.auth` (`basic` с `user`/`password` или `bearer` с `token`), `verify_ssl`, -`ca_bundle`, `timeout`, `poll_seconds`. `hub_url` вида `http://…` или `https://…` SHALL означать HTTP-транспорт, вида +Агент SHALL состоять из файла `agent.py` (Python ≥ 3.9, только стандартная библиотека), запускателя `run.py` (те же +ограничения) и файла `agent.json`: `hub_url`, `hub_token`, `jira.base_url`, `jira.auth` (`basic` с `user`/`password` +или `bearer` с `token`), `verify_ssl`, `ca_bundle`, `timeout`, `poll_seconds`, необязательный `auto_update` (по +умолчанию `true`). `hub_url` вида `http://…` или `https://…` SHALL означать HTTP-транспорт, вида `folder:<абсолютный путь>` — транспорт через общую папку. Всё остальное (id полей, типы, карта переходов, типы связей, доска, темп) агент SHALL получать от хаба при старте и при изменении `settings_version`. Учётные данные Jira MUST NOT -отправляться в хаб и MUST NOT попадать в журнал, логи и общую папку. +отправляться в хаб и MUST NOT попадать в журнал, логи и общую папку. Команды `agent.py`: `run` (по умолчанию), `check`, +`selftest`, `prune`. #### Scenario: Проверка без записи - **WHEN** `python3 agent.py check` @@ -22,6 +24,11 @@ - **THEN** `check` печатает «хаб: доступен через папку /Volumes/My Shared Files/RSHB/jira-bridge, настройки v, проект »; если каталога нет или реле не ответило за 10 с — строка с «НЕДОСТУПЕН» и причиной, код выхода 1 +#### Scenario: Самопроверка новой версии +- **WHEN** `python3 agent.py selftest`, хаб и Jira доступны +- **THEN** печатается «jira-agent v» и строки `check`; код 0; блокировка журнала не берётся, в Jira ничего не пишется; + если хаб или Jira недоступны — код 3 + ### Requirement: Цикл выполнения Агент SHALL работать одним процессом: `claim` к хабу (долгий запрос) → при получении команды: досыпать до `pause_seconds` от последнего вызова Jira → запись «started» в журнал → выполнение по контракту `commands` → запись @@ -99,11 +106,12 @@ rate_limited, at}`, `acked {command_id, at}`. При старте агент SHA - **THEN** 2 вызова `search` + 1 на соседей; `sync_run.issues_count = 180 + соседи` ### Requirement: Доставка и запуск -README агента SHALL описывать: копирование `agent.py` и `agent.example.json` через общую папку, заполнение `agent.json` -(включая выбор транспорта в `hub_url`), `check`, запуск в терминале и через `nohup` с логом, опциональный launchd-plist, -`prune` (только при остановленном агенте, иначе откажет). Версия агента SHALL быть в константе файла (4 — со сравнением -текстов без различия переводов строк и уточнённым восстановлением темпа) и в каждом отчёте; хаб предупреждает в -`agent_status`, если версия ниже минимальной из настроек. +README агента SHALL описывать: первую установку (копирование `agent.py`, `run.py` и `agent.example.json` через общую +папку в локальный каталог виртуалки, заполнение `agent.json` с выбором транспорта в `hub_url`, `check`), запуск +`python3 run.py` в терминале и через `nohup` с логом, опциональный launchd-plist с `run.py`, самообновление и +выключатель `auto_update`, ручное обновление `run.py`, `prune` (только при остановленном агенте, иначе откажет). +Версия агента SHALL быть в константе `AGENT_VERSION` файла (5 — самообновление) и в каждом `claim` и отчёте; хаб +предупреждает в `agent_status`, если версия ниже минимальной из настроек. #### Scenario: Устаревший агент - **WHEN** агент версии 1 выходит на связь, а в настройках `min_agent_version = 2` @@ -137,3 +145,105 @@ README агента SHALL описывать: копирование `agent.py` - **WHEN** в `resp/.json` лежит `{"id": …, "status": "ok", "body": 5}` - **THEN** агент выбрасывает `ConnectionError` («нечитаемый ответ»), процесс не падает; цикл повторяет вызов как при недоступном хабе + +### Requirement: Самообновление агента +Агент SHALL передавать в каждом `claim` `update_state` (содержимое `update.json` рядом с `agent.py` или `null`) и +`launcher_version` (из переменной окружения `JIRA_AGENT_LAUNCHER` или `null`). Проверять обновление агент SHALL только +после ответа `claim` с `command = null`, без сигнала остановки, при `auto_update ≠ false` и +`agent_release.version > AGENT_VERSION`. Версию с теми же `to_version` и `sha256`, что в `update.json` в состоянии +`rejected` или `rolled_back`, агент MUST NOT ставить снова. Без `JIRA_AGENT_LAUNCHER` агент MUST NOT выходить для +обновления: он пишет в лог «доступна v; для самообновления запускайте через run.py» не чаще раза в час. + +Подготовка версии SHALL идти по шагам: `GET /agent/release`; совпадение `version`/`sha256` ответа с `agent_release` +(иначе временный сбой); sha256 от `content` в UTF-8 равен `sha256` (иначе `rejected`); `content` компилируется и +содержит `AGENT_VERSION = ` (иначе `rejected`); запись `agent.py.new` атомарно; `python3 agent.py.new selftest` +с таймаутом 120 с — код 0 годен, код 3 или таймаут — временный сбой, иной код — `rejected` с последними 500 символами +вывода. Годную версию агент SHALL отметить в `update.json` состоянием `staged` и завершить `run` с кодом 75. При +`rejected` и временном сбое `agent.py.new` SHALL удаляться, агент продолжает работу; при `rejected` пишется +`update.json`; повтор после временного сбоя — не раньше чем через 5 минут. Первый успешный `claim` версии, для которой +`update.json` = `trial` с `to_version = AGENT_VERSION`, SHALL записывать состояние `ok`. `update.json` пишется атомарно +(временный файл и переименование). + +#### Scenario: Обновление в паузе +- **WHEN** агент v5 под запускателем получил `claim` без команды и `agent_release = {version: 6, sha256: S}`, файл + проходит проверки и `selftest` +- **THEN** рядом лежит `agent.py.new`, `update.json` = `{state: staged, from_version: 5, to_version: 6, sha256: S}`, + процесс завершается с кодом 75; журнал не изменился + +#### Scenario: Команда в очереди +- **WHEN** опубликована v6, а `claim` агента v5 вернул команду +- **THEN** агент выполняет команду и отчитывается; к обновлению переходит только после `claim` без команды + +#### Scenario: Неверный sha256 +- **WHEN** `GET /agent/release` вернул `content`, sha256 которого не равен `sha256` +- **THEN** `agent.py.new` не остаётся, `update.json` = `rejected` «sha256 не сходится»; следующий `claim` несёт этот + `update_state`; агент v5 работает дальше и v6 с тем же sha256 больше не скачивает + +#### Scenario: Хаб недоступен во время selftest +- **WHEN** `selftest` новой версии вернул код 3 +- **THEN** `agent.py.new` удалён, `update.json` не изменён, следующая попытка — не раньше чем через 5 минут + +#### Scenario: Запуск без запускателя +- **WHEN** агент v5 запущен `python3 agent.py`, опубликована v6 +- **THEN** агент не скачивает файл и не выходит; в логе раз в час «доступна v6; для самообновления запускайте через + run.py»; `claim` несёт `launcher_version = null` + +#### Scenario: Подтверждение новой версии +- **WHEN** запускатель запустил v6, `update.json` = `trial` с `to_version = 6`, первый `claim` v6 успешен +- **THEN** `update.json` = `ok`; лог «обновление до v6 подтверждено» + +### Requirement: Запускатель run.py +`python3 run.py [аргументы]` SHALL работать с файлами в каталоге самого `run.py` (`agent.py`, `agent.py.new`, +`agent.py.prev`, `update.json`) и запускать дочерним процессом `agent.py run` с переданными аргументами, переменной +окружения `JIRA_AGENT_LAUNCHER = LAUNCHER_VERSION` и унаследованными stdout/stderr. Запускатель SHALL брать +неблокирующую блокировку `run.py.lock`; занята — «запускатель уже работает», код 1. SIGINT при живом дочернем процессе +запускатель SHALL только запоминать как остановку человеком (Ctrl-C получает и агент), без дочернего процесса (пауза +перед перезапуском) — завершаться; SIGTERM — пересылать дочернему процессу. После сигнала остановки запускатель MUST NOT +запускать агента снова. + +По коду выхода дочернего процесса: +- `0`, `130` или завершение по SIGINT после Ctrl-C (или после SIGTERM запускателю) — запускатель завершается с тем же + кодом; те же коды без сигнала запускателю — падение; +- `75` — при `update.json` = `staged` и совпадении sha256 `agent.py.new`: копия `agent.py` в `agent.py.prev`, атомарная + замена `agent.py` на `agent.py.new`, `update.json` = `trial`, запуск новой версии; иначе `agent.py.new` удаляется, + `update.json` = `rejected` «файл изменился после проверки», запуск текущей версии; если перед этим был Ctrl-C — + после подмены запускатель завершается, пробный период начнётся при следующем старте; +- иной код при `update.json` = `trial` — откат; иначе — перезапуск той же версии с паузой 5 с, удваиваемой до 300 с + при каждом следующем падении и сбрасываемой после 10 минут работы. + +Пока `update.json` = `trial`, запускатель SHALL ждать состояния `ok` не дольше 10 минут; по истечении — SIGTERM, через +60 с SIGKILL и откат. Откат: `agent.py.prev` возвращается на место `agent.py`, `update.json` = `rolled_back` с причиной +(«код через с» или «не вышла на связь за 10 мин»), прежняя версия запускается без паузы. Файл `agent.py` MUST +существовать на каждом шаге подмены. При старте запускатель SHALL выполнять подмену, если `update.json` = `staged` и `agent.py.new` есть, и удалять +`agent.py.new` без состояния `staged`. + +#### Scenario: Подмена версии +- **WHEN** дочерний v5 вышел с кодом 75, `update.json` = `staged` для v6, sha256 `agent.py.new` совпадает +- **THEN** `agent.py.prev` — прежний v5, `agent.py` — v6, `update.json` = `trial`, запущен v6 + +#### Scenario: Откат при падении на старте +- **WHEN** `update.json` = `trial` для v7, дочерний процесс вышел с кодом 1 через 3 с +- **THEN** `agent.py` — снова v6 (из `agent.py.prev`), `update.json` = `{state: rolled_back, to_version: 7, error: "код 1 + через 3 с"}`, запущен v6; v6 видит `rolled_back` для v7 с тем же sha256 и v7 больше не ставит + +#### Scenario: Новая версия не выходит на связь +- **WHEN** `update.json` = `trial`, дочерний процесс работает, но за 10 минут `ok` не записан +- **THEN** запускатель посылает SIGTERM, через 60 с при необходимости SIGKILL, делает откат с причиной «не вышла на связь + за 10 мин» + +#### Scenario: Новая версия сама вышла с кодом 0 +- **WHEN** `update.json` = `trial` для v7, дочерний процесс вышел с кодом 0, сигналов запускатель не получал +- **THEN** откат на v6 с причиной «код 0 через с», как при падении + +#### Scenario: Падение после подтверждения +- **WHEN** `update.json` = `ok`, агент дважды подряд упал с кодом 1, проработав меньше минуты +- **THEN** запускатель перезапускает ту же версию через 5 с, затем через 10 с; отката нет + +#### Scenario: Остановка в терминале +- **WHEN** `python3 run.py` работает в терминале, нажат Ctrl-C +- **THEN** агент доделывает команду и выходит с кодом 0, запускатель завершается с кодом 0; повторный Ctrl-C — агент + выходит с 130, запускатель тоже + +#### Scenario: Второй запускатель +- **WHEN** `run.py` работает, и запускается ещё один `run.py` в том же каталоге +- **THEN** второй пишет «запускатель уже работает» и завершается с кодом 1, агента не запускает diff --git a/openspec/specs/hub-api/spec.md b/openspec/specs/hub-api/spec.md index 13432fd..ebf66f6 100644 --- a/openspec/specs/hub-api/spec.md +++ b/openspec/specs/hub-api/spec.md @@ -28,10 +28,13 @@ JSON, ошибки — `{error, detail}` с кодами 400 (неверное ### Requirement: Маршруты агента Хаб SHALL предоставлять агенту следующие маршруты (все под агентским токеном): -- `POST /agent/claim` `{agent_version, settings_version, last_jira_call_at}` — долгий запрос до 30 с; ответ `{command: - {id, type, params, pause_seconds}, settings_version}` или `{command: null, settings_version}`; обновляет `agent_status` - (`last_jira_call_at = null` не затирает прежнее значение). Если есть команда в `claimed`, `claim` SHALL выдать её снова - (повторная выдача, см. `commands`), а не следующую из очереди. +- `POST /agent/claim` `{agent_version, settings_version, last_jira_call_at, update_state, launcher_version}` — долгий + запрос до 30 с; ответ `{command: {id, type, params, pause_seconds}, settings_version, agent_release}` или `{command: + null, settings_version, agent_release}`, где `agent_release` — `{version, sha256}` опубликованной версии агента или + `null`; обновляет `agent_status` (`last_jira_call_at = null` не затирает прежнее значение). `update_state` (объект или + `null`, `error` хранится до 500 символов) и `launcher_version` (целое или `null`) необязательны; хаб SHALL хранить + последние значения в памяти процесса, значения неверного типа игнорировать, не отвергая `claim`. Если есть команда в + `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}` — идемпотентно: повторный @@ -43,6 +46,8 @@ JSON, ошибки — `{error, detail}` с кодами 400 (неверное пачками; `neighbor_keys` — ключи вне базовой JQL, дочитанные по связям; хаб обновляет зеркало и `sync_run`. Каждая пачка применяется независимо; `page` и `last_page` необязательны и хабом не используются. - `GET /agent/known-keys` — ключи зеркала (для определения соседей). +- `GET /agent/release` — опубликованная версия агента `{version, sha256, content}` (`content` — текст `agent.py`); + нет публикации или файл не сходится с манифестом по sha256 — 404 `no_release`. #### Scenario: Выдача с подстановкой ключа - **WHEN** следующая команда ссылается на `new:60`, а `ensure_issue` для `new:60` завершилась с `AURORA-1590` @@ -65,6 +70,20 @@ JSON, ошибки — `{error, detail}` с кодами 400 (неверное прислал `done` с `result.key = AURORA-1630` - **THEN** команда `failed`, в `result` — `AURORA-1630`; заметка `new:8` перенесена на `AURORA-1630` +#### Scenario: Публикация в ответе claim +- **WHEN** опубликована v6, агент v5 отправил `claim`, команд нет +- **THEN** ответ `{command: null, settings_version, agent_release: {version: 6, sha256: …}}` + +#### Scenario: Состояние обновления от агента +- **WHEN** агент прислал в `claim` `update_state = {state: rolled_back, from_version: 6, to_version: 7, error: "код 1 через 3 с"}` + и `launcher_version = 1` +- **THEN** `GET /agent` отдаёт это `update_state`, `launcher_version = 1` и `update_hint` «откат с v7 на v6: код 1 через 3 с» + +#### Scenario: Скачивание версии +- **WHEN** агентским токеном `GET /agent/release`, опубликована v6 +- **THEN** 200 `{version: 6, sha256, content}`, sha256 от `content` в UTF-8 равен `sha256`; клиентским токеном — 401; + без публикации — 404 `no_release` + ### Requirement: Маршруты интерфейсов Хаб SHALL предоставлять интерфейсам (MCP, позже просмотрщик) следующие маршруты под клиентским токеном: - `GET /issues` с фильтрами `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, @@ -253,3 +272,31 @@ JSON, ошибки — `{error, detail}` с кодами 400 (неверное #### Scenario: Перенос раскладки - **WHEN** в режиме хаба импортирован JSON, выгруженный старой страницей «Спринт 2 линк» - **THEN** раскладка, дорожки и фильтры из JSON применены к задачам хаба; задачи и связи — из хаба, не из JSON + +### Requirement: Публикация версии агента +`jira-hub agent publish PATH` SHALL публиковать файл агента: текст в UTF-8 не больше 1 МБ, ровно одна строка +`AGENT_VERSION = <целое>`, версия больше опубликованной. Файл SHALL записываться в `/release/agent.py` +(каталог 0700), затем `manifest.json` `{version, sha256, size, published_at}`; оба — через временный файл и +переименование, манифест последним. Команда SHALL работать без запущенного `serve` и печатать «опубликована v, +sha256 <первые 12 символов>…, байт»; при ошибке — сообщение и код 1, опубликованное не меняется. Хаб SHALL +читать манифест при каждом `claim`, `GET /agent/release` и `GET /agent`; битый манифест означает «публикации нет» +(предупреждение в лог). `GET /agent` SHALL дополняться полями `release` (`{version, sha256, published_at}` или `null`), +`update_state`, `launcher_version` и `update_hint`: «агент запущен без run.py — обновится только вручную» (опубликована +версия выше версии агента, агент онлайн, `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`; вывод «опубликована v6, sha256 …»; код 0 + +#### Scenario: Версия не выше опубликованной +- **WHEN** опубликована v6, публикуется файл с `AGENT_VERSION = 6` +- **THEN** «опубликована v6, новая должна быть больше», код 1; `release/` не изменён + +#### Scenario: Нет строки версии +- **WHEN** публикуется файл без строки `AGENT_VERSION = <целое>` или с двумя такими строками +- **THEN** сообщение об ошибке, код 1; `release/` не изменён + +#### Scenario: Агент без запускателя +- **WHEN** опубликована v6, агент v5 онлайн, в последнем `claim` `launcher_version = null` +- **THEN** `GET /agent` → `update_hint` = «агент запущен без run.py — обновится только вручную» diff --git a/openspec/specs/mcp/spec.md b/openspec/specs/mcp/spec.md index b3a2372..ce47256 100644 --- a/openspec/specs/mcp/spec.md +++ b/openspec/specs/mcp/spec.md @@ -27,7 +27,7 @@ MCP-сервер хаба (`jira-hub mcp`, stdio) для Claude: инструм | `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 за сутки, версия настроек | +| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек; публикация агента («публикация: v от <дата>» или «публикации нет»), состояние обновления («обновление: v → v»), запускатель («запускатель: v» или «без запускателя»), `update_hint`, если он есть | | `sync_log` | `limit` | последние `sync_run` | | `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` | @@ -51,3 +51,8 @@ MCP-сервер хаба (`jira-hub mcp`, stdio) для Claude: инструм #### 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: …»