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:
parent
866f9d7b4d
commit
20b858c984
@ -78,7 +78,8 @@ nohup python3 -u run.py > agent.log 2>&1 &
|
|||||||
`python3 agent.py` без запускателя работает, но тогда агент себя не обновляет и раз в час пишет «доступна v<N>; для
|
`python3 agent.py` без запускателя работает, но тогда агент себя не обновляет и раз в час пишет «доступна v<N>; для
|
||||||
самообновления запускайте через run.py».
|
самообновления запускайте через run.py».
|
||||||
|
|
||||||
Остановка: Ctrl-C или `kill <pid>`. Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит.
|
Остановка: Ctrl-C в терминале или `kill <pid run.py>` (не pid агента: агент, вышедший без сигнала запускателю,
|
||||||
|
считается упавшим и перезапускается). Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит.
|
||||||
Повторный сигнал — немедленный выход (код 130). Остановка во время паузы перед командой безопасна: команда уже
|
Повторный сигнал — немедленный выход (код 130). Остановка во время паузы перед командой безопасна: команда уже
|
||||||
записана в журнал как `claimed`, при следующем запуске агент запрашивает её у хаба и выполняет через проверку, выдержав паузу темпа от последней записи журнала.
|
записана в журнал как `claimed`, при следующем запуске агент запрашивает её у хаба и выполняет через проверку, выдержав паузу темпа от последней записи журнала.
|
||||||
|
|
||||||
|
|||||||
@ -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` обновляется вручную | нет |
|
||||||
|
|||||||
@ -1295,7 +1295,8 @@ class Runner:
|
|||||||
self.execute(cmd)
|
self.execute(cmd)
|
||||||
continue
|
continue
|
||||||
if self.updater and not self.stop and self.updater.check(resp.get("agent_release")):
|
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
|
return
|
||||||
idle += 1
|
idle += 1
|
||||||
if self.idle_limit is not None and idle >= self.idle_limit:
|
if self.idle_limit is not None and idle >= self.idle_limit:
|
||||||
|
|||||||
23
src/run.py
23
src/run.py
@ -25,6 +25,7 @@ TRIAL_SECONDS = 600 # новая версия должна запи
|
|||||||
KILL_GRACE = 60 # после SIGTERM до SIGKILL
|
KILL_GRACE = 60 # после SIGTERM до SIGKILL
|
||||||
BACKOFF_START, BACKOFF_MAX = 5, 300
|
BACKOFF_START, BACKOFF_MAX = 5, 300
|
||||||
HEALTHY_UPTIME = 600 # проработал столько — пауза перезапуска с начала
|
HEALTHY_UPTIME = 600 # проработал столько — пауза перезапуска с начала
|
||||||
|
STOP_CODES = (0, 130, -signal.SIGINT) # выход агента после Ctrl-C (-SIGINT — Ctrl-C до установки его обработчиков)
|
||||||
|
|
||||||
|
|
||||||
def log(msg: str) -> None:
|
def log(msg: str) -> None:
|
||||||
@ -77,11 +78,14 @@ class Launcher:
|
|||||||
self.trial_seconds, self.kill_grace = TRIAL_SECONDS, KILL_GRACE
|
self.trial_seconds, self.kill_grace = TRIAL_SECONDS, KILL_GRACE
|
||||||
self.backoff = BACKOFF_START
|
self.backoff = BACKOFF_START
|
||||||
self.stopping = False
|
self.stopping = False
|
||||||
|
self.interrupted = False # был Ctrl-C: выход агента с 0/130 — остановка человеком, а не сбой версии
|
||||||
self.child = None
|
self.child = None
|
||||||
|
|
||||||
# --- сигналы ---------------------------------------------------------------
|
# --- сигналы ---------------------------------------------------------------
|
||||||
def on_sigint(self, signum, frame):
|
def on_sigint(self, signum, frame):
|
||||||
"""Ctrl-C в терминале приходит и агенту (общая группа): пока агент жив — решает он. Без агента (пауза) — выход."""
|
"""Ctrl-C в терминале приходит и агенту (общая группа): пока агент жив — решает он, запускатель только запоминает,
|
||||||
|
что человек остановил (I-1). Без агента (пауза) — выход."""
|
||||||
|
self.interrupted = True
|
||||||
if self.child is None:
|
if self.child is None:
|
||||||
self.stopping = True
|
self.stopping = True
|
||||||
|
|
||||||
@ -161,8 +165,11 @@ class Launcher:
|
|||||||
def pause(self, seconds: float) -> bool:
|
def pause(self, seconds: float) -> bool:
|
||||||
"""Пауза перед перезапуском; False — остановлены во время паузы."""
|
"""Пауза перед перезапуском; False — остановлены во время паузы."""
|
||||||
end = time.monotonic() + seconds
|
end = time.monotonic() + seconds
|
||||||
while not self.stopping and time.monotonic() < end:
|
while not self.stopping:
|
||||||
time.sleep(min(1.0, end - time.monotonic()))
|
left = end - time.monotonic() # одно чтение часов: остаток не уйдёт в минус между проверкой и sleep (M-1)
|
||||||
|
if left <= 0:
|
||||||
|
break
|
||||||
|
time.sleep(min(1.0, left))
|
||||||
return not self.stopping
|
return not self.stopping
|
||||||
|
|
||||||
def next_backoff(self, uptime: float) -> int:
|
def next_backoff(self, uptime: float) -> int:
|
||||||
@ -174,11 +181,17 @@ class Launcher:
|
|||||||
|
|
||||||
def run(self) -> int:
|
def run(self) -> int:
|
||||||
self.recover_files()
|
self.recover_files()
|
||||||
|
code = 0
|
||||||
while True:
|
while True:
|
||||||
|
if self.stopping: # сигнал пришёл между запусками — нового агента не запускаем (M-2)
|
||||||
|
return code
|
||||||
st = read_state(self.state_path)
|
st = read_state(self.state_path)
|
||||||
trial = bool(st and st.get("state") == "trial")
|
trial = bool(st and st.get("state") == "trial")
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
|
self.interrupted = False # Ctrl-C относится к этому запуску агента
|
||||||
self.child = self.spawn()
|
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)
|
code, timed_out = self.wait_child(self.child, trial)
|
||||||
self.child = None
|
self.child = None
|
||||||
uptime = time.monotonic() - started
|
uptime = time.monotonic() - started
|
||||||
@ -186,11 +199,13 @@ class Launcher:
|
|||||||
if timed_out:
|
if timed_out:
|
||||||
if self.rollback(st, "не вышла на связь за 10 мин"):
|
if self.rollback(st, "не вышла на связь за 10 мин"):
|
||||||
continue
|
continue
|
||||||
elif code in (0, 130) or self.stopping:
|
elif self.stopping or (self.interrupted and code in STOP_CODES):
|
||||||
return code
|
return code
|
||||||
elif code == EXIT_UPDATE:
|
elif code == EXIT_UPDATE:
|
||||||
self.apply_update()
|
self.apply_update()
|
||||||
self.backoff = BACKOFF_START
|
self.backoff = BACKOFF_START
|
||||||
|
if self.interrupted: # Ctrl-C во время подготовки: подмена сделана, trial — при следующем старте
|
||||||
|
return 0
|
||||||
continue
|
continue
|
||||||
elif trial and not (st and st.get("state") == "ok"):
|
elif trial and not (st and st.get("state") == "ok"):
|
||||||
if self.rollback(st, "код %s через %d с" % (code, uptime)):
|
if self.rollback(st, "код %s через %d с" % (code, uptime)):
|
||||||
|
|||||||
@ -28,6 +28,11 @@ STATE_HELPERS = textwrap.dedent('''
|
|||||||
json.dump(kw, open(STATE, "w", encoding="utf-8"))
|
json.dump(kw, open(STATE, "w", encoding="utf-8"))
|
||||||
def mark(name):
|
def mark(name):
|
||||||
open(os.path.join(HERE, "runs.log"), "a").write(name + "\\n")
|
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)
|
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())
|
write(state="staged", from_version=5, to_version=6, sha256=hashlib.sha256(data).hexdigest())
|
||||||
sys.exit(75)
|
sys.exit(75)
|
||||||
sys.exit(0)
|
human_stop()
|
||||||
''')
|
''')
|
||||||
# v5 с подменённым после проверки файлом: sha в staged не сходится с agent.py.new.
|
# v5 с подменённым после проверки файлом: sha в staged не сходится с agent.py.new.
|
||||||
V5_BAD_STAGE = fake("v5", '''
|
V5_BAD_STAGE = fake("v5", '''
|
||||||
@ -53,12 +58,12 @@ V5_BAD_STAGE = fake("v5", '''
|
|||||||
open(os.path.join(HERE, "agent.py.new"), "w").write("мусор")
|
open(os.path.join(HERE, "agent.py.new"), "w").write("мусор")
|
||||||
write(state="staged", from_version=5, to_version=6, sha256="чужой")
|
write(state="staged", from_version=5, to_version=6, sha256="чужой")
|
||||||
sys.exit(75)
|
sys.exit(75)
|
||||||
sys.exit(0)
|
human_stop()
|
||||||
''')
|
''')
|
||||||
V6_OK_THEN_STOP = fake("v6", '''
|
V6_OK_THEN_STOP = fake("v6", '''
|
||||||
st = state()
|
st = state()
|
||||||
write(**dict(st, state="ok"))
|
write(**dict(st, state="ok"))
|
||||||
sys.exit(0)
|
human_stop()
|
||||||
''')
|
''')
|
||||||
V6_CRASH = fake("v6", 'sys.exit(1)\n')
|
V6_CRASH = fake("v6", 'sys.exit(1)\n')
|
||||||
V6_HANG = fake("v6", 'import time\ntime.sleep(60)\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.l = run.Launcher(self.dir, ["--journal", "j.jsonl"])
|
||||||
self.pauses = []
|
self.pauses = []
|
||||||
self.l.pause = lambda s: (self.pauses.append(s), True)[1]
|
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):
|
def tearDown(self):
|
||||||
|
signal.signal(signal.SIGINT, self.old_sigint)
|
||||||
self.tmp.cleanup()
|
self.tmp.cleanup()
|
||||||
|
|
||||||
def put(self, name, text):
|
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("update.json", json.dumps({"state": "ok", "from_version": 5, "to_version": 6, "sha256": "s"}))
|
||||||
self.put("agent.py", fake("v6", '''
|
self.put("agent.py", fake("v6", '''
|
||||||
n = len(open(os.path.join(HERE, "runs.log")).read().split())
|
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.l.run(), 0)
|
||||||
self.assertEqual(self.pauses, [5, 10])
|
self.assertEqual(self.pauses, [5, 10])
|
||||||
@ -154,32 +161,32 @@ class LauncherTest(unittest.TestCase):
|
|||||||
self.assertEqual((st["state"], st["error"]), ("rejected", "файл изменился после проверки"))
|
self.assertEqual((st["state"], st["error"]), ("rejected", "файл изменился после проверки"))
|
||||||
|
|
||||||
def test_staged_at_start_is_applied(self):
|
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.stage(V6_OK_THEN_STOP)
|
||||||
self.assertEqual(self.l.run(), 0)
|
self.assertEqual(self.l.run(), 0)
|
||||||
self.assertEqual(self.runs(), ["v6"])
|
self.assertEqual(self.runs(), ["v6"])
|
||||||
self.assertEqual(self.state()["state"], "ok")
|
self.assertEqual(self.state()["state"], "ok")
|
||||||
|
|
||||||
def test_stray_new_removed_at_start(self):
|
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.put("agent.py.new", "остаток")
|
||||||
self.assertEqual(self.l.run(), 0)
|
self.assertEqual(self.l.run(), 0)
|
||||||
self.assertFalse(os.path.exists(os.path.join(self.dir, "agent.py.new")))
|
self.assertFalse(os.path.exists(os.path.join(self.dir, "agent.py.new")))
|
||||||
|
|
||||||
def test_missing_agent_restored_from_prev(self):
|
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.l.run(), 0)
|
||||||
self.assertEqual(self.runs(), ["v5"])
|
self.assertEqual(self.runs(), ["v5"])
|
||||||
|
|
||||||
def test_garbage_update_json_is_no_state(self):
|
def test_garbage_update_json_is_no_state(self):
|
||||||
self.put("update.json", '{"state": "tri')
|
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)
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
|
||||||
def test_env_and_args_passed(self):
|
def test_env_and_args_passed(self):
|
||||||
self.put("agent.py", fake("v5", '''
|
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:]))
|
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.l.run(), 130)
|
||||||
self.assertEqual(self.read("env.txt"), "1 run --journal j.jsonl")
|
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.child = object()
|
||||||
self.l.on_sigint(signal.SIGINT, None)
|
self.l.on_sigint(signal.SIGINT, None)
|
||||||
self.assertFalse(self.l.stopping)
|
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):
|
def test_second_launcher_refused(self):
|
||||||
with open(os.path.join(SRC, "run.py"), encoding="utf-8") as f:
|
with open(os.path.join(SRC, "run.py"), encoding="utf-8") as f:
|
||||||
|
|||||||
@ -76,6 +76,17 @@ class RunnerTest(unittest.TestCase):
|
|||||||
self.assertEqual(self.runner.exit_code, agent.EXIT_UPDATE)
|
self.assertEqual(self.runner.exit_code, agent.EXIT_UPDATE)
|
||||||
self.assertEqual(self.hub.claims, 1)
|
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):
|
def test_command_first_then_update(self):
|
||||||
self.hub.release = {"version": 6, "sha256": "s", "content": "x"}
|
self.hub.release = {"version": 6, "sha256": "s", "content": "x"}
|
||||||
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
|
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user