diff --git a/README.md b/README.md index f05b8e1..01ddc17 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ nohup python3 -u run.py > agent.log 2>&1 & `python3 agent.py` без запускателя работает, но тогда агент себя не обновляет и раз в час пишет «доступна v; для самообновления запускайте через run.py». -Остановка: Ctrl-C или `kill `. Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит. +Остановка: Ctrl-C в терминале или `kill ` (не pid агента: агент, вышедший без сигнала запускателю, +считается упавшим и перезапускается). Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит. Повторный сигнал — немедленный выход (код 130). Остановка во время паузы перед командой безопасна: команда уже записана в журнал как `claimed`, при следующем запуске агент запрашивает её у хаба и выполняет через проверку, выдержав паузу темпа от последней записи журнала. diff --git a/openspec/changes/agent-self-update/design.md b/openspec/changes/agent-self-update/design.md index 206f73c..faf06d1 100644 --- a/openspec/changes/agent-self-update/design.md +++ b/openspec/changes/agent-self-update/design.md @@ -14,3 +14,6 @@ | Маркер | Дата | Решение | Обоснование | Цена ошибки | |---|---|---|---|---| +| R-19 | 2026-10-14 | `test_version_is_4` → `test_version_is_5` | план не учёл старый тест версии; спека требует v5 | нет | +| R-20 (design #16, #17 хаба) | 2026-10-14 | Выход 0/130 — остановка человеком, только если запускатель сам получил SIGINT/SIGTERM; иначе падение (в `trial` — откат); Ctrl-C при выходе с 75 — подмена и выход; агент, получивший Ctrl-C при подготовке, выходит с 0 | финальное ревью I-1: версия, сама вышедшая с 0, останавливала мост без отката | `kill ` перезапускает агента — останавливать `run.py` | +| R-21 | 2026-10-14 | M-1 финального ревью (отрицательный `sleep` в `pause`) повышена до Important и исправлена | падение запускателя останавливает мост до человека, `run.py` обновляется вручную | нет | diff --git a/src/agent.py b/src/agent.py index 1669b30..3fabb69 100644 --- a/src/agent.py +++ b/src/agent.py @@ -1295,7 +1295,8 @@ class Runner: self.execute(cmd) continue if self.updater and not self.stop and self.updater.check(resp.get("agent_release")): - self.exit_code = EXIT_UPDATE + if not self.stop: # Ctrl-C во время подготовки — обычная остановка; staged доделает run.py + self.exit_code = EXIT_UPDATE return idle += 1 if self.idle_limit is not None and idle >= self.idle_limit: diff --git a/src/run.py b/src/run.py index 70e8ceb..2ba8b8e 100644 --- a/src/run.py +++ b/src/run.py @@ -25,6 +25,7 @@ TRIAL_SECONDS = 600 # новая версия должна запи KILL_GRACE = 60 # после SIGTERM до SIGKILL BACKOFF_START, BACKOFF_MAX = 5, 300 HEALTHY_UPTIME = 600 # проработал столько — пауза перезапуска с начала +STOP_CODES = (0, 130, -signal.SIGINT) # выход агента после Ctrl-C (-SIGINT — Ctrl-C до установки его обработчиков) def log(msg: str) -> None: @@ -77,11 +78,14 @@ class Launcher: self.trial_seconds, self.kill_grace = TRIAL_SECONDS, KILL_GRACE self.backoff = BACKOFF_START self.stopping = False + self.interrupted = False # был Ctrl-C: выход агента с 0/130 — остановка человеком, а не сбой версии self.child = None # --- сигналы --------------------------------------------------------------- def on_sigint(self, signum, frame): - """Ctrl-C в терминале приходит и агенту (общая группа): пока агент жив — решает он. Без агента (пауза) — выход.""" + """Ctrl-C в терминале приходит и агенту (общая группа): пока агент жив — решает он, запускатель только запоминает, + что человек остановил (I-1). Без агента (пауза) — выход.""" + self.interrupted = True if self.child is None: self.stopping = True @@ -161,8 +165,11 @@ class Launcher: 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())) + while not self.stopping: + left = end - time.monotonic() # одно чтение часов: остаток не уйдёт в минус между проверкой и sleep (M-1) + if left <= 0: + break + time.sleep(min(1.0, left)) return not self.stopping def next_backoff(self, uptime: float) -> int: @@ -174,11 +181,17 @@ class Launcher: def run(self) -> int: self.recover_files() + 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") started = time.monotonic() + self.interrupted = False # Ctrl-C относится к этому запуску агента self.child = self.spawn() + if self.stopping: # SIGTERM между Popen и присваиванием child до агента не дошёл + self.child.send_signal(signal.SIGTERM) code, timed_out = self.wait_child(self.child, trial) self.child = None uptime = time.monotonic() - started @@ -186,11 +199,13 @@ class Launcher: if timed_out: if self.rollback(st, "не вышла на связь за 10 мин"): continue - elif code in (0, 130) or self.stopping: + elif self.stopping or (self.interrupted and code in STOP_CODES): return code elif code == EXIT_UPDATE: self.apply_update() self.backoff = BACKOFF_START + if self.interrupted: # Ctrl-C во время подготовки: подмена сделана, trial — при следующем старте + return 0 continue elif trial and not (st and st.get("state") == "ok"): if self.rollback(st, "код %s через %d с" % (code, uptime)): diff --git a/tests/test_launcher.py b/tests/test_launcher.py index bb4b995..c6538d2 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -28,6 +28,11 @@ STATE_HELPERS = textwrap.dedent(''' json.dump(kw, open(STATE, "w", encoding="utf-8")) def mark(name): open(os.path.join(HERE, "runs.log"), "a").write(name + "\\n") + def human_stop(code=0): + # Ctrl-C в терминале получает вся группа: запускатель тоже видит SIGINT, затем агент выходит + import signal + os.kill(os.getppid(), signal.SIGINT) + sys.exit(code) ''') @@ -44,7 +49,7 @@ V5_UPDATES = fake("v5", ''' 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) + human_stop() ''') # v5 с подменённым после проверки файлом: sha в staged не сходится с agent.py.new. V5_BAD_STAGE = fake("v5", ''' @@ -53,12 +58,12 @@ V5_BAD_STAGE = fake("v5", ''' 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) + human_stop() ''') V6_OK_THEN_STOP = fake("v6", ''' st = state() write(**dict(st, state="ok")) - sys.exit(0) + human_stop() ''') V6_CRASH = fake("v6", 'sys.exit(1)\n') V6_HANG = fake("v6", 'import time\ntime.sleep(60)\n') @@ -71,8 +76,10 @@ class LauncherTest(unittest.TestCase): self.l = run.Launcher(self.dir, ["--journal", "j.jsonl"]) self.pauses = [] self.l.pause = lambda s: (self.pauses.append(s), True)[1] + self.old_sigint = signal.signal(signal.SIGINT, self.l.on_sigint) # как в run.main: Ctrl-C видит и запускатель def tearDown(self): + signal.signal(signal.SIGINT, self.old_sigint) self.tmp.cleanup() def put(self, name, text): @@ -134,7 +141,7 @@ class LauncherTest(unittest.TestCase): 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) + human_stop() if n > 2 else sys.exit(1) ''')) self.assertEqual(self.l.run(), 0) self.assertEqual(self.pauses, [5, 10]) @@ -154,32 +161,32 @@ class LauncherTest(unittest.TestCase): 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.put("agent.py", fake("v5", "human_stop()\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", fake("v5", "human_stop()\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.put("agent.py.prev", fake("v5", "human_stop()\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.put("agent.py", fake("v5", "human_stop()\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) + human_stop(130) ''')) self.assertEqual(self.l.run(), 130) self.assertEqual(self.read("env.txt"), "1 run --journal j.jsonl") @@ -192,6 +199,79 @@ class LauncherTest(unittest.TestCase): self.l.child = object() self.l.on_sigint(signal.SIGINT, None) self.assertFalse(self.l.stopping) + self.assertTrue(self.l.interrupted) # выход агента с 0 — остановка человеком + + def test_exit_0_without_signal_in_trial_rolls_back(self): + self.put("agent.py", V5_UPDATES) + self.next_version(fake("v6", "sys.exit(0)\n")) # сама вышла, никто не нажимал Ctrl-C (I-1) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.read("agent.py"), V5_UPDATES) + st = self.state() + self.assertEqual(st["state"], "rolled_back") + self.assertRegex(st["error"], r"^код 0 через \d+ с$") + self.assertEqual(self.runs(), ["v5", "v6", "v5"]) + + def test_exit_0_without_signal_after_ok_restarts(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()) + human_stop() if n > 1 else sys.exit(0) + ''')) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.pauses, [5]) # выход 0 без сигнала — как падение + self.assertEqual(self.runs(), ["v6", "v6"]) + + def test_ctrl_c_with_75_applies_and_stops(self): + self.put("next.py", V6_OK_THEN_STOP) + self.put("agent.py", fake("v5", ''' + import hashlib, signal + 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()) + os.kill(os.getppid(), signal.SIGINT) # Ctrl-C пришёл, когда версия уже подготовлена + sys.exit(75) + ''')) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.read("agent.py"), V6_OK_THEN_STOP) # подмена сделана, новая не запущена + self.assertEqual(self.state()["state"], "trial") # пробный период начнётся при старте + self.assertEqual(self.runs(), ["v5"]) + + def test_stale_interrupt_does_not_hide_exit_0(self): + self.put("update.json", json.dumps({"state": "ok", "from_version": 5, "to_version": 6, "sha256": "s"})) + self.put("agent.py", fake("v6", ''' + import signal + n = len(open(os.path.join(HERE, "runs.log")).read().split()) + if n == 1: + os.kill(os.getppid(), signal.SIGINT) # Ctrl-C до установки обработчиков агента + sys.exit(1) + human_stop() if n > 2 else sys.exit(0) + ''')) + self.l.stopping = False + orig = self.l.on_sigint + def sigint_child_alive(signum, frame): # агент жив — запускатель только запоминает Ctrl-C + self.l.child = self.l.child or object() + orig(signum, frame) + signal.signal(signal.SIGINT, sigint_child_alive) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.runs(), ["v6", "v6", "v6"]) # второй выход 0 без сигнала — перезапуск + self.assertEqual(len(self.pauses), 2) + + def test_stop_before_spawn(self): + self.put("agent.py", fake("v5", "human_stop()\n")) + self.l.stopping = True # SIGTERM пришёл между запусками (M-2) + self.assertEqual(self.l.run(), 0) + self.assertFalse(os.path.exists(os.path.join(self.dir, "runs.log"))) + + def test_pause_never_sleeps_negative(self): + clock = iter([0.0, 0.5, 1.5, 1.6]) # остаток перешёл через ноль между вызовами (M-1) + slept = [] + orig_m, orig_s = run.time.monotonic, run.time.sleep + run.time.monotonic, run.time.sleep = (lambda: next(clock)), slept.append + try: + self.assertTrue(run.Launcher.pause(self.l, 1.0)) + finally: + run.time.monotonic, run.time.sleep = orig_m, orig_s + self.assertTrue(all(x >= 0 for x in slept), slept) def test_second_launcher_refused(self): with open(os.path.join(SRC, "run.py"), encoding="utf-8") as f: diff --git a/tests/test_runner.py b/tests/test_runner.py index c573423..1c43343 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -76,6 +76,17 @@ class RunnerTest(unittest.TestCase): self.assertEqual(self.runner.exit_code, agent.EXIT_UPDATE) self.assertEqual(self.hub.claims, 1) + def test_stop_during_check_exits_0(self): + self.hub.release = {"version": 6, "sha256": "s", "content": "x"} + self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1) + def check(r): + self.runner.stop = True # Ctrl-C пришёл, когда версия уже staged + return True + self.runner.updater.check = check + self.runner.idle_limit = 5 + self.runner.loop() + self.assertEqual(self.runner.exit_code, 0) # остановка человеком; staged доделает run.py при старте + 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"})