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
139 lines
5.3 KiB
Python
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)
|