## 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 как «закрыто», правка агента не нужна | нет |