feat(agent): update.json, selftest, update_state и launcher_version в claim, версия 5 (#19)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 22:17:52 +04:00
parent d2cefa72c1
commit 82428ea869
6 changed files with 179 additions and 9 deletions

View File

@ -8,16 +8,22 @@ Python 3.9. Спека: jira-hub/openspec/specs/agent/spec.md.
python3 agent.py check # доступ к хабу и Jira, без записи python3 agent.py check # доступ к хабу и Jira, без записи
python3 agent.py run # основной цикл (по умолчанию) python3 agent.py run # основной цикл (по умолчанию)
python3 agent.py selftest # самопроверка новой версии перед обновлением: версия + check; код 0 или 3
python3 agent.py prune # почистить journal.jsonl от подтверждённых записей старше 30 дней python3 agent.py prune # почистить journal.jsonl от подтверждённых записей старше 30 дней
Самообновление — через запускатель run.py (спека agent, «Самообновление агента»).
""" """
import argparse import argparse
import base64 import base64
import fcntl import fcntl
import hashlib
import http.client import http.client
import json import json
import os import os
import re
import signal import signal
import ssl import ssl
import subprocess
import sys import sys
import time import time
import urllib.error import urllib.error
@ -27,7 +33,8 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
AGENT_VERSION = 4 AGENT_VERSION = 5
EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
API = "/rest/api/2" API = "/rest/api/2"
AGILE = "/rest/agile/1.0" AGILE = "/rest/agile/1.0"
@ -61,6 +68,32 @@ def load_config(path: str) -> dict:
return cfg return cfg
# ---------------------------------------------------------------------------
# Самообновление: update.json рядом с agent.py (спека agent «Самообновление агента», design 7)
# ---------------------------------------------------------------------------
def read_update_state(path: str) -> Optional[dict]:
"""Состояние обновления; нет файла или мусор — None."""
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
return None
except (OSError, ValueError):
log("update.json не читается — считаю, что состояния нет")
return None
return data if isinstance(data, dict) else None
def write_update_state(path: str, state: str, from_version, to_version, sha256, error=None) -> dict:
data = {"state": state, "from_version": from_version, "to_version": to_version, "sha256": sha256,
"error": error, "at": now_iso()}
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
os.replace(tmp, path)
return data
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# HTTP # HTTP
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -273,9 +306,13 @@ class HubClient(Http):
path += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None}) path += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
return self.folder.call(method, path, body, timeout or self.timeout) return self.folder.call(method, path, body, timeout or self.timeout)
def claim(self, agent_version, settings_version, last_jira_call_at) -> dict: def claim(self, agent_version, settings_version, last_jira_call_at, update_state=None, launcher_version=None) -> dict:
return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version, return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version,
"last_jira_call_at": last_jira_call_at}, timeout=max(self.timeout, 45)) "last_jira_call_at": last_jira_call_at, "update_state": update_state,
"launcher_version": launcher_version}, timeout=max(self.timeout, 45))
def release(self) -> dict:
return self.call("GET", "/agent/release")
def settings(self) -> dict: def settings(self) -> dict:
return self.call("GET", "/agent/settings") return self.call("GET", "/agent/settings")
@ -911,6 +948,28 @@ class Executor:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Цикл: claim → пауза → журнал → выполнение → отчёт до подтверждения; восстановление при старте # Цикл: claim → пауза → журнал → выполнение → отчёт до подтверждения; восстановление при старте
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class Updater:
"""Самообновление агента (спека agent «Самообновление агента», design 7–12). Работает только под run.py."""
def __init__(self, hub, base_dir: str, launcher_version: Optional[int], enabled: bool = True,
config_path: Optional[str] = None):
self.hub, self.dir, self.launcher_version, self.enabled = hub, base_dir, launcher_version, enabled
self.config_path = config_path or os.path.join(base_dir, "agent.json")
self.state_path = os.path.join(base_dir, "update.json")
self.new_path = os.path.join(base_dir, "agent.py.new")
self.agent_path = os.path.join(base_dir, "agent.py")
def state(self) -> Optional[dict]:
return read_update_state(self.state_path)
def confirm(self) -> None:
"""Первый успешный claim новой версии: trial этой версии → ok (запускатель перестаёт ждать отката)."""
st = self.state()
if st and st.get("state") == "trial" and st.get("to_version") == AGENT_VERSION:
write_update_state(self.state_path, "ok", st.get("from_version"), AGENT_VERSION, st.get("sha256"))
log("обновление до v%d подтверждено" % AGENT_VERSION)
class Runner: class Runner:
RETRY_ATTEMPTS = 3 # повторов команды после обрыва связи с Jira / 5xx (R-10) RETRY_ATTEMPTS = 3 # повторов команды после обрыва связи с Jira / 5xx (R-10)
RETRY_MIN_WAIT = 30 # секунд до повтора RETRY_MIN_WAIT = 30 # секунд до повтора
@ -922,6 +981,8 @@ class Runner:
self.stop = False self.stop = False
self.sleep = time.sleep self.sleep = time.sleep
self.idle_limit = None # type: Optional[int] self.idle_limit = None # type: Optional[int]
self.updater = None # type: Optional[Updater]
self.exit_code = 0 # EXIT_UPDATE — выход для подмены версии
@property @property
def poll(self) -> float: def poll(self) -> float:
@ -1113,10 +1174,14 @@ class Runner:
recovered = not self.stop recovered = not self.stop
continue continue
try: try:
resp = self.hub.claim(AGENT_VERSION, self.settings.get("settings_version"), self.jira.last_call_iso) upd = self.updater
resp = self.hub.claim(AGENT_VERSION, self.settings.get("settings_version"), self.jira.last_call_iso,
upd.state() if upd else None, upd.launcher_version if upd else None)
except (ConnectionError, HttpError) as e: except (ConnectionError, HttpError) as e:
self.sleep(self.hub_wait(e, max(self.poll, 1))) self.sleep(self.hub_wait(e, max(self.poll, 1)))
continue continue
if self.updater:
self.updater.confirm()
cmd = resp.get("command") cmd = resp.get("command")
if resp.get("settings_version") != self.settings.get("settings_version"): if resp.get("settings_version") != self.settings.get("settings_version"):
if not self.reload_settings_before(cmd): if not self.reload_settings_before(cmd):
@ -1217,15 +1282,24 @@ def cmd_check(cfg: dict) -> int:
return 0 if ok else 1 return 0 if ok else 1
def cmd_selftest(cfg: dict) -> int:
"""Самопроверка версии перед обновлением: её запускает предыдущая версия. Контракт кодов MUST сохраняться:
0 — хаб и Jira доступны, 3 — что-то недоступно (временно); любой другой код — файл негоден (design 10)."""
log("jira-agent v%d" % AGENT_VERSION)
return 0 if cmd_check(cfg) == 0 else 3
def main(argv=None) -> int: def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--config", default=os.path.join(HERE, "agent.json")) ap.add_argument("--config", default=os.path.join(HERE, "agent.json"))
ap.add_argument("--journal", default=os.path.join(HERE, "journal.jsonl")) ap.add_argument("--journal", default=os.path.join(HERE, "journal.jsonl"))
ap.add_argument("cmd", nargs="?", default="run", choices=["run", "check", "prune"]) ap.add_argument("cmd", nargs="?", default="run", choices=["run", "check", "selftest", "prune"])
args = ap.parse_args(argv) args = ap.parse_args(argv)
cfg = load_config(args.config) cfg = load_config(args.config)
if args.cmd == "check": if args.cmd == "check":
return cmd_check(cfg) return cmd_check(cfg)
if args.cmd == "selftest":
return cmd_selftest(cfg)
if args.cmd == "prune": if args.cmd == "prune":
return cmd_prune(args.journal) return cmd_prune(args.journal)
return cmd_run(cfg, args.journal) return cmd_run(cfg, args.journal)

