Files
multi-agent-mux/.agents/skills/lib_py/delegation/finalize.py
T
GodopuandClaude Opus 5 cb7ed1aa38 fix(issue-5): close the phase 2b gate holes found by all three reviewers
Seven blocking items across three reviews, every one reproduced here before
being accepted. Two themes run through them: a gate whose strictness depended on
what the caller remembered to pass, and a boundary that trusted its only current
caller instead of enforcing its own rule.

Independent verification is no longer optional. Omitting the report directory
skipped the disk check entirely and handed the decision back to the collector's
own flags -- the self-certification this design exists to prevent. It is now a
refusal, as is an empty directory or a job with no recorded dispatch.

Evidence is now bound to its target. The policy compares a scenario against the
dispatch context carried inside that same scenario, which is self-consistent by
construction, so one job's evidence could complete another. The gate now checks
the job, attempt, session and pane generation against the recorded dispatch.

A reviewer with no verdict token is no longer completed. Phase 0 made this a
safety contract and run_loop reads a missing token as NOT PASS, so inferring
completion first would start a correction round against a review that was never
finished. The requirement applies to reviewers only.

Bookkeeping may write one field. A status-free request was an unrestricted merge
on a terminal record: it could rewrite the prompt and the session. Settling is
also conditional now on the exact intent that was sent, because publishing
happens outside the lock and a newer intent can legitimately land mid-flight;
clearing unconditionally dropped a notification nobody had sent. The intent's
own identity travels in the payload, since the transport sequence cannot
identify a retry of the same intent.

Side-effect evidence must be evidence. A kind with no reference names something
without evidencing it, and the CLI parser could not be the only check because
the API and hand-written manifests bypass it.

Also honours MAM_ENGINE_CAS=0, which the operations note already promised
disables auto-completion, matches backend keys exactly rather than by prefix so
a future name cannot inherit evidence never collected for it, records an
absolute report path, and imports the notifier at module scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd
2026-09-06 22:25:50 +09:00

197 lines
7.8 KiB
Python

