Files
multi-agent-mux/.agents/skills/lib_py/loop/autocomplete.py
T
GodopuandClaude Opus 5 12cba25c18 fix(issue-5): keep finalize inside delegation and refresh the CLI golden
Two failures the full suite caught, both mine.

finalize.py started in lib_py/loop, which made publish_event import a sibling
package. That is the rule phase 1 wrote down and phase 2a already made me move
the engine for: under a frozen loop, delegation is bound to one tree while the
parent may be another, so reaching sideways mixes them. The manifest is written
into the job's own report directory and the CLI that declares it is a delegation
entry point, so finalize belongs there. Second time on the same rule, and the
guard caught it both times.

The publish_event --help golden no longer matched, because phase 2b
intentionally added the finalization options. The diff is exactly those three
options and nothing else, so the capture is refreshed rather than the assertion
weakened, and the test now states when a refresh is legitimate: an intended
extension, never drift nobody meant.

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

139 lines
5.3 KiB
Python

"""The auto-completion gate.
Phase 2a already decides, correctly and fail-closed. This does not re-decide;
it supplies real evidence to that decision and, only if three independent gates
agree, asks the transition boundary to commit.
Three gates, all of which must hold:
* the global switch is on (default off),
* the backend has observed evidence that it reaches a quiescent state,
* the job itself opted in.
Everything else is refusal. A failure anywhere -- an unreadable manifest, an
exception in the policy, an unknown decision -- holds. "Could not decide" is
never "done".
"""
from __future__ import annotations
import logging
import os
from typing import Any, Dict, List, Optional
from lib_py.delegation import finalize
from lib_py.loop import policy, store
logger = logging.getLogger("loop.autocomplete")
FLAG = "MAM_AUTO_COMPLETE"
BACKENDS_FLAG = "MAM_AUTO_COMPLETE_BACKENDS"
# Backends with recorded evidence that they reach a quiescent state. Auto
# completion infers "finished" from stillness, so a backend whose stillness has
# never been observed has no basis for the inference. agy has only been seen
# working; hermes and opencode have no observations at all.
EVIDENCE_BACKED_BACKENDS = ("claude", "grok", "codex")
SOURCE = "auto_complete"
def enabled() -> bool:
"""Default off. Read per call so an operator can flip it without a restart."""
return os.environ.get(FLAG, "0") == "1"
def allowed_backends() -> tuple:
"""The allowlist may be narrowed by an operator, never widened.
Widening would need new observations, so an unknown name in the environment
is dropped rather than trusted.
"""
raw = os.environ.get(BACKENDS_FLAG)
if not raw:
return EVIDENCE_BACKED_BACKENDS
requested = [name.strip() for name in raw.split(",") if name.strip()]
return tuple(n for n in requested if n in EVIDENCE_BACKED_BACKENDS)
class GateResult(object):
__slots__ = ("committed", "decision", "reason")
def __init__(self, committed, decision, reason):
self.committed = committed
self.decision = decision
self.reason = reason
def __repr__(self): # pragma: no cover - debugging aid
return "GateResult(committed={!r}, {!r}, {!r})".format(
self.committed, self.decision, self.reason)
def _gates_open(record: Dict[str, Any]) -> Optional[str]:
"""None when all three gates pass, else why they did not."""
if not enabled():
return "auto-completion is disabled ({}=0)".format(FLAG)
backend = record.get("agent") or ""
# `agent` may be a delegate key such as `claude-code`; match on the prefix.
if not any(backend == b or backend.startswith(b) for b in allowed_backends()):
return "backend {!r} has no observed quiescent-state evidence".format(backend)
if not record.get("auto_complete"):
return "job did not opt in to auto-completion"
return None
def evaluate(job_id, scenario, registry_dir=None, workspace=None,
report_dir=None) -> GateResult:
"""Decide, and commit only if every gate agrees.
`scenario` is the composite evidence the watcher assembled, in the same shape
the phase 0 fixtures use, so the decision is made by exactly the code the
scenario contracts already pin.
"""
from lib_py.delegation import engine, mqtt_common
registry_dir = registry_dir or mqtt_common.DEFAULT_REGISTRY_DIR
try:
record = mqtt_common.load_job(job_id, registry_dir)
except FileNotFoundError:
return GateResult(False, "hold", "no such job")
closed = _gates_open(record)
if closed:
return GateResult(False, "hold", closed)
# The manifest is verified against the file on disk, not against itself, and
# against any side effects this job declared it would perform.
dispatch = store.load_dispatch(job_id, workspace) or {}
attempt = dispatch.get("attempt_id")
if report_dir is not None:
verdict = finalize.verify_manifest(
report_dir, job_id, expected_attempt=attempt,
expected_artifacts=record.get("expected_artifacts") or [])
if not verdict.ok:
return GateResult(False, "hold", verdict.reason)
try:
decision = policy.decide(scenario)
except Exception as exc: # a policy that cannot decide has not approved
logger.warning("policy raised for %s: %s", job_id, exc)
return GateResult(False, "hold", "policy error: {}".format(exc))
if not decision.would_complete:
return GateResult(False, decision.decision, decision.reason)
# The attempt guard lives here, in the evidence, not in the record: a record
# carries a loop-iteration counter, which is a different thing.
result = engine.transition(
job_id, "completed", registry_dir=registry_dir,
expected_status="running", source=SOURCE,
fields={"completed_by": SOURCE, "auto_complete_reason": decision.reason},
notify="completed",
)
if not result.committed:
# A late explicit terminal, or a job that moved on. The boundary refused;
# that is the boundary doing its job, not an error here.
logger.info("auto-completion declined for %s: %s", job_id, result.reason)
return GateResult(False, "hold", result.reason)
logger.info("auto-completed %s (%s)", job_id, decision.reason)
return GateResult(True, decision.decision, decision.reason)