diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d5bee2c..d0b3edc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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`. ## Границы diff --git a/README.md b/README.md index 0154f7c..d2b3f36 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 14f57e3..1c53223 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -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 diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index 02d93e4..e538b1a 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -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) diff --git a/src/jira_hub/release.py b/src/jira_hub/release.py index 14101a1..e27b3b2 100644 --- a/src/jira_hub/release.py +++ b/src/jira_hub/release.py @@ -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": diff --git a/tests/test_api.py b/tests/test_api.py index 5a1b0c6..e41ee42 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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 — обновится только вручную" diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 26abec3..029be47 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.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 diff --git a/tests/test_release.py b/tests/test_release.py index 597089f..4cf2c6a 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -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"