Публикация агента: прерванная прошлая публикация восстанавливается по манифесту (#129, #39 T2.2, M-6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-26 02:48:21 +04:00
parent 151a5e6175
commit 5dc96dd43c
2 changed files with 132 additions and 0 deletions

View File

@ -99,6 +99,7 @@ def publish(release_dir, source) -> dict:
raise PublishError("файл не в UTF-8") from e
version = parse_version(text)
rd = Path(release_dir)
_recover(rd)
prev = current(rd)
if prev and version <= prev["version"]:
raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше")
@ -123,6 +124,41 @@ def publish(release_dir, source) -> dict:
return manifest
def _sha256(path: Path) -> str | None:
try:
return hashlib.sha256(path.read_bytes()).hexdigest()
except FileNotFoundError:
return None
def _recover(rd: Path) -> None:
"""Привести release/ к опубликованной версии после прерванной публикации (design hub-backend-fixes, решение 9).
Сверка с manifest.json по sha256: agent.py.prev совпадает, а agent.py нет или не совпадает — .prev возвращается на
место; agent.py совпадает — лишний .prev удаляется; иначе .prev не трогается, только предупреждение. Сбой
(OSError) — предупреждение в лог, публикация продолжается."""
agent, prev_file = rd / "agent.py", rd / "agent.py.prev"
try:
if not prev_file.exists():
return
try:
m = json.loads((rd / "manifest.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
m = None
sha = m.get("sha256") if _manifest_problem(m) is None else None
if sha is None:
log.warning("release: найден %s, но манифеста нет или он негоден — не трогаем", prev_file)
elif _sha256(agent) == sha:
prev_file.unlink()
log.warning("release: лишний %s удалён: agent.py сходится с манифестом", prev_file)
elif _sha256(prev_file) == sha:
os.replace(prev_file, agent)
log.warning("release: прерванная публикация: %s возвращён на место agent.py", prev_file)
else:
log.warning("release: найден %s, не сходится с манифестом — не трогаем", prev_file)
except OSError as e:
log.warning("release: восстановление после прерванной публикации не удалось: %s", e)
def _undo(agent: Path, prev_file: Path, moved: bool, existed: bool) -> None:
"""Вернуть release/ к прежней публикации после сбоя записи: agent.py.prev на место agent.py; если прежнего не было —
убрать записанный agent.py. Прежний agent.py, которого не сдвинули (сбой до переименования), не трогаем.

View File

@ -298,3 +298,99 @@ def test_invalid_manifest_warning_not_repeated_across_alternating_dirs(tmp_path,
assert release.current(bad) is None
assert release.current(none) is None
assert len([r for r in caplog.records if "манифест" in r.getMessage()]) == 1
# --- пакет C: восстановление прерванной публикации (design hub-backend-fixes, решение 9; #39 T2.2, M-6) ----------
def _no_space(monkeypatch):
def no_space(fd):
raise OSError(28, "No space left on device")
monkeypatch.setattr(release.os, "fsync", no_space)
def test_recover_missing_agent_restores_prev(tmp_path, monkeypatch, caplog):
rd = tmp_path / "release"
v5 = agent_file(tmp_path, 5).read_bytes()
release.publish(rd, agent_file(tmp_path, 5))
os.replace(rd / "agent.py", rd / "agent.py.prev") # процесс упал между переименованием и записью
_no_space(monkeypatch)
with pytest.raises(release.PublishError, match="не удалось записать публикацию"):
release.publish(rd, agent_file(tmp_path, 6, name="new.py"))
monkeypatch.undo()
assert (rd / "agent.py").read_bytes() == v5
assert not (rd / "agent.py.prev").exists()
assert release.load(rd)["version"] == 5
assert "agent.py.prev" in caplog.text
def test_recover_partial_agent_keeps_good_prev(tmp_path, monkeypatch):
rd = tmp_path / "release"
v5 = agent_file(tmp_path, 5).read_bytes()
release.publish(rd, agent_file(tmp_path, 5))
(rd / "agent.py.prev").write_bytes(v5)
(rd / "agent.py").write_bytes("AGENT_VERSION = 6\n# обрыв".encode())
_no_space(monkeypatch)
with pytest.raises(release.PublishError, match="не удалось записать публикацию"):
release.publish(rd, agent_file(tmp_path, 6, name="new.py"))
monkeypatch.undo()
assert (rd / "agent.py").read_bytes() == v5
assert not (rd / "agent.py.prev").exists() or (rd / "agent.py.prev").read_bytes() == v5
assert release.load(rd)["version"] == 5
def test_recover_success_after_crash(tmp_path):
rd = tmp_path / "release"
v5 = agent_file(tmp_path, 5).read_bytes()
release.publish(rd, agent_file(tmp_path, 5))
(rd / "agent.py.prev").write_bytes(v5)
(rd / "agent.py").write_bytes("AGENT_VERSION = 6\n# обрыв".encode())
v6 = agent_file(tmp_path, 6, name="new.py")
release.publish(rd, v6)
assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"]
assert (rd / "agent.py").read_bytes() == v6.read_bytes()
assert release.load(rd)["version"] == 6
def test_recover_leftover_prev_removed(tmp_path, monkeypatch):
"""agent.py совпадает с манифестом, рядом лишний .prev: _recover удаляет его, даже если публикация потом падает
до переименования agent.py в .prev."""
rd = tmp_path / "release"
v5 = agent_file(tmp_path, 5).read_bytes()
release.publish(rd, agent_file(tmp_path, 5))
(rd / "agent.py.prev").write_bytes(v5)
_fail_replace_into(monkeypatch, "agent.py.prev")
with pytest.raises(release.PublishError, match="не удалось записать публикацию"):
release.publish(rd, agent_file(tmp_path, 6, name="new.py"))
monkeypatch.undo()
assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"]
assert release.load(rd)["version"] == 5
def test_recover_unknown_state_untouched(tmp_path, caplog):
rd = tmp_path / "release"
rd.mkdir()
(rd / "agent.py.prev").write_bytes(b"AGENT_VERSION = 3\n")
release._recover(rd)
assert _listing(rd) == {"agent.py.prev": b"AGENT_VERSION = 3\n"}
assert "agent.py.prev" in caplog.text
assert release.publish(rd, agent_file(tmp_path, 1))["version"] == 1 # первая публикация проходит
assert release.load(rd)["version"] == 1
def test_recover_oserror_does_not_stop_publish(tmp_path, monkeypatch, caplog):
rd = tmp_path / "release"
v5 = agent_file(tmp_path, 5).read_bytes()
release.publish(rd, agent_file(tmp_path, 5))
(rd / "agent.py.prev").write_bytes(v5)
real = Path.unlink
def fake(self, *a, **kw):
if self.name == "agent.py.prev" and not getattr(fake, "done", False):
fake.done = True
raise OSError(13, "Permission denied")
return real(self, *a, **kw)
monkeypatch.setattr(Path, "unlink", fake)
assert release.publish(rd, agent_file(tmp_path, 6, name="new.py"))["version"] == 6
assert "Permission denied" in caplog.text