View File

@ -20,6 +20,8 @@ class FakeHub:
self.fail_results = 0 self.fail_results = 0
self.reject_results = {} self.reject_results = {}
self.in_flight = None self.in_flight = None
self.release = None # {"version", "sha256", "content"} — опубликованная версия агента
self.release_requests = 0
self.lock = threading.Lock() self.lock = threading.Lock()
hub = self hub = self
@ -50,6 +52,10 @@ class FakeHub:
self.queue.append(cid) self.queue.append(cid)
return cid return cid
def _release_ref(self):
r = self.release
return {"version": r["version"], "sha256": r["sha256"]} if r else None
def handle(self, h, method): def handle(self, h, method):
length = int(h.headers.get("Content-Length") or 0) length = int(h.headers.get("Content-Length") or 0)
body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {} body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {}
@ -72,15 +78,20 @@ class FakeHub:
self.claim_bodies.append(body or {}) self.claim_bodies.append(body or {})
self.claims += 1 self.claims += 1
if self.in_flight and self.commands[self.in_flight]["status"] == "claimed": if self.in_flight and self.commands[self.in_flight]["status"] == "claimed":
return 200, {"command": None, "settings_version": self.settings["settings_version"]} return 200, {"command": None, "settings_version": self.settings["settings_version"],
"agent_release": self._release_ref()}
if not self.queue: if not self.queue:
return 200, {"command": None, "settings_version": self.settings["settings_version"]} return 200, {"command": None, "settings_version": self.settings["settings_version"],
"agent_release": self._release_ref()}
cid = self.queue.pop(0) cid = self.queue.pop(0)
self.commands[cid]["status"] = "claimed" self.commands[cid]["status"] = "claimed"
self.in_flight = cid self.in_flight = cid
c = self.commands[cid] c = self.commands[cid]
return 200, {"command": {"id": cid, "type": c["type"], "params": c["params"], "pause_seconds": c["pause_seconds"]}, return 200, {"command": {"id": cid, "type": c["type"], "params": c["params"], "pause_seconds": c["pause_seconds"]},
"settings_version": self.settings["settings_version"]} "settings_version": self.settings["settings_version"], "agent_release": self._release_ref()}
if path == "/agent/release":
self.release_requests += 1
return (200, self.release) if self.release else (404, {"error": "no_release"})
if path == "/agent/settings": if path == "/agent/settings":
return 200, self.settings return 200, self.settings
if path == "/agent/known-keys": if path == "/agent/known-keys":

