fix(run): подтверждённая версия не откатывается после записей агента в update.json (I-1)

wait_child запоминает подтверждение (любое состояние update.json, кроме trial этой версии, пока агент жив),
а итоговое решение учитывает и случай, когда ok и следующая запись уложились между опросами.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 00:36:03 +04:00
parent ba0fd2a299
commit 6b3109863d
2 changed files with 49 additions and 7 deletions

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", '''