diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8267ba8..2f71e27 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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/.json` → реле хаба → `resp/.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`). diff --git a/CLAUDE.md b/CLAUDE.md index 6ea9558..a0a0cde 100644 --- a/CLAUDE.md +++ b/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…» +(причина — в логе агента на виртуалке). Это пункт чеклиста «закрывай» для PR агента. `run.py` публикацией не +обновляется — только вручную через общую папку. + ## OpenSpec и superpowers - Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать. - Когда brainstorming классифицировал задачу как architectural, структуру change создать через `openspec new change ` (или `/opsx:propose `), а содержимое proposal.md, specs/ и design.md написать по итогам brainstorming. tasks.md из OpenSpec не заполнять — задачи живут в плане superpowers. diff --git a/README.md b/README.md index 35d1d12..f05b8e1 100644 --- a/README.md +++ b/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`; код 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; для +самообновления запускайте через run.py». + Остановка: Ctrl-C или `kill `. Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит. Повторный сигнал — немедленный выход (код 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 & /usr/bin/python3 -u - /Users/USER/jira-agent/agent.py + /Users/USER/jira-agent/run.py WorkingDirectory/Users/USER/jira-agent RunAtLoad @@ -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 `, дождаться «остановлен»), скопировать новый `agent.py` поверх -старого и запустить снова (или `launchctl unload` + `load`). Два процесса с одним журналом работать не могут: хаб +С версии 5 — само (раздел «Самообновление»). Вручную обновляются только `run.py` и переход с версии 4: остановить +старый процесс (Ctrl-C или `kill `, дождаться «остановлен»), скопировать `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` не трогать: журнал переживает обновление, а неподтверждённые отчёты досылаются при старте. ## Известные ограничения / эксплуатация diff --git a/src/agent.example.json b/src/agent.example.json index 15d7765..20245eb 100644 --- a/src/agent.example.json +++ b/src/agent.example.json @@ -8,5 +8,6 @@ "verify_ssl": true, "ca_bundle": null, "timeout": 60, - "poll_seconds": 5 + "poll_seconds": 5, + "auto_update": true } diff --git a/tests/test_self_update_e2e.py b/tests/test_self_update_e2e.py new file mode 100644 index 0000000..0421389 --- /dev/null +++ b/tests/test_self_update_e2e.py @@ -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)