Andrey Epifancev 36bfd95a87 docs(openspec): решения исполнения folder-transport R-1, R-2 (#5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:50:10 +04:00

84 lines
11 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.

## Context
Мост из `bridge-core`: хаб (`jira-hub serve`, Mac mini, launchd, `0.0.0.0:8790`) и агент (`agent.py` на виртуалке,
Python 3.9, stdlib). Агент обращается к хабу шестью запросами (`claim` с ожиданием до 30 с, `settings`, `commands/{id}`,
`result`, `state`, `known-keys`); все идут через `Http.call()` агента, ошибки сведены к `ConnectionError` (хаб
недоступен) и `HttpError` (код ответа). Повторы отчёта, журнал `claimed/started/finished/acked` и восстановление опираются
только на эти два исключения. В хабе `serve()` уже держит фоновый поток `ticker`.
При включённом VPN «Континент ZTN» сеть между виртуалкой и Mac mini закрыта (см. proposal.md). Общая папка UTM
(VirtioFS) проверена: круг 0,75–0,82 с, атомарное переименование видно целиком, удаления видны с обеих сторон.
## Goals / Non-Goals
Goals: агент работает с хабом при включённом Континенте; всё, что проверено прогоном на моке Jira 12.10.2026 (пакет всех
типов, повтор без записей, остановка в паузе, обрыв Jira), работает так же через папку; HTTP API хаба не меняется.
Non-goals — см. proposal.md.
## Decisions
1. **Реле внутри `serve` (вариант A, решение владельца).** Один сервис под launchd, один лог. Поток реле стартует всегда и
спит, пока `relay_dir` пуст; включение и выключение — правкой настройки, без перезапуска.
2. **Реле — HTTP-клиент своего же хаба.** Запрос из файла пересылается на `127.0.0.1:<port>` с заголовком
`Authorization` из файла. Маршрутизация, проверка токенов, лимит тела и коды ошибок остаются в одном месте.
3. **Папка моста** `<relay_dir>` с подпапками `req/` (запросы агента), `resp/` (ответы), `work/` (взятые реле запросы).
Обе стороны пишут файл под именем с точкой и переименовывают в `<id>.json`; имена с точкой никто не читает.
4. **Формат.** Запрос: `{id, method, path, body, authorization, deadline}`; `deadline` — момент UTC (ISO 8601), после
которого ответ агенту не нужен. Ответ: `{id, status, body}`.
5. **Взятие запроса — переименование `req/<id>.json` → `work/<id>.json`.** Повторно его никто не возьмёт; каждый взятый
запрос обрабатывается в своём потоке, потому что `claim` держит ответ до 30 с.
6. **Ошибки агента — те же, что у HTTP.** Ответ 2xx → тело; иной код → `HttpError(status, body)`; нет ответа к `deadline` →
агент удаляет свой запрос (если его ещё не взяли) и выбрасывает `ConnectionError`. Runner, журнал и восстановление
не меняются.
7. **Защита `claim` по запасу времени.** Реле пересылает `POST /agent/claim`, только если до `deadline` осталось ≥ 35 с
(хаб держит `claim` ≤ 30 с); иначе запрос выбрасывается без ответа. Агент ставит для `claim` `deadline` = сейчас +
max(timeout, 45) с, как сейчас ставит таймаут HTTP. Это закрывает потерю ответа `claim` из-за медленной папки; общий
случай M-2 остаётся отложенным.
8. **Просроченные запросы выбрасываются без ответа** (агент уже ушёл с `ConnectionError`). Если `deadline` истёк после
пересылки, ответ всё равно пишется — его уберёт уборка.
9. **Хаб недоступен изнутри** (сбой соединения с `127.0.0.1`) — ответ со статусом 502 и текстом ошибки.
10. **Перезапуск реле**: файлы в `work/` удаляются с записью в лог; агент дождётся своего `deadline` и повторит.
11. **Уборка раз в минуту**: ответы в `resp/` старше 10 мин, запросы в `req/` с `deadline` старше 60 с, файлы с точкой
старше 10 мин.
12. **Папки нет** (SSD не подключён) — одно предупреждение в лог, реле ждёт и проверяет снова; подпапки `req/`, `resp/`,
`work/` реле создаёт само, когда `relay_dir` существует.
13. **Лог** — одна строка на запрос в лог хаба: `relay <METHOD> <path> <status> <секунды>`.
14. **Агент выбирает транспорт по `hub_url`**: `http://…`/`https://…` — как сейчас, `folder:<путь>` — файловый клиент
с тем же интерфейсом `HubClient`. `check` печатает «хаб: доступен через папку <путь>, настройки vN» или
«НЕДОСТУПЕН» с причиной (нет папки; реле не ответило за 10 с). Версия агента — 2.
15. **Токен агента остаётся в файле запроса.** Общая папка доступна только Mac mini и виртуалке; пароль Jira туда не
попадает (как и в HTTP-транспорте, хаб получает только результаты и состояние).
## Risks / Trade-offs
- **Доступ launchd к внешнему диску.** macOS может не пустить процесс из launchd на `/Volumes/externalSSD` без разрешения
«Съёмные тома» или «Полный доступ к диску». Проверяется первым шагом приёмки на настоящей папке; при отказе — выдать
доступ `uv`/python в «Конфиденциальность и безопасность».
- **Кеш VirtioFS** даёт постоянные ~0,75 с на круг. Для команд с паузами 30–120 с несущественно; `refresh` на 140 задач —
около 10 обменов.
- **Токен агента в файлах общей папки** живёт до ответа и уборки. Приемлемо для домашней пары машин.
- **Опоздавший ответ** на `result`/`state`: агент повторит запрос; `result` идемпотентен, повторная загрузка `state`
перезаписывает те же строки зеркала.
## Migration Plan
1. Слить PR хаба, перезапустить launchd-задание хаба; `settings_set relay_dir "/Volumes/externalSSD/utm/rshb/jira-bridge"`.
2. Прогон на моке Jira через временную папку (сценарий 12.10.2026), затем короткий прогон через настоящую папку.
3. Слить PR агента; на виртуалке заменить `agent.py`, в `agent.json` — `"hub_url": "folder:/Volumes/My Shared Files/RSHB/jira-bridge"`.
4. При включённом Континенте: `check` (обе строки «доступен»), запуск агента, `refresh full`, `jira-hub import`, сверка с CSV.
Откат: `relay_dir = null` и прежний `hub_url` (сеть работает только без Континента).
## Decisions made without discussion
| Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|
| 2026-10-12 | Имя файла — `uuid4().hex` агента; ответ под тем же именем | уникальность без координации сторон | нет |
| 2026-10-12 | Тело ответа в файле — строка (как пришло от HTTP), агент разбирает JSON сам | реле не трактует ответы хаба; не-JSON ответ даёт ту же ошибку, что по HTTP | нет |
| 2026-10-12 | `deadline` — абсолютное время UTC, а не таймаут | реле на другой машине сравнивает со своими часами; Mac mini и виртуалка синхронизируют время с одного роутера/NTP | расхождение часов > 10 с ломает защиту `claim`; лечится запасом или переходом на относительный срок |
| 2026-10-12 | Реле перечитывает `relay_dir` раз в 5 с, опрос `req/` — каждые 0,1 с, агент опрашивает `resp/` каждые 0,1 с | быстрее не нужно (кеш VirtioFS ~0,75 с), реже — заметная задержка | лишние вызовы `listdir` — дёшево |
| 2026-10-12 | `relay_dir` — абсолютный путь строкой или `null`; относительный путь — 422 | путь читает процесс launchd с неочевидным cwd | нет |
| 2026-10-12 | Нечитаемый JSON запроса или ответа перечитывается до 2 с, затем запрос выбрасывается (реле) / `ConnectionError` (агент) | атомарное переименование делает это маловероятным (проверка: 0 случаев) | нет |
| 2026-10-12 | `relay_dir` не входит в настройки для агента (`AGENT_KEYS`) | агенту путь на стороне Mac mini не нужен | нет |
| 2026-10-13 | В `ARCHITECTURE.md` граница «HTTP живёт только в `api` и `client`» дополнена `relay` (R-1) | реле само ходит в HTTP своего хаба; иначе документ противоречит коду | нет |
| 2026-10-13 | В `tests/test_py39.py` агента в белый список stdlib добавлен `uuid` (R-2, подробно — `jira-agent/openspec/changes/folder-transport/design.md`) | спека требует `uuid4().hex` | нет |