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:
parent
57934383e7
commit
8a319ea595
@ -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`.
|
||||
|
||||
## Границы
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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 — обновится только вручную"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user