## 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` не проверяется. - `reconcile` после отчёта `refresh` подтверждает `claimed` команды по зеркалу (`check_confirmed`); для `ensure_issue` нужен `reporter` из настройки, которая никогда не задаётся, — не подтверждается никогда. - Отчёт импорта: связь из `links.csv` «есть», только если в зеркале `source→target` того же типа. - Агент: `JiraClient.last_call_at` (monotonic) только в памяти; `Runner.wait_pause` без `last_call_at` не ждёт; `recover` ставит `cmd["pause_seconds"] = 0` и вызывает `execute`, который пишет `claimed` ещё раз. Агент приходит за новой командой (`claim`) только когда своей незавершённой нет: каждый путь `execute` кончается подтверждённым отчётом или остановкой процесса, `recover` выполняется до первого `claim`. Единственное место, где агент сам теряет выданную команду, — `reload_settings_before` при недоступном хабе без остановки (команда не пишется в журнал, цикл идёт на новый `claim`); довыдача закрывает и его. - `db.migrate` только создаёт таблицы (`CREATE TABLE IF NOT EXISTS`) и пишет `schema_version = 1`; миграций по версии нет. `db.connect` вызывается на каждый запрос API и при старте. ## Goals / Non-Goals Goals: ни одна команда не застревает в `claimed` без пути восстановления, кроме явного предела выдач; созданная задача не теряется при отмене; после перезапуска агента темп Jira соблюдается; живая база мигрирует без потери данных; агент v2 продолжает работать с новым хабом; отчёт импорта не даёт ложных `missing_links` для связей в обратную сторону. Non-goals — см. proposal.md. ## Decisions 1. **Повторная выдача (решение владельца, вариант A).** Если при `claim` есть команды в `claimed`, хаб выдаёт самую старую из них (по `claimed_at`, затем `id`) снова: тот же `id`, те же (уже подставленные) параметры, `pause_seconds` по текущей политике темпа, новый `claimed_at`, `deliveries + 1`, `agent_status.current_command_id` = её `id`. Основание — инвариант агента «новый `claim` только без незавершённой команды»: раз агент пришёл, выданную он не получил или потерял. Все команды идемпотентны («проверь, потом сделай»), поэтому довыдача уже выполненной команды даёт `done` без записи; для `ensure_issue` агент находит свою задачу поиском дубля (`reporter = currentUser()`, тема, 7 дней). Это не повтор в смысле правила «хаб не повторяет команды сам»: новая команда не создаётся, решение о повторе после `failed` по-прежнему за человеком. Резервная копия перед первой командой пакета при довыдаче не делается (уже сделана). 2. **Предел выдач — 3.** Если у самой старой `claimed` уже `deliveries >= 3`, хаб переводит её в `failed` с `error = "агент не отчитался после 3 выдач"`, `finished_at = now`, снимает `current_command_id` и выбирает дальше по тем же правилам (следующая `claimed` или `_next`). Защищает очередь от команды, на которой агент падает. Число 3 — константа `MAX_DELIVERIES`, не настройка. 3. **Удаляются `_fresh_claimed_exists`, `_definer_stale`, `STALE_MINUTES` и обход R-13.** При довыдаче команда не может «зависнуть» без агента: если агента нет, очередь всё равно стоит. `maybe_auto_refresh` вместо «свежей claimed» проверяет «есть любая claimed». `agent_view.stuck` заменяется на `redelivered` — команды в `claimed` с `deliveries > 1` (`id`, `type`, `claimed_at`, `deliveries`). 4. **R-11 закрывается довыдачей.** Подтверждение по состоянию остаётся (`reconcile` после `refresh`) для команд, которые окажутся в `claimed` одновременно с выполненным `refresh` (две `claimed` после миграции живой базы, гонка двух `claim`), но для `ensure_issue` оно не делается: `check_confirmed` для неё всегда «нет», параметр `reporter` и чтение настройки `reporter` удаляются. Совпадение темы с задачей неизвестного автора — не доказательство. 5. **Миграция схемы v1 → v2.** `SCHEMA_VERSION = 2`; новая база создаётся сразу в v2 (`deliveries` в `CREATE TABLE`). Для существующей базы `migrate` читает `setting.schema_version`; если 1 — копия файла базы через `backup.make_backup(conn, <каталог базы>/backups, "schema-v1", keep=0)` (без ротации), затем в `transaction`: повторная проверка версии (параллельный `connect` мог успеть), `ALTER TABLE command ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0`, `UPDATE command SET deliveries = 1 WHERE status = 'claimed'`, `schema_version = 2`. Для `:memory:` копия не делается. `<каталог базы>/backups` совпадает с `paths.backups_dir()` при штатном `JIRA_HUB_HOME`. 6. **Отчёты.** `report_result` по команде в `queued` — `NotClaimedError` (подкласс `ValueError` не годится: API уже отдаёт 422 на `ValueError`), API отвечает 409 `not_claimed`. Агент на 409 уже считает команду закрытой (`send_result`), правка агента не нужна. По команде в `cancelled` типа `ensure_issue` с `status = done` и `result.key`, если `result` у неё ещё пуст: `result`, `jira_calls`, `rate_limited` записываются, `finished_at` и статус не меняются, заметка `new:N` переименовывается на ключ. Для остальных терминальных статусов — без изменений, как сейчас. 7. **Дубль темы в пакете — 422.** `validate` пакета отвергает две и более `ensure_issue` с одинаковой `summary` после `strip()`, перечисляя повторяющиеся темы. Регистр не нормализуется: поиск дубля агентом — точное сравнение. 8. **Отчёт импорта: `reversed_links`.** Связь из `links.csv`, которой нет как `source→target` того же типа, но есть `target→source` того же типа, попадает в `reversed_links`, а не в `missing_links`. Симметричность типа хабу не нужна: человек видит, что связь в Jira есть, и решает сам. CLI выводит новое поле в том же JSON. 9. **Агент: темп после перезапуска.** При старте `Runner` берёт максимальное `at` среди записей журнала (любого вида) и, если оно не в будущем, ставит `JiraClient.last_call_at = monotonic() − (now − at)`, `last_call_iso = at`. Запись `finished` пишется сразу после последнего вызова Jira команды, поэтому оценка не раньше настоящего вызова — ждём не меньше нужного. Пустой журнал или нечитаемое `at` — `last_call_at` пуст, как сейчас. `recover` не обнуляет `pause_seconds`: берёт значение из ответа `GET /agent/commands/{id}`. 10. **Агент: `claimed` при восстановлении не пишется повторно.** `execute(cmd, journal_claimed=True)`; `recover` вызывает с `False` — запись `claimed`/`started` для этого id уже есть. 11. **Версия агента 3**, `min_agent_version` не поднимается. Тела запросов агента не меняются. 12. **Признак `online`.** Спека data-model меняется под код: `agent_status` отдаёт `online: false`, если `last_seen_at` старше 5 минут (решение без обсуждения, см. ниже). ## Risks / Trade-offs - Нарушение инварианта агента (например, будущая параллельность) превратит довыдачу в двойное выполнение — безопасно из-за идемпотентности, но тратит вызовы Jira. Инвариант фиксируется в спеке agent. - Довыдача после долгого простоя агента выполнит старую команду: если человек за это время поменял задачу вручную, команда «вернёт» желаемое значение. Так же вела бы себя команда в очереди; для отмены есть `cancel force`. - Гонка двух `claim` через папку (старый долгий запрос ещё ждёт в хабе, агент прислал новый): старый берёт команду, ответ никто не читает, новый её довыдаёт — `deliveries = 2` без реального сбоя. Предел 3 оставляет запас на один такой случай плюс одну настоящую недоставку. - Миграция живой базы — первая в проекте; страхуется копией перед миграцией и тестом на базе v1. - Оценка паузы из журнала по `at` старше настоящего последнего вызова, если агент упал во время `refresh` между страницами (последний вызов Jira позже последней записи). Пауза `refresh` — 2 с, ошибка мала. ## Migration Plan 1. Хаб: merge → `git pull` в основном checkout → перезапуск launchd (`launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub`) → в `backups/` копия `*-schema-v1.sqlite`, `schema_version = 2`; агент v2 продолжает работу. 2. Агент v3: файл через общую папку на виртуалку, `check`, перезапуск `run`. 3. Проверка: повторный `jira-hub import admin/jira-sync` — три связи в `reversed_links`, `missing_links: 0`. Откат: остановить хаб, вернуть копию `backups/*-schema-v1.sqlite` на место `hub.sqlite`, предыдущий код хаба; агент v3 работает с хабом v1 (тела запросов не менялись). ## Decisions made without discussion | Дата | Решение | Обоснование | Цена ошибки | |---|---|---|---| | 2026-10-13 | Спека data-model приводится к коду: признак `online` (bool), а не `offline` | порог 5 мин совпадает, `online` уже читают MCP и автообновление; правка кода дала бы лишнюю смену контракта | нет: клиентов с `offline` нет | | 2026-10-13 | Предел довыдач — 3, константа кода | одной недоставки на сбой папки хватает с запасом; «ядовитая» команда не держит очередь | при 3 сбоях подряд на одной команде — ложный `failed`, лечится `command_retry` | | 2026-10-13 | Миграция v1→v2 с копией базы перед ней; существующим `claimed` — `deliveries = 1` | первая миграция живой базы; честный счёт выдач | нет | | 2026-10-13 | Дубль темы в пакете — после `strip`, без нормализации регистра | агент ищет дубль точным сравнением `summary` | темы, различающиеся только регистром, пройдут — так же их различит агент | | 2026-10-13 | Оценка последнего вызова Jira после перезапуска — максимальное `at` журнала | `finished` пишется сразу после последнего вызова; без новой записи на каждый вызов Jira | см. Risks: `refresh` между страницами, ошибка ≤ 2 с | | 2026-10-13 | Поздний отчёт по отменённой `ensure_issue`: результат пишется, статус `cancelled` и `finished_at` сохраняются | зависимые команды уже `skipped`, смена статуса задним числом запутала бы пакет; ключ виден в результате и в заметке | нет | | 2026-10-13 | `report_result` по `queued` — 409 `not_claimed` | агент уже трактует 409 как «закрыто», правка агента не нужна | нет | | 2026-10-13 | Связи «в обратную сторону» в отчёте импорта — отдельный список `reversed_links` без учёта симметричности типа | хаб не знает симметричность; человек видит, что связь есть | несимметричная связь, поставленная наоборот, не попадёт в `missing_links` — видна в `reversed_links` | | 2026-10-13 | Довыдача не делает резервную копию базы | копия перед первой командой пакета уже сделана при первой выдаче | нет |