feat(issue-5): shadow watcher that logs would-complete and cannot write
policy.decide is a pure function so the phase-0 scenario fixtures can be fed to it verbatim. Those seven decisions survived three-reviewer scrutiny, so they are the contract rather than a fresh set of rules, and the test asserts against expected_policy.decision directly. Order inside the policy matters. Transport doubt and staleness invalidate the evidence itself, so they are answered before anything is inferred from it; a blocked agent is waiting for a human and can never be completed; attribution including the execution anchor comes before quiescence, because idle is not evidence of finishing when a session is reused; and a report must be finalized, never merely paused, since a debounce is a candidate filter and not an authority. The shadow runner is never handed the engine, so it has no route to a write. That is the structural half of P2A-03. The measured half hashes every file under the registry directory around a full run and asserts it is unchanged, which is a stronger claim than checking a status field. Adds the five scenarios P2A-04 requires: a raced double dispatch, a reused attempt with no anchor, a mid-write report, an unknown status, and a pane restarted mid-flight. They pass phase 0's own fixture validator, which is what keeps them honest. The phase-0 test that pinned exactly seven scenarios now requires the seven to still exist rather than forbidding growth. Also pins the two phase 1 handoffs as dormant: nothing here spawns a daemon, so preexec_fn stays unreachable, and nothing calls a frozen shell helper. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd
This commit is contained in:
1 parent
b49af56c3c
commit
964abc6965
10 files changed
+639
-1
No files matched your search
@@ -0,0 +1,90 @@
|
||||
"""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")
|
||||
|
||||
|
||||
def _timeline_by_kind(scenario):
|
||||
out = {}
|
||||
for item in scenario.get("timeline") or []:
|
||||
out.setdefault(item.get("source_kind"), []).append(item)
|
||||
return out
|
||||
|
||||
|
||||
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, including the execution anchor.
|
||||
attributed = attribution.check(dispatch)
|
||||
if not attributed.ok:
|
||||
return Decision("hold", attributed.reason)
|
||||
|
||||
# 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)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Shadow runner: decides what it *would* commit, and cannot commit it.
|
||||
|
||||
P2A-03 requires that this phase never writes registry state. That is enforced
|
||||
structurally rather than promised: ShadowRunner is never given the transition
|
||||
engine, so it has no means to write. The gate goes further and hashes the whole
|
||||
registry directory around a run, which is a stronger claim than checking that a
|
||||
status field did not change.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from lib_py.loop import policy, store
|
||||
|
||||
logger = logging.getLogger("loop.shadow")
|
||||
|
||||
|
||||
class ShadowRunner(object):
|
||||
"""Runs observation -> decision -> log. Holds no writer of any kind."""
|
||||
|
||||
def __init__(self, workspace=None, decide: Callable = policy.decide, sink=None):
|
||||
self.workspace = workspace
|
||||
self._decide = decide
|
||||
self._sink = sink if sink is not None else self._default_sink
|
||||
|
||||
@staticmethod
|
||||
def _default_sink(job_id, entry, workspace=None):
|
||||
store.append_decision(job_id, entry, workspace=workspace)
|
||||
|
||||
def evaluate(self, scenario: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Decide for one composite scenario and record the would-be outcome."""
|
||||
job_id = (scenario.get("dispatch_context") or {}).get("job_id") or \
|
||||
scenario.get("scenario_id") or "unknown"
|
||||
decision = self._decide(scenario)
|
||||
entry = {
|
||||
"job_id": job_id,
|
||||
"scenario_id": scenario.get("scenario_id"),
|
||||
"would_complete": decision.would_complete,
|
||||
"shadow": True,
|
||||
}
|
||||
entry.update(decision.as_dict())
|
||||
self._sink(job_id, entry, workspace=self.workspace)
|
||||
if decision.would_complete:
|
||||
logger.info("shadow: WOULD-COMPLETE job %s (%s)", job_id, decision.reason)
|
||||
else:
|
||||
logger.debug("shadow: hold job %s (%s)", job_id, decision.reason)
|
||||
return entry
|
||||
|
||||
def evaluate_all(self, scenarios) -> list:
|
||||
return [self.evaluate(s) for s in scenarios]
|
||||
Reference in new issue
Block a user