sdlc-hub/docs/superpowers/plans/2026-10-14-agent-self-update-hub.md
Andrey Epifancev 4e8b32310e docs(plan): agent-self-update — план хаба (#22)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:59:03 +04:00

32 KiB
Raw Permalink Blame History

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<N>, sha256 <первые 12 символов>…, <size> байт; ошибка — сообщение в 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 <noreply@anthropic.com>.
  • Весь набор: 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:

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:

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:
def release_dir() -> Path:
    return home() / "release"

src/jira_hub/release.py:

"""Опубликованная версия агента для самообновления: <данные>/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", …):

    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; БД не нужна):

    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
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 <noreply@anthropic.com>"

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"):

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__:

    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:

    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:

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:

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:

    ("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
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 <noreply@anthropic.com>"

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:

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:
    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, новый раздел после «Транспорт через общую папку»:

## Самообновление агента

Агент 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<N> …»; исправление публикуется следующим номером.
Снять публикацию нельзя: откат «назад» — тот же старый код с новым номером.

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:

| `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
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 <noreply@anthropic.com>"