test(agent): сквозной тест самообновления; README, ARCHITECTURE, CLAUDE.md — run.py и публикация (#19)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7512acb083
commit
866f9d7b4d
@ -1,6 +1,6 @@
|
||||
# Архитектура
|
||||
|
||||
Агент — один файл `src/agent.py` (Python 3.9, только стандартная библиотека). Тесты в `tests/` работают на фейках
|
||||
Агент — один файл `src/agent.py` и запускатель `src/run.py` (Python 3.9, только стандартная библиотека). Тесты в `tests/` работают на фейках
|
||||
Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, план агента: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md` (план хаба: `../jira-hub/docs/superpowers/plans/2026-10-11-bridge-core-hub.md`).
|
||||
|
||||
## Разделы файла `src/agent.py`
|
||||
@ -12,10 +12,20 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл
|
||||
| Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` |
|
||||
| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система |
|
||||
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба |
|
||||
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте | `HubClient`, `JiraClient`, `Journal`, `Executor` |
|
||||
| CLI | `cmd_run`, `cmd_check`, `cmd_prune`, `main` | команды `run`, `check`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` |
|
||||
| Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` |
|
||||
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте; после `claim` без команды спрашивает `Updater`, готова версия — выход с `EXIT_UPDATE` (75) | `HubClient`, `JiraClient`, `Journal`, `Executor` |
|
||||
| CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` |
|
||||
|
||||
## Запускатель `src/run.py`
|
||||
|
||||
Не импортирует `agent.py` (тот меняется). `Launcher` запускает `agent.py run` дочерним процессом с
|
||||
`JIRA_AGENT_LAUNCHER`; коды 0/130 — выход; 75 — `apply_update` (копия в `agent.py.prev`, `os.replace`, `trial`);
|
||||
падение в `trial` или 10 минут без `ok` — `rollback`; иначе перезапуск с паузой 5…300 с. Блокировка `run.py.lock`;
|
||||
SIGINT при живом агенте игнорирует (Ctrl-C получает агент), SIGTERM пересылает. Спека — «Запускатель run.py».
|
||||
|
||||
## Границы
|
||||
- Код новой версии приходит только через API хаба (`GET /agent/release`) и пишется в каталог агента; общая папка для
|
||||
кода не используется.
|
||||
- Пароль и токен Jira — только в `agent.json`. В хаб, журнал и логи не попадают.
|
||||
- Политика темпа не хранится в агенте: зазоры (`pacing`) и `pause_seconds` приходят от хаба. Агент досыпает паузу
|
||||
до зазора от последнего вызова Jira (`JiraClient.last_call_at`).
|
||||
|
||||
20
CLAUDE.md
20
CLAUDE.md
@ -17,7 +17,9 @@
|
||||
## Архитектура
|
||||
- `src/agent.py` — весь агент, один файл. Разделы в порядке файла: HTTP-клиенты (`Http`, `JiraClient`, `HubClient`; `FolderTransport` — транспорт `folder:` к хабу через общую папку, ошибки как у `Http`, спека `jira-hub/openspec/specs/agent/`),
|
||||
`Journal`, `Executor` (по методу `do_<тип>` на каждый тип команды), `Runner` (цикл claim → пауза → журнал →
|
||||
выполнение → отчёт), CLI (`cmd_check`, `cmd_run`, `cmd_prune`, `main`).
|
||||
выполнение → отчёт), `Updater` (самообновление: `update.json`, скачивание, `selftest`, выход 75), CLI (`cmd_check`,
|
||||
`cmd_selftest`, `cmd_run`, `cmd_prune`, `main`).
|
||||
- `src/run.py` — запускатель: дочерний `agent.py run`, подмена версии, пробный период, откат, перезапуск; `agent.py` не импортирует.
|
||||
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`).
|
||||
`Runner` знает и хаб, и Jira, и журнал. Журнал не знает о Jira.
|
||||
- `src/agent.example.json` — шаблон настроек, `README.md` — эксплуатация.
|
||||
@ -29,6 +31,8 @@
|
||||
- `/usr/bin/python3 src/agent.py check` — доступность хаба и Jira, без записи в Jira.
|
||||
- `/usr/bin/python3 src/agent.py` — основной цикл (нужен `agent.json` рядом с `agent.py`).
|
||||
- `/usr/bin/python3 src/agent.py prune` — очистка журнала (при работающем агенте откажет).
|
||||
- `/usr/bin/python3 src/agent.py selftest` — версия + `check`; код 0 или 3.
|
||||
- `/usr/bin/python3 src/run.py` — запуск через запускатель (самообновление).
|
||||
|
||||
## Тесты
|
||||
- Тесты в `tests/`, запуск из корня worktree. Внешних сервисов нет: Jira и хаб заменяют фейки `tests/fake_jira.py` и `tests/fake_hub.py`.
|
||||
@ -36,14 +40,24 @@
|
||||
Паузы в тестах отключаются через `ZERO_PACING` и `no_sleep`.
|
||||
- Тесты разбиты по областям, а не по типам команд: `test_executor_fields.py` (`ensure_issue`, `ensure_fields`, `ensure_comment`),
|
||||
`test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `test_refresh.py` (`refresh`),
|
||||
`test_runner.py` (цикл и восстановление), `test_journal.py` (журнал), `test_clients.py` (HTTP и 429).
|
||||
`test_runner.py` (цикл и восстановление), `test_journal.py` (журнал), `test_clients.py` (HTTP и 429),
|
||||
`test_update.py` (самообновление агента), `test_launcher.py` (запускатель на поддельных агентах),
|
||||
`test_self_update_e2e.py` (`run.py` + настоящий `agent.py` + фейки).
|
||||
- Golden-фикстур пока нет: каждый тест строит сценарий в коде.
|
||||
|
||||
## Первая подсистема
|
||||
Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд,
|
||||
журнал, восстановление после перезапуска, CLI `run|check|prune`.
|
||||
журнал, восстановление после перезапуска, CLI `run|check|selftest|prune`, самообновление через `run.py`.
|
||||
Текущий change — `../jira-hub/openspec/changes/agent-self-update/`, план — `docs/superpowers/plans/2026-10-14-agent-self-update-agent.md`.
|
||||
План и спека живут в `jira-hub`: `openspec/changes/bridge-core/` и `docs/superpowers/plans/2026-10-11-bridge-core-hub.md`; план агента лежит здесь: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md`.
|
||||
|
||||
## Выкатка новой версии
|
||||
Самообновление (с v5): после merge PR агента в main — поднять `AGENT_VERSION` (если не поднят в PR) и опубликовать:
|
||||
`uv run --project ../jira-hub jira-hub agent publish src/agent.py` (из основного checkout `jira-agent` на main).
|
||||
Через несколько минут MCP `agent_status` должен показать «обновление: ok v<старая> → v<новая>»; откат — «откат с v<N>…»
|
||||
(причина — в логе агента на виртуалке). Это пункт чеклиста «закрывай» для PR агента. `run.py` публикацией не
|
||||
обновляется — только вручную через общую папку.
|
||||
|
||||
## OpenSpec и superpowers
|
||||
- Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать.
|
||||
- Когда brainstorming классифицировал задачу как architectural, структуру change создать через `openspec new change <id>` (или `/opsx:propose <id>`), а содержимое proposal.md, specs/ и design.md написать по итогам brainstorming. tasks.md из OpenSpec не заполнять — задачи живут в плане superpowers.
|
||||
|
||||
50
README.md
50
README.md
@ -2,14 +2,16 @@
|
||||
|
||||
Клиент моста Jira для виртуалки с доступом к Jira банка. Агент опрашивает хаб (`jira-hub` на Mac mini),
|
||||
выполняет команды «желаемый результат» в Jira по правилу «проверь, потом сделай», ведёт журнал на диске
|
||||
и возвращает хабу состояние задач. Один файл `src/agent.py`, только стандартная библиотека, Python 3.9.
|
||||
и возвращает хабу состояние задач. Один файл `src/agent.py` и запускатель `src/run.py`, только стандартная
|
||||
библиотека, Python 3.9.
|
||||
|
||||
Спека: `../jira-hub/openspec/specs/agent/spec.md` (вместе с `commands/` и `hub-api/`).
|
||||
Хаб, очередь команд и зеркало задач живут в репозитории `jira-hub`; агент с ним общается только по HTTP.
|
||||
|
||||
## Доставка на виртуалку
|
||||
|
||||
1. Скопировать `src/agent.py` и `src/agent.example.json` через общую папку в каталог на виртуалке, например `~/jira-agent/`.
|
||||
1. Скопировать `src/agent.py`, `src/run.py` и `src/agent.example.json` через общую папку в локальный каталог на
|
||||
виртуалке, например `~/jira-agent/`. Запускать из этого каталога, не из общей папки.
|
||||
2. Переименовать `agent.example.json` в `agent.json` и заполнить:
|
||||
- `hub_url` — `http://<адрес Mac mini>:8790`, или, если сеть до Mac mini закрыта VPN (Континент), `folder:/Volumes/My Shared Files/RSHB/jira-bridge` — обмен через общую папку UTM (на хабе должна быть задана настройка `relay_dir`, см. README хаба);
|
||||
- `hub_token` — агентский токен из `jira-hub tokens` на Mac mini;
|
||||
@ -17,7 +19,8 @@
|
||||
- `jira.auth` — `{"type": "basic", "user": ..., "password": ...}` или `{"type": "bearer", "token": ...}`;
|
||||
- `verify_ssl`, `ca_bundle` — при корпоративном сертификате (см. ограничение 2 ниже);
|
||||
- `timeout` — таймаут запроса в секундах (по умолчанию 60);
|
||||
- `poll_seconds` — пауза между опросами хаба, когда команд нет (по умолчанию 5).
|
||||
- `poll_seconds` — пауза между опросами хаба, когда команд нет (по умолчанию 5);
|
||||
- `auto_update` — самообновление (по умолчанию `true`; `false` — агент не обновляется сам).
|
||||
3. `agent.json` содержит учётные данные Jira. Никуда не копировать и никуда не отправлять, кроме этой виртуалки.
|
||||
Хаб получает от агента только результаты команд и состояние задач, без паролей и токенов Jira.
|
||||
|
||||
@ -41,6 +44,9 @@ python3 agent.py check
|
||||
Код выхода 0. Если хаб или Jira недоступны, строка содержит `НЕДОСТУПЕН` и текст ошибки, код выхода 1.
|
||||
Команда ничего не пишет в Jira.
|
||||
|
||||
`python3 agent.py selftest` — то же, что `check`, плюс первая строка `jira-agent v<N>`; код 0 — всё доступно, 3 — что-то
|
||||
недоступно. Её вызывает предыдущая версия агента перед самообновлением.
|
||||
|
||||
## Связь с хабом через общую папку
|
||||
|
||||
Когда VPN «Континент ZTN» включён, его фильтр закрывает локальную сеть: хаб по `http://` недоступен. Тогда в `agent.json`:
|
||||
@ -59,19 +65,40 @@ python3 agent.py check
|
||||
В терминале:
|
||||
|
||||
```
|
||||
python3 agent.py
|
||||
python3 run.py
|
||||
```
|
||||
|
||||
В фоне с логом:
|
||||
|
||||
```
|
||||
nohup python3 -u agent.py > agent.log 2>&1 &
|
||||
nohup python3 -u run.py > agent.log 2>&1 &
|
||||
```
|
||||
|
||||
`run.py` запускает `agent.py` дочерним процессом и обновляет его (раздел «Самообновление»). Запуск
|
||||
`python3 agent.py` без запускателя работает, но тогда агент себя не обновляет и раз в час пишет «доступна v<N>; для
|
||||
самообновления запускайте через run.py».
|
||||
|
||||
Остановка: Ctrl-C или `kill <pid>`. Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит.
|
||||
Повторный сигнал — немедленный выход (код 130). Остановка во время паузы перед командой безопасна: команда уже
|
||||
записана в журнал как `claimed`, при следующем запуске агент запрашивает её у хаба и выполняет через проверку, выдержав паузу темпа от последней записи журнала.
|
||||
|
||||
## Самообновление
|
||||
|
||||
С версии 5 агент обновляется сам. После `jira-hub agent publish` на Mac mini хаб сообщает новую версию в ответе на
|
||||
`claim`. Агент в паузе между командами (очередь пуста, отчёты подтверждены) скачивает файл по `GET /agent/release`,
|
||||
сверяет sha256, компилирует, проверяет `AGENT_VERSION`, кладёт рядом `agent.py.new`, запускает его `selftest` и выходит
|
||||
с кодом 75. `run.py` копирует текущий `agent.py` в `agent.py.prev`, ставит новый и запускает его в пробном режиме:
|
||||
версия подтверждена, когда она получила первый ответ хаба (`update.json` → `ok`). Если новая версия упала или не вышла
|
||||
на связь за 10 минут — `run.py` возвращает `agent.py.prev` и пишет `rolled_back`; эту версию агент больше не ставит,
|
||||
хаб показывает откат в `agent_status`. Код в общую папку не попадает: файлы пишутся только в каталог `run.py`.
|
||||
|
||||
Файлы рядом с `agent.py`: `update.json` (состояние: `staged`, `trial`, `ok`, `rolled_back`, `rejected`),
|
||||
`agent.py.prev` (прежняя версия), `run.py.lock`. Не удалять во время работы.
|
||||
|
||||
Если агент упал уже после подтверждения, `run.py` перезапускает ту же версию с паузой 5 с, удваивая её до 300 с при
|
||||
повторных падениях. Выключить самообновление: `"auto_update": false` в `agent.json`. `run.py` обновляется вручную
|
||||
(меняется редко).
|
||||
|
||||
## Автозапуск через launchd (опционально)
|
||||
|
||||
Пример `~/Library/LaunchAgents/ru.aepif.jira-agent.plist`:
|
||||
@ -86,7 +113,7 @@ nohup python3 -u agent.py > agent.log 2>&1 &
|
||||
<array>
|
||||
<string>/usr/bin/python3</string>
|
||||
<string>-u</string>
|
||||
<string>/Users/USER/jira-agent/agent.py</string>
|
||||
<string>/Users/USER/jira-agent/run.py</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key><string>/Users/USER/jira-agent</string>
|
||||
<key>RunAtLoad</key><true/>
|
||||
@ -153,9 +180,9 @@ python3 agent.py prune
|
||||
`finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики
|
||||
суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке
|
||||
по сигналу команду подхватит восстановление.
|
||||
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 4) отправляется в каждом `claim`, в каждом отчёте
|
||||
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 5) отправляется в каждом `claim`, в каждом отчёте
|
||||
(`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени
|
||||
журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой.
|
||||
журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`.
|
||||
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
|
||||
при этом выдаются по-прежнему.
|
||||
|
||||
@ -171,11 +198,12 @@ python3 agent.py prune
|
||||
|
||||
## Обновление агента
|
||||
|
||||
Остановить старый процесс (Ctrl-C или `kill <pid>`, дождаться «остановлен»), скопировать новый `agent.py` поверх
|
||||
старого и запустить снова (или `launchctl unload` + `load`). Два процесса с одним журналом работать не могут: хаб
|
||||
С версии 5 — само (раздел «Самообновление»). Вручную обновляются только `run.py` и переход с версии 4: остановить
|
||||
старый процесс (Ctrl-C или `kill <pid>`, дождаться «остановлен»), скопировать `agent.py` и `run.py` в каталог агента,
|
||||
`python3 agent.py check`, запустить `python3 run.py` (или `launchctl unload` + `load`). Два процесса с одним журналом работать не могут: хаб
|
||||
довыдаёт команду без отчёта, и второй процесс выполнял бы её одновременно с первым. Начиная с версии 3 второй процесс
|
||||
не стартует («агент уже запущен…», код 1) — блокировка `journal.jsonl.lock` рядом с журналом.
|
||||
`agent.json` и `journal.jsonl` не трогать: журнал переживает обновление, а неподтверждённые отчёты досылаются при старте.
|
||||
`agent.json`, `journal.jsonl` и `update.json` не трогать: журнал переживает обновление, а неподтверждённые отчёты досылаются при старте.
|
||||
|
||||
## Известные ограничения / эксплуатация
|
||||
|
||||
|
||||
@ -8,5 +8,6 @@
|
||||
"verify_ssl": true,
|
||||
"ca_bundle": null,
|
||||
"timeout": 60,
|
||||
"poll_seconds": 5
|
||||
"poll_seconds": 5,
|
||||
"auto_update": true
|
||||
}
|
||||
|
||||
78
tests/test_self_update_e2e.py
Normal file
78
tests/test_self_update_e2e.py
Normal file
@ -0,0 +1,78 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from _common import SETTINGS, SRC
|
||||
from fake_hub import FakeHub
|
||||
from fake_jira import FakeJira
|
||||
|
||||
|
||||
def wait_for(pred, timeout=30):
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
if pred():
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
|
||||
class SelfUpdateE2E(unittest.TestCase):
|
||||
"""run.py + настоящий agent.py: v5 видит публикацию v6, ставит её, v6 подтверждает; Ctrl-C группе — выход 0."""
|
||||
|
||||
def setUp(self):
|
||||
self.jira = FakeJira()
|
||||
self.hub = FakeHub(SETTINGS)
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
d = self.tmp.name
|
||||
shutil.copy(os.path.join(SRC, "run.py"), d)
|
||||
shutil.copy(os.path.join(SRC, "agent.py"), d)
|
||||
with open(os.path.join(d, "agent.json"), "w", encoding="utf-8") as f:
|
||||
json.dump({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5, "poll_seconds": 0.2,
|
||||
"jira": {"base_url": self.jira.base_url,
|
||||
"auth": {"type": "basic", "user": "me", "password": "secret"}}}, f)
|
||||
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
|
||||
src = f.read()
|
||||
content = src.replace("AGENT_VERSION = 5\n", "AGENT_VERSION = 6\n", 1)
|
||||
self.assertNotEqual(content, src)
|
||||
self.hub.release = {"version": 6, "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(), "content": content}
|
||||
self.content = content
|
||||
self.log = open(os.path.join(d, "out.log"), "wb")
|
||||
self.proc = subprocess.Popen([sys.executable, os.path.join(d, "run.py")], stdout=self.log,
|
||||
stderr=subprocess.STDOUT, start_new_session=True)
|
||||
|
||||
def tearDown(self):
|
||||
if self.proc.poll() is None:
|
||||
os.killpg(self.proc.pid, signal.SIGKILL)
|
||||
self.proc.wait()
|
||||
self.log.close()
|
||||
self.hub.stop()
|
||||
self.jira.stop()
|
||||
self.tmp.cleanup()
|
||||
|
||||
def state(self):
|
||||
try:
|
||||
with open(os.path.join(self.tmp.name, "update.json"), encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
def test_update_to_v6_and_stop_by_ctrl_c(self):
|
||||
ok = wait_for(lambda: (self.state() or {}).get("state") == "ok", 60)
|
||||
with open(os.path.join(self.tmp.name, "out.log"), encoding="utf-8", errors="replace") as f:
|
||||
out = f.read()
|
||||
self.assertTrue(ok, out)
|
||||
with open(os.path.join(self.tmp.name, "agent.py"), encoding="utf-8") as f:
|
||||
self.assertEqual(f.read(), self.content)
|
||||
self.assertTrue(os.path.exists(os.path.join(self.tmp.name, "agent.py.prev")))
|
||||
self.assertTrue(wait_for(lambda: any(b.get("agent_version") == 6 for b in self.hub.claim_bodies)))
|
||||
last = [b for b in self.hub.claim_bodies if b.get("agent_version") == 6][-1]
|
||||
self.assertEqual(last["launcher_version"], 1)
|
||||
os.killpg(self.proc.pid, signal.SIGINT) # Ctrl-C в терминале — всей группе
|
||||
self.assertEqual(self.proc.wait(30), 0)
|
||||
Loading…
x
Reference in New Issue
Block a user