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 run # основной цикл (по умолчанию)
python3 agent.py selftest # самопроверка новой версии перед обновлением: версия + check; код 0 или 3
python3 agent.py prune # почистить journal.jsonl от подтверждённых записей старше 30 дней
Самообновление — через запускатель run.py (спека agent, «Самообновление агента»).
"""
import argparse
import base64
import fcntl
import hashlib
import http.client
import json
import os
import re
import signal
import ssl
import subprocess
import sys
import time
import urllib.error
@ -27,7 +33,8 @@ import uuid
from datetime import datetime, timezone
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__))
API = "/rest/api/2"
AGILE = "/rest/agile/1.0"
@ -61,6 +68,32 @@ def load_config(path: str) -> dict:
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
# ---------------------------------------------------------------------------
@ -273,9 +306,13 @@ class HubClient(Http):
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)
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,
"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:
return self.call("GET", "/agent/settings")
@ -911,6 +948,28 @@ class Executor:
# ---------------------------------------------------------------------------
# Цикл: 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:
RETRY_ATTEMPTS = 3 # повторов команды после обрыва связи с Jira / 5xx (R-10)
RETRY_MIN_WAIT = 30 # секунд до повтора
@ -922,6 +981,8 @@ class Runner:
self.stop = False
self.sleep = time.sleep
self.idle_limit = None # type: Optional[int]
self.updater = None # type: Optional[Updater]
self.exit_code = 0 # EXIT_UPDATE — выход для подмены версии
@property
def poll(self) -> float:
@ -1113,10 +1174,14 @@ class Runner:
recovered = not self.stop
continue
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:
self.sleep(self.hub_wait(e, max(self.poll, 1)))
continue
if self.updater:
self.updater.confirm()
cmd = resp.get("command")
if resp.get("settings_version") != self.settings.get("settings_version"):
if not self.reload_settings_before(cmd):
@ -1217,15 +1282,24 @@ def cmd_check(cfg: dict) -> int:
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:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
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("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)
cfg = load_config(args.config)
if args.cmd == "check":
return cmd_check(cfg)
if args.cmd == "selftest":
return cmd_selftest(cfg)
if args.cmd == "prune":
return cmd_prune(args.journal)
return cmd_run(cfg, args.journal)

View File

@ -20,6 +20,8 @@ class FakeHub:
self.fail_results = 0
self.reject_results = {}
self.in_flight = None
self.release = None # {"version", "sha256", "content"} — опубликованная версия агента
self.release_requests = 0
self.lock = threading.Lock()
hub = self
@ -50,6 +52,10 @@ class FakeHub:
self.queue.append(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):
length = int(h.headers.get("Content-Length") or 0)
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.claims += 1
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:
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)
self.commands[cid]["status"] = "claimed"
self.in_flight = cid
c = self.commands[cid]
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":
return 200, self.settings
if path == "/agent/known-keys":

View File

@ -200,5 +200,22 @@ class ClientsTest(unittest.TestCase):
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__":
unittest.main()

View File

@ -17,7 +17,7 @@ class Py39Test(unittest.TestCase):
def test_no_third_party_imports(self):
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
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):
if isinstance(node, ast.Import):
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.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):
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,

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()