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
52 lines
2.0 KiB
Python
52 lines
2.0 KiB
Python
"""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]
|