"""Report finalization: the completion manifest.
Who writes this matters more than what is in it. If the watcher computed the
hash itself, the manifest would attest only that the watcher saw a stable file
-- self-certification, which is exactly the evidence shape a reviewer rejected
earlier in this issue. So the side that knows the work finished declares it, and
the reader independently re-derives the hash rather than trusting the number in
the file.
Consequence, and it is the useful one: an agent that declares nothing leaves no
manifest, a missing manifest already holds, and the explicit-event path is
therefore preserved for unsupported agents without any special case.
It lives in `lib_py.delegation`, not `lib_py.loop`, for the same reason the
transition engine does: the artifact it writes sits in the job's own directory,
and the CLI an agent calls to declare a finalization is a delegation entry point.
A frozen loop can bind delegation to a different tree than the parent package, so
delegation reaching sideways into `lib_py.loop` would mix trees -- the rule that
caught this, twice now.
writing report-final.md.tmp is being written
stable_candidate the file stopped changing (a filter, never an authority)
finalized atomically renamed, and this manifest exists
"""
from __future__ import annotations
import hashlib
import json
import os
import tempfile
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
MANIFEST_NAME = "report-manifest.json"
SCHEMA_VERSION = "1.0.0"
def manifest_path(report_path) -> Path:
"""The manifest sits beside the report it describes."""
return Path(report_path).parent / MANIFEST_NAME
def manifest_in(report_dir) -> Path:
return Path(report_dir) / MANIFEST_NAME
def sha256_of(path) -> str:
digest = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(65536), b""):
digest.update(chunk)
return digest.hexdigest()
def _atomic_write(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix="." + path.name + ".", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(text)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path)
except BaseException:
if os.path.exists(tmp):
os.unlink(tmp)
raise
def validate_side_effect(entry) -> Optional[str]:
"""None when the entry is usable evidence, else why it is not.
A kind with no reference names something without evidencing it, which is the
shape "declared but unproven" takes. The CLI parser cannot be the only place
this is checked: finalize_report is a public API and a manifest can also be
written by hand.
"""
if not isinstance(entry, dict):
return "side effect entry is not an object"
kind = str(entry.get("kind") or "").strip()
reference = str(entry.get("reference") or "").strip()
if not kind:
return "side effect entry has no kind"
if not reference:
return "side effect {!r} has no reference".format(kind)
return None
def parse_side_effects(raw: Optional[List[str]]) -> List[Dict[str, str]]:
"""`kind=reference` pairs from the command line into evidence entries."""
out = []
for item in raw or []:
kind, _, reference = str(item).partition("=")
kind = kind.strip()
if not kind or not reference.strip():
raise ValueError("side effect must be '<kind>=<reference>', got {!r}".format(item))
out.append({"kind": kind, "reference": reference.strip(),
"verified_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())})
return out
def finalize_report(report_path, job_id, attempt_id, side_effects=None,
declared_by="agent") -> Dict[str, Any]:
"""Promote a report to finalized and declare the manifest.
If `<report>.tmp` exists it is renamed into place first, so a reader can
never observe a half-written report as finalized. The rename is what makes
the transition atomic; the manifest is what makes it attributable.
"""
for entry in side_effects or []:
problem = validate_side_effect(entry)
if problem:
raise ValueError(problem)
# Absolute, so a later reader in a different working directory resolves the
# same file rather than a same-named one beside itself.
report_path = Path(report_path).resolve()
staged = report_path.with_name(report_path.name + ".tmp")
if staged.exists():
os.replace(str(staged), str(report_path))
if not report_path.exists():
raise FileNotFoundError("no report to finalize at {}".format(report_path))
manifest = {
"schema_version": SCHEMA_VERSION,
"job_id": job_id,
"attempt_id": attempt_id,
"report_path": str(report_path),
"sha256": sha256_of(report_path),
"finalized_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"side_effects": list(side_effects or []),
"declared_by": declared_by,
}
_atomic_write(manifest_path(report_path), json.dumps(manifest, indent=2,
ensure_ascii=False) + "\n")
return manifest
class ManifestVerdict(object):
__slots__ = ("ok", "reason", "manifest")
def __init__(self, ok, reason, manifest=None):
self.ok = ok
self.reason = reason
self.manifest = manifest
def verify_manifest(report_dir, job_id, expected_attempt=None,
expected_artifacts=None) -> ManifestVerdict:
"""Read a manifest and check it against the world, not against itself.
The hash is recomputed from the file on disk. A report rewritten after the
declaration therefore invalidates it, which is the contract: a new body needs
a new finalization.
"""
path = manifest_in(report_dir)
if not path.exists():
return ManifestVerdict(False, "no completion manifest")
try:
manifest = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
return ManifestVerdict(False, "manifest is unreadable: {}".format(exc))
if manifest.get("schema_version") != SCHEMA_VERSION:
return ManifestVerdict(False, "unsupported manifest schema {!r}".format(
manifest.get("schema_version")))
if manifest.get("job_id") != job_id:
return ManifestVerdict(False, "manifest belongs to job {!r}".format(manifest.get("job_id")))
if expected_attempt is not None and manifest.get("attempt_id") != expected_attempt:
return ManifestVerdict(False, "manifest belongs to attempt {!r}, not {!r}".format(
manifest.get("attempt_id"), expected_attempt))
report = Path(manifest.get("report_path") or "")
if not report.exists():
return ManifestVerdict(False, "the finalized report is gone: {}".format(report))
actual = sha256_of(report)
if actual != manifest.get("sha256"):
return ManifestVerdict(
False, "report changed after finalization (declared {}, found {})".format(
str(manifest.get("sha256"))[:12], actual[:12]))
# A job that declared side effects must show evidence for every one of them.
# Partial evidence is no evidence: the work is not known to be complete.
evidenced = set()
for entry in manifest.get("side_effects") or []:
problem = validate_side_effect(entry)
if problem:
return ManifestVerdict(False, problem)
evidenced.add(str(entry["kind"]).strip())
missing = [a for a in (expected_artifacts or []) if a not in evidenced]
if missing:
return ManifestVerdict(False, "no side-effect evidence for {}".format(missing))
return ManifestVerdict(True, "manifest verified", manifest)