"""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.loop import finalize, 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)