Агент v9 и run.py v2: устойчивость самообновления, диагностика, фейки (#23) #24

Merged
aep merged 14 commits from feat/23-agent-v9 into main 2026-10-14 23:51:05 +03:00
2 changed files with 49 additions and 7 deletions
Showing only changes of commit 6b3109863d - Show all commits

View File

@ -210,20 +210,26 @@ class Launcher:
child.wait()
def wait_child(self, child, trial: bool):
"""Код выхода и признак «пробный период истёк». В пробный период ждём ok не дольше trial_seconds."""
"""(код выхода, «пробный период истёк», «версия подтверждена»). В пробный период ждём ok не дольше trial_seconds.
Подтверждение — любое состояние update.json, кроме trial этой же версии, пока агент жив: запускатель сам
update.json при живом агенте не пишет, значит это сделал подтвердивший агент (ok, затем rejected/staged для
следующей версии — I-1)."""
start = time.monotonic()
st0 = read_state(self.state_path) if trial else None
trial_sha = (st0 or {}).get("sha256")
confirmed = False
while True:
try:
return child.wait(1), False
return child.wait(1), False, confirmed
except subprocess.TimeoutExpired:
pass
if trial:
if trial and not confirmed:
st = read_state(self.state_path)
if st and st.get("state") == "ok":
trial = False
if st and not (st.get("state") == "trial" and st.get("sha256") == trial_sha):
confirmed = True
elif time.monotonic() - start >= self.trial_seconds:
self.terminate(child)
return child.returncode, True
return child.returncode, True, False
def pause(self, seconds: float) -> bool:
"""Пауза перед перезапуском; False — остановлены во время паузы."""
@ -249,16 +255,21 @@ class Launcher:
if self.stopping: # сигнал пришёл между запусками — нового агента не запускаем (M-2)
return code
trial = self.in_trial()
trial_sha = (read_state(self.state_path) or {}).get("sha256") if trial else None
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)
code, timed_out, confirmed = self.wait_child(self.child, trial)
self.child = None
uptime = time.monotonic() - started
st = read_state(self.state_path)
ok = bool(st and st.get("state") == "ok")
if trial and not (ok or confirmed) and st and not (st.get("state") == "trial" and st.get("sha256") == trial_sha) \
and sha256_file(self.agent) == trial_sha:
confirmed = True # ok и следующая запись агента случились между опросами (I-1)
ok = ok or confirmed
reason = "агент упал (код %s)" % code
if timed_out and ok: # ответ claim пришёл в последнюю секунду (M-4, design 3)
log("подтверждение пришло в последний момент — отката нет")

View File

@ -85,6 +85,20 @@ V6_OK_ON_TERM = fake("v6", '''
''')
# v6 подтвердился (ok), через delay с записал rejected для v10 с другим sha (как Updater._reject) и упал с кодом 1.
V6_OK_THEN_REJECT_NEXT = fake("v6", '''
import time
n = len(open(os.path.join(HERE, "runs.log")).read().split())
if n > 2:
human_stop()
st = state()
write(**dict(st, state="ok"))
time.sleep(DELAY)
write(state="rejected", from_version=6, to_version=10, sha256="чужой", error="отвергнута")
sys.exit(1)
''')
class LauncherTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
@ -364,6 +378,23 @@ class LauncherTest(unittest.TestCase):
self.assertEqual(self.runs(), ["v5", "v6", "v6"])
self.assertEqual(self.pauses, []) # перезапуск без паузы
def _confirmed_then_rejected_next(self, delay):
self.put("agent.py", V5_UPDATES)
self.next_version(V6_OK_THEN_REJECT_NEXT.replace("DELAY", repr(delay)))
self.assertEqual(self.l.run(), 0)
self.assertEqual(self.read("agent.py"), V6_OK_THEN_REJECT_NEXT.replace("DELAY", repr(delay)))
self.assertEqual(self.runs(), ["v5", "v6", "v6"])
st = self.state()
self.assertEqual((st["state"], st["to_version"]), ("rejected", 10)) # не rolled_back: v10 не заблокирована
def test_confirmed_then_next_rejected_no_rollback(self):
"""I-1: агент записал ok, потом сам перезаписал update.json (rejected для v10) и упал — v6 здоровая, откат не нужен."""
self._confirmed_then_rejected_next(2.5)
def test_confirmed_and_rejected_within_poll_no_rollback(self):
"""I-1: ok и rejected записаны быстрее, чем запускатель опрашивает файл раз в секунду."""
self._confirmed_then_rejected_next(0)
def test_locked_in_trial_no_rollback(self):
"""M-7: журнал держит осиротевший агент — код 73 не откатывает новую версию."""
v6 = fake("v6", '''