fix(agent): agent_reported — до первого claim запускатель неизвестен, подсказки «без run.py» нет (M-2 #25) (#32)

GET /agent отдаёт agent_reported; release.hint(reported=); MCP agent_status: «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)» (решения 7–8, R-1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 10:36:19 +04:00
parent 57934383e7
commit 8a319ea595
8 changed files with 80 additions and 10 deletions

View File

@ -34,7 +34,7 @@
- Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`.
- Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`.
- Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом.
- Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти и отдаёт в `GET /agent`.
- Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`.
- Токены: агентский — только `/agent/*`, клиентский — остальное; MCP берёт клиентский токен из базы через `HubClient.from_home`.
## Границы

View File

@ -116,6 +116,10 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py
не может разобрать (`version` не целое, `sha256` не 64 hex-символа), означает «публикации нет» — одно предупреждение в
логе хаба на каждую новую ошибку.
Состояние обновления и версию запускателя агент сообщает в каждом `claim`, хаб держит их в памяти: сразу после
перезапуска хаба, пока агент не вышел на связь (до 30 с), `agent_status` пишет «запускатель: неизвестно (агент ещё не
выходил на связь после перезапуска хаба)», а подсказки «агент запущен без run.py» нет.
## HTTP API
Все маршруты, кроме веб-приложения (`GET /app…`) и перенаправления старых адресов (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON,
@ -139,7 +143,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py
| `POST /batches/{id}/cancel` | клиент | отмена пакета |
| `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет) / отмена (`force` для взятой) |
| `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` |
| `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `update_hint`), журнал загрузок |
| `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок |
| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`) |
```bash

View File

@ -50,7 +50,9 @@ class Hub:
self.web_dir = Path(web_dir) if web_dir else WEB_APP
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)
# последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version
# неизвестен, а не null (hub-hygiene-d, решение 7)
self.agent_update = {"update_state": None, "launcher_version": None, "reported": False}
self.claim_wait = 30
secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии
conn = self.conn()
@ -74,7 +76,8 @@ class Hub:
def note_agent_update(self, body):
"""update_state и launcher_version из claim: хранятся в памяти, агент шлёт их в каждом claim (R-3).
Значение неверного типа игнорируется — claim из-за него не отвергается."""
Значение неверного типа игнорируется — claim из-за него не отвергается; сам claim отмечается (reported)."""
self.agent_update["reported"] = True
us = body.get("update_state")
if us is None or isinstance(us, dict):
if us is not None and isinstance(us.get("error"), str):
@ -285,8 +288,10 @@ def h_refresh(hub, conn, m, q, body):
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))
au = hub.agent_update
us, lv, reported = au["update_state"], au["launcher_version"], au["reported"]
view.update(release=rel, update_state=us, launcher_version=lv, agent_reported=reported,
update_hint=release.hint(rel, view, us, lv, reported=reported))
return 200, view

View File

@ -112,7 +112,10 @@ def fmt_agent(a) -> str:
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("agent_reported") is False: # хаб перезапущен, claim ещё не было (решение 8); старый хаб поля не шлёт
out.append("запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)")
else:
out.append(f"запускатель: v{lv}" if lv is not None else "без запускателя")
if a.get("update_hint"):
out.append(a["update_hint"])
return "\n".join(out)

View File

@ -168,13 +168,15 @@ def load(release_dir) -> dict | None:
return {"version": m["version"], "sha256": m["sha256"], "content": content}
def hint(rel, agent, update_state, launcher_version) -> str | None:
"""Подсказка для agent_status (design решение 5)."""
def hint(rel, agent, update_state, launcher_version, reported=True) -> str | None:
"""Подсказка для agent_status (design agent-self-update решение 5). reported=False — после запуска хаба claim
ещё не было: launcher_version неизвестен, подсказки «без run.py» нет (hub-hygiene-d, решение 7)."""
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:
if (reported and 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":

View File

@ -429,11 +429,13 @@ def test_hint_without_launcher(hub, tmp_path):
hub["agent"].post("/agent/claim", {"agent_version": 5})
v = hub["client"].get("/agent")
assert v["online"] is True and v["update_hint"] == "агент запущен без run.py — обновится только вручную"
assert v["agent_reported"] is True
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
assert v["agent_reported"] is False
def test_claim_survives_unreadable_release(hub):
@ -484,3 +486,26 @@ def test_agent_release_non_utf8_is_404(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"
def test_agent_reported_after_hub_restart(hub, tmp_path):
"""M-2 #25, спека «Хаб перезапущен, агент ещё не выходил на связь»: в базе агент онлайн, claim с запуска не было."""
_publish(hub, tmp_path, 10)
conn = hub["hub"].conn()
conn.execute("UPDATE agent_status SET last_seen_at = ?, agent_version = '9' WHERE id = 1", (db.now(),))
conn.close()
v = hub["client"].get("/agent")
assert v["online"] is True and v["agent_reported"] is False
assert v["launcher_version"] is None and v["update_hint"] is None
hub["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2})
v = hub["client"].get("/agent")
assert v["agent_reported"] is True and v["launcher_version"] == 2 and v["update_hint"] is None
def test_agent_reported_even_with_bad_update_fields(hub, tmp_path):
"""Review Focus 5: claim с полями неверного типа — claim всё равно был: agent_reported = true."""
_publish(hub, tmp_path, 6)
hub["agent"].post("/agent/claim", {"agent_version": 5, "update_state": "ok", "launcher_version": "1"})
v = hub["client"].get("/agent")
assert v["agent_reported"] is True and v["launcher_version"] is None
assert v["update_hint"] == "агент запущен без run.py — обновится только вручную"

View File

@ -215,3 +215,24 @@ def test_fmt_agent_update_lines():
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
def test_fmt_agent_launcher_unknown_after_restart():
"""M-2 #25, решение 8: agent_reported = false — «неизвестно», а не «без запускателя»; поля нет — прежний текст."""
base = {"online": True, "agent_version": "9", "current_command_id": None, "rate_limited_24h": 0, "redelivered": [],
"warning": None, "last_seen_at": "2026-10-15T10:00:00+00:00", "settings_version": 3,
"release": {"version": 10, "sha256": "s", "published_at": "2026-10-15T09:00:00+00:00"},
"update_state": None, "launcher_version": None, "update_hint": None}
text = mcp_server.fmt_agent({**base, "agent_reported": False})
assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in text
assert "без запускателя" not in text and "агент запущен без run.py" not in text
assert "без запускателя" in mcp_server.fmt_agent({**base, "agent_reported": True})
assert "без запускателя" in mcp_server.fmt_agent(base) # старый хаб: поля agent_reported нет
def test_agent_status_right_after_hub_start(live):
"""Спека mcp «Статус агента сразу после перезапуска хаба»."""
assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in mcp_server.agent_status()
live["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2})
text = mcp_server.agent_status()
assert "запускатель: v2" in text and "неизвестно" not in text

View File

@ -267,3 +267,13 @@ def test_move_failure_keeps_previous_agent(tmp_path, monkeypatch):
monkeypatch.undo()
assert _listing(rd) == before
assert release.load(rd)["version"] == 8
def test_hint_unknown_launcher_until_reported():
"""M-2 #25, решение 7: пока после запуска хаба не было claim, «без run.py» не утверждается; откат — по update_state."""
rel = {"version": 10, "sha256": "s", "published_at": "t"}
online9 = {"agent_version": "9", "online": True}
assert release.hint(rel, online9, None, None, reported=False) is None
assert release.hint(rel, online9, None, None, reported=True) == "агент запущен без run.py — обновится только вручную"
rb = {"state": "rolled_back", "from_version": 9, "to_version": 10, "error": "код 1"}
assert release.hint(rel, online9, rb, None, reported=False) == "откат с v10 на v9: код 1"