sdlc-hub/src/jira_hub/release.py
Andrey Epifancev 8a319ea595 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>
2026-10-15 10:36:19 +04:00

187 lines
9.1 KiB
Python
Raw 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.

"""Опубликованная версия агента для самообновления: <данные>/release/agent.py + manifest.json, без БД.
Спека hub-api «Публикация версии агента», design agent-self-update решения 1–2, 5; hub-hygiene-d решения 1–7."""
import hashlib
import json
import logging
import os
import re
import threading
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)
SHA256_RE = re.compile(r"[0-9a-f]{64}")
_warned: str | None = None # текст последнего предупреждения о манифесте: одно на каждую новую ошибку (решение 5)
_warned_lock = threading.Lock()
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 в том же каталоге, write + flush + fsync, переименование (решение 1).
При OSError временный файл удаляется (ошибка удаления не важна), исключение пробрасывается."""
tmp = path.with_name("." + path.name + ".tmp")
try:
with open(tmp, "wb") as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
except OSError:
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
raise
def publish(release_dir, source) -> dict:
"""Опубликовать файл агента: только вверх; agent.py, затем манифест (по нему версия считается опубликованной).
Прежний agent.py на время записи — agent.py.prev; сбой записи (OSError) возвращает его на место (решение 2):
опубликованной остаётся прежняя версия, наружу — только PublishError."""
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']}, новая должна быть больше")
manifest = {"version": version, "sha256": hashlib.sha256(data).hexdigest(), "size": len(data),
"published_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()}
agent, prev_file = rd / "agent.py", rd / "agent.py.prev"
existed, moved = agent.exists(), False
try:
secure_dir(rd)
if agent.exists():
os.replace(agent, prev_file)
moved = True
_write_atomic(agent, data)
_write_atomic(rd / "manifest.json", json.dumps(manifest, ensure_ascii=False).encode("utf-8"))
except OSError as e:
_undo(agent, prev_file, moved, existed)
raise PublishError(f"не удалось записать публикацию: {e}") from e
try:
prev_file.unlink(missing_ok=True) # и прежний, и оставленный упавшей публикацией (риск в design)
except OSError as e:
log.warning("release: %s не удалён: %s", prev_file, e)
return manifest
def _undo(agent: Path, prev_file: Path, moved: bool, existed: bool) -> None:
"""Вернуть release/ к прежней публикации после сбоя записи: agent.py.prev на место agent.py; если прежнего не было —
убрать записанный agent.py. Прежний agent.py, которого не сдвинули (сбой до переименования), не трогаем.
Сбой возврата — в лог: команда всё равно завершается сообщением."""
try:
if moved:
os.replace(prev_file, agent)
elif not existed:
agent.unlink(missing_ok=True)
except OSError as e:
log.error("release: прежняя публикация не восстановлена (%s): %s", prev_file if moved else agent, e)
def _manifest_problem(m) -> str | None:
"""Почему манифест негоден, или None (решение 4; та же проверка, что у агента v9)."""
if not isinstance(m, dict):
return "не объект"
version = m.get("version")
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
return f"version — не целое ≥ 1: {version!r}"
sha = m.get("sha256")
if not isinstance(sha, str) or not SHA256_RE.fullmatch(sha):
return "sha256 — не 64 символа [0-9a-f]"
return None
def _note_manifest(problem: str | None, path: Path) -> None:
"""Предупреждение о негодном манифесте — один раз на каждый новый текст; годный или отсутствующий манифест
сбрасывает память (решение 5). Замок: claim и GET /agent читают манифест из разных потоков."""
global _warned
text = None if problem is None else f"release: манифест {path}: {problem} — публикации нет"
with _warned_lock:
if text is not None and text != _warned:
log.warning("%s", text)
_warned = text
def current(release_dir) -> dict | None:
"""Опубликованная версия по манифесту; нет, битый или негодный манифест — None (агенту нечего обновлять)."""
path = Path(release_dir) / "manifest.json"
try:
m = json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, NotADirectoryError):
_note_manifest(None, path)
return None
except (OSError, ValueError) as e: # нет прав, битый JSON: публикации нет, мост работает
_note_manifest(f"не читается ({e})", path)
return None
problem = _manifest_problem(m)
_note_manifest(problem, path)
if problem:
return None
return {"version": m["version"], "sha256": m["sha256"], "published_at": m.get("published_at")}
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
try:
content = data.decode("utf-8")
except UnicodeDecodeError: # решение 6: 404, а не 500
log.error("release: agent.py не в UTF-8 — публикации нет")
return None
return {"version": m["version"], "sha256": m["sha256"], "content": content}
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 (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":
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