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:
parent
d2cefa72c1
commit
82428ea869
84
src/agent.py
84
src/agent.py
@ -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)
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
53
tests/test_update.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user