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:
parent
ba0fd2a299
commit
6b3109863d
25
src/run.py
25
src/run.py
@ -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("подтверждение пришло в последний момент — отката нет")
|
||||
|
||||
@ -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", '''
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user