View File

@ -200,5 +200,22 @@ class ClientsTest(unittest.TestCase):
self.assertEqual(agent.main(["--config", path, "check"]), 1) self.assertEqual(agent.main(["--config", path, "check"]), 1)
def test_selftest_command(self):
import contextlib
import io
import json
import os
import tempfile
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "agent.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(jira_cfg(self.fake), f)
out = io.StringIO()
with contextlib.redirect_stdout(out):
# хаб недоступен (порт 1), Jira доступен — selftest печатает версию и возвращает 3, а не 1
self.assertEqual(agent.main(["--config", path, "selftest"]), 3)
self.assertIn("jira-agent v5", out.getvalue())
self.assertIn("Jira: доступен", out.getvalue())
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -17,7 +17,7 @@ class Py39Test(unittest.TestCase):
def test_no_third_party_imports(self): def test_no_third_party_imports(self):
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f: with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
tree = ast.parse(f.read()) tree = ast.parse(f.read())
allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing", "uuid", "fcntl"} allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing", "uuid", "fcntl", "hashlib", "re", "subprocess"}
for node in ast.walk(tree): for node in ast.walk(tree):
if isinstance(node, ast.Import): if isinstance(node, ast.Import):
for a in node.names: for a in node.names:

View File

@ -51,6 +51,21 @@ class RunnerTest(unittest.TestCase):
self.assertEqual(self.kinds(), [("claimed", 1), ("started", 1), ("finished", 1), ("acked", 1), ("claimed", 2), ("started", 2), ("finished", 2), ("acked", 2)]) self.assertEqual(self.kinds(), [("claimed", 1), ("started", 1), ("finished", 1), ("acked", 1), ("claimed", 2), ("started", 2), ("finished", 2), ("acked", 2)])
self.assertEqual(self.sleeps, []) # пауз не было: last_call_at пуст, потом pause 0 self.assertEqual(self.sleeps, []) # пауз не было: last_call_at пуст, потом pause 0
def test_claim_sends_update_state_and_launcher(self):
self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1)
agent.write_update_state(self.runner.updater.state_path, "trial", 4, agent.AGENT_VERSION, "s")
self.runner.loop()
body = self.hub.claim_bodies[0]
self.assertEqual(body["launcher_version"], 1)
self.assertEqual(body["update_state"]["state"], "trial")
self.assertEqual(agent.read_update_state(self.runner.updater.state_path)["state"], "ok") # первый успешный claim
self.assertEqual(self.hub.claim_bodies[0]["agent_version"], 5)
def test_claim_without_updater_sends_nulls(self):
self.runner.loop()
self.assertIsNone(self.hub.claim_bodies[0]["update_state"])
self.assertIsNone(self.hub.claim_bodies[0]["launcher_version"])
def test_restart_pace_from_old_finished(self): def test_restart_pace_from_old_finished(self):
at = (datetime.now(timezone.utc) - timedelta(seconds=10)).replace(microsecond=0).isoformat() at = (datetime.now(timezone.utc) - timedelta(seconds=10)).replace(microsecond=0).isoformat()
self.journal.append({"kind": "finished", "command_id": 99, "status": "done", "result": None, "error": None, self.journal.append({"kind": "finished", "command_id": 99, "status": "done", "result": None, "error": None,

53
tests/test_update.py Normal file
View File

@ -0,0 +1,53 @@
import json
import os
import tempfile
import unittest
from _common import agent
class UpdateStateTest(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.path = os.path.join(self.dir.name, "update.json")
def tearDown(self):
self.dir.cleanup()
def test_write_and_read(self):
d = agent.write_update_state(self.path, "staged", 5, 6, "abc")
self.assertEqual(agent.read_update_state(self.path), d)
self.assertEqual((d["state"], d["from_version"], d["to_version"], d["sha256"], d["error"]), ("staged", 5, 6, "abc", None))
self.assertTrue(d["at"])
self.assertEqual(os.listdir(self.dir.name), ["update.json"]) # временный файл не остался
def test_read_missing(self):
self.assertIsNone(agent.read_update_state(self.path))
def test_read_update_state_garbage(self):
for text in ('{"state": "tri', "[1, 2]", ""):
with open(self.path, "w", encoding="utf-8") as f:
f.write(text)
self.assertIsNone(agent.read_update_state(self.path))
def test_confirm_trial_of_this_version(self):
u = agent.Updater(None, self.dir.name, 1)
agent.write_update_state(u.state_path, "trial", 4, agent.AGENT_VERSION, "s")
u.confirm()
st = agent.read_update_state(u.state_path)
self.assertEqual((st["state"], st["from_version"], st["to_version"], st["sha256"]), ("ok", 4, agent.AGENT_VERSION, "s"))
def test_confirm_ignores_other_states(self):
u = agent.Updater(None, self.dir.name, 1)
u.confirm()
self.assertIsNone(u.state()) # нет файла — не создаётся
agent.write_update_state(u.state_path, "trial", 4, agent.AGENT_VERSION + 1, "s")
u.confirm()
self.assertEqual(u.state()["state"], "trial") # пробная другая версия — не наша
agent.write_update_state(u.state_path, "rolled_back", 5, 6, "s", "код 1 через 3 с")
u.confirm()
self.assertEqual(u.state()["state"], "rolled_back")
if __name__ == "__main__":
unittest.main()