Andrey Epifancev a7fed00419 docs(openspec): change bridge-hygiene — пакет C гигиены моста (#13)
Proposal, design и дельты спек hub-api, data-model, commands, agent, mcp, migration;
Purpose во всех шести спеках (O-4 #8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:15:21 +04:00

91 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## MODIFIED Requirements
### Requirement: Журнал на диске и восстановление
Агент SHALL вести `journal.jsonl` (только дозапись) рядом с собой: строки `claimed {command_id, at}` (сразу после
получения команды, до паузы), `started {command_id, at}`, `finished {command_id, status, result, error, jira_calls,
rate_limited, at}`, `acked {command_id, at}`. При старте агент SHALL: отправить хабу все `finished` без `acked`; для
`claimed`/`started` без `finished` — запросить команду у хаба по id и, если она в хабе всё ещё `claimed`, выполнить её
заново по правилу «проверь, потом сделай» с паузой `pause_seconds` из ответа хаба, не записывая `claimed` повторно.
Команда `python3 agent.py prune` SHALL удалять из журнала записи с `acked` старше 30 дней; она SHALL брать ту же
блокировку `<журнал>.lock`, что `run`, и при занятой блокировке завершаться с кодом 1 и сообщением «агент запущен»,
не меняя журнал.
#### Scenario: Падение после ответа Jira
- **WHEN** агент упал между `POST /issue` и записью `finished`, затем перезапущен
- **THEN** агент находит в журнале `started` без `finished`, запрашивает команду у хаба, находит созданную задачу поиском
дубля и отчитывается `done` с её ключом; второй задачи нет
#### Scenario: Восстановление без лишней записи
- **WHEN** в журнале `claimed 40` без `finished`, хаб отдаёт команду 40 в `claimed` с `pause_seconds = 30`
- **THEN** в журнал дописываются `started 40`, `finished 40`, `acked 40`; второй строки `claimed 40` нет; перед выполнением
агент выдерживает 30 с от последнего вызова Jira
#### Scenario: prune при работающем агенте
- **WHEN** `agent.py run` работает, и запущен `agent.py prune` с тем же журналом
- **THEN** `prune` пишет «агент запущен…» и завершается с кодом 1; журнал не изменён
### Requirement: Ограничение темпа и 429
Агент SHALL помнить время последнего вызова Jira (любого) и не начинать команду раньше `pause_seconds` после него. При
старте агент SHALL принять за время последнего вызова Jira самое позднее `at` в журнале; если оно в будущем (часы
сдвинулись назад) — текущий момент (пауза перед первой командой — полная `pause_seconds`); при пустом журнале паузы
перед первой командой нет. Оценка из журнала служит только паузе: в `claim` агент SHALL передавать `last_jira_call_at`
только после настоящего вызова Jira в текущем процессе, до него — `null`. На HTTP 429 и 503 агент SHALL ждать
`Retry-After` (иначе 60 с × номер попытки) и повторять тот же запрос до 5 раз, считая попытки в `rate_limited`; после
5 неудач — `failed`. Пауза между записями внутри одной команды — по зазору типа команды.
#### Scenario: 429 с Retry-After
- **WHEN** Jira ответила 429 с `Retry-After: 90`
- **THEN** агент ждёт 90 с, повторяет тот же запрос; в отчёте `rate_limited = 1`
#### Scenario: Перезапуск не сбивает темп
- **WHEN** последняя запись журнала `finished` сделана 10 с назад, агент перезапущен и получил команду с `pause_seconds = 30`
- **THEN** агент ждёт около 20 с перед первым вызовом Jira по команде
#### Scenario: Время журнала в будущем
- **WHEN** последняя запись журнала на 2 часа позже текущего времени, агент перезапущен и получил команду с `pause_seconds = 30`
- **THEN** агент ждёт 30 с перед первым вызовом Jira; в логе — что время журнала в будущем
#### Scenario: После перезапуска хаб не получает оценку из журнала
- **WHEN** агент перезапущен с непустым журналом и отправляет первый `claim`
- **THEN** `last_jira_call_at` в `claim` — `null`; после первого вызова Jira следующий `claim` несёт его время
### Requirement: Доставка и запуск
README агента SHALL описывать: копирование `agent.py` и `agent.example.json` через общую папку, заполнение `agent.json`
(включая выбор транспорта в `hub_url`), `check`, запуск в терминале и через `nohup` с логом, опциональный launchd-plist,
`prune` (только при остановленном агенте, иначе откажет). Версия агента SHALL быть в константе файла (4 — со сравнением
текстов без различия переводов строк и уточнённым восстановлением темпа) и в каждом отчёте; хаб предупреждает в
`agent_status`, если версия ниже минимальной из настроек.
#### Scenario: Устаревший агент
- **WHEN** агент версии 1 выходит на связь, а в настройках `min_agent_version = 2`
- **THEN** `agent_status.warning` = «обновите агента», команды выдаются по-прежнему
### Requirement: Транспорт через общую папку
При `hub_url = "folder:<путь>"` агент SHALL обращаться к хабу теми же запросами, что по HTTP, через файлы в `<путь>`:
на каждый вызов записать `req/<id>.json` = `{id, method, path, body, authorization, deadline}` (`id` — `uuid4().hex`,
`authorization` — `Bearer <hub_token>`, `deadline` — момент UTC = сейчас + таймаут вызова; для `claim` таймаут не меньше
45 с) через временный файл с именем на точку и переименование, затем опрашивать `resp/<id>.json` каждые 0,1 с.
Полученный ответ агент SHALL удалить; `status` 2xx — вернуть разобранное тело, иной — выбросить `HttpError(status, body)`
так же, как HTTP-клиент. Если к `deadline` ответа нет, агент SHALL удалить свой `req/<id>.json` (если он ещё там) и
выбросить `ConnectionError`. Нечитаемый ответ — не JSON-объект, `status` не целое число или `body` не строка — SHALL
перечитываться до 2 с, затем `ConnectionError`. Чужие файлы в `resp/` агент MUST NOT трогать. Цикл выполнения, журнал,
повторы отчёта и восстановление SHALL работать без изменений.
#### Scenario: Ответ хаба через папку
- **WHEN** агент вызвал `settings`, реле положило `resp/<id>.json` со `status = 200`
- **THEN** агент получает настройки, файл ответа удалён
#### Scenario: Реле не отвечает
- **WHEN** агент вызвал `claim`, а ответа нет 45 с
- **THEN** `req/<id>.json` удалён агентом, выброшен `ConnectionError`; цикл пишет «хаб недоступен» и повторяет `claim`
через `poll_seconds`, как при недоступном хабе по сети
#### Scenario: Код ошибки хаба через папку
- **WHEN** агент отправил `result` по команде, которой в хабе нет, реле положило ответ со `status = 404`
- **THEN** агент получает `HttpError(404)` и считает команду закрытой, как по HTTP
#### Scenario: Ответ с неверными типами
- **WHEN** в `resp/<id>.json` лежит `{"id": …, "status": "ok", "body": 5}`
- **THEN** агент выбрасывает `ConnectionError` («нечитаемый ответ»), процесс не падает; цикл повторяет вызов как при
недоступном хабе