fix(backup): копия перед миграцией схемы не ротируется; design: один процесс агента, откат без восстановления копии (M-1, I-1, #9)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-13 23:11:32 +04:00
parent 817c761fb2
commit 1fc27055ba
4 changed files with 45 additions and 11 deletions

View File

@ -15,9 +15,8 @@
- Агент: `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`); довыдача закрывает и его.
или остановкой процесса, `recover` выполняется до первого `claim`. Инвариант держится только для одного процесса
агента на журнал — второй процесс получил бы довыданную команду одновременно с первым (решение 13).
- `db.migrate` только создаёт таблицы (`CREATE TABLE IF NOT EXISTS`) и пишет `schema_version = 1`; миграций по версии нет.
`db.connect` вызывается на каждый запрос API и при старте.
@ -74,13 +73,21 @@ Non-goals — см. proposal.md.
10. **Агент: `claimed` при восстановлении не пишется повторно.** `execute(cmd, journal_claimed=True)`; `recover` вызывает с
`False` — запись `claimed`/`started` для этого id уже есть.
11. **Версия агента 3**, `min_agent_version` не поднимается. Тела запросов агента не меняются.
13. **Один процесс агента на журнал (финальное ревью, I-1).** `agent.py run` берёт исключительную блокировку
`<журнал>.lock` (`fcntl.flock`, `LOCK_NB`); занята — сообщение «агент уже запущен…» и код 1. Без неё два процесса
(например, при перезапуске через `nohup`/launchd без остановки старого) выполняли бы довыданную команду одновременно:
идемпотентность «проверь, потом сделай» от одновременного выполнения не защищает (две `ensure_issue` могут не найти
дубль друг друга). Агент v2 блокировки не имеет — при выкатке хаба убедиться, что процесс агента один.
12. **Признак `online`.** Спека data-model меняется под код: `agent_status` отдаёт `online: false`, если `last_seen_at`
старше 5 минут (решение без обсуждения, см. ниже).
## Risks / Trade-offs
- Нарушение инварианта агента (например, будущая параллельность) превратит довыдачу в двойное выполнение — безопасно
из-за идемпотентности, но тратит вызовы Jira. Инвариант фиксируется в спеке agent.
- Нарушение инварианта агента (второй процесс с тем же журналом, будущая параллельность) превращает довыдачу в
одновременное выполнение одной команды двумя исполнителями — для `ensure_issue` это возможный дубль задачи, для
`ensure_comment` — два комментария. Закрыто блокировкой процесса в агенте v3 (решение 13); инвариант зафиксирован в
спеке agent. Предел выдач (решение 2) защищает от потери доставки, но не от падения процесса агента на команде:
восстановление по журналу берёт команду через `GET /agent/commands/{id}` и выдач не добавляет.
- Довыдача после долгого простоя агента выполнит старую команду: если человек за это время поменял задачу вручную,
команда «вернёт» желаемое значение. Так же вела бы себя команда в очереди; для отмены есть `cancel force`.
- Гонка двух `claim` через папку (старый долгий запрос ещё ждёт в хабе, агент прислал новый): старый берёт команду, ответ
@ -92,12 +99,16 @@ Non-goals — см. proposal.md.
## Migration Plan
0. До перезапуска хаба: убедиться, что на виртуалке работает один процесс агента; посмотреть команды в `claimed`
(`agent_status`) — все они после старта будут довыданы и выполнены через проверку; ненужные отменить с `force`.
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`.
2. Агент v3: файл через общую папку на виртуалку, `check`, остановить v2 (дождаться «остановлен»), запустить v3.
3. Проверка: повторный `jira-hub import admin/jira-sync` — три связи в `reversed_links`, `missing_links: 0`.
Откат: остановить хаб, вернуть копию `backups/*-schema-v1.sqlite` на место `hub.sqlite`, предыдущий код хаба; агент v3
работает с хабом v1 (тела запросов не менялись).
Откат: предыдущий код хаба работает и на базе v2 (лишняя колонка `deliveries` с `DEFAULT 0` ему не мешает), поэтому
достаточно вернуть код и перезапустить хаб. Копия `backups/*-schema-v1.sqlite` (ротации не подлежит) — только на случай
повреждения базы: восстановление из неё теряет всё, записанное после миграции. Агент v3 работает с хабом v1 (тела
запросов не менялись).
## Decisions made without discussion
@ -112,3 +123,8 @@ Non-goals — см. proposal.md.
| 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) | довыдача отдаёт команду любому пришедшему процессу | нет: второй процесс просто не стартует |

View File

@ -5,13 +5,18 @@
`pause_seconds` от последнего вызова Jira → запись «started» в журнал → выполнение по контракту `commands` → запись
«finished» с результатом → отчёт хабу, повторяемый до подтверждения. Одна команда в полёте: новый `claim` агент SHALL
отправлять, только когда у него нет выданной, но не завершённой и не отчитанной команды (на этом хаб строит повторную
выдачу, см. `commands`). При изменении `settings_version` в ответе `claim` агент SHALL перечитать настройки до выполнения
выдачу, см. `commands`). Один процесс на журнал: `run` SHALL брать исключительную блокировку файла `<журнал>.lock` и,
если она занята, завершаться с кодом 1 и сообщением «агент уже запущен». При изменении `settings_version` в ответе `claim` агент SHALL перечитать настройки до выполнения
команды.
#### Scenario: Остановка между командами
- **WHEN** получен Ctrl-C во время выполнения команды
- **THEN** текущая команда доделывается и отчёт отправляется, затем процесс завершается
#### Scenario: Второй процесс с тем же журналом
- **WHEN** агент работает, и запускается ещё один `agent.py run` с тем же `journal.jsonl`
- **THEN** второй процесс пишет «агент уже запущен…» и завершается с кодом 1, не обращаясь к хабу и Jira
### Requirement: Журнал на диске и восстановление
Агент SHALL вести `journal.jsonl` (только дозапись) рядом с собой: строки `claimed {command_id, at}` (сразу после
получения команды, до паузы), `started {command_id, at}`, `finished {command_id, status, result, error, jira_calls,
@ -48,7 +53,7 @@ rate_limited, at}`, `acked {command_id, at}`. При старте агент SHA
### Requirement: Доставка и запуск
README агента SHALL описывать: копирование `agent.py` и `agent.example.json` через общую папку, заполнение `agent.json`
(включая выбор транспорта в `hub_url`), `check`, запуск в терминале и через `nohup` с логом, опциональный launchd-plist,
`prune`. Версия агента SHALL быть в константе файла (3 — с восстановлением темпа после перезапуска) и в каждом отчёте;
`prune`. Версия агента SHALL быть в константе файла (3 — с восстановлением темпа после перезапуска и блокировкой одного процесса) и в каждом отчёте;
хаб предупреждает в `agent_status`, если версия ниже минимальной из настроек.
#### Scenario: Устаревший агент

View File

@ -8,7 +8,8 @@ def make_backup(conn, backups_dir: Path, label: str, keep: int = 30) -> Path:
backups_dir.mkdir(parents=True, exist_ok=True)
target = backups_dir / f"{datetime.now():%Y%m%d-%H%M%S-%f}-{label}.sqlite"
conn.execute("VACUUM INTO ?", (str(target),))
files = sorted(backups_dir.glob("*.sqlite"))
# копии перед миграцией схемы (db.migrate) — для отката, ротации не подлежат
files = sorted(p for p in backups_dir.glob("*.sqlite") if "-schema-v" not in p.name)
if keep > 0:
for old in files[:-keep]:
old.unlink()

View File

@ -205,6 +205,18 @@ def test_make_backup_rotates(tmp_path):
assert db.connect(tmp_path / "backups" / names[-1]).execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
def test_rotation_keeps_schema_migration_copy(tmp_path):
"""Копия базы перед миграцией схемы не участвует в ротации копий пакетов (M-1 финального ревью)."""
from jira_hub import backup
conn = db.connect(tmp_path / "hub.sqlite")
backup.make_backup(conn, tmp_path / "backups", "schema-v1", keep=0)
for i in range(4):
backup.make_backup(conn, tmp_path / "backups", f"batch-{i}", keep=2)
names = sorted(p.name for p in (tmp_path / "backups").glob("*.sqlite"))
assert len([n for n in names if n.endswith("-schema-v1.sqlite")]) == 1
assert len([n for n in names if "-batch-" in n]) == 2
def _claimed_ensure_issue(conn, summary="Тема"):
queue.create_batch(conn, "x", "claude", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}}])
c = queue.claim(conn)