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

616 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<N>, новая должна быть больше».
- Файл агента: 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<to> на v<from>: <error>»; «v<to> отвергнута: <error>»; иначе `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`:
```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 <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"`):
```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 <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`:
```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<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`:
```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 <noreply@anthropic.com>"
```