Files
multi-agent-mux/.agents/skills/lib_py/loop/policy.py
T
GodopuandClaude Opus 5 cb575fa851 fix(issue-5): address phase 2a round 2 findings (F-C, F-D, R1, R4)
Both reviewers sustained the round 1 fixes and found four more. Each was
reproduced here before being accepted; none were rebutted.

F-C (claude, blocking) -- my own round 1 fix caused this. Making refused writes
exit non-zero also classified an idempotent ACK as a refusal, and the wrapper
finalises an approved job with `status --set completed` on a record that is
already completed. It runs under `set -e`, so the *success* path aborted with
exit 4 before its validation hook and its final stdout line. Reproduced at the
shell. An idempotent result is now distinguishable from a refusal and exits 0; a
genuine conflict still exits 4.

F-D (claude) -- routing the operator write around update_status dropped the
status vocabulary check. Under the rollback flag an invalid status was written
to the record with exit 0, where before it was refused with exit 1 and the record
left alone. The check now runs first, in both modes.

R1 (codex, blocking) -- the previous fix compared identifiers only when they
happened to be present, so evidence carrying none at all was still approved on
the dispatch record's own flags. That is the self-assertion the check exists to
replace. Every identifier must now be supplied and agree, and a report manifest
must associate with the attempt being judged. Identifiers are read from a
collector envelope beside the payload rather than from inside it: a native Herdr
payload may not carry MAM fields, so one appearing there is either fabricated or
a contract violation, and either way it is not evidence.

R4 (codex) -- a sequence number orders nothing unless the compare and the write
are atomic. Two flushes could both read the old value, the newer land first, and
the slower overwrite it. Reproduced deterministically. The mirror now serialises
read-compare-write under a per-job audit lock, taken outside the registry
transaction so a slow mirror still cannot lengthen it, and each write uses its
own temp file instead of a shared name.

All eight new regressions fail against the pre-fix code.

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

160 lines
7.8 KiB
Python

