fix(agent): run.py — выход 0 без Ctrl-C как падение, сигнал перед запуском, pause без отрицательного sleep (I-1, M-1, M-2, #19)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 22:40:59 +04:00
parent 866f9d7b4d
commit 20b858c984
6 changed files with 126 additions and 15 deletions

View File

@ -78,7 +78,8 @@ nohup python3 -u run.py > agent.log 2>&1 &
`python3 agent.py` без запускателя работает, но тогда агент себя не обновляет и раз в час пишет «доступна v<N>; для
самообновления запускайте через run.py».
Остановка: Ctrl-C или `kill <pid>`. Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит.
Остановка: Ctrl-C в терминале или `kill <pid run.py>` (не pid агента: агент, вышедший без сигнала запускателю,
считается упавшим и перезапускается). Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит.
Повторный сигнал — немедленный выход (код 130). Остановка во время паузы перед командой безопасна: команда уже
записана в журнал как `claimed`, при следующем запуске агент запрашивает её у хаба и выполняет через проверку, выдержав паузу темпа от последней записи журнала.

View File

@ -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 <pid агента>` перезапускает агента — останавливать `run.py` |
| R-21 | 2026-10-14 | M-1 финального ревью (отрицательный `sleep` в `pause`) повышена до Important и исправлена | падение запускателя останавливает мост до человека, `run.py` обновляется вручную | нет |

View File

@ -1295,6 +1295,7 @@ class Runner:
self.execute(cmd)
continue
if self.updater and not self.stop and self.updater.check(resp.get("agent_release")):
if not self.stop: # Ctrl-C во время подготовки — обычная остановка; staged доделает run.py
self.exit_code = EXIT_UPDATE
return
idle += 1

View File

@ -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)):

View File

@ -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:

View File

@ -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"})