feat(agent): run.py v2 — сверка update.json с файлом, ok в последний момент, код 73, OSError при подмене (M-3…M-7, M-10 #22)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fa936ba716
commit
69f9ffe690
@ -23,6 +23,10 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл
|
||||
падение в `trial` или 10 минут без `ok` — `rollback`; иначе перезапуск с паузой 5…300 с. Блокировка `run.py.lock`;
|
||||
SIGINT при живом агенте игнорирует (Ctrl-C получает агент), SIGTERM пересылает. Спека — «Запускатель run.py».
|
||||
|
||||
`reconcile` при старте и `in_trial` перед каждым запуском сверяют `update.json` с sha256 `agent.py` (таблица — design 1 change'а
|
||||
agent-v9-hardening); коды агента: 75 — подмена, 73 — журнал занят (не сбой версии); `OSError` при подмене — `rejected`,
|
||||
запускатель не падает.
|
||||
|
||||
## Границы
|
||||
- Код новой версии приходит только через API хаба (`GET /agent/release`) и пишется в каталог агента; общая папка для
|
||||
кода не используется.
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
## Первая подсистема
|
||||
Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд,
|
||||
журнал, восстановление после перезапуска, CLI `run|check|selftest|prune`, самообновление через `run.py`.
|
||||
Последний change — `../jira-hub/openspec/changes/archive/2026-10-14-agent-self-update/` (решения агента — `openspec/changes/archive/2026-10-14-agent-self-update/design.md`), план — `docs/superpowers/plans/2026-10-14-agent-self-update-agent.md`.
|
||||
Последний change — `../jira-hub/openspec/changes/agent-v9-hardening/` (решения агента — `openspec/changes/agent-v9-hardening/design.md`), план — `docs/superpowers/plans/2026-10-14-agent-v9-hardening-agent.md`.
|
||||
План и спека живут в `jira-hub`: `openspec/changes/bridge-core/` и `docs/superpowers/plans/2026-10-11-bridge-core-hub.md`; план агента лежит здесь: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md`.
|
||||
|
||||
## Выкатка новой версии
|
||||
|
||||
18
README.md
18
README.md
@ -82,6 +82,7 @@ nohup python3 -u run.py > agent.log 2>&1 &
|
||||
считается упавшим и перезапускается). Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит.
|
||||
Повторный сигнал — немедленный выход (код 130). Остановка во время паузы перед командой безопасна: команда уже
|
||||
записана в журнал как `claimed`, при следующем запуске агент запрашивает её у хаба и выполняет через проверку, выдержав паузу темпа от последней записи журнала.
|
||||
Если журнал занят другим процессом (осталась прежняя копия агента), `agent.py run` выходит с кодом 73, а `run.py` повторяет запуск с паузой и не считает это сбоем версии.
|
||||
|
||||
## Самообновление
|
||||
|
||||
@ -93,6 +94,13 @@ nohup python3 -u run.py > agent.log 2>&1 &
|
||||
на связь за 10 минут — `run.py` возвращает `agent.py.prev` и пишет `rolled_back`; эту версию агент больше не ставит,
|
||||
хаб показывает откат в `agent_status`. Код в общую папку не попадает: файлы пишутся только в каталог `run.py`.
|
||||
|
||||
Запускатель v2 при старте и перед каждым запуском сверяет `update.json` с sha256 файла `agent.py`: если `agent.py` уже
|
||||
новая версия, а `update.json` = `staged` (запускатель упал посреди подмены), пробный период продолжается; если
|
||||
`update.json` = `trial`, а `agent.py` другая версия (прерван откат или файл заменён вручную), пишется `rolled_back`
|
||||
«пробный период прерван…» и эта версия больше сама не ставится — вернуть её можно публикацией с номером выше. Если
|
||||
подмена не удалась (`OSError`, например диск полон), текущая версия остаётся, а новая получает `rejected` «подмена не
|
||||
удалась»: освободить место и опубликовать следующую версию. Временные файлы `*.tmp` удаляются при старте.
|
||||
|
||||
Файлы рядом с `agent.py`: `update.json` (состояние: `staged`, `trial`, `ok`, `rolled_back`, `rejected`),
|
||||
`agent.py.prev` (прежняя версия), `run.py.lock`. Не удалять во время работы.
|
||||
|
||||
@ -181,9 +189,11 @@ python3 agent.py prune
|
||||
`finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики
|
||||
суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке
|
||||
по сигналу команду подхватит восстановление.
|
||||
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 5) отправляется в каждом `claim`, в каждом отчёте
|
||||
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 9) отправляется в каждом `claim`, в каждом отчёте
|
||||
(`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени
|
||||
журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`.
|
||||
Версия 9 — код 73 при занятом журнале, `OSError` при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в
|
||||
`refresh` (номера 6–8 заняты живой проверкой самообновления).
|
||||
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
|
||||
при этом выдаются по-прежнему.
|
||||
|
||||
@ -203,9 +213,13 @@ python3 agent.py prune
|
||||
старый процесс (Ctrl-C или `kill <pid>`, дождаться «остановлен»), скопировать `agent.py` и `run.py` в каталог агента,
|
||||
`python3 agent.py check`, запустить `python3 run.py` (или `launchctl unload` + `load`). Два процесса с одним журналом работать не могут: хаб
|
||||
довыдаёт команду без отчёта, и второй процесс выполнял бы её одновременно с первым. Начиная с версии 3 второй процесс
|
||||
не стартует («агент уже запущен…», код 1) — блокировка `journal.jsonl.lock` рядом с журналом.
|
||||
не стартует («агент уже запущен…», код 73) — блокировка `journal.jsonl.lock` рядом с журналом.
|
||||
`agent.json`, `journal.jsonl` и `update.json` не трогать: журнал переживает обновление, а неподтверждённые отчёты досылаются при старте.
|
||||
|
||||
Переход на `run.py` v2: сначала агент должен обновиться до v9 (MCP `agent_status` — «обновление: ok v8 → v9»); затем
|
||||
`launchctl unload ~/Library/LaunchAgents/ru.aepif.jira-agent.plist`, скопировать `run.py` в `~/jira-agent-bundle`,
|
||||
`launchctl load …`; `agent_status` покажет «запускатель: v2».
|
||||
|
||||
## Известные ограничения / эксплуатация
|
||||
|
||||
В порядке важности:
|
||||
|
||||
120
src/run.py
120
src/run.py
@ -19,8 +19,9 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
LAUNCHER_VERSION = 1
|
||||
LAUNCHER_VERSION = 2
|
||||
EXIT_UPDATE = 75 # агент: «agent.py.new проверен, update.json = staged»
|
||||
EXIT_LOCKED = 73 # агент: «журнал занят другим процессом» — не сбой версии (design 6)
|
||||
TRIAL_SECONDS = 600 # новая версия должна записать ok за это время
|
||||
KILL_GRACE = 60 # после SIGTERM до SIGKILL
|
||||
BACKOFF_START, BACKOFF_MAX = 5, 300
|
||||
@ -41,20 +42,36 @@ def read_state(path: str) -> Optional[dict]:
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def write_state(path: str, state: str, base: Optional[dict], error=None) -> None:
|
||||
def write_state(path: str, state: str, base: Optional[dict], error=None) -> bool:
|
||||
"""Атомарная запись update.json. False — не записано (диск полон): запускатель не падает (design 5)."""
|
||||
base = base or {}
|
||||
data = {"state": state, "from_version": base.get("from_version"), "to_version": base.get("to_version"),
|
||||
"sha256": base.get("sha256"), "error": error,
|
||||
"at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()}
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False)
|
||||
os.replace(tmp, path)
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False)
|
||||
os.replace(tmp, path)
|
||||
except OSError as e:
|
||||
log("не удалось записать update.json: %s" % e)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def sha256_file(path: str) -> str:
|
||||
with open(path, "rb") as f:
|
||||
return hashlib.sha256(f.read()).hexdigest()
|
||||
def sha256_file(path: str) -> Optional[str]:
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return hashlib.sha256(f.read()).hexdigest()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def remove(path: str) -> None:
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def acquire_lock(base_dir: str):
|
||||
@ -96,34 +113,80 @@ class Launcher:
|
||||
child.send_signal(signal.SIGTERM)
|
||||
|
||||
# --- файлы версии ----------------------------------------------------------
|
||||
def recover_files(self) -> None:
|
||||
def reconcile(self) -> None:
|
||||
"""Сверка update.json с файлами при старте (design 1): запускатель мог упасть посреди подмены или отката."""
|
||||
for tmp in (self.new + ".tmp", self.prev + ".tmp", self.state_path + ".tmp"):
|
||||
remove(tmp)
|
||||
if not os.path.exists(self.agent) and os.path.exists(self.prev):
|
||||
os.replace(self.prev, self.agent)
|
||||
log("agent.py не найден — восстановлен из agent.py.prev")
|
||||
if os.path.exists(self.new):
|
||||
st = read_state(self.state_path)
|
||||
if st and st.get("state") == "staged":
|
||||
st = read_state(self.state_path)
|
||||
state, sha = (st or {}).get("state"), (st or {}).get("sha256")
|
||||
if state == "staged":
|
||||
if sha and sha256_file(self.agent) == sha: # подмена сделана, trial не записан (M-5)
|
||||
remove(self.new)
|
||||
write_state(self.state_path, "trial", st)
|
||||
log("v%s уже на месте — пробный запуск" % st.get("to_version"))
|
||||
elif sha and os.path.exists(self.new) and sha256_file(self.new) == sha:
|
||||
log("найдена подготовленная версия — ставлю")
|
||||
self.apply_update()
|
||||
else:
|
||||
os.remove(self.new)
|
||||
remove(self.new)
|
||||
write_state(self.state_path, "rejected", st, "подготовленный файл потерян")
|
||||
log("подготовленная v%s потеряна — отвергнута" % st.get("to_version"))
|
||||
return
|
||||
remove(self.new)
|
||||
if state == "trial" and (not sha or sha256_file(self.agent) != sha):
|
||||
self.drop_stale_trial(st)
|
||||
|
||||
def in_trial(self) -> bool:
|
||||
"""Пробный период для этого запуска: trial и agent.py — именно эта версия (design 2)."""
|
||||
st = read_state(self.state_path)
|
||||
if not (st and st.get("state") == "trial"):
|
||||
return False
|
||||
if not st.get("sha256") or sha256_file(self.agent) != st.get("sha256"):
|
||||
self.drop_stale_trial(st)
|
||||
return False
|
||||
return True
|
||||
|
||||
def drop_stale_trial(self, st: dict) -> None:
|
||||
"""agent.py не та версия, что в trial (прерван откат, ручная подмена): пробного периода нет, версию не ставить (M-3)."""
|
||||
reason = "пробный период прерван: agent.py не v%s" % st.get("to_version")
|
||||
write_state(self.state_path, "rolled_back", st, reason)
|
||||
log(reason)
|
||||
|
||||
def apply_update(self) -> None:
|
||||
st = read_state(self.state_path)
|
||||
if not (st and st.get("state") == "staged" and os.path.exists(self.new) and sha256_file(self.new) == st.get("sha256")):
|
||||
if os.path.exists(self.new):
|
||||
os.remove(self.new)
|
||||
remove(self.new)
|
||||
write_state(self.state_path, "rejected", st, "файл изменился после проверки")
|
||||
log("обновление отвергнуто: файл изменился после проверки")
|
||||
return
|
||||
tmp = self.prev + ".tmp"
|
||||
with open(self.agent, "rb") as src, open(tmp, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
os.replace(tmp, self.prev)
|
||||
os.replace(self.new, self.agent) # agent.py существует на каждом шаге
|
||||
write_state(self.state_path, "trial", st)
|
||||
try:
|
||||
with open(self.agent, "rb") as src, open(tmp, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
os.replace(tmp, self.prev)
|
||||
os.replace(self.new, self.agent) # agent.py существует на каждом шаге
|
||||
except OSError as e:
|
||||
self.fail_update(st, e)
|
||||
return
|
||||
if not write_state(self.state_path, "trial", st):
|
||||
try:
|
||||
os.replace(self.prev, self.agent) # без trial новая версия осталась бы без пробного периода
|
||||
except OSError as e:
|
||||
log("не удалось вернуть agent.py.prev: %s" % e)
|
||||
self.fail_update(st, "update.json не записан")
|
||||
return
|
||||
log("v%s → v%s: пробный запуск" % (st.get("from_version"), st.get("to_version")))
|
||||
|
||||
def fail_update(self, st: dict, err) -> None:
|
||||
"""Подмена не удалась (диск полон, права): текущая версия остаётся, эта — rejected навсегда (design 4)."""
|
||||
remove(self.prev + ".tmp")
|
||||
remove(self.new)
|
||||
write_state(self.state_path, "rejected", st, "подмена не удалась: %s" % err)
|
||||
log("подмена v%s не удалась: %s" % (st.get("to_version"), err))
|
||||
|
||||
def rollback(self, st: Optional[dict], reason: str) -> bool:
|
||||
if not os.path.exists(self.prev):
|
||||
log("откат невозможен: нет agent.py.prev")
|
||||
@ -180,13 +243,12 @@ class Launcher:
|
||||
return wait
|
||||
|
||||
def run(self) -> int:
|
||||
self.recover_files()
|
||||
self.reconcile()
|
||||
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")
|
||||
trial = self.in_trial()
|
||||
started = time.monotonic()
|
||||
self.interrupted = False # Ctrl-C относится к этому запуску агента
|
||||
self.child = self.spawn()
|
||||
@ -196,6 +258,12 @@ class Launcher:
|
||||
self.child = None
|
||||
uptime = time.monotonic() - started
|
||||
st = read_state(self.state_path)
|
||||
ok = bool(st and st.get("state") == "ok")
|
||||
reason = "агент упал (код %s)" % code
|
||||
if timed_out and ok: # ответ claim пришёл в последнюю секунду (M-4, design 3)
|
||||
log("подтверждение пришло в последний момент — отката нет")
|
||||
self.backoff = BACKOFF_START
|
||||
continue
|
||||
if timed_out:
|
||||
if self.rollback(st, "не вышла на связь за 10 мин"):
|
||||
continue
|
||||
@ -207,13 +275,15 @@ class Launcher:
|
||||
if self.interrupted: # Ctrl-C во время подготовки: подмена сделана, trial — при следующем старте
|
||||
return 0
|
||||
continue
|
||||
elif trial and not (st and st.get("state") == "ok"):
|
||||
elif code == EXIT_LOCKED: # журнал держит другой процесс — версия ни при чём (M-7, design 6)
|
||||
reason = "журнал занят другим процессом (агент уже запущен)"
|
||||
elif trial and not ok:
|
||||
if self.rollback(st, "код %s через %d с" % (code, uptime)):
|
||||
continue
|
||||
if self.stopping:
|
||||
return code
|
||||
wait = self.next_backoff(uptime)
|
||||
log("агент упал (код %s), перезапуск через %d с" % (code, wait))
|
||||
log("%s, перезапуск через %d с" % (reason, wait))
|
||||
if not self.pause(wait):
|
||||
return code
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from _common import SRC
|
||||
@ -67,6 +68,21 @@ V6_OK_THEN_STOP = fake("v6", '''
|
||||
''')
|
||||
V6_CRASH = fake("v6", 'sys.exit(1)\n')
|
||||
V6_HANG = fake("v6", 'import time\ntime.sleep(60)\n')
|
||||
V6_SEES_STATE = fake("v6", '''
|
||||
mark("seen:" + (state() or {}).get("state", "-"))
|
||||
write(**dict(state(), state="ok"))
|
||||
human_stop()
|
||||
''')
|
||||
V6_OK_ON_TERM = fake("v6", '''
|
||||
import signal, time
|
||||
if len(open(os.path.join(HERE, "runs.log")).read().split()) > 2:
|
||||
human_stop()
|
||||
def term(*a):
|
||||
write(**dict(state(), state="ok")) # ответ claim пришёл в последнюю секунду пробного периода
|
||||
sys.exit(0)
|
||||
signal.signal(signal.SIGTERM, term)
|
||||
time.sleep(60)
|
||||
''')
|
||||
|
||||
|
||||
class LauncherTest(unittest.TestCase):
|
||||
@ -106,6 +122,12 @@ class LauncherTest(unittest.TestCase):
|
||||
self.put("update.json", json.dumps({"state": "staged", "from_version": 5, "to_version": to_version, "sha256": sha}))
|
||||
return sha
|
||||
|
||||
def sha(self, text):
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
def path(self, name):
|
||||
return os.path.join(self.dir, name)
|
||||
|
||||
def test_update_applied_and_confirmed(self):
|
||||
self.put("agent.py", V5_UPDATES)
|
||||
sha = self.next_version(V6_OK_THEN_STOP)
|
||||
@ -189,7 +211,7 @@ class LauncherTest(unittest.TestCase):
|
||||
human_stop(130)
|
||||
'''))
|
||||
self.assertEqual(self.l.run(), 130)
|
||||
self.assertEqual(self.read("env.txt"), "1 run --journal j.jsonl")
|
||||
self.assertEqual(self.read("env.txt"), "2 run --journal j.jsonl")
|
||||
|
||||
def test_sigint_without_child_stops(self):
|
||||
self.l.on_sigint(signal.SIGINT, None)
|
||||
@ -287,6 +309,149 @@ class LauncherTest(unittest.TestCase):
|
||||
self.assertIn("запускатель уже работает", p.stdout.decode("utf-8"))
|
||||
self.assertFalse(os.path.exists(os.path.join(self.dir, "runs.log")))
|
||||
|
||||
def test_staged_already_replaced_resumes_trial(self):
|
||||
"""M-5: запускатель упал между os.replace(new, agent) и записью trial."""
|
||||
self.stage(V6_SEES_STATE)
|
||||
os.replace(self.path("agent.py.new"), self.path("agent.py"))
|
||||
self.put("agent.py.prev", fake("v5", "human_stop()\n"))
|
||||
self.assertEqual(self.l.run(), 0)
|
||||
self.assertEqual(self.runs(), ["v6", "seen:trial"]) # пробный период, а не staged навсегда
|
||||
self.assertEqual(self.state()["state"], "ok")
|
||||
|
||||
def test_staged_without_file_rejected(self):
|
||||
self.put("agent.py", fake("v5", "human_stop()\n"))
|
||||
self.put("update.json", json.dumps({"state": "staged", "from_version": 5, "to_version": 6, "sha256": "a" * 64}))
|
||||
self.assertEqual(self.l.run(), 0)
|
||||
st = self.state()
|
||||
self.assertEqual((st["state"], st["error"]), ("rejected", "подготовленный файл потерян"))
|
||||
self.assertEqual(self.runs(), ["v5"])
|
||||
|
||||
def test_stale_trial_marked_rolled_back(self):
|
||||
"""M-3: откат прерван — agent.py уже прежняя версия, update.json всё ещё trial."""
|
||||
self.l.trial_seconds = 1
|
||||
self.put("agent.py", fake("v8", "import time\ntime.sleep(2)\nhuman_stop()\n")) # дольше пробного периода
|
||||
self.put("update.json", json.dumps({"state": "trial", "from_version": 8, "to_version": 9, "sha256": "a" * 64}))
|
||||
self.assertEqual(self.l.run(), 0)
|
||||
st = self.state()
|
||||
self.assertEqual((st["state"], st["to_version"], st["error"]),
|
||||
("rolled_back", 9, "пробный период прерван: agent.py не v9"))
|
||||
self.assertEqual(self.runs(), ["v8"]) # не остановлен через пробный период
|
||||
|
||||
def test_trial_without_sha_is_stale(self):
|
||||
self.put("agent.py", fake("v8", "human_stop()\n"))
|
||||
self.put("update.json", json.dumps({"state": "trial", "from_version": 8, "to_version": 9}))
|
||||
self.assertEqual(self.l.run(), 0)
|
||||
self.assertEqual(self.state()["state"], "rolled_back")
|
||||
|
||||
def test_trial_survives_launcher_restart(self):
|
||||
"""M-10 #22: trial при перезапуске run.py — пробный период продолжается, падение даёт откат."""
|
||||
self.put("agent.py", V6_CRASH)
|
||||
self.put("agent.py.prev", fake("v5", "human_stop()\n"))
|
||||
self.put("update.json", json.dumps({"state": "trial", "from_version": 5, "to_version": 6, "sha256": self.sha(V6_CRASH)}))
|
||||
self.assertEqual(self.l.run(), 0)
|
||||
self.assertEqual(self.runs(), ["v6", "v5"])
|
||||
self.assertEqual(self.state()["state"], "rolled_back")
|
||||
|
||||
def test_ok_at_last_moment_no_rollback(self):
|
||||
"""M-4: пробный период истёк, но агент записал ok, получив SIGTERM."""
|
||||
self.l.trial_seconds = 1
|
||||
self.l.kill_grace = 5
|
||||
self.put("agent.py", V5_UPDATES)
|
||||
self.next_version(V6_OK_ON_TERM)
|
||||
self.assertEqual(self.l.run(), 0)
|
||||
self.assertEqual(self.read("agent.py"), V6_OK_ON_TERM)
|
||||
self.assertEqual(self.state()["state"], "ok")
|
||||
self.assertEqual(self.runs(), ["v5", "v6", "v6"])
|
||||
self.assertEqual(self.pauses, []) # перезапуск без паузы
|
||||
|
||||
def test_locked_in_trial_no_rollback(self):
|
||||
"""M-7: журнал держит осиротевший агент — код 73 не откатывает новую версию."""
|
||||
v6 = fake("v6", '''
|
||||
n = len(open(os.path.join(HERE, "runs.log")).read().split())
|
||||
human_stop() if n > 1 else sys.exit(73)
|
||||
''')
|
||||
self.put("agent.py", v6)
|
||||
self.put("agent.py.prev", fake("v5", "human_stop()\n"))
|
||||
self.put("update.json", json.dumps({"state": "trial", "from_version": 5, "to_version": 6, "sha256": self.sha(v6)}))
|
||||
self.assertEqual(self.l.run(), 0)
|
||||
self.assertEqual(self.runs(), ["v6", "v6"])
|
||||
self.assertEqual(self.pauses, [5])
|
||||
self.assertEqual(self.read("agent.py"), v6)
|
||||
self.assertEqual(self.state()["state"], "trial")
|
||||
|
||||
def test_locked_outside_trial_backs_off(self):
|
||||
self.put("agent.py", fake("v6", '''
|
||||
n = len(open(os.path.join(HERE, "runs.log")).read().split())
|
||||
human_stop() if n > 3 else sys.exit(73)
|
||||
'''))
|
||||
self.assertEqual(self.l.run(), 0)
|
||||
self.assertEqual(self.pauses, [5, 10, 20])
|
||||
|
||||
def test_os_error_before_replace_rejected(self):
|
||||
"""M-6: копия в agent.py.prev.tmp не удалась (здесь — на месте файла каталог)."""
|
||||
self.put("agent.py", V5_UPDATES)
|
||||
self.next_version(V6_OK_THEN_STOP)
|
||||
os.mkdir(self.path("agent.py.prev.tmp"))
|
||||
self.assertEqual(self.l.run(), 0)
|
||||
self.assertEqual(self.read("agent.py"), V5_UPDATES)
|
||||
st = self.state()
|
||||
self.assertEqual(st["state"], "rejected")
|
||||
self.assertTrue(st["error"].startswith("подмена не удалась: "), st["error"])
|
||||
self.assertFalse(os.path.exists(self.path("agent.py.new")))
|
||||
self.assertEqual(self.runs(), ["v5", "v5"])
|
||||
|
||||
def test_trial_not_written_reverts(self):
|
||||
orig = run.write_state
|
||||
def flaky(path, state, base, error=None):
|
||||
return False if state == "trial" else orig(path, state, base, error)
|
||||
run.write_state = flaky
|
||||
self.addCleanup(setattr, run, "write_state", orig)
|
||||
self.put("agent.py", V5_UPDATES)
|
||||
self.next_version(V6_OK_THEN_STOP)
|
||||
self.assertEqual(self.l.run(), 0)
|
||||
self.assertEqual(self.read("agent.py"), V5_UPDATES) # agent.py.prev вернулся на место
|
||||
st = self.state()
|
||||
self.assertEqual((st["state"], st["error"]), ("rejected", "подмена не удалась: update.json не записан"))
|
||||
self.assertEqual(self.runs(), ["v5", "v5"])
|
||||
|
||||
def test_write_state_error_does_not_raise(self):
|
||||
self.assertFalse(run.write_state(self.path("нет/update.json"), "ok", {}))
|
||||
|
||||
def test_sha256_file_unreadable_is_none(self):
|
||||
self.assertIsNone(run.sha256_file(self.path("нет-файла")))
|
||||
|
||||
def test_tmp_files_removed_at_start(self):
|
||||
self.put("agent.py", fake("v5", "human_stop()\n"))
|
||||
for name in ("agent.py.new.tmp", "agent.py.prev.tmp", "update.json.tmp"):
|
||||
self.put(name, "остаток")
|
||||
self.assertEqual(self.l.run(), 0)
|
||||
for name in ("agent.py.new.tmp", "agent.py.prev.tmp", "update.json.tmp"):
|
||||
self.assertFalse(os.path.exists(self.path(name)), name)
|
||||
|
||||
def test_sigterm_forwarded_and_no_restart(self):
|
||||
"""M-10 #22: on_sigterm — пересылка агенту, нового запуска нет."""
|
||||
import threading
|
||||
self.put("agent.py", fake("v5", '''
|
||||
import signal, time
|
||||
def term(*a):
|
||||
mark("term")
|
||||
sys.exit(0)
|
||||
signal.signal(signal.SIGTERM, term)
|
||||
open(os.path.join(HERE, "ready"), "w").close()
|
||||
time.sleep(30)
|
||||
'''))
|
||||
def send():
|
||||
for _ in range(300):
|
||||
if os.path.exists(self.path("ready")):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
self.l.on_sigterm(signal.SIGTERM, None)
|
||||
t = threading.Thread(target=send)
|
||||
t.start()
|
||||
self.assertEqual(self.l.run(), 0)
|
||||
t.join()
|
||||
self.assertEqual(self.runs(), ["v5", "term"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@ -77,6 +77,6 @@ class SelfUpdateE2E(unittest.TestCase):
|
||||
self.assertTrue(os.path.exists(os.path.join(self.tmp.name, "agent.py.prev")))
|
||||
self.assertTrue(wait_for(lambda: any(b.get("agent_version") == self.cur + 1 for b in self.hub.claim_bodies)))
|
||||
last = [b for b in self.hub.claim_bodies if b.get("agent_version") == self.cur + 1][-1]
|
||||
self.assertEqual(last["launcher_version"], 1)
|
||||
self.assertEqual(last["launcher_version"], 2)
|
||||
os.killpg(self.proc.pid, signal.SIGINT) # Ctrl-C в терминале — всей группе
|
||||
self.assertEqual(self.proc.wait(30), 0)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user