diff --git a/docs/superpowers/plans/2026-10-14-agent-self-update-agent.md b/docs/superpowers/plans/2026-10-14-agent-self-update-agent.md new file mode 100644 index 0000000..4084e11 --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-agent-self-update-agent.md @@ -0,0 +1,1363 @@ +# agent-self-update (агент) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** агент v5 сам обновляется с хаба в паузе между командами, а запускатель `run.py` подменяет файл, проверяет новую версию пробным периодом и откатывает плохую. + +**Architecture:** в `agent.py` — функции `update.json` (`read_update_state`/`write_update_state`), класс `Updater` (проверка публикации, скачивание, sha256, компиляция, `selftest`, состояние `staged`) и точка в `Runner.loop` после `claim` без команды; `run` выходит с кодом 75. Новый файл `run.py` (без импорта `agent.py`: тот меняется) запускает `agent.py run` дочерним процессом, по коду 75 подменяет файл, ведёт пробный период и откат, перезапускает после падения с паузой. + +**Tech Stack:** Python 3.9.6 (`/usr/bin/python3`), только stdlib, `unittest`. + +**Spec:** `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/self-update/openspec/changes/agent-self-update/` (design.md решения 7–24, specs/agent/spec.md — «Самообновление агента», «Запускатель run.py»). Решения агента при исполнении — `openspec/changes/agent-self-update/design.md` этого репозитория. План хаба — `jira-hub/docs/superpowers/plans/2026-10-14-agent-self-update-hub.md`; агенту хаб в тестах не нужен (FakeHub). + +**Worktree:** `/Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/self-update`, ветка `feat/19-agent-self-update`. Команды тестов — из этого каталога; git — `git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/self-update …`. + +## Global Constraints + +- Python 3.9, только стандартная библиотека; `tests/test_py39.py` проверяет `agent.py` и `run.py`. Без `X | Y` в аннотациях, без `match`. +- `AGENT_VERSION = 5`; `LAUNCHER_VERSION = 1`; код выхода «обновление готово» — `75`. +- Файлы — в каталоге `agent.py`/`run.py`: `agent.py.new`, `agent.py.prev`, `update.json`, `run.py.lock`. Общая папка для кода не используется. +- `update.json` = `{state, from_version, to_version, sha256, error, at}`; состояния `staged`, `trial`, `ok`, `rolled_back`, `rejected`; запись — временный файл + `os.replace`. +- `selftest`: печатает `jira-agent v`, затем как `check`; код 0 — всё доступно, 3 — что-то недоступно; блокировку журнала не берёт. +- Подготовка: таймаут `selftest` 120 с; повтор после временного сбоя — не раньше 300 с; подсказка без запускателя — не чаще 3600 с; ошибка `rejected` от `selftest` — последние 500 символов вывода. +- Запускатель: пробный период 600 с; SIGTERM, через 60 с SIGKILL; пауза после падения 5 → ×2 → не больше 300 с, сброс после 600 с работы. +- Тексты лога: «доступна v; для самообновления запускайте через run.py», «v готова, перезапуск через run.py», «обновление до v подтверждено», «run: откат v → v: <причина>», «run: агент упал (код ), перезапуск через с», «запускатель уже работает». +- Причины отката: «код через с», «не вышла на связь за 10 мин»; отказ при подмене: «файл изменился после проверки»; sha: «sha256 не сходится». +- Язык сообщений, комментариев, коммитов — русский. Коммит заканчивается строкой `Co-Authored-By: Claude Opus 5.5 `. +- Весь набор: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — зелёным считается только весь набор. + +## Review Focus + +- Ctrl-C во время `selftest` новой версии: SIGINT получает и дочерний `selftest` (общая группа), он падает с кодом 1 — это НЕ должно стать `rejected`; агент удаляет `.new` и выходит как обычно (тест в Task 2: `test_stop_during_selftest_is_not_reject`). +- Ответ `GET /agent/release` с другой версией/sha, чем в `claim` (публикация сменилась между запросами) — временный сбой, а не `rejected` (тест в Task 2: `test_release_changed_between_requests_is_transient`). +- Мусор в `update.json` (обрезан, не объект) — агент и запускатель не падают, считают «состояния нет» (тесты в Task 1: `test_read_update_state_garbage`, в Task 3: `test_garbage_update_json_is_no_state`). +- Падение запускателя между выходом агента с 75 и подменой — при следующем старте подмена доделывается (тест в Task 3: `test_staged_at_start_is_applied`). +- Ctrl-C в паузе перед перезапуском после падения — запускатель выходит, а не висит (тест в Task 3: `test_sigint_without_child_stops`). + +--- + +### Task 1: Состояние обновления, `selftest`, поля `claim`, версия 5 + +**Files:** +- Modify: `src/agent.py` (константы, `read_update_state`, `write_update_state`, `HubClient.claim`/`release`, класс `Updater` — только `state`/`confirm`, `Runner` — передача полей в `claim` и подтверждение, `cmd_selftest`, `main`) +- Modify: `tests/fake_hub.py` (поле `release`, маршрут `/agent/release`, `agent_release` в ответе `claim`) +- Modify: `tests/test_py39.py` (разрешённые импорты `hashlib`, `re`, `subprocess`) +- Create: `tests/test_update.py` +- Test: `tests/test_runner.py`, `tests/test_clients.py` + +**Interfaces:** +- Produces (используют Task 2 и Task 4): + - `agent.AGENT_VERSION = 5`, `agent.EXIT_UPDATE = 75` + - `agent.read_update_state(path: str) -> Optional[dict]` + - `agent.write_update_state(path: str, state: str, from_version, to_version, sha256, error=None) -> dict` + - `agent.HubClient.claim(agent_version, settings_version, last_jira_call_at, update_state=None, launcher_version=None) -> dict` + - `agent.HubClient.release() -> dict` + - `agent.Updater(hub, base_dir: str, launcher_version: Optional[int], enabled: bool = True, config_path: Optional[str] = None)`; атрибуты `state_path`, `new_path`, `launcher_version`; методы `state() -> Optional[dict]`, `confirm() -> None` + - `agent.Runner.updater` (по умолчанию `None`), `agent.Runner.exit_code` (по умолчанию 0) + - `agent.cmd_selftest(cfg) -> int` (0 или 3); `main([... "selftest"])` + - `FakeHub.release` — `None` или `{"version", "sha256", "content"}`; `FakeHub.release_requests: int` + +- [ ] **Step 1: Write the failing tests** + +`tests/test_update.py`: + +```python +import json +import os +import tempfile +import unittest + +from _common import agent + + +class UpdateStateTest(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.path = os.path.join(self.dir.name, "update.json") + + def tearDown(self): + self.dir.cleanup() + + def test_write_and_read(self): + d = agent.write_update_state(self.path, "staged", 5, 6, "abc") + self.assertEqual(agent.read_update_state(self.path), d) + self.assertEqual((d["state"], d["from_version"], d["to_version"], d["sha256"], d["error"]), ("staged", 5, 6, "abc", None)) + self.assertTrue(d["at"]) + self.assertEqual(os.listdir(self.dir.name), ["update.json"]) # временный файл не остался + + def test_read_missing(self): + self.assertIsNone(agent.read_update_state(self.path)) + + def test_read_update_state_garbage(self): + for text in ('{"state": "tri', "[1, 2]", ""): + with open(self.path, "w", encoding="utf-8") as f: + f.write(text) + self.assertIsNone(agent.read_update_state(self.path)) + + def test_confirm_trial_of_this_version(self): + u = agent.Updater(None, self.dir.name, 1) + agent.write_update_state(u.state_path, "trial", 4, agent.AGENT_VERSION, "s") + u.confirm() + st = agent.read_update_state(u.state_path) + self.assertEqual((st["state"], st["from_version"], st["to_version"], st["sha256"]), ("ok", 4, agent.AGENT_VERSION, "s")) + + def test_confirm_ignores_other_states(self): + u = agent.Updater(None, self.dir.name, 1) + u.confirm() + self.assertIsNone(u.state()) # нет файла — не создаётся + agent.write_update_state(u.state_path, "trial", 4, agent.AGENT_VERSION + 1, "s") + u.confirm() + self.assertEqual(u.state()["state"], "trial") # пробная другая версия — не наша + agent.write_update_state(u.state_path, "rolled_back", 5, 6, "s", "код 1 через 3 с") + u.confirm() + self.assertEqual(u.state()["state"], "rolled_back") + + +if __name__ == "__main__": + unittest.main() +``` + +`tests/test_runner.py`, в `RunnerTest`: + +```python + def test_claim_sends_update_state_and_launcher(self): + self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1) + agent.write_update_state(self.runner.updater.state_path, "trial", 4, agent.AGENT_VERSION, "s") + self.runner.loop() + body = self.hub.claim_bodies[0] + self.assertEqual(body["launcher_version"], 1) + self.assertEqual(body["update_state"]["state"], "trial") + self.assertEqual(agent.read_update_state(self.runner.updater.state_path)["state"], "ok") # первый успешный claim + self.assertEqual(self.hub.claim_bodies[0]["agent_version"], 5) + + def test_claim_without_updater_sends_nulls(self): + self.runner.loop() + self.assertIsNone(self.hub.claim_bodies[0]["update_state"]) + self.assertIsNone(self.hub.claim_bodies[0]["launcher_version"]) +``` + +`tests/test_clients.py` — после `test_check_command` (в том же классе, где `self.fake` — FakeJira): + +```python + def test_selftest_command(self): + import contextlib + import io + import json + import os + import tempfile + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "agent.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(jira_cfg(self.fake), f) + out = io.StringIO() + with contextlib.redirect_stdout(out): + # хаб недоступен (порт 1), Jira доступен — selftest печатает версию и возвращает 3, а не 1 + self.assertEqual(agent.main(["--config", path, "selftest"]), 3) + self.assertIn("jira-agent v5", out.getvalue()) + self.assertIn("Jira: доступен", out.getvalue()) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `/usr/bin/python3 -m unittest tests.test_update tests.test_runner tests.test_clients -v 2>&1 | tail -20` +Expected: FAIL/ERROR — `AttributeError: module 'agent' has no attribute 'write_update_state'`, `KeyError: 'update_state'`, `invalid choice: 'selftest'`. + +- [ ] **Step 3: Implement** + +`tests/test_py39.py`: в `allowed` добавить `"hashlib", "re", "subprocess"`. + +`src/agent.py`: + +1. Docstring модуля — строки команд дополнить: + +``` + python3 agent.py selftest # самопроверка новой версии перед обновлением: версия + check; код 0 или 3 +``` + +и абзац: «Самообновление — через запускатель run.py (спека agent, «Самообновление агента»).» + +2. Импорты: добавить `hashlib`, `re`, `subprocess` (по алфавиту в существующем списке). + +3. Константы: + +```python +AGENT_VERSION = 5 +EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new +``` + +4. После `load_config` — функции состояния обновления: + +```python +# --------------------------------------------------------------------------- +# Самообновление: update.json рядом с agent.py (спека agent «Самообновление агента», design 7) +# --------------------------------------------------------------------------- +def read_update_state(path: str) -> Optional[dict]: + """Состояние обновления; нет файла или мусор — None.""" + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + except FileNotFoundError: + return None + except (OSError, ValueError): + log("update.json не читается — считаю, что состояния нет") + return None + return data if isinstance(data, dict) else None + + +def write_update_state(path: str, state: str, from_version, to_version, sha256, error=None) -> dict: + data = {"state": state, "from_version": from_version, "to_version": to_version, "sha256": sha256, + "error": error, "at": now_iso()} + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + os.replace(tmp, path) + return data +``` + +5. `HubClient`: + +```python + def claim(self, agent_version, settings_version, last_jira_call_at, update_state=None, launcher_version=None) -> dict: + return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version, + "last_jira_call_at": last_jira_call_at, "update_state": update_state, + "launcher_version": launcher_version}, timeout=max(self.timeout, 45)) + + def release(self) -> dict: + return self.call("GET", "/agent/release") +``` + +6. Класс `Updater` (перед `class Runner`), пока только состояние и подтверждение — `check`/`prepare` добавит Task 2: + +```python +class Updater: + """Самообновление агента (спека agent «Самообновление агента», design 7–12). Работает только под run.py.""" + + def __init__(self, hub, base_dir: str, launcher_version: Optional[int], enabled: bool = True, + config_path: Optional[str] = None): + self.hub, self.dir, self.launcher_version, self.enabled = hub, base_dir, launcher_version, enabled + self.config_path = config_path or os.path.join(base_dir, "agent.json") + self.state_path = os.path.join(base_dir, "update.json") + self.new_path = os.path.join(base_dir, "agent.py.new") + self.agent_path = os.path.join(base_dir, "agent.py") + + def state(self) -> Optional[dict]: + return read_update_state(self.state_path) + + def confirm(self) -> None: + """Первый успешный claim новой версии: trial этой версии → ok (запускатель перестаёт ждать отката).""" + st = self.state() + if st and st.get("state") == "trial" and st.get("to_version") == AGENT_VERSION: + write_update_state(self.state_path, "ok", st.get("from_version"), AGENT_VERSION, st.get("sha256")) + log("обновление до v%d подтверждено" % AGENT_VERSION) +``` + +7. `Runner.__init__`: добавить + +```python + self.updater = None # type: Optional[Updater] + self.exit_code = 0 # EXIT_UPDATE — выход для подмены версии +``` + +8. `Runner.loop` — вызов `claim` и подтверждение: + +```python + try: + upd = self.updater + resp = self.hub.claim(AGENT_VERSION, self.settings.get("settings_version"), self.jira.last_call_iso, + upd.state() if upd else None, upd.launcher_version if upd else None) + except (ConnectionError, HttpError) as e: + self.sleep(self.hub_wait(e, max(self.poll, 1))) + continue + if self.updater: + self.updater.confirm() +``` + +9. `cmd_selftest` после `cmd_check`: + +```python +def cmd_selftest(cfg: dict) -> int: + """Самопроверка версии перед обновлением: её запускает предыдущая версия. Контракт кодов MUST сохраняться: + 0 — хаб и Jira доступны, 3 — что-то недоступно (временно); любой другой код — файл негоден (design 10).""" + log("jira-agent v%d" % AGENT_VERSION) + return 0 if cmd_check(cfg) == 0 else 3 +``` + +`main`: `choices=["run", "check", "selftest", "prune"]` и ветка `if args.cmd == "selftest": return cmd_selftest(cfg)`. + +`tests/fake_hub.py`: в `__init__` — `self.release = None` и `self.release_requests = 0`; в `route` для `/agent/claim` оба ответа `{"command": …, "settings_version": …}` дополнить `"agent_release": self._release_ref()`; метод и маршрут: + +```python + def _release_ref(self): + r = self.release + return {"version": r["version"], "sha256": r["sha256"]} if r else None +``` + +```python + if path == "/agent/release": + self.release_requests += 1 + return (200, self.release) if self.release else (404, {"error": "no_release"}) +``` + +(маршрут — до разбора `/agent/commands/...`). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t . 2>&1 | tail -3` +Expected: `OK`. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/self-update add src/agent.py tests/ +git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/self-update commit -m "feat(agent): update.json, selftest, update_state и launcher_version в claim, версия 5 (#19) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Обновление в паузе — `Updater.check`/`prepare`, выход 75 + +**Files:** +- Modify: `src/agent.py` (`Updater.check`, `prepare`, `_run_selftest`, `_reject`, `_transient`, `_drop_new`; `Runner.loop`; `cmd_run`/`_run`; `main`) +- Test: `tests/test_update.py`, `tests/test_runner.py` + +**Interfaces:** +- Consumes: Task 1 (`Updater`, `read_update_state`, `write_update_state`, `HubClient.release`, `FakeHub.release`). +- Produces: + - `Updater.check(release: Optional[dict]) -> bool` — True: `agent.py.new` готов, `update.json` = `staged`, `run` должен выйти с 75 + - `Updater.run_selftest: Callable[[str], Tuple[Optional[int], str]]` — подменяется в тестах; `None` — таймаут + - `Updater.clock` (по умолчанию `time.monotonic`), `Updater.should_stop: Callable[[], bool]` + - `Updater.RETRY_SECONDS = 300`, `HINT_SECONDS = 3600`, `SELFTEST_TIMEOUT = 120` + - `cmd_run(cfg, journal_path, config_path=None) -> int` — 0 или `EXIT_UPDATE` + +- [ ] **Step 1: Write the failing tests** — `tests/test_update.py`, новый класс: + +```python +import hashlib + +from fake_hub import FakeHub +from _common import SETTINGS + + +def make_release(version, body="print('ok')\n"): + content = "AGENT_VERSION = %d\n%s" % (version, body) + return {"version": version, "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(), "content": content} + + +class UpdaterCheckTest(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.hub = FakeHub(SETTINGS) + cfg = {"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5} + self.u = agent.Updater(agent.HubClient(cfg), self.dir.name, 1) + self.selftests = [] + self.code = 0 + self.u.run_selftest = lambda path: (self.selftests.append(path), (self.code, "вывод selftest"))[1] + self.now = [1000.0] + self.u.clock = lambda: self.now[0] + + def tearDown(self): + self.hub.stop() + self.dir.cleanup() + + def ref(self, rel): + return {"version": rel["version"], "sha256": rel["sha256"]} + + def test_good_version_is_staged(self): + rel = make_release(6) + self.hub.release = rel + self.assertTrue(self.u.check(self.ref(rel))) + with open(self.u.new_path, encoding="utf-8") as f: + self.assertEqual(f.read(), rel["content"]) + st = self.u.state() + self.assertEqual((st["state"], st["from_version"], st["to_version"], st["sha256"]), ("staged", 5, 6, rel["sha256"])) + self.assertEqual(self.selftests, [self.u.new_path]) + + def test_not_newer_or_disabled_or_no_release(self): + self.hub.release = make_release(5) + self.assertFalse(self.u.check(self.ref(self.hub.release))) + self.assertFalse(self.u.check(None)) + self.hub.release = make_release(6) + self.u.enabled = False + self.assertFalse(self.u.check(self.ref(self.hub.release))) + self.assertEqual(self.hub.release_requests, 0) + + def test_sha_mismatch_rejected_once(self): + rel = make_release(6) + self.hub.release = dict(rel, content=rel["content"] + "# подмена\n") + ref = self.ref(rel) + self.hub.release["sha256"] = rel["sha256"] + self.assertFalse(self.u.check(ref)) + st = self.u.state() + self.assertEqual((st["state"], st["to_version"], st["error"]), ("rejected", 6, "sha256 не сходится")) + self.assertFalse(os.path.exists(self.u.new_path)) + self.assertFalse(self.u.check(ref)) + self.assertEqual(self.hub.release_requests, 1) # отвергнутую версию больше не качает + + def test_syntax_error_and_wrong_version_rejected(self): + bad = make_release(6, body="def (:\n") + self.hub.release = bad + self.assertFalse(self.u.check(self.ref(bad))) + self.assertIn("не компилируется", self.u.state()["error"]) + wrong = make_release(7) + wrong = dict(wrong, version=8) + self.hub.release = wrong + self.assertFalse(self.u.check(self.ref(wrong))) + self.assertIn("AGENT_VERSION", self.u.state()["error"]) + self.assertEqual(self.selftests, []) + + def test_selftest_bad_code_rejected(self): + self.hub.release = make_release(6) + self.code = 1 + self.assertFalse(self.u.check(self.ref(self.hub.release))) + st = self.u.state() + self.assertEqual(st["state"], "rejected") + self.assertTrue(st["error"].startswith("selftest: код 1")) + self.assertIn("вывод selftest", st["error"]) + self.assertFalse(os.path.exists(self.u.new_path)) + + def test_selftest_unavailable_is_transient(self): + self.hub.release = make_release(6) + self.code = 3 + ref = self.ref(self.hub.release) + self.assertFalse(self.u.check(ref)) + self.assertIsNone(self.u.state()) # update.json не тронут + self.assertFalse(os.path.exists(self.u.new_path)) + self.now[0] += 299 + self.assertFalse(self.u.check(ref)) + self.assertEqual(len(self.selftests), 1) # раньше 5 минут не повторяет + self.now[0] += 2 + self.code = 0 + self.assertTrue(self.u.check(ref)) + + def test_selftest_timeout_is_transient(self): + self.hub.release = make_release(6) + self.code = None + self.assertFalse(self.u.check(self.ref(self.hub.release))) + self.assertIsNone(self.u.state()) + + def test_download_failure_is_transient(self): + rel = make_release(6) # в claim есть, а /agent/release — 404 + self.assertFalse(self.u.check(self.ref(rel))) + self.assertIsNone(self.u.state()) + + def test_release_changed_between_requests_is_transient(self): + old, new = make_release(6), make_release(7) + self.hub.release = new + self.assertFalse(self.u.check(self.ref(old))) + self.assertIsNone(self.u.state()) + self.assertEqual(self.selftests, []) + + def test_rolled_back_same_sha_not_retried(self): + rel = make_release(6) + self.hub.release = rel + agent.write_update_state(self.u.state_path, "rolled_back", 5, 6, rel["sha256"], "код 1 через 3 с") + self.assertFalse(self.u.check(self.ref(rel))) + fixed = make_release(6, body="print('fixed')\n") + self.hub.release = fixed + self.assertTrue(self.u.check(self.ref(fixed))) # тот же номер, другой sha — пробуем + + def test_without_launcher_only_hint(self): + self.u.launcher_version = None + self.hub.release = make_release(6) + logs = [] + orig = agent.log + agent.log = logs.append + try: + self.assertFalse(self.u.check(self.ref(self.hub.release))) + self.assertFalse(self.u.check(self.ref(self.hub.release))) + self.now[0] += 3600 + self.assertFalse(self.u.check(self.ref(self.hub.release))) + finally: + agent.log = orig + self.assertEqual(logs.count("доступна v6; для самообновления запускайте через run.py"), 2) + self.assertEqual(self.hub.release_requests, 0) + + def test_stop_during_selftest_is_not_reject(self): + self.hub.release = make_release(6) + stop = [False] + self.u.should_stop = lambda: stop[0] + + def selftest(path): + stop[0] = True # Ctrl-C пришёл и selftest упал с 1 + return 1, "KeyboardInterrupt" + self.u.run_selftest = selftest + self.assertFalse(self.u.check(self.ref(self.hub.release))) + self.assertIsNone(self.u.state()) + self.assertFalse(os.path.exists(self.u.new_path)) + + def test_real_selftest_runs_new_file(self): + u = agent.Updater(None, self.dir.name, 1, config_path=os.path.join(self.dir.name, "agent.json")) + path = os.path.join(self.dir.name, "agent.py.new") + with open(path, "w", encoding="utf-8") as f: + f.write("import sys\nprint('args', sys.argv[1:])\nsys.exit(3)\n") + code, out = u._run_selftest(path) + self.assertEqual(code, 3) + self.assertIn("'--config', '%s', 'selftest'" % os.path.join(self.dir.name, "agent.json"), out) +``` + +`tests/test_runner.py`, в `RunnerTest`: + +```python + def test_idle_with_release_exits_for_update(self): + rel = {"version": 6, "sha256": "s", "content": "x"} + self.hub.release = rel + self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1) + self.runner.updater.check = lambda r: r == {"version": 6, "sha256": "s"} + self.runner.idle_limit = 5 + self.runner.loop() + self.assertEqual(self.runner.exit_code, agent.EXIT_UPDATE) + self.assertEqual(self.hub.claims, 1) + + def test_command_first_then_update(self): + self.hub.release = {"version": 6, "sha256": "s", "content": "x"} + self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}) + self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1) + seen = [] + self.runner.updater.check = lambda r: (seen.append(r), True)[1] + self.runner.loop() + self.assertEqual(self.hub.results[1]["status"], "done") # команда выполнена и подтверждена + self.assertEqual(len(seen), 1) # проверка — только после claim без команды + self.assertEqual(self.runner.exit_code, agent.EXIT_UPDATE) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `/usr/bin/python3 -m unittest tests.test_update tests.test_runner -v 2>&1 | tail -20` +Expected: ERROR — `AttributeError: 'Updater' object has no attribute 'check'`. + +- [ ] **Step 3: Implement** — в `class Updater`: + +```python + RETRY_SECONDS = 300 # временный сбой подготовки — повтор не раньше (R-8) + HINT_SECONDS = 3600 # подсказка «запускайте через run.py» — не чаще (R-10) + SELFTEST_TIMEOUT = 120 + VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M) +``` + +в `__init__` дописать: + +```python + self.retry_after = {} # (version, sha256) -> clock(), раньше которого не повторять + self.hint_at = None # type: Optional[float] + self.clock = time.monotonic + self.should_stop = lambda: False + self.run_selftest = self._run_selftest +``` + +методы: + +```python + def check(self, release) -> bool: + """Вызывается после claim без команды. True — agent.py.new проверен, update.json = staged: run выходит с 75.""" + if not self.enabled or not isinstance(release, dict): + return False + try: + version, sha = int(release.get("version")), str(release.get("sha256")) + except (TypeError, ValueError): + return False + if version <= AGENT_VERSION: + return False + st = self.state() + if st and st.get("state") in ("rejected", "rolled_back") and st.get("to_version") == version \ + and st.get("sha256") == sha: + return False + if self.launcher_version is None: + now = self.clock() + if self.hint_at is None or now - self.hint_at >= self.HINT_SECONDS: + self.hint_at = now + log("доступна v%d; для самообновления запускайте через run.py" % version) + return False + until = self.retry_after.get((version, sha)) + if until is not None and self.clock() < until: + return False + return self.prepare(version, sha) + + def prepare(self, version: int, sha: str) -> bool: + log("доступна v%d — готовлю обновление" % version) + try: + rel = self.hub.release() + except (ConnectionError, HttpError) as e: + return self._transient(version, sha, "скачивание: %s" % e) + content = rel.get("content") if isinstance(rel, dict) else None + if not isinstance(content, str) or rel.get("version") != version or rel.get("sha256") != sha: + return self._transient(version, sha, "публикация сменилась между запросами") + data = content.encode("utf-8") + if hashlib.sha256(data).hexdigest() != sha: + return self._reject(version, sha, "sha256 не сходится") + try: + compile(content, "agent.py.new", "exec") + except (SyntaxError, ValueError) as e: + return self._reject(version, sha, "не компилируется: %s" % e) + if self.VERSION_RE.findall(content) != [str(version)]: + return self._reject(version, sha, "AGENT_VERSION в файле не %d" % version) + tmp = self.new_path + ".tmp" + with open(tmp, "wb") as f: + f.write(data) + try: + os.chmod(tmp, os.stat(self.agent_path).st_mode & 0o777) + except OSError: + pass + os.replace(tmp, self.new_path) + if self.should_stop(): + self._drop_new() + return False + code, out = self.run_selftest(self.new_path) + if self.should_stop(): # Ctrl-C уронил и selftest — это не отказ версии + self._drop_new() + return False + if code == 0: + write_update_state(self.state_path, "staged", AGENT_VERSION, version, sha) + log("v%d готова, перезапуск через run.py" % version) + return True + if code is None: + return self._transient(version, sha, "selftest: таймаут %d с" % self.SELFTEST_TIMEOUT) + if code == 3: + return self._transient(version, sha, "selftest: хаб или Jira недоступны") + return self._reject(version, sha, "selftest: код %s: %s" % (code, out[-500:])) + + def _run_selftest(self, path: str): + try: + p = subprocess.run([sys.executable, path, "--config", self.config_path, "selftest"], cwd=self.dir, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=self.SELFTEST_TIMEOUT) + except subprocess.TimeoutExpired: + return None, "" + return p.returncode, p.stdout.decode("utf-8", "replace") + + def _drop_new(self) -> None: + for path in (self.new_path, self.new_path + ".tmp"): + try: + os.remove(path) + except FileNotFoundError: + pass + + def _transient(self, version, sha, reason) -> bool: + self._drop_new() + self.retry_after[(version, sha)] = self.clock() + self.RETRY_SECONDS + log("обновление до v%d отложено на %d мин: %s" % (version, self.RETRY_SECONDS // 60, reason)) + return False + + def _reject(self, version, sha, reason) -> bool: + self._drop_new() + write_update_state(self.state_path, "rejected", AGENT_VERSION, version, sha, reason) + log("v%d отвергнута: %s" % (version, reason)) + return False +``` + +`Runner.loop` — после `if cmd: … continue`, до `idle += 1`: + +```python + if self.updater and not self.stop and self.updater.check(resp.get("agent_release")): + self.exit_code = EXIT_UPDATE + return +``` + +`cmd_run`/`_run` — передать путь настроек и собрать `Updater`: + +```python +def cmd_run(cfg: dict, journal_path: str, config_path: Optional[str] = None) -> int: + ... + return _run(cfg, journal_path, config_path) +``` + +```python +def launcher_version() -> Optional[int]: + """Версия запускателя из окружения (run.py ставит JIRA_AGENT_LAUNCHER); без запускателя — None.""" + try: + return int(os.environ["JIRA_AGENT_LAUNCHER"]) + except (KeyError, ValueError): + return None +``` + +в `_run` после создания `runner`: + +```python + hub = runner.hub + runner.updater = Updater(hub, HERE, launcher_version(), cfg.get("auto_update", True) is not False, config_path) + runner.updater.should_stop = lambda: runner.stop +``` + +конец `_run`: + +```python + runner.loop() + log("выход для обновления (код %d)" % EXIT_UPDATE if runner.exit_code == EXIT_UPDATE else "остановлен") + return runner.exit_code +``` + +`main`: `return cmd_run(cfg, args.journal, args.config)`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t . 2>&1 | tail -3` +Expected: `OK`. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/self-update add src/agent.py tests/ +git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/self-update commit -m "feat(agent): самообновление в паузе — скачивание, sha256, selftest, выход 75 (#19) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Запускатель `run.py` + +**Files:** +- Create: `src/run.py` +- Create: `tests/test_launcher.py` +- Modify: `tests/test_py39.py` (проверка `run.py`) + +**Interfaces:** +- Consumes: контракт агента — код 75 + `update.json` = `staged` + `agent.py.new`; `JIRA_AGENT_LAUNCHER`; агент пишет `ok` (Task 1–2). `run.py` НЕ импортирует `agent.py`. +- Produces: + - `run.LAUNCHER_VERSION = 1`, `run.EXIT_UPDATE = 75` + - `run.Launcher(base_dir: str, args: List[str])`; атрибуты `trial_seconds` (600), `kill_grace` (60), `pause` (метод `pause(seconds) -> bool`, подменяется в тестах), `stopping`, `child`; методы `run() -> int`, `on_sigint(signum, frame)`, `on_sigterm(signum, frame)` + - `run.acquire_lock(base_dir) -> Optional[file]`; `run.main(argv=None) -> int` + +- [ ] **Step 1: Write the failing tests** — `tests/test_launcher.py`: + +```python +import hashlib +import json +import os +import signal +import subprocess +import sys +import tempfile +import textwrap +import unittest + +from _common import SRC + +if SRC not in sys.path: + sys.path.insert(0, SRC) +import run # noqa: E402 + +# Поддельные агенты: маленькие скрипты, которые ведут себя как agent.py по контракту (коды выхода, update.json). +STATE_HELPERS = textwrap.dedent(''' + import json, os, sys + HERE = os.path.dirname(os.path.abspath(__file__)) + STATE = os.path.join(HERE, "update.json") + def state(): + try: + return json.load(open(STATE, encoding="utf-8")) + except Exception: + return None + def write(**kw): + json.dump(kw, open(STATE, "w", encoding="utf-8")) + def mark(name): + open(os.path.join(HERE, "runs.log"), "a").write(name + "\\n") +''') + + +def fake(name, body): + return STATE_HELPERS + "mark(%r)\n" % name + textwrap.dedent(body) + + +# v5 «нашёл» новую версию: кладёт agent.py.new из next.py, пишет staged и выходит с 75; после отката/отказа — выход 0. +V5_UPDATES = fake("v5", ''' + import hashlib + st = state() + if st is None: + data = open(os.path.join(HERE, "next.py"), "rb").read() + open(os.path.join(HERE, "agent.py.new"), "wb").write(data) + write(state="staged", from_version=5, to_version=6, sha256=hashlib.sha256(data).hexdigest()) + sys.exit(75) + sys.exit(0) +''') +# v5 с подменённым после проверки файлом: sha в staged не сходится с agent.py.new. +V5_BAD_STAGE = fake("v5", ''' + st = state() + if st is None: + open(os.path.join(HERE, "agent.py.new"), "w").write("мусор") + write(state="staged", from_version=5, to_version=6, sha256="чужой") + sys.exit(75) + sys.exit(0) +''') +V6_OK_THEN_STOP = fake("v6", ''' + st = state() + write(**dict(st, state="ok")) + sys.exit(0) +''') +V6_CRASH = fake("v6", 'sys.exit(1)\n') +V6_HANG = fake("v6", 'import time\ntime.sleep(60)\n') + + +class LauncherTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.dir = self.tmp.name + self.l = run.Launcher(self.dir, ["--journal", "j.jsonl"]) + self.pauses = [] + self.l.pause = lambda s: (self.pauses.append(s), True)[1] + + def tearDown(self): + self.tmp.cleanup() + + def put(self, name, text): + with open(os.path.join(self.dir, name), "w", encoding="utf-8") as f: + f.write(text) + + def read(self, name): + with open(os.path.join(self.dir, name), encoding="utf-8") as f: + return f.read() + + def state(self): + return json.loads(self.read("update.json")) + + def runs(self): + return self.read("runs.log").split() + + def next_version(self, text): + self.put("next.py", text) + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + def stage(self, new_text, to_version=6): + self.put("agent.py.new", new_text) + sha = hashlib.sha256(new_text.encode("utf-8")).hexdigest() + self.put("update.json", json.dumps({"state": "staged", "from_version": 5, "to_version": to_version, "sha256": sha})) + return sha + + def test_update_applied_and_confirmed(self): + self.put("agent.py", V5_UPDATES) + sha = self.next_version(V6_OK_THEN_STOP) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.read("agent.py"), V6_OK_THEN_STOP) + self.assertEqual(self.read("agent.py.prev"), V5_UPDATES) + self.assertFalse(os.path.exists(os.path.join(self.dir, "agent.py.new"))) + st = self.state() + self.assertEqual((st["state"], st["from_version"], st["to_version"], st["sha256"]), ("ok", 5, 6, sha)) + self.assertEqual(self.runs(), ["v5", "v6"]) + + def test_crash_in_trial_rolls_back(self): + self.put("agent.py", V5_UPDATES) + sha = self.next_version(V6_CRASH) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.read("agent.py"), V5_UPDATES) + st = self.state() + self.assertEqual((st["state"], st["to_version"], st["sha256"]), ("rolled_back", 6, sha)) + self.assertRegex(st["error"], r"^код 1 через \d+ с$") + self.assertEqual(self.runs(), ["v5", "v6", "v5"]) + self.assertEqual(self.pauses, []) # откат без паузы + + def test_hang_in_trial_rolls_back(self): + self.l.trial_seconds = 1 + self.l.kill_grace = 5 + self.put("agent.py", V5_UPDATES) + self.next_version(V6_HANG) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.read("agent.py"), V5_UPDATES) + self.assertEqual(self.state()["error"], "не вышла на связь за 10 мин") + + def test_crash_after_ok_restarts_with_backoff(self): + self.put("update.json", json.dumps({"state": "ok", "from_version": 5, "to_version": 6, "sha256": "s"})) + self.put("agent.py", fake("v6", ''' + n = len(open(os.path.join(HERE, "runs.log")).read().split()) + sys.exit(1 if n <= 2 else 0) + ''')) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.pauses, [5, 10]) + self.assertEqual(self.state()["state"], "ok") # отката нет + + def test_backoff_caps_and_resets(self): + self.assertEqual([self.l.next_backoff(10) for _ in range(8)], [5, 10, 20, 40, 80, 160, 300, 300]) + self.assertEqual(self.l.next_backoff(600), 5) # проработал 10 минут — с начала + + def test_75_without_staged_rejected(self): + self.put("agent.py", V5_BAD_STAGE) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.read("agent.py"), V5_BAD_STAGE) + self.assertEqual(self.runs(), ["v5", "v5"]) + self.assertFalse(os.path.exists(os.path.join(self.dir, "agent.py.new"))) + st = self.state() + self.assertEqual((st["state"], st["error"]), ("rejected", "файл изменился после проверки")) + + def test_staged_at_start_is_applied(self): + self.put("agent.py", fake("v5", "sys.exit(0)\n")) + self.stage(V6_OK_THEN_STOP) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.runs(), ["v6"]) + self.assertEqual(self.state()["state"], "ok") + + def test_stray_new_removed_at_start(self): + self.put("agent.py", fake("v5", "sys.exit(0)\n")) + self.put("agent.py.new", "остаток") + self.assertEqual(self.l.run(), 0) + self.assertFalse(os.path.exists(os.path.join(self.dir, "agent.py.new"))) + + def test_missing_agent_restored_from_prev(self): + self.put("agent.py.prev", fake("v5", "sys.exit(0)\n")) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.runs(), ["v5"]) + + def test_garbage_update_json_is_no_state(self): + self.put("update.json", '{"state": "tri') + self.put("agent.py", fake("v5", "sys.exit(0)\n")) + self.assertEqual(self.l.run(), 0) + + def test_env_and_args_passed(self): + self.put("agent.py", fake("v5", ''' + open(os.path.join(HERE, "env.txt"), "w").write(os.environ.get("JIRA_AGENT_LAUNCHER", "") + " " + " ".join(sys.argv[1:])) + sys.exit(130) + ''')) + self.assertEqual(self.l.run(), 130) + self.assertEqual(self.read("env.txt"), "1 run --journal j.jsonl") + + def test_sigint_without_child_stops(self): + self.l.on_sigint(signal.SIGINT, None) + self.assertTrue(self.l.stopping) + + def test_sigint_with_child_ignored(self): + self.l.child = object() + self.l.on_sigint(signal.SIGINT, None) + self.assertFalse(self.l.stopping) + + def test_second_launcher_refused(self): + with open(os.path.join(SRC, "run.py"), encoding="utf-8") as f: + self.put("run.py", f.read()) + self.put("agent.py", fake("v5", "sys.exit(0)\n")) + lock = run.acquire_lock(self.dir) + try: + p = subprocess.run([sys.executable, os.path.join(self.dir, "run.py")], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=30) + finally: + lock.close() + self.assertEqual(p.returncode, 1) + self.assertIn("запускатель уже работает", p.stdout.decode("utf-8")) + self.assertFalse(os.path.exists(os.path.join(self.dir, "runs.log"))) + + +if __name__ == "__main__": + unittest.main() +``` + +`tests/test_py39.py` — оба теста по циклу `for name in ("agent.py", "run.py")` (через `self.subTest(name)`), `allowed` для `run.py` тот же набор плюс `subprocess`, `hashlib` уже есть с Task 1. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `/usr/bin/python3 -m unittest tests.test_launcher -v 2>&1 | tail -5` +Expected: ERROR — `ModuleNotFoundError: No module named 'run'`. + +- [ ] **Step 3: Implement** — `src/run.py`: + +```python +#!/usr/bin/env python3 +"""run.py — запускатель jira-agent: запускает agent.py дочерним процессом, ставит новую версию после самообновления +агента, откатывает её, если она упала или не вышла на связь, и поднимает агента после падения. +Только стандартная библиотека, Python 3.9. agent.py не импортирует: тот меняется, запускатель — нет. +Спека: jira-hub/openspec/specs/agent/spec.md, «Запускатель run.py». + + python3 run.py [--config agent.json] [--journal journal.jsonl] # аргументы уходят в agent.py run + +Обновляется вручную (редко): остановить, заменить файл, запустить. +""" +import fcntl +import hashlib +import json +import os +import signal +import subprocess +import sys +import time +from datetime import datetime, timezone +from typing import List, Optional + +LAUNCHER_VERSION = 1 +EXIT_UPDATE = 75 # агент: «agent.py.new проверен, update.json = staged» +TRIAL_SECONDS = 600 # новая версия должна записать ok за это время +KILL_GRACE = 60 # после SIGTERM до SIGKILL +BACKOFF_START, BACKOFF_MAX = 5, 300 +HEALTHY_UPTIME = 600 # проработал столько — пауза перезапуска с начала + + +def log(msg: str) -> None: + print("[%s] run: %s" % (datetime.now().strftime("%H:%M:%S"), msg), flush=True) + + +def read_state(path: str) -> Optional[dict]: + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + except (OSError, ValueError): + return None + return data if isinstance(data, dict) else None + + +def write_state(path: str, state: str, base: Optional[dict], error=None) -> None: + base = base or {} + data = {"state": state, "from_version": base.get("from_version"), "to_version": base.get("to_version"), + "sha256": base.get("sha256"), "error": error, + "at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()} + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + os.replace(tmp, path) + + +def sha256_file(path: str) -> str: + with open(path, "rb") as f: + return hashlib.sha256(f.read()).hexdigest() + + +def acquire_lock(base_dir: str): + """Один запускатель на каталог. None — уже занято.""" + f = open(os.path.join(base_dir, "run.py.lock"), "a") + try: + fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + f.close() + return None + return f + + +class Launcher: + def __init__(self, base_dir: str, args: List[str]): + self.dir, self.args = base_dir, list(args) + self.agent = os.path.join(base_dir, "agent.py") + self.new = os.path.join(base_dir, "agent.py.new") + self.prev = os.path.join(base_dir, "agent.py.prev") + self.state_path = os.path.join(base_dir, "update.json") + self.trial_seconds, self.kill_grace = TRIAL_SECONDS, KILL_GRACE + self.backoff = BACKOFF_START + self.stopping = False + self.child = None + + # --- сигналы --------------------------------------------------------------- + def on_sigint(self, signum, frame): + """Ctrl-C в терминале приходит и агенту (общая группа): пока агент жив — решает он. Без агента (пауза) — выход.""" + if self.child is None: + self.stopping = True + + def on_sigterm(self, signum, frame): + self.stopping = True + child = self.child + if child is not None and hasattr(child, "send_signal"): + child.send_signal(signal.SIGTERM) + + # --- файлы версии ---------------------------------------------------------- + def recover_files(self) -> None: + if not os.path.exists(self.agent) and os.path.exists(self.prev): + os.replace(self.prev, self.agent) + log("agent.py не найден — восстановлен из agent.py.prev") + if os.path.exists(self.new): + st = read_state(self.state_path) + if st and st.get("state") == "staged": + log("найдена подготовленная версия — ставлю") + self.apply_update() + else: + os.remove(self.new) + + def apply_update(self) -> None: + st = read_state(self.state_path) + if not (st and st.get("state") == "staged" and os.path.exists(self.new) and sha256_file(self.new) == st.get("sha256")): + if os.path.exists(self.new): + os.remove(self.new) + write_state(self.state_path, "rejected", st, "файл изменился после проверки") + log("обновление отвергнуто: файл изменился после проверки") + return + tmp = self.prev + ".tmp" + with open(self.agent, "rb") as src, open(tmp, "wb") as dst: + dst.write(src.read()) + os.replace(tmp, self.prev) + os.replace(self.new, self.agent) # agent.py существует на каждом шаге + write_state(self.state_path, "trial", st) + log("v%s → v%s: пробный запуск" % (st.get("from_version"), st.get("to_version"))) + + def rollback(self, st: Optional[dict], reason: str) -> bool: + if not os.path.exists(self.prev): + log("откат невозможен: нет agent.py.prev") + return False + os.replace(self.prev, self.agent) + write_state(self.state_path, "rolled_back", st, reason) + log("откат v%s → v%s: %s" % ((st or {}).get("to_version"), (st or {}).get("from_version"), reason)) + return True + + # --- процесс агента -------------------------------------------------------- + def spawn(self): + env = dict(os.environ, JIRA_AGENT_LAUNCHER=str(LAUNCHER_VERSION)) + return subprocess.Popen([sys.executable, "-u", self.agent, "run"] + self.args, env=env, cwd=self.dir) + + def terminate(self, child) -> None: + child.send_signal(signal.SIGTERM) + try: + child.wait(self.kill_grace) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + + def wait_child(self, child, trial: bool): + """Код выхода и признак «пробный период истёк». В пробный период ждём ok не дольше trial_seconds.""" + start = time.monotonic() + while True: + try: + return child.wait(1), False + except subprocess.TimeoutExpired: + pass + if trial: + st = read_state(self.state_path) + if st and st.get("state") == "ok": + trial = False + elif time.monotonic() - start >= self.trial_seconds: + self.terminate(child) + return child.returncode, True + + def pause(self, seconds: float) -> bool: + """Пауза перед перезапуском; False — остановлены во время паузы.""" + end = time.monotonic() + seconds + while not self.stopping and time.monotonic() < end: + time.sleep(min(1.0, end - time.monotonic())) + return not self.stopping + + def next_backoff(self, uptime: float) -> int: + if uptime >= HEALTHY_UPTIME: + self.backoff = BACKOFF_START + wait = self.backoff + self.backoff = min(self.backoff * 2, BACKOFF_MAX) + return wait + + def run(self) -> int: + self.recover_files() + while True: + st = read_state(self.state_path) + trial = bool(st and st.get("state") == "trial") + started = time.monotonic() + self.child = self.spawn() + code, timed_out = self.wait_child(self.child, trial) + self.child = None + uptime = time.monotonic() - started + st = read_state(self.state_path) + if timed_out: + if self.rollback(st, "не вышла на связь за 10 мин"): + continue + elif code in (0, 130) or self.stopping: + return code + elif code == EXIT_UPDATE: + self.apply_update() + self.backoff = BACKOFF_START + continue + elif trial and not (st and st.get("state") == "ok"): + if self.rollback(st, "код %s через %d с" % (code, uptime)): + continue + if self.stopping: + return code + wait = self.next_backoff(uptime) + log("агент упал (код %s), перезапуск через %d с" % (code, wait)) + if not self.pause(wait): + return code + + +def main(argv=None) -> int: + base = os.path.dirname(os.path.abspath(__file__)) + lock = acquire_lock(base) + if lock is None: + log("запускатель уже работает в %s — второй не стартует" % base) + return 1 + try: + launcher = Launcher(base, sys.argv[1:] if argv is None else argv) + signal.signal(signal.SIGINT, launcher.on_sigint) + signal.signal(signal.SIGTERM, launcher.on_sigterm) + log("запускатель v%d: агент %s" % (LAUNCHER_VERSION, launcher.agent)) + return launcher.run() + finally: + lock.close() + + +if __name__ == "__main__": + sys.exit(main()) +``` + +Примечание к `test_hang_in_trial_rolls_back`: таймаут пробного периода в тесте 1 с, а причина в `update.json` — всегда «не вышла на связь за 10 мин» (текст спеки, не зависит от `trial_seconds`). Поддельный v6 без обработчика SIGTERM умирает с кодом `-15` — это не 0/130, но `timed_out` проверяется первым. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t . 2>&1 | tail -3` +Expected: `OK`. Если `ResourceWarning` на `Popen` — в `run()` после `wait_child` объект `Popen` уже дождан; на `open(...)` в поддельных агентах предупреждения в дочернем процессе не ловятся. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/self-update add src/run.py tests/test_launcher.py tests/test_py39.py +git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/self-update commit -m "feat(agent): запускатель run.py — подмена версии, пробный период, откат, перезапуск (#19) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Сквозной тест `run.py` + настоящий `agent.py` и документация + +**Files:** +- Create: `tests/test_self_update_e2e.py` +- Modify: `src/agent.example.json` (`"auto_update": true`) +- Modify: `README.md` («Доставка на виртуалку», «Запуск», «Автозапуск через launchd», «Обновление агента», раздел «Самообновление») +- Modify: `ARCHITECTURE.md` (раздел `Updater`, `run.py`, границы) +- Modify: `CLAUDE.md` (архитектура, команды, текущий change, пункт публикации в закрытии) + +**Interfaces:** +- Consumes: всё из Task 1–3; `FakeHub.release`, `FakeJira` (`/rest/api/2/myself`). + +- [ ] **Step 1: Write the failing test** — `tests/test_self_update_e2e.py`: + +```python +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) +``` + +- [ ] **Step 2: Run test to verify it fails or passes** + +Run: `/usr/bin/python3 -m unittest tests.test_self_update_e2e -v 2>&1 | tail -15` +Expected: PASS, если Task 1–3 выполнены верно (это интеграционный тест, а не TDD новой логики). Если FAIL — разбирать вывод `out.log` из сообщения теста (superpowers:systematic-debugging), чинить в `agent.py`/`run.py` с отдельным тестом на найденный случай в `test_update.py`/`test_launcher.py`. + +- [ ] **Step 3: Documentation** + +`src/agent.example.json` — после `"poll_seconds": 5` строка `"auto_update": true`. + +`README.md`: +- первая строка описания: «Один файл `src/agent.py` и запускатель `src/run.py`, только стандартная библиотека, Python 3.9.» +- «Доставка на виртуалку», шаг 1: копировать `src/agent.py`, `src/run.py` и `src/agent.example.json` в локальный каталог виртуалки (`~/jira-agent-bundle`), **не запускать из общей папки**; в список настроек — `auto_update` (по умолчанию `true`; `false` — не обновляться самому). +- «Проверка»: добавить абзац про `python3 agent.py selftest` («то же, что `check`, плюс строка `jira-agent v`; код 3 — что-то недоступно; её вызывает предыдущая версия перед обновлением»). +- «Запуск»: `python3 run.py` в терминале, `nohup python3 -u run.py > agent.log 2>&1 &` в фоне; Ctrl-C — как раньше (первый — доделать команду, второй — выйти сразу); запуск `python3 agent.py` без запускателя возможен, но тогда агент себя не обновляет и раз в час пишет «доступна v…». +- «Автозапуск через launchd»: в `ProgramArguments` — `/Users/USER/jira-agent/run.py`. +- новый раздел «Самообновление» (после «Запуск»): + +```markdown +## Самообновление + +С версии 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`. Не удалять во время работы. + +Выключить: `"auto_update": false` в `agent.json`. `run.py` обновляется вручную (меняется редко). +``` + +- «Обновление агента» переписать: «С версии 5 — само (раздел «Самообновление»). Вручную — только `run.py` или переход с v4: остановить старый процесс, скопировать `agent.py` и `run.py` в каталог агента, `python3 agent.py check`, запустить `python3 run.py`. `agent.json`, `journal.jsonl`, `update.json` не трогать.» (абзац о двух процессах и блокировке журнала сохранить). +- «Версия» в «Правила работы»: «сейчас 5 — самообновление через run.py». + +`ARCHITECTURE.md`: +- первая строка: агент — `src/agent.py` и запускатель `src/run.py`. +- строка таблицы перед «Цикл»: + +```markdown +| Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` | +``` + +- у «Цикл» — «после `claim` без команды спрашивает `Updater`; готова версия — выход с `EXIT_UPDATE` (75)»; у CLI — `selftest`. +- новый абзац «## Запускатель `src/run.py`»: не импортирует `agent.py`; `Launcher` — дочерний `agent.py run`, коды 0/130 → выход, 75 → `apply_update` (`.prev`, `os.replace`, `trial`), падение в `trial` или 10 мин без `ok` → `rollback`, иначе пауза 5…300 с; блокировка `run.py.lock`; SIGINT при живом агенте игнорирует, SIGTERM пересылает. +- «Границы»: «Код новой версии приходит только через API хаба и пишется в каталог агента; общая папка для кода не используется.» + +`CLAUDE.md`: +- «Архитектура»: `src/run.py` — запускатель (не импортирует `agent.py`); раздел `Updater` между `Runner` и CLI; CLI — `run|check|selftest|prune`. +- «Команды»: `/usr/bin/python3 src/run.py` — запуск через запускатель (самообновление); `/usr/bin/python3 src/agent.py selftest`. +- «Тесты»: `test_update.py` (самообновление агента), `test_launcher.py` (запускатель на поддельных агентах), `test_self_update_e2e.py` (run.py + настоящий agent.py + фейки). +- «Первая подсистема», последний абзац: текущий change — `../jira-hub/openspec/changes/agent-self-update/`, план — `docs/superpowers/plans/2026-10-14-agent-self-update-agent.md`. +- новый раздел: + +```markdown +## Выкатка новой версии +Самообновление (с 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` публикацией не +обновляется — только вручную через общую папку. +``` + +- [ ] **Step 4: Run all tests** + +Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t . 2>&1 | tail -3` +Expected: `OK`. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/self-update add tests/test_self_update_e2e.py src/agent.example.json README.md ARCHITECTURE.md CLAUDE.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/self-update commit -m "test(agent): сквозной тест самообновления; README, ARCHITECTURE, CLAUDE.md — run.py и публикация (#19) + +Co-Authored-By: Claude Opus 5.5 " +```