"""The completion decision. A pure function, deliberately.
No I/O, no clock, no globals: that is what lets the phase-0 scenario fixtures be
fed to it verbatim as test vectors. Those seven scenarios and their
`expected_policy.decision` survived three-reviewer scrutiny, so they are the
contract this implements rather than a fresh set of rules.
Fail-closed throughout: every gate must pass for `candidate_approved`. Anything
unproven holds.
"""
from __future__ import annotations
from lib_py.loop import attribution
from lib_py.loop.evidence import Decision
# Native states that mean "not doing anything right now". Idle alone proves
# nothing about completion; it only makes completion *possible* to consider.
QUIESCENT = ("idle", "done")
# The four identifiers an external collector must supply before a completion can
# be attributed to this attempt.
ATTRIBUTION_FIELDS = ("job_id", "attempt_id", "agent_session_uuid",
"pane_process_generation")
def _timeline_by_kind(scenario):
out = {}
for item in scenario.get("timeline") or []:
out.setdefault(item.get("source_kind"), []).append(item)
return out
def collect_observed_identity(scenario) -> dict:
"""Gather identifiers the *evidence* carries, for comparison with dispatch.
Two sources, and the distinction matters. Association of a job or attempt to
an observation is the external collector's business, so it lives in a
``collector_attribution`` envelope beside the payload -- never inside it,
because a native Herdr payload must stay native (phase 0 forbids MAM fields
there, and a fabricated one would be indistinguishable from a real reading).
The report manifest is a filesystem fact and stays in that payload.
Every occurrence is kept. Taking only the first would drop a later,
contradicting observation, which is precisely what this exists to catch.
"""
observed = {}
for item in scenario.get("timeline") or []:
envelope = item.get("collector_attribution") or {}
for field in ATTRIBUTION_FIELDS:
if envelope.get(field) is not None:
observed.setdefault(field, []).append(envelope[field])
payload = item.get("payload") or {}
if item.get("source_kind") != "herdr":
# A MAM-side source (an event, a filesystem fact) legitimately names
# the job or attempt in its own payload. A native Herdr payload never
# may: phase 0 forbids MAM fields there, so an identifier appearing
# inside one is either fabricated or a contract violation, and either
# way it is not evidence.
for field in ATTRIBUTION_FIELDS:
if payload.get(field) is not None:
observed.setdefault(field, []).append(payload[field])
manifest = payload.get("report_manifest")
if isinstance(manifest, dict) and manifest.get("attempt_id") is not None:
observed.setdefault("manifest_attempt_id", []).append(manifest["attempt_id"])
return observed
def decide(scenario) -> Decision:
"""Map one composite scenario to a decision.
Order matters: transport doubt and staleness invalidate the evidence itself,
so they are answered before anything is inferred from it.
"""
dispatch = scenario.get("dispatch_context") or {}
by_kind = _timeline_by_kind(scenario)
herdr_items = by_kind.get("herdr") or []
latest = herdr_items[-1]["payload"] if herdr_items else {}
filesystem = scenario.get("filesystem") or {}
# 1. Transport doubt: evidence gathered before a gap cannot be trusted.
for item in herdr_items:
payload = item.get("payload") or {}
if payload.get("transport_healthy") is False or payload.get("error"):
return Decision("hold_reconnect",
"herdr transport failed; cached observations are void")
# 2. A signal from a previous attempt or process generation decides nothing.
for item in herdr_items:
payload = item.get("payload") or {}
pane_generation = payload.get("pane_process_generation")
if pane_generation and dispatch.get("pane_process_generation") and \
pane_generation != dispatch["pane_process_generation"]:
return Decision("reject_stale",
"signal belongs to a different process generation")
pane_id = payload.get("pane_id")
if pane_id and dispatch.get("pane_process_generation") and \
not attribution.pane_matches_generation(
pane_id, dispatch["pane_process_generation"]):
return Decision("reject_stale", "signal belongs to a different pane")
# 3. A blocked agent is waiting for a human. Completion is forbidden.
if latest.get("agent_status") == "blocked" or latest.get("matched_rule"):
return Decision("hold_blocked", "agent is blocked on an interactive prompt")
# 4. Attribution: the dispatch record must be well formed AND agree with the
# identifiers the evidence itself carries. Flags on the dispatch record
# alone are self-assertion, not correspondence.
observed = collect_observed_identity(scenario)
manifest_attempts = observed.pop("manifest_attempt_id", [])
attributed = attribution.check(dispatch)
if not attributed.ok:
return Decision("hold", attributed.reason)
# Absence of proof is not proof. Comparing only the identifiers that happen
# to be present would let evidence carrying none at all be approved on the
# dispatch record's own say-so, which is the self-assertion this check exists
# to replace. Every identifier must be supplied, then must agree.
for field in ATTRIBUTION_FIELDS:
if not observed.get(field):
return Decision("hold",
"no observed {} to attribute this completion to".format(field))
for value in observed[field]:
if value != dispatch.get(field):
return Decision("hold", "attribution mismatch on {}: observed {!r}, "
"dispatched {!r}".format(field, value,
dispatch.get(field)))
# The finalized report must belong to the attempt being judged. A missing
# manifest attempt holds for the same reason: it proves nothing.
if not manifest_attempts:
return Decision("hold", "no report manifest attempt to associate with this attempt")
for manifest_attempt in manifest_attempts:
if manifest_attempt != dispatch.get("attempt_id"):
return Decision("hold",
"report manifest belongs to attempt {!r}, not {!r}".format(
manifest_attempt, dispatch.get("attempt_id")))
# 5. Still working, or a tool is running underneath a ready prompt.
if latest.get("agent_status") not in QUIESCENT:
return Decision("hold", "agent is not quiescent (status={!r})".format(
latest.get("agent_status")), attribution_ok=True)
for item in by_kind.get("harness") or []:
if (item.get("payload") or {}).get("active"):
return Decision("hold", "a tool is still executing", attribution_ok=True)
# 6. The report must be finalized, not merely paused. A debounce alone is a
# candidate filter, never an authority to complete.
if not filesystem.get("exists"):
return Decision("hold", "no report present", attribution_ok=True)
if not filesystem.get("manifest_committed"):
return Decision("hold", "report is not finalized (no commit manifest)",
attribution_ok=True)
if not filesystem.get("hash_verified"):
return Decision("hold", "report manifest hash unverified", attribution_ok=True)
return Decision("candidate_approved",
"attribution, quiescence and a verified manifest all hold",
attribution_ok=True)