feat(issue-5): auto-completion gate and durable pending notification

The gate does not re-decide anything. Phase 2a already decides, fail-closed and
against contract-pinned vectors; this supplies real evidence to that decision and
commits only if three independent gates agree: the global switch is on, the
backend has observed evidence that it reaches a quiescent state, and the job
itself opted in. Default is off.

The backend allowlist is evidence-based rather than optimistic. Inferring
"finished" from stillness requires stillness to have been observed, and only
three of six backends have that. An operator may narrow the list; a name that
was never observed is dropped rather than trusted, so it cannot be widened by
configuration.

Everything else holds. An unreadable manifest, a report rewritten after
finalization, missing evidence for a declared side effect, a policy that raises:
all refuse. "Could not decide" is never "done". A late explicit terminal wins,
because the boundary refuses the inferred one -- that is the boundary working,
not an error in the gate.

Notification intent is committed in the same atomic write as the state, so a
crash between committing and publishing leaves a record that still says the
world has not been told. Recovery re-sends; duplicates are already dropped by
sequence. A failed send deliberately leaves the intent pending, because an
unsent notification that looks sent is worse than one that is retried.

Two fixes this surfaced. Settling used a request that terminal records refuse,
which made an owed notification impossible to ever settle; settling is now a
status-free bookkeeping request, valid in any state. And the phase 2a guard
against shell references was matching hashlib.sha256 as a substring, so it now
requires a word boundary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd
This commit is contained in:
GodopuandClaude Opus 5 committed 2026-09-06 21:30:38 +09:00
1 parent 3ea5757cfe
commit 803ba43884
5 files changed
+513 -4

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
"""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)