2026-10-13 23:17:32 +04:00

20 KiB
Raw Blame History

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. Инвариант держится только для одного процесса агента на журнал — второй процесс получил бы довыданную команду одновременно с первым (решение 13).
  • 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 минут (решение без обсуждения, см. ниже).
  13. Один процесс агента на журнал (финальное ревью, I-1). agent.py run берёт исключительную блокировку <журнал>.lock (fcntl.flock, LOCK_NB); занята — сообщение «агент уже запущен…» и код 1. Без неё два процесса (например, при перезапуске через nohup/launchd без остановки старого) выполняли бы довыданную команду одновременно: идемпотентность «проверь, потом сделай» от одновременного выполнения не защищает (две ensure_issue могут не найти дубль друг друга). Агент v2 блокировки не имеет — при выкатке хаба убедиться, что процесс агента один.

Risks / Trade-offs

  • Нарушение инварианта агента (второй процесс с тем же журналом, будущая параллельность) превращает довыдачу в одновременное выполнение одной команды двумя исполнителями — для ensure_issue это возможный дубль задачи, для ensure_comment — два комментария. Закрыто блокировкой процесса в агенте v3 (решение 13); инвариант зафиксирован в спеке agent. Предел выдач (решение 2) защищает от потери доставки, но не от падения процесса агента на команде: восстановление по журналу берёт команду через GET /agent/commands/{id} и выдач не добавляет.
  • Довыдача после долгого простоя агента выполнит старую команду: если человек за это время поменял задачу вручную, команда «вернёт» желаемое значение. Так же вела бы себя команда в очереди; для отмены есть cancel force.
  • Гонка двух claim через папку (старый долгий запрос ещё ждёт в хабе, агент прислал новый): старый берёт команду, ответ никто не читает, новый её довыдаёт — deliveries = 2 без реального сбоя. Предел 3 оставляет запас на один такой случай плюс одну настоящую недоставку.
  • Миграция живой базы — первая в проекте; страхуется копией перед миграцией и тестом на базе v1.
  • Оценка паузы из журнала по at старше настоящего последнего вызова, если агент упал во время refresh между страницами (последний вызов Jira позже последней записи). Пауза refresh — 2 с, ошибка мала.

Migration Plan

  1. До перезапуска хаба: убедиться, что на виртуалке работает один процесс агента; посмотреть команды в claimed (agent_status) — все они после старта будут довыданы и выполнены через проверку; ненужные отменить с force.
  2. Хаб: merge → git pull в основном checkout → перезапуск launchd (launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub) → в backups/ копия *-schema-v1.sqlite, schema_version = 2; агент v2 продолжает работу.
  3. Агент v3: файл через общую папку на виртуалку, check, остановить v2 (дождаться «остановлен»), запустить v3.
  4. Проверка: повторный jira-hub import admin/jira-sync — три связи в reversed_links, missing_links: 0. Откат: предыдущий код хаба работает и на базе v2 (лишняя колонка deliveries с DEFAULT 0 ему не мешает), поэтому достаточно вернуть код и перезапустить хаб. Копия backups/*-schema-v1.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 Довыдача не делает резервную копию базы копия перед первой командой пакета уже сделана при первой выдаче нет
2026-10-13 Шаг плана «автообновление при claimed без отчёта» вынесен из test_auto_refresh_conditions в отдельный тест (R-1) в конце исходного теста auto_refresh_minutes = 0, проверка прошла бы и без правки нет
2026-10-13 В CLAUDE.md и ARCHITECTURE.md хаба отмечено, что db вызывает backup (копия перед миграцией), и указан текущий change (R-2) иначе документы противоречат коду нет
2026-10-13 Копии *-schema-v*.sqlite исключены из ротации резервных копий (R-3, финальное ревью M-1) ротация копий пакетов (keep = 30) удалила бы копию перед миграцией первой копии перед миграциями копятся без ограничения — по одной на миграцию
2026-10-13 Один процесс агента на журнал — блокировка <журнал>.lock в агенте v3 (R-4, финальное ревью I-1; подробно — решение 13) довыдача отдаёт команду любому пришедшему процессу нет: второй процесс просто не стартует