From 4e8b32310eb0025fae9ac3763376dedce340b027 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 21:59:03 +0400 Subject: [PATCH] =?UTF-8?q?docs(plan):=20agent-self-update=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=85=D0=B0=D0=B1=D0=B0=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-14-agent-self-update-hub.md | 615 ++++++++++++++++++ 1 file changed, 615 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-14-agent-self-update-hub.md diff --git a/docs/superpowers/plans/2026-10-14-agent-self-update-hub.md b/docs/superpowers/plans/2026-10-14-agent-self-update-hub.md new file mode 100644 index 0000000..4cfb155 --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-agent-self-update-hub.md @@ -0,0 +1,615 @@ +# agent-self-update (хаб) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** хаб публикует версию агента (`jira-hub agent publish`), сообщает её в `claim`, отдаёт файл по `GET /agent/release` и показывает состояние обновления в `GET /agent` и MCP `agent_status`. + +**Architecture:** новый модуль `release.py` — файлы `<данные>/release/agent.py` + `manifest.json`, без БД; `api.Hub` держит последние `update_state`/`launcher_version` агента в памяти; `h_claim`, `h_agent_get` и новый `h_agent_release` читают манифест на каждый запрос; MCP только форматирует новые поля `GET /agent`. + +**Tech Stack:** Python 3.13, uv, pytest; stdlib (`hashlib`, `json`, `os.replace`). + +**Spec:** `openspec/changes/agent-self-update/` (proposal.md, design.md решения 1–6, 21–24; specs/hub-api, specs/mcp). Агентская часть — отдельный план `jira-agent/docs/superpowers/plans/2026-10-14-agent-self-update-agent.md`. + +**Worktree:** `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/self-update`, ветка `feat/22-agent-self-update`. Все команды — из этого каталога; git — `git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/self-update …`. Параллельно в `.wt/web-app` идёт #21 — его не трогать. + +## Global Constraints + +- Публикация — только вверх: «опубликована v, новая должна быть больше». +- Файл агента: UTF-8, не больше 1 МБ (1 048 576 байт), ровно одна строка `AGENT_VERSION = <целое>`. +- Каталог `release/` — права 0700 (`paths.secure_dir`); `agent.py`, затем `manifest.json` — через временный файл и `os.replace`, манифест последним. +- `manifest.json` = `{version, sha256, size, published_at}`; sha256 — от байтов файла (hex). +- Вывод публикации: `опубликована v, sha256 <первые 12 символов>…, байт`; ошибка — сообщение в stderr, код 1. +- `GET /agent/release` — агентский токен; нет публикации или sha не сходится — `404 {error: "no_release"}`. +- `update_state.error` хранится не больше 500 символов; значения неверного типа в `claim` игнорируются, `claim` не отвергается. +- `update_hint` (в порядке проверки): «агент запущен без run.py — обновится только вручную»; «откат с v на v: »; «v отвергнута: »; иначе `null`. +- Схема БД не меняется. Язык сообщений, комментариев, коммитов — русский. Коммит заканчивается строкой `Co-Authored-By: Claude Opus 5.5 `. +- Весь набор: `uv run pytest` — зелёным считается только весь набор. + +## Review Focus + +- Манифест есть, а `agent.py` ещё старый (публикация в процессе) или файл подменён руками — `GET /agent/release` должен дать 404, а не отдать файл с чужим sha (тест в Task 1: `test_load_rejects_sha_mismatch`). +- Битый `manifest.json` (обрезан, не JSON) — хаб не падает, считает «публикации нет» (тест в Task 1: `test_current_broken_manifest`). +- Агент v4 (старый) шлёт `claim` без `update_state`/`launcher_version` — `claim` работает, `launcher_version = null` (тест в Task 2: `test_claim_without_update_fields`). +- `update_state` строкой или `launcher_version` строкой/булевым — не 400/500, а игнор с сохранением прежнего (тест в Task 2: `test_claim_bad_update_types_ignored`). +- `agent_version` в `agent_status` — строка (`TEXT`), сравнение с `release.version` числом; `None` у нового хаба до первого `claim` (тест в Task 2: `test_hint_without_launcher`, `test_hint_none_when_no_agent`). + +--- + +### Task 1: Модуль `release` и `jira-hub agent publish` + +**Files:** +- Create: `src/jira_hub/release.py` +- Modify: `src/jira_hub/paths.py` (функция `release_dir`) +- Modify: `src/jira_hub/cli.py` (подкоманда `agent publish`) +- Test: `tests/test_release.py` (новый), `tests/test_cli.py` + +**Interfaces:** +- Produces: + - `release.PublishError(Exception)` + - `release.parse_version(text: str) -> int` + - `release.publish(release_dir: Path | str, source: Path | str) -> dict` — манифест `{version, sha256, size, published_at}` + - `release.current(release_dir) -> dict | None` — `{version: int, sha256: str, published_at: str | None}` + - `release.load(release_dir) -> dict | None` — `{version, sha256, content: str}` + - `release.hint(rel: dict | None, agent: dict, update_state: dict | None, launcher_version: int | None) -> str | None` + - `paths.release_dir() -> Path` = `home() / "release"` + +- [ ] **Step 1: Write the failing tests** — `tests/test_release.py`: + +```python +import json +import os +import stat + +import pytest + +from jira_hub import release + + +def agent_file(tmp_path, version, body="print('x')\n", name="agent.py"): + p = tmp_path / name + p.write_text(f'#!/usr/bin/env python3\n"""агент"""\nAGENT_VERSION = {version}\n{body}', encoding="utf-8") + return p + + +def test_publish_writes_file_and_manifest(tmp_path): + rd = tmp_path / "release" + src = agent_file(tmp_path, 6) + m = release.publish(rd, src) + assert m["version"] == 6 and m["size"] == src.stat().st_size and len(m["sha256"]) == 64 + assert (rd / "agent.py").read_bytes() == src.read_bytes() + assert json.loads((rd / "manifest.json").read_text(encoding="utf-8")) == m + assert stat.S_IMODE(os.stat(rd).st_mode) == 0o700 + assert release.current(rd) == {"version": 6, "sha256": m["sha256"], "published_at": m["published_at"]} + loaded = release.load(rd) + assert loaded == {"version": 6, "sha256": m["sha256"], "content": src.read_text(encoding="utf-8")} + assert not [p.name for p in rd.iterdir() if p.name.startswith(".")] # временных файлов не осталось + + +def test_publish_only_up(tmp_path): + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 6)) + before = (rd / "manifest.json").read_bytes() + with pytest.raises(release.PublishError, match="опубликована v6, новая должна быть больше"): + release.publish(rd, agent_file(tmp_path, 6, body="print('y')\n")) + with pytest.raises(release.PublishError, match="опубликована v6"): + release.publish(rd, agent_file(tmp_path, 5)) + assert (rd / "manifest.json").read_bytes() == before + assert release.publish(rd, agent_file(tmp_path, 7))["version"] == 7 + + +@pytest.mark.parametrize("text", ["print(1)\n", "AGENT_VERSION = 5\nAGENT_VERSION = 6\n", "AGENT_VERSION = 'v5'\n", + " AGENT_VERSION = 5\n"]) +def test_version_line_required_once(tmp_path, text): + p = tmp_path / "a.py" + p.write_text(text, encoding="utf-8") + with pytest.raises(release.PublishError, match="AGENT_VERSION"): + release.publish(tmp_path / "release", p) + assert not (tmp_path / "release" / "manifest.json").exists() + + +def test_publish_rejects_non_utf8_and_big(tmp_path): + p = tmp_path / "a.py" + p.write_bytes(b"AGENT_VERSION = 5\n\xff\xfe") + with pytest.raises(release.PublishError, match="UTF-8"): + release.publish(tmp_path / "release", p) + p.write_bytes(b"AGENT_VERSION = 5\n" + b"#" * (1024 * 1024)) + with pytest.raises(release.PublishError, match="1 МБ"): + release.publish(tmp_path / "release", p) + with pytest.raises(release.PublishError, match="не прочитан"): + release.publish(tmp_path / "release", tmp_path / "нет.py") + + +def test_current_none_without_release(tmp_path): + assert release.current(tmp_path / "release") is None + assert release.load(tmp_path / "release") is None + + +def test_current_broken_manifest(tmp_path): + rd = tmp_path / "release" + rd.mkdir() + (rd / "manifest.json").write_text('{"version": 6, "sha', encoding="utf-8") + assert release.current(rd) is None + (rd / "manifest.json").write_text('{"version": "шесть", "sha256": "x"}', encoding="utf-8") + assert release.current(rd) is None + + +def test_load_rejects_sha_mismatch(tmp_path): + rd = tmp_path / "release" + release.publish(rd, agent_file(tmp_path, 6)) + (rd / "agent.py").write_text("AGENT_VERSION = 6\nподменён\n", encoding="utf-8") + assert release.current(rd)["version"] == 6 + assert release.load(rd) is None + + +def test_hint(): + rel = {"version": 6, "sha256": "s", "published_at": "t"} + online5 = {"agent_version": "5", "online": True} + assert release.hint(rel, online5, None, None) == "агент запущен без run.py — обновится только вручную" + assert release.hint(rel, online5, None, 1) is None + assert release.hint(rel, {"agent_version": "5", "online": False}, None, None) is None + assert release.hint(rel, {"agent_version": "6", "online": True}, None, None) is None + assert release.hint(None, online5, None, None) is None + rb = {"state": "rolled_back", "from_version": 6, "to_version": 7, "error": "код 1 через 3 с"} + assert release.hint(rel, {"agent_version": "6", "online": True}, rb, 1) == "откат с v7 на v6: код 1 через 3 с" + rj = {"state": "rejected", "from_version": 5, "to_version": 6, "error": "sha256 не сходится"} + assert release.hint(rel, online5, rj, 1) == "v6 отвергнута: sha256 не сходится" + assert release.hint(rel, online5, {"state": "ok", "from_version": 5, "to_version": 6}, 1) is None + + +def test_hint_none_when_no_agent(): + rel = {"version": 6, "sha256": "s", "published_at": "t"} + assert release.hint(rel, {"agent_version": None, "online": False}, None, None) is None +``` + +Добавить в `tests/test_cli.py`: + +```python +def test_agent_publish(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) + src = tmp_path / "agent.py" + src.write_text("AGENT_VERSION = 6\n", encoding="utf-8") + assert cli.main(["agent", "publish", str(src)]) == 0 + out = capsys.readouterr().out + assert out.startswith("опубликована v6, sha256 ") and "…, 18 байт" in out + assert (paths.release_dir() / "manifest.json").exists() + assert cli.main(["agent", "publish", str(src)]) == 1 + assert "опубликована v6, новая должна быть больше" in capsys.readouterr().err +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_release.py tests/test_cli.py -q` +Expected: FAIL — `ImportError: cannot import name 'release'` и `invalid choice: 'agent'`. + +- [ ] **Step 3: Implement** — `src/jira_hub/paths.py`, после `backups_dir`: + +```python +def release_dir() -> Path: + return home() / "release" +``` + +`src/jira_hub/release.py`: + +```python +"""Опубликованная версия агента для самообновления: <данные>/release/agent.py + manifest.json, без БД. +Спека hub-api «Публикация версии агента», design agent-self-update решения 1–2, 5.""" +import hashlib +import json +import logging +import os +import re +from datetime import datetime, timezone +from pathlib import Path + +from .paths import secure_dir + +log = logging.getLogger(__name__) + +MAX_SIZE = 1024 * 1024 +VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M) + + +class PublishError(Exception): + pass + + +def parse_version(text: str) -> int: + found = VERSION_RE.findall(text) + if len(found) != 1: + raise PublishError(f"в файле должна быть ровно одна строка AGENT_VERSION = <целое>, найдено {len(found)}") + return int(found[0]) + + +def _write_atomic(path: Path, data: bytes) -> None: + tmp = path.with_name("." + path.name + ".tmp") + tmp.write_bytes(data) + os.replace(tmp, path) + + +def publish(release_dir, source) -> dict: + """Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной).""" + src = Path(source) + try: + data = src.read_bytes() + except OSError as e: + raise PublishError(f"файл не прочитан {src}: {e}") from e + if len(data) > MAX_SIZE: + raise PublishError(f"файл больше 1 МБ ({len(data)} байт)") + try: + text = data.decode("utf-8") + except UnicodeDecodeError as e: + raise PublishError("файл не в UTF-8") from e + version = parse_version(text) + rd = Path(release_dir) + prev = current(rd) + if prev and version <= prev["version"]: + raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше") + secure_dir(rd) + manifest = {"version": version, "sha256": hashlib.sha256(data).hexdigest(), "size": len(data), + "published_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()} + _write_atomic(rd / "agent.py", data) + _write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8")) + return manifest + + +def current(release_dir) -> dict | None: + """Опубликованная версия по манифесту; нет или битый манифест — None (агенту нечего обновлять).""" + path = Path(release_dir) / "manifest.json" + if not path.exists(): + return None + try: + m = json.loads(path.read_text(encoding="utf-8")) + return {"version": int(m["version"]), "sha256": str(m["sha256"]), "published_at": m.get("published_at")} + except (OSError, ValueError, KeyError, TypeError) as e: + log.warning("release: манифест %s не читается (%s) — публикации нет", path, e) + return None + + +def load(release_dir) -> dict | None: + """Файл агента для GET /agent/release; sha256 пересчитывается — несовпадение с манифестом = публикации нет.""" + m = current(release_dir) + if m is None: + return None + try: + data = (Path(release_dir) / "agent.py").read_bytes() + except OSError as e: + log.error("release: agent.py не прочитан: %s", e) + return None + if hashlib.sha256(data).hexdigest() != m["sha256"]: + log.error("release: agent.py не сходится с манифестом по sha256 — публикации нет") + return None + return {"version": m["version"], "sha256": m["sha256"], "content": data.decode("utf-8")} + + +def hint(rel, agent, update_state, launcher_version) -> str | None: + """Подсказка для agent_status (design решение 5).""" + try: + agent_version = int(agent.get("agent_version") or 0) + except (TypeError, ValueError): + agent_version = 0 + if rel and agent.get("online") and agent_version and rel["version"] > agent_version and launcher_version is None: + return "агент запущен без run.py — обновится только вручную" + us = update_state or {} + if us.get("state") == "rolled_back": + return f"откат с v{us.get('to_version')} на v{us.get('from_version')}: {us.get('error') or '—'}" + if us.get("state") == "rejected": + return f"v{us.get('to_version')} отвергнута: {us.get('error') or '—'}" + return None +``` + +`src/jira_hub/cli.py`: в docstring модуля добавить `agent publish`; после `sub.add_parser("tokens", …)`: + +```python + p = sub.add_parser("agent", help="версия агента для самообновления") + agent_sub = p.add_subparsers(dest="agent_cmd", required=True) + p = agent_sub.add_parser("publish", help="опубликовать agent.py (версия только вверх)") + p.add_argument("path") +``` + +и сразу после `args = ap.parse_args(argv)` (до `serve`; БД не нужна): + +```python + if args.cmd == "agent": + from . import release + try: + m = release.publish(paths.release_dir(), args.path) + except release.PublishError as e: + print(e, file=sys.stderr) + return 1 + print(f"опубликована v{m['version']}, sha256 {m['sha256'][:12]}…, {m['size']} байт") + return 0 +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_release.py tests/test_cli.py -q` +Expected: PASS. Затем весь набор `uv run pytest -q` — PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/self-update add src/jira_hub/release.py src/jira_hub/paths.py src/jira_hub/cli.py tests/test_release.py tests/test_cli.py +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/self-update commit -m "feat(hub): публикация версии агента — release.py и jira-hub agent publish (#22) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: API — `agent_release` в claim, `GET /agent/release`, состояние обновления в `GET /agent` + +**Files:** +- Modify: `src/jira_hub/api.py` (`Hub.__init__`, новый `Hub.note_agent_update`, `h_claim`, новый `h_agent_release`, `h_agent_get`, `ROUTES`) +- Test: `tests/test_api.py` + +**Interfaces:** +- Consumes: `release.current`, `release.load`, `release.hint` (Task 1). +- Produces: + - `api.Hub(db_path, backups_dir=None, release_dir=None)`; `hub.release_dir: Path` (по умолчанию `db_path.parent / "release"`); `hub.agent_update: dict` = `{"update_state": dict | None, "launcher_version": int | None}` + - ответ `POST /agent/claim`: `{command, settings_version, agent_release: {version, sha256} | None}` + - `GET /agent/release` → `{version, sha256, content}` | 404 `no_release` + - `GET /agent`: прежние поля + `release` (`{version, sha256, published_at}` | None), `update_state`, `launcher_version`, `update_hint` + +- [ ] **Step 1: Write the failing tests** — в `tests/test_api.py` (фикстура `hub` уже есть; `Hub` в ней создаётся с `tmp_path / "hub.sqlite"`, значит `release_dir` = `tmp_path / "release"`): + +```python +from jira_hub import release + + +def _publish(hub, tmp_path, version): + src = tmp_path / f"agent{version}.py" + src.write_text(f"AGENT_VERSION = {version}\nprint('v{version}')\n", encoding="utf-8") + return release.publish(hub["hub"].release_dir, src) + + +def test_claim_release_field(hub, tmp_path): + a = hub["agent"] + assert a.post("/agent/claim", {"agent_version": 5})["agent_release"] is None + m = _publish(hub, tmp_path, 6) + r = a.post("/agent/claim", {"agent_version": 5}) + assert r["command"] is None and r["agent_release"] == {"version": 6, "sha256": m["sha256"]} + + +def test_agent_release_route(hub, tmp_path): + with pytest.raises(HubError) as e: + hub["agent"].get("/agent/release") + assert e.value.status == 404 and e.value.error == "no_release" + m = _publish(hub, tmp_path, 6) + r = hub["agent"].get("/agent/release") + assert r == {"version": 6, "sha256": m["sha256"], "content": "AGENT_VERSION = 6\nprint('v6')\n"} + with pytest.raises(HubError) as e: + hub["client"].get("/agent/release") + assert e.value.status == 401 + + +def test_claim_update_state_shown_in_agent(hub, tmp_path): + _publish(hub, tmp_path, 7) + us = {"state": "rolled_back", "from_version": 6, "to_version": 7, "sha256": "s", "error": "код 1 через 3 с" + "x" * 600, + "at": "2026-10-14T20:00:00+00:00"} + hub["agent"].post("/agent/claim", {"agent_version": 6, "update_state": us, "launcher_version": 1}) + v = hub["client"].get("/agent") + assert v["release"]["version"] == 7 and v["release"]["published_at"] + assert v["launcher_version"] == 1 + assert v["update_state"]["state"] == "rolled_back" and len(v["update_state"]["error"]) == 500 + assert v["update_hint"].startswith("откат с v7 на v6: код 1 через 3 с") + + +def test_claim_without_update_fields(hub, tmp_path): + hub["agent"].post("/agent/claim", {"agent_version": 6, "update_state": {"state": "ok", "from_version": 5, + "to_version": 6}, "launcher_version": 1}) + hub["agent"].post("/agent/claim", {"agent_version": 4}) # агент v4: полей нет + v = hub["client"].get("/agent") + assert v["launcher_version"] is None and v["update_state"] is None + + +def test_claim_bad_update_types_ignored(hub): + a = hub["agent"] + a.post("/agent/claim", {"agent_version": 5, "update_state": {"state": "ok"}, "launcher_version": 1}) + r = a.post("/agent/claim", {"agent_version": 5, "update_state": "ok", "launcher_version": "1"}) + assert "command" in r + a.post("/agent/claim", {"agent_version": 5, "update_state": {"state": "ok"}, "launcher_version": True}) + v = hub["client"].get("/agent") + assert v["update_state"] == {"state": "ok"} and v["launcher_version"] == 1 + + +def test_hint_without_launcher(hub, tmp_path): + _publish(hub, tmp_path, 6) + hub["agent"].post("/agent/claim", {"agent_version": 5}) + v = hub["client"].get("/agent") + assert v["online"] is True and v["update_hint"] == "агент запущен без run.py — обновится только вручную" + + +def test_agent_view_without_release(hub): + v = hub["client"].get("/agent") + assert v["release"] is None and v["update_state"] is None and v["launcher_version"] is None and v["update_hint"] is None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_api.py -q -k "release or update or hint or without"` +Expected: FAIL — `KeyError: 'agent_release'`, 404 `not_found` на `/agent/release`, `AttributeError: 'Hub' object has no attribute 'release_dir'`. + +- [ ] **Step 3: Implement** — `src/jira_hub/api.py`: + +импорт: `from . import backup, db, mirror, queue, relay, release, settings` + +`Hub.__init__`: + +```python + def __init__(self, db_path, backups_dir=None, release_dir=None): + self.db_path = Path(db_path) + self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() + self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release" + self.agent_update = {"update_state": None, "launcher_version": None} # последнее из claim; в памяти (R-3) + self.claim_wait = 30 + ... +``` + +метод `Hub`: + +```python + def note_agent_update(self, body): + """update_state и launcher_version из claim: хранятся в памяти, агент шлёт их в каждом claim (R-3). + Значение неверного типа игнорируется — claim из-за него не отвергается.""" + us = body.get("update_state") + if us is None or isinstance(us, dict): + if us is not None and isinstance(us.get("error"), str): + us = {**us, "error": us["error"][:500]} + self.agent_update["update_state"] = us + lv = body.get("launcher_version") + if lv is None or (isinstance(lv, int) and not isinstance(lv, bool)): + self.agent_update["launcher_version"] = lv +``` + +`h_claim`: + +```python +def h_claim(hub, conn, m, q, body): + hub.note_agent_update(body) + deadline = time.monotonic() + hub.claim_wait + while True: + cmd = queue.claim(conn, body.get("agent_version"), body.get("settings_version"), body.get("last_jira_call_at"), + backup=hub.make_backup) + if cmd or time.monotonic() >= deadline: + break + time.sleep(1) + rel = release.current(hub.release_dir) + return 200, {"command": cmd, "settings_version": settings.get(conn, "settings_version"), + "agent_release": {"version": rel["version"], "sha256": rel["sha256"]} if rel else None} + + +def h_agent_release(hub, conn, m, q, body): + rel = release.load(hub.release_dir) + if rel is None: + raise ApiError(404, "no_release", "версия агента не опубликована") + return 200, rel +``` + +`h_agent_get`: + +```python +def h_agent_get(hub, conn, m, q, body): + view = queue.agent_view(conn) + rel = release.current(hub.release_dir) + us, lv = hub.agent_update["update_state"], hub.agent_update["launcher_version"] + view.update(release=rel, update_state=us, launcher_version=lv, update_hint=release.hint(rel, view, us, lv)) + return 200, view +``` + +`ROUTES` — после `known-keys`: + +```python + ("GET", r"^/agent/release$", h_agent_release, "agent"), +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest -q` +Expected: весь набор PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/self-update add src/jira_hub/api.py tests/test_api.py +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/self-update commit -m "feat(hub): agent_release в claim, GET /agent/release, состояние обновления в GET /agent (#22) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: MCP `agent_status` и документация хаба + +**Files:** +- Modify: `src/jira_hub/mcp_server.py` (`fmt_agent`, docstring `agent_status`) +- Modify: `README.md` (новый раздел «Самообновление агента» после «Транспорт через общую папку»; строка `GET /agent/release` в «HTTP API») +- Modify: `ARCHITECTURE.md` (строка модуля `release`, `cli` — `agent publish`, поток данных) +- Modify: `CLAUDE.md` (данные `release/`, команда `agent publish`, текущий change) +- Test: `tests/test_mcp.py` + +**Interfaces:** +- Consumes: поля `GET /agent` из Task 2: `release`, `update_state`, `launcher_version`, `update_hint`. + +- [ ] **Step 1: Write the failing test** — в `tests/test_mcp.py`: + +```python +def test_fmt_agent_update_lines(): + base = {"online": True, "agent_version": "6", "current_command_id": None, "rate_limited_24h": 0, "redelivered": [], + "warning": None, "last_seen_at": "2026-10-14T20:00:00+00:00", "settings_version": 3} + text = mcp_server.fmt_agent({**base, "release": {"version": 7, "sha256": "s", "published_at": "2026-10-14T19:00:00+00:00"}, + "update_state": {"state": "rolled_back", "from_version": 6, "to_version": 7}, + "launcher_version": 1, "update_hint": "откат с v7 на v6: код 1 через 3 с"}) + assert "публикация: v7 от 2026-10-14T19:00:00+00:00" in text + assert "обновление: rolled_back v6 → v7" in text + assert "запускатель: v1" in text and "откат с v7 на v6: код 1 через 3 с" in text + text = mcp_server.fmt_agent({**base, "release": None, "update_state": None, "launcher_version": None, "update_hint": None}) + assert "публикации нет" in text and "без запускателя" in text and "обновление:" not in text +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_mcp.py -q -k fmt_agent_update` +Expected: FAIL — `AssertionError` (строк «публикация» нет). + +- [ ] **Step 3: Implement** — `fmt_agent` в `src/jira_hub/mcp_server.py`, перед `return`: + +```python + rel = a.get("release") + out.append(f"публикация: v{rel['version']} от {_v(rel.get('published_at'))}" if rel else "публикации нет") + us = a.get("update_state") + if us: + out.append(f"обновление: {us.get('state')} v{_v(us.get('from_version'))} → v{_v(us.get('to_version'))}") + lv = a.get("launcher_version") + out.append(f"запускатель: v{lv}" if lv is not None else "без запускателя") + if a.get("update_hint"): + out.append(a["update_hint"]) +``` + +docstring `agent_status`: `"""Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия, +состояние самообновления и запускатель."""` + +Документация (текст — русский, как в соседних разделах): + +`README.md`, новый раздел после «Транспорт через общую папку»: + +```markdown +## Самообновление агента + +Агент v5+ под запускателем `run.py` обновляется сам (спека `openspec/specs/agent/`, «Самообновление агента»). +Опубликовать новую версию: + + uv run jira-hub agent publish ../jira-agent/src/agent.py + +Версия берётся из строки `AGENT_VERSION = N` и должна быть больше опубликованной; файл и `manifest.json` ложатся в +`~/.local/share/jira-hub/release/` (0700). Перезапуск хаба не нужен: версия уходит агенту в ответе `claim` +(`agent_release`), файл — по `GET /agent/release`. Агент ставит её в паузе между командами; через несколько минут +`agent_status` (MCP) покажет «обновление: ok v<старая> → v<новая>». Если новая версия упала или не вышла на связь, +запускатель вернёт прежнюю — `agent_status` покажет «откат с v …»; исправление публикуется следующим номером. +Снять публикацию нельзя: откат «назад» — тот же старый код с новым номером. +``` + +`README.md`, раздел «HTTP API» — в список маршрутов агента добавить `GET /agent/release` — опубликованная версия агента (`{version, sha256, content}`, 404 `no_release`); у `POST /agent/claim` — поле ответа `agent_release` и поля тела `update_state`, `launcher_version`; у `GET /agent` — `release`, `update_state`, `launcher_version`, `update_hint`. + +`ARCHITECTURE.md`: строка таблицы после `relay`: + +```markdown +| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх), `current`, `load` (с проверкой sha256), подсказка `hint` для `agent_status` | `paths` | +``` + +у `api` в «Зависит от» добавить `release`; у `cli` — `agent publish` в ответственности и `release` в зависимостях; в «Потоки данных» строку: «Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти и отдаёт в `GET /agent`.» + +`CLAUDE.md`: в «Стек и ограничения», строка «Данные» — добавить `release/` (опубликованная версия агента, 0700); в «Команды» — `uv run jira-hub agent publish PATH` — опубликовать версию агента для самообновления; в «Что это» заменить «текущего change нет (последний — …viewer-live/)» на «текущий change — `openspec/changes/agent-self-update/`». + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest -q` и `openspec validate agent-self-update --strict` +Expected: весь набор PASS; `Change 'agent-self-update' is valid`. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/self-update add src/jira_hub/mcp_server.py tests/test_mcp.py README.md ARCHITECTURE.md CLAUDE.md +git -C /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/self-update commit -m "feat(hub): agent_status — публикация и самообновление агента; README, ARCHITECTURE (#22) + +Co-Authored-By: Claude Opus 5.5 " +```