diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2f71e27..388fbdb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -23,6 +23,10 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл падение в `trial` или 10 минут без `ok` — `rollback`; иначе перезапуск с паузой 5…300 с. Блокировка `run.py.lock`; SIGINT при живом агенте игнорирует (Ctrl-C получает агент), SIGTERM пересылает. Спека — «Запускатель run.py». +`reconcile` при старте и `in_trial` перед каждым запуском сверяют `update.json` с sha256 `agent.py` (таблица — design 1 change'а +agent-v9-hardening); коды агента: 75 — подмена, 73 — журнал занят (не сбой версии); `OSError` при подмене — `rejected`, +запускатель не падает. + ## Границы - Код новой версии приходит только через API хаба (`GET /agent/release`) и пишется в каталог агента; общая папка для кода не используется. diff --git a/CLAUDE.md b/CLAUDE.md index 143e3b6..d812130 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ ## Первая подсистема Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд, журнал, восстановление после перезапуска, CLI `run|check|selftest|prune`, самообновление через `run.py`. -Последний change — `../jira-hub/openspec/changes/archive/2026-10-14-agent-self-update/` (решения агента — `openspec/changes/archive/2026-10-14-agent-self-update/design.md`), план — `docs/superpowers/plans/2026-10-14-agent-self-update-agent.md`. +Последний change — `../jira-hub/openspec/changes/agent-v9-hardening/` (решения агента — `openspec/changes/agent-v9-hardening/design.md`), план — `docs/superpowers/plans/2026-10-14-agent-v9-hardening-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`. ## Выкатка новой версии diff --git a/README.md b/README.md index 01ddc17..489d724 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ nohup python3 -u run.py > agent.log 2>&1 & считается упавшим и перезапускается). Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит. Повторный сигнал — немедленный выход (код 130). Остановка во время паузы перед командой безопасна: команда уже записана в журнал как `claimed`, при следующем запуске агент запрашивает её у хаба и выполняет через проверку, выдержав паузу темпа от последней записи журнала. +Если журнал занят другим процессом (осталась прежняя копия агента), `agent.py run` выходит с кодом 73, а `run.py` повторяет запуск с паузой и не считает это сбоем версии. ## Самообновление @@ -93,6 +94,13 @@ nohup python3 -u run.py > agent.log 2>&1 & на связь за 10 минут — `run.py` возвращает `agent.py.prev` и пишет `rolled_back`; эту версию агент больше не ставит, хаб показывает откат в `agent_status`. Код в общую папку не попадает: файлы пишутся только в каталог `run.py`. +Запускатель v2 при старте и перед каждым запуском сверяет `update.json` с sha256 файла `agent.py`: если `agent.py` уже +новая версия, а `update.json` = `staged` (запускатель упал посреди подмены), пробный период продолжается; если +`update.json` = `trial`, а `agent.py` другая версия (прерван откат или файл заменён вручную), пишется `rolled_back` +«пробный период прерван…» и эта версия больше сама не ставится — вернуть её можно публикацией с номером выше. Если +подмена не удалась (`OSError`, например диск полон), текущая версия остаётся, а новая получает `rejected` «подмена не +удалась»: освободить место и опубликовать следующую версию. Временные файлы `*.tmp` удаляются при старте. + Файлы рядом с `agent.py`: `update.json` (состояние: `staged`, `trial`, `ok`, `rolled_back`, `rejected`), `agent.py.prev` (прежняя версия), `run.py.lock`. Не удалять во время работы. @@ -181,9 +189,11 @@ python3 agent.py prune `finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке по сигналу команду подхватит восстановление. -- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 5) отправляется в каждом `claim`, в каждом отчёте +- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 9) отправляется в каждом `claim`, в каждом отчёте (`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`. + Версия 9 — код 73 при занятом журнале, `OSError` при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в + `refresh` (номера 6–8 заняты живой проверкой самообновления). Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды при этом выдаются по-прежнему. @@ -203,9 +213,13 @@ python3 agent.py prune старый процесс (Ctrl-C или `kill `, дождаться «остановлен»), скопировать `agent.py` и `run.py` в каталог агента, `python3 agent.py check`, запустить `python3 run.py` (или `launchctl unload` + `load`). Два процесса с одним журналом работать не могут: хаб довыдаёт команду без отчёта, и второй процесс выполнял бы её одновременно с первым. Начиная с версии 3 второй процесс -не стартует («агент уже запущен…», код 1) — блокировка `journal.jsonl.lock` рядом с журналом. +не стартует («агент уже запущен…», код 73) — блокировка `journal.jsonl.lock` рядом с журналом. `agent.json`, `journal.jsonl` и `update.json` не трогать: журнал переживает обновление, а неподтверждённые отчёты досылаются при старте. +Переход на `run.py` v2: сначала агент должен обновиться до v9 (MCP `agent_status` — «обновление: ok v8 → v9»); затем +`launchctl unload ~/Library/LaunchAgents/ru.aepif.jira-agent.plist`, скопировать `run.py` в `~/jira-agent-bundle`, +`launchctl load …`; `agent_status` покажет «запускатель: v2». + ## Известные ограничения / эксплуатация В порядке важности: diff --git a/src/run.py b/src/run.py index 2ba8b8e..79e95aa 100644 --- a/src/run.py +++ b/src/run.py @@ -19,8 +19,9 @@ import time from datetime import datetime, timezone from typing import List, Optional -LAUNCHER_VERSION = 1 +LAUNCHER_VERSION = 2 EXIT_UPDATE = 75 # агент: «agent.py.new проверен, update.json = staged» +EXIT_LOCKED = 73 # агент: «журнал занят другим процессом» — не сбой версии (design 6) TRIAL_SECONDS = 600 # новая версия должна записать ok за это время KILL_GRACE = 60 # после SIGTERM до SIGKILL BACKOFF_START, BACKOFF_MAX = 5, 300 @@ -41,20 +42,36 @@ def read_state(path: str) -> Optional[dict]: return data if isinstance(data, dict) else None -def write_state(path: str, state: str, base: Optional[dict], error=None) -> None: +def write_state(path: str, state: str, base: Optional[dict], error=None) -> bool: + """Атомарная запись update.json. False — не записано (диск полон): запускатель не падает (design 5).""" 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) + try: + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + os.replace(tmp, path) + except OSError as e: + log("не удалось записать update.json: %s" % e) + return False + return True -def sha256_file(path: str) -> str: - with open(path, "rb") as f: - return hashlib.sha256(f.read()).hexdigest() +def sha256_file(path: str) -> Optional[str]: + try: + with open(path, "rb") as f: + return hashlib.sha256(f.read()).hexdigest() + except OSError: + return None + + +def remove(path: str) -> None: + try: + os.remove(path) + except OSError: + pass def acquire_lock(base_dir: str): @@ -96,34 +113,80 @@ class Launcher: child.send_signal(signal.SIGTERM) # --- файлы версии ---------------------------------------------------------- - def recover_files(self) -> None: + def reconcile(self) -> None: + """Сверка update.json с файлами при старте (design 1): запускатель мог упасть посреди подмены или отката.""" + for tmp in (self.new + ".tmp", self.prev + ".tmp", self.state_path + ".tmp"): + remove(tmp) 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": + st = read_state(self.state_path) + state, sha = (st or {}).get("state"), (st or {}).get("sha256") + if state == "staged": + if sha and sha256_file(self.agent) == sha: # подмена сделана, trial не записан (M-5) + remove(self.new) + write_state(self.state_path, "trial", st) + log("v%s уже на месте — пробный запуск" % st.get("to_version")) + elif sha and os.path.exists(self.new) and sha256_file(self.new) == sha: log("найдена подготовленная версия — ставлю") self.apply_update() else: - os.remove(self.new) + remove(self.new) + write_state(self.state_path, "rejected", st, "подготовленный файл потерян") + log("подготовленная v%s потеряна — отвергнута" % st.get("to_version")) + return + remove(self.new) + if state == "trial" and (not sha or sha256_file(self.agent) != sha): + self.drop_stale_trial(st) + + def in_trial(self) -> bool: + """Пробный период для этого запуска: trial и agent.py — именно эта версия (design 2).""" + st = read_state(self.state_path) + if not (st and st.get("state") == "trial"): + return False + if not st.get("sha256") or sha256_file(self.agent) != st.get("sha256"): + self.drop_stale_trial(st) + return False + return True + + def drop_stale_trial(self, st: dict) -> None: + """agent.py не та версия, что в trial (прерван откат, ручная подмена): пробного периода нет, версию не ставить (M-3).""" + reason = "пробный период прерван: agent.py не v%s" % st.get("to_version") + write_state(self.state_path, "rolled_back", st, reason) + log(reason) 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) + 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) + try: + 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 существует на каждом шаге + except OSError as e: + self.fail_update(st, e) + return + if not write_state(self.state_path, "trial", st): + try: + os.replace(self.prev, self.agent) # без trial новая версия осталась бы без пробного периода + except OSError as e: + log("не удалось вернуть agent.py.prev: %s" % e) + self.fail_update(st, "update.json не записан") + return log("v%s → v%s: пробный запуск" % (st.get("from_version"), st.get("to_version"))) + def fail_update(self, st: dict, err) -> None: + """Подмена не удалась (диск полон, права): текущая версия остаётся, эта — rejected навсегда (design 4).""" + remove(self.prev + ".tmp") + remove(self.new) + write_state(self.state_path, "rejected", st, "подмена не удалась: %s" % err) + log("подмена v%s не удалась: %s" % (st.get("to_version"), err)) + def rollback(self, st: Optional[dict], reason: str) -> bool: if not os.path.exists(self.prev): log("откат невозможен: нет agent.py.prev") @@ -180,13 +243,12 @@ class Launcher: return wait def run(self) -> int: - self.recover_files() + self.reconcile() code = 0 while True: if self.stopping: # сигнал пришёл между запусками — нового агента не запускаем (M-2) return code - st = read_state(self.state_path) - trial = bool(st and st.get("state") == "trial") + trial = self.in_trial() started = time.monotonic() self.interrupted = False # Ctrl-C относится к этому запуску агента self.child = self.spawn() @@ -196,6 +258,12 @@ class Launcher: self.child = None uptime = time.monotonic() - started st = read_state(self.state_path) + ok = bool(st and st.get("state") == "ok") + reason = "агент упал (код %s)" % code + if timed_out and ok: # ответ claim пришёл в последнюю секунду (M-4, design 3) + log("подтверждение пришло в последний момент — отката нет") + self.backoff = BACKOFF_START + continue if timed_out: if self.rollback(st, "не вышла на связь за 10 мин"): continue @@ -207,13 +275,15 @@ class Launcher: if self.interrupted: # Ctrl-C во время подготовки: подмена сделана, trial — при следующем старте return 0 continue - elif trial and not (st and st.get("state") == "ok"): + elif code == EXIT_LOCKED: # журнал держит другой процесс — версия ни при чём (M-7, design 6) + reason = "журнал занят другим процессом (агент уже запущен)" + elif trial and not 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)) + log("%s, перезапуск через %d с" % (reason, wait)) if not self.pause(wait): return code diff --git a/tests/test_launcher.py b/tests/test_launcher.py index c6538d2..6d2f757 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -6,6 +6,7 @@ import subprocess import sys import tempfile import textwrap +import time import unittest from _common import SRC @@ -67,6 +68,21 @@ V6_OK_THEN_STOP = fake("v6", ''' ''') V6_CRASH = fake("v6", 'sys.exit(1)\n') V6_HANG = fake("v6", 'import time\ntime.sleep(60)\n') +V6_SEES_STATE = fake("v6", ''' + mark("seen:" + (state() or {}).get("state", "-")) + write(**dict(state(), state="ok")) + human_stop() +''') +V6_OK_ON_TERM = fake("v6", ''' + import signal, time + if len(open(os.path.join(HERE, "runs.log")).read().split()) > 2: + human_stop() + def term(*a): + write(**dict(state(), state="ok")) # ответ claim пришёл в последнюю секунду пробного периода + sys.exit(0) + signal.signal(signal.SIGTERM, term) + time.sleep(60) +''') class LauncherTest(unittest.TestCase): @@ -106,6 +122,12 @@ class LauncherTest(unittest.TestCase): self.put("update.json", json.dumps({"state": "staged", "from_version": 5, "to_version": to_version, "sha256": sha})) return sha + def sha(self, text): + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + def path(self, name): + return os.path.join(self.dir, name) + def test_update_applied_and_confirmed(self): self.put("agent.py", V5_UPDATES) sha = self.next_version(V6_OK_THEN_STOP) @@ -189,7 +211,7 @@ class LauncherTest(unittest.TestCase): human_stop(130) ''')) self.assertEqual(self.l.run(), 130) - self.assertEqual(self.read("env.txt"), "1 run --journal j.jsonl") + self.assertEqual(self.read("env.txt"), "2 run --journal j.jsonl") def test_sigint_without_child_stops(self): self.l.on_sigint(signal.SIGINT, None) @@ -287,6 +309,149 @@ class LauncherTest(unittest.TestCase): self.assertIn("запускатель уже работает", p.stdout.decode("utf-8")) self.assertFalse(os.path.exists(os.path.join(self.dir, "runs.log"))) + def test_staged_already_replaced_resumes_trial(self): + """M-5: запускатель упал между os.replace(new, agent) и записью trial.""" + self.stage(V6_SEES_STATE) + os.replace(self.path("agent.py.new"), self.path("agent.py")) + self.put("agent.py.prev", fake("v5", "human_stop()\n")) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.runs(), ["v6", "seen:trial"]) # пробный период, а не staged навсегда + self.assertEqual(self.state()["state"], "ok") + + def test_staged_without_file_rejected(self): + self.put("agent.py", fake("v5", "human_stop()\n")) + self.put("update.json", json.dumps({"state": "staged", "from_version": 5, "to_version": 6, "sha256": "a" * 64})) + self.assertEqual(self.l.run(), 0) + st = self.state() + self.assertEqual((st["state"], st["error"]), ("rejected", "подготовленный файл потерян")) + self.assertEqual(self.runs(), ["v5"]) + + def test_stale_trial_marked_rolled_back(self): + """M-3: откат прерван — agent.py уже прежняя версия, update.json всё ещё trial.""" + self.l.trial_seconds = 1 + self.put("agent.py", fake("v8", "import time\ntime.sleep(2)\nhuman_stop()\n")) # дольше пробного периода + self.put("update.json", json.dumps({"state": "trial", "from_version": 8, "to_version": 9, "sha256": "a" * 64})) + self.assertEqual(self.l.run(), 0) + st = self.state() + self.assertEqual((st["state"], st["to_version"], st["error"]), + ("rolled_back", 9, "пробный период прерван: agent.py не v9")) + self.assertEqual(self.runs(), ["v8"]) # не остановлен через пробный период + + def test_trial_without_sha_is_stale(self): + self.put("agent.py", fake("v8", "human_stop()\n")) + self.put("update.json", json.dumps({"state": "trial", "from_version": 8, "to_version": 9})) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.state()["state"], "rolled_back") + + def test_trial_survives_launcher_restart(self): + """M-10 #22: trial при перезапуске run.py — пробный период продолжается, падение даёт откат.""" + self.put("agent.py", V6_CRASH) + self.put("agent.py.prev", fake("v5", "human_stop()\n")) + self.put("update.json", json.dumps({"state": "trial", "from_version": 5, "to_version": 6, "sha256": self.sha(V6_CRASH)})) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.runs(), ["v6", "v5"]) + self.assertEqual(self.state()["state"], "rolled_back") + + def test_ok_at_last_moment_no_rollback(self): + """M-4: пробный период истёк, но агент записал ok, получив SIGTERM.""" + self.l.trial_seconds = 1 + self.l.kill_grace = 5 + self.put("agent.py", V5_UPDATES) + self.next_version(V6_OK_ON_TERM) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.read("agent.py"), V6_OK_ON_TERM) + self.assertEqual(self.state()["state"], "ok") + self.assertEqual(self.runs(), ["v5", "v6", "v6"]) + self.assertEqual(self.pauses, []) # перезапуск без паузы + + def test_locked_in_trial_no_rollback(self): + """M-7: журнал держит осиротевший агент — код 73 не откатывает новую версию.""" + v6 = fake("v6", ''' + n = len(open(os.path.join(HERE, "runs.log")).read().split()) + human_stop() if n > 1 else sys.exit(73) + ''') + self.put("agent.py", v6) + self.put("agent.py.prev", fake("v5", "human_stop()\n")) + self.put("update.json", json.dumps({"state": "trial", "from_version": 5, "to_version": 6, "sha256": self.sha(v6)})) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.runs(), ["v6", "v6"]) + self.assertEqual(self.pauses, [5]) + self.assertEqual(self.read("agent.py"), v6) + self.assertEqual(self.state()["state"], "trial") + + def test_locked_outside_trial_backs_off(self): + self.put("agent.py", fake("v6", ''' + n = len(open(os.path.join(HERE, "runs.log")).read().split()) + human_stop() if n > 3 else sys.exit(73) + ''')) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.pauses, [5, 10, 20]) + + def test_os_error_before_replace_rejected(self): + """M-6: копия в agent.py.prev.tmp не удалась (здесь — на месте файла каталог).""" + self.put("agent.py", V5_UPDATES) + self.next_version(V6_OK_THEN_STOP) + os.mkdir(self.path("agent.py.prev.tmp")) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.read("agent.py"), V5_UPDATES) + st = self.state() + self.assertEqual(st["state"], "rejected") + self.assertTrue(st["error"].startswith("подмена не удалась: "), st["error"]) + self.assertFalse(os.path.exists(self.path("agent.py.new"))) + self.assertEqual(self.runs(), ["v5", "v5"]) + + def test_trial_not_written_reverts(self): + orig = run.write_state + def flaky(path, state, base, error=None): + return False if state == "trial" else orig(path, state, base, error) + run.write_state = flaky + self.addCleanup(setattr, run, "write_state", orig) + self.put("agent.py", V5_UPDATES) + self.next_version(V6_OK_THEN_STOP) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.read("agent.py"), V5_UPDATES) # agent.py.prev вернулся на место + st = self.state() + self.assertEqual((st["state"], st["error"]), ("rejected", "подмена не удалась: update.json не записан")) + self.assertEqual(self.runs(), ["v5", "v5"]) + + def test_write_state_error_does_not_raise(self): + self.assertFalse(run.write_state(self.path("нет/update.json"), "ok", {})) + + def test_sha256_file_unreadable_is_none(self): + self.assertIsNone(run.sha256_file(self.path("нет-файла"))) + + def test_tmp_files_removed_at_start(self): + self.put("agent.py", fake("v5", "human_stop()\n")) + for name in ("agent.py.new.tmp", "agent.py.prev.tmp", "update.json.tmp"): + self.put(name, "остаток") + self.assertEqual(self.l.run(), 0) + for name in ("agent.py.new.tmp", "agent.py.prev.tmp", "update.json.tmp"): + self.assertFalse(os.path.exists(self.path(name)), name) + + def test_sigterm_forwarded_and_no_restart(self): + """M-10 #22: on_sigterm — пересылка агенту, нового запуска нет.""" + import threading + self.put("agent.py", fake("v5", ''' + import signal, time + def term(*a): + mark("term") + sys.exit(0) + signal.signal(signal.SIGTERM, term) + open(os.path.join(HERE, "ready"), "w").close() + time.sleep(30) + ''')) + def send(): + for _ in range(300): + if os.path.exists(self.path("ready")): + break + time.sleep(0.05) + self.l.on_sigterm(signal.SIGTERM, None) + t = threading.Thread(target=send) + t.start() + self.assertEqual(self.l.run(), 0) + t.join() + self.assertEqual(self.runs(), ["v5", "term"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_self_update_e2e.py b/tests/test_self_update_e2e.py index b0d3e68..6d1c553 100644 --- a/tests/test_self_update_e2e.py +++ b/tests/test_self_update_e2e.py @@ -77,6 +77,6 @@ class SelfUpdateE2E(unittest.TestCase): self.assertTrue(os.path.exists(os.path.join(self.tmp.name, "agent.py.prev"))) self.assertTrue(wait_for(lambda: any(b.get("agent_version") == self.cur + 1 for b in self.hub.claim_bodies))) last = [b for b in self.hub.claim_bodies if b.get("agent_version") == self.cur + 1][-1] - self.assertEqual(last["launcher_version"], 1) + self.assertEqual(last["launcher_version"], 2) os.killpg(self.proc.pid, signal.SIGINT) # Ctrl-C в терминале — всей группе self.assertEqual(self.proc.wait(30), 0)