20 KiB
20 KiB
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
- Повторная выдача (решение владельца, вариант 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по-прежнему за человеком. Резервная копия перед первой командой пакета при довыдаче не делается (уже сделана). - Предел выдач — 3. Если у самой старой
claimedужеdeliveries >= 3, хаб переводит её вfailedсerror = "агент не отчитался после 3 выдач",finished_at = now, снимаетcurrent_command_idи выбирает дальше по тем же правилам (следующаяclaimedили_next). Защищает очередь от команды, на которой агент падает. Число 3 — константаMAX_DELIVERIES, не настройка. - Удаляются
_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). - R-11 закрывается довыдачей. Подтверждение по состоянию остаётся (
reconcileпослеrefresh) для команд, которые окажутся вclaimedодновременно с выполненнымrefresh(двеclaimedпосле миграции живой базы, гонка двухclaim), но дляensure_issueоно не делается:check_confirmedдля неё всегда «нет», параметрreporterи чтение настройкиreporterудаляются. Совпадение темы с задачей неизвестного автора — не доказательство. - Миграция схемы 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. - Отчёты.
report_resultпо команде вqueued—NotClaimedError(подклассValueErrorне годится: API уже отдаёт 422 наValueError), API отвечает 409not_claimed. Агент на 409 уже считает команду закрытой (send_result), правка агента не нужна. По команде вcancelledтипаensure_issueсstatus = doneиresult.key, еслиresultу неё ещё пуст:result,jira_calls,rate_limitedзаписываются,finished_atи статус не меняются, заметкаnew:Nпереименовывается на ключ. Для остальных терминальных статусов — без изменений, как сейчас. - Дубль темы в пакете — 422.
validateпакета отвергает две и болееensure_issueс одинаковойsummaryпослеstrip(), перечисляя повторяющиеся темы. Регистр не нормализуется: поиск дубля агентом — точное сравнение. - Отчёт импорта:
reversed_links. Связь изlinks.csv, которой нет какsource→targetтого же типа, но естьtarget→sourceтого же типа, попадает вreversed_links, а не вmissing_links. Симметричность типа хабу не нужна: человек видит, что связь в Jira есть, и решает сам. CLI выводит новое поле в том же JSON. - Агент: темп после перезапуска. При старте
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}. - Агент:
claimedпри восстановлении не пишется повторно.execute(cmd, journal_claimed=True);recoverвызывает сFalse— записьclaimed/startedдля этого id уже есть. - Версия агента 3,
min_agent_versionне поднимается. Тела запросов агента не меняются. - Признак
online. Спека data-model меняется под код:agent_statusотдаётonline: false, еслиlast_seen_atстарше 5 минут (решение без обсуждения, см. ниже). - Один процесс агента на журнал (финальное ревью, 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
- До перезапуска хаба: убедиться, что на виртуалке работает один процесс агента; посмотреть команды в
claimed(agent_status) — все они после старта будут довыданы и выполнены через проверку; ненужные отменить сforce. - Хаб: merge →
git pullв основном checkout → перезапуск launchd (launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub) → вbackups/копия*-schema-v1.sqlite,schema_version = 2; агент v2 продолжает работу. - Агент v3: файл через общую папку на виртуалку,
check, остановить v2 (дождаться «остановлен»), запустить v3. - Проверка: повторный
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) |
довыдача отдаёт команду любому пришедшему процессу | нет: второй процесс просто не стартует |