From 82428ea86948e9abe9bacba319a9f3f487a3b2c1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 22:17:52 +0400 Subject: [PATCH] =?UTF-8?q?feat(agent):=20update.json,=20selftest,=20updat?= =?UTF-8?q?e=5Fstate=20=D0=B8=20launcher=5Fversion=20=D0=B2=20claim,=20?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=205=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 84 ++++++++++++++++++++++++++++++++++++++++--- tests/fake_hub.py | 17 +++++++-- tests/test_clients.py | 17 +++++++++ tests/test_py39.py | 2 +- tests/test_runner.py | 15 ++++++++ tests/test_update.py | 53 +++++++++++++++++++++++++++ 6 files changed, 179 insertions(+), 9 deletions(-) create mode 100644 tests/test_update.py diff --git a/src/agent.py b/src/agent.py index 1f0a770..f93bc83 100644 --- a/src/agent.py +++ b/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) diff --git a/tests/fake_hub.py b/tests/fake_hub.py index 0b5b701..14d6297 100644 --- a/tests/fake_hub.py +++ b/tests/fake_hub.py @@ -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": diff --git a/tests/test_clients.py b/tests/test_clients.py index 9467b80..0af5015 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -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() diff --git a/tests/test_py39.py b/tests/test_py39.py index 1520da6..c63eee3 100644 --- a/tests/test_py39.py +++ b/tests/test_py39.py @@ -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: diff --git a/tests/test_runner.py b/tests/test_runner.py index caedae8..65c9ceb 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -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, diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 0000000..e392878 --- /dev/null +++ b/tests/test_update.py @@ -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()