diff --git a/.agents/skills/lib_py/loop/policy.py b/.agents/skills/lib_py/loop/policy.py new file mode 100644 index 0000000..76639a1 --- /dev/null +++ b/.agents/skills/lib_py/loop/policy.py @@ -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) diff --git a/.agents/skills/lib_py/loop/shadow.py b/.agents/skills/lib_py/loop/shadow.py new file mode 100644 index 0000000..8ac377e --- /dev/null +++ b/.agents/skills/lib_py/loop/shadow.py @@ -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] diff --git a/tests/fixtures/issue_5/watcher/attempt_reuse.json b/tests/fixtures/issue_5/watcher/attempt_reuse.json new file mode 100644 index 0000000..53c067a --- /dev/null +++ b/tests/fixtures/issue_5/watcher/attempt_reuse.json @@ -0,0 +1,61 @@ +{ + "schema_version": "1.0.0", + "scenario_id": "attempt_reuse", + "backend": "backend_agnostic", + "provenance": "simulated", + "description": "The same session was reused for a new attempt whose execution was never observed. Idle here is leftover quiescence from the prior attempt.", + "dispatch_context": { + "job_id": "test-job-attempt-reuse-01", + "attempt_id": 2, + "agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6", + "pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z", + "dispatch_ack": true, + "execution_anchor_observed": false + }, + "timeline": [ + { + "collector_clock_id": "CLOCK_MONOTONIC", + "relative_ms": 0, + "source_kind": "event", + "source_provenance": "simulated", + "payload": { + "event": "started", + "job_id": "test-job-finalized-01" + } + }, + { + "collector_clock_id": "CLOCK_MONOTONIC", + "relative_ms": 5000, + "source_kind": "filesystem", + "source_provenance": "simulated", + "payload": { + "report_manifest": { + "report_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "attempt_id": 1, + "finalized": true + } + } + }, + { + "collector_clock_id": "CLOCK_MONOTONIC", + "relative_ms": 6000, + "source_kind": "herdr", + "source_provenance": "simulated", + "payload": { + "agent_status": "idle", + "interactive_ready": true + } + } + ], + "filesystem": { + "report_path": ".mam/jobs/test-job-finalized-01/report-final.md", + "exists": true, + "manifest_committed": true, + "hash_verified": true + }, + "expected_policy": { + "decision": "hold", + "reason": "no execution anchor observed for the current attempt", + "policy_version": "2.0.0" + } +} diff --git a/tests/fixtures/issue_5/watcher/manifest.json b/tests/fixtures/issue_5/watcher/manifest.json index 975ba32..3a5ba94 100644 --- a/tests/fixtures/issue_5/watcher/manifest.json +++ b/tests/fixtures/issue_5/watcher/manifest.json @@ -43,6 +43,36 @@ "provenance": "simulated", "expected_decision": "reject_stale", "description": "Pane process restarted with new PID/start time; stale attempt rejected." + }, + "race_double_dispatch": { + "file": "race_double_dispatch.json", + "provenance": "simulated", + "expected_decision": "reject_stale", + "description": "Two dispatches raced; the observed signal carries the previous process generation. The stale attempt must not decide anything." + }, + "attempt_reuse": { + "file": "attempt_reuse.json", + "provenance": "simulated", + "expected_decision": "hold", + "description": "The same session was reused for a new attempt whose execution was never observed. Idle here is leftover quiescence from the prior attempt." + }, + "partial_report": { + "file": "partial_report.json", + "provenance": "simulated", + "expected_decision": "hold", + "description": "A report is mid-write: bytes on disk but no atomic rename and no commit manifest." + }, + "unknown_status": { + "file": "unknown_status.json", + "provenance": "simulated", + "expected_decision": "hold", + "description": "Native status is unknown. An unreadable state is not a quiescent one." + }, + "restart_midflight": { + "file": "restart_midflight.json", + "provenance": "simulated", + "expected_decision": "reject_stale", + "description": "The pane was restarted mid-flight, so the idle signal comes from a different pane than the one dispatched to." } } } diff --git a/tests/fixtures/issue_5/watcher/partial_report.json b/tests/fixtures/issue_5/watcher/partial_report.json new file mode 100644 index 0000000..e8ab9c9 --- /dev/null +++ b/tests/fixtures/issue_5/watcher/partial_report.json @@ -0,0 +1,59 @@ +{ + "schema_version": "1.0.0", + "scenario_id": "partial_report", + "backend": "backend_agnostic", + "provenance": "simulated", + "description": "A report is mid-write: bytes on disk but no atomic rename and no commit manifest.", + "dispatch_context": { + "job_id": "test-job-partial-report-01", + "attempt_id": 1, + "agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6", + "pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z", + "dispatch_ack": true, + "execution_anchor_observed": true + }, + "timeline": [ + { + "collector_clock_id": "CLOCK_MONOTONIC", + "relative_ms": 0, + "source_kind": "event", + "source_provenance": "simulated", + "payload": { + "event": "started", + "job_id": "test-job-finalized-01" + } + }, + { + "collector_clock_id": "CLOCK_MONOTONIC", + "relative_ms": 5000, + "source_kind": "filesystem", + "source_provenance": "simulated", + "payload": { + "report_manifest": null, + "bytes": 512, + "phase": "writing" + } + }, + { + "collector_clock_id": "CLOCK_MONOTONIC", + "relative_ms": 6000, + "source_kind": "herdr", + "source_provenance": "simulated", + "payload": { + "agent_status": "idle", + "interactive_ready": true + } + } + ], + "filesystem": { + "report_path": ".mam/jobs/test-job-finalized-01/report-final.md", + "exists": true, + "manifest_committed": false, + "hash_verified": false + }, + "expected_policy": { + "decision": "hold", + "reason": "report is still being written; no finalization manifest", + "policy_version": "2.0.0" + } +} diff --git a/tests/fixtures/issue_5/watcher/race_double_dispatch.json b/tests/fixtures/issue_5/watcher/race_double_dispatch.json new file mode 100644 index 0000000..0fd36db --- /dev/null +++ b/tests/fixtures/issue_5/watcher/race_double_dispatch.json @@ -0,0 +1,62 @@ +{ + "schema_version": "1.0.0", + "scenario_id": "race_double_dispatch", + "backend": "backend_agnostic", + "provenance": "simulated", + "description": "Two dispatches raced; the observed signal carries the previous process generation. The stale attempt must not decide anything.", + "dispatch_context": { + "job_id": "test-job-race-01", + "attempt_id": 2, + "agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6", + "pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z", + "dispatch_ack": true, + "execution_anchor_observed": true + }, + "timeline": [ + { + "collector_clock_id": "CLOCK_MONOTONIC", + "relative_ms": 0, + "source_kind": "event", + "source_provenance": "simulated", + "payload": { + "event": "started", + "job_id": "test-job-finalized-01" + } + }, + { + "collector_clock_id": "CLOCK_MONOTONIC", + "relative_ms": 5000, + "source_kind": "filesystem", + "source_provenance": "simulated", + "payload": { + "report_manifest": { + "report_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "attempt_id": 1, + "finalized": true + } + } + }, + { + "collector_clock_id": "CLOCK_MONOTONIC", + "relative_ms": 6000, + "source_kind": "herdr", + "source_provenance": "simulated", + "payload": { + "agent_status": "idle", + "interactive_ready": true, + "pane_process_generation": "w2X:p9:9999:2026-09-06T00:00:00Z" + } + } + ], + "filesystem": { + "report_path": ".mam/jobs/test-job-finalized-01/report-final.md", + "exists": true, + "manifest_committed": true, + "hash_verified": true + }, + "expected_policy": { + "decision": "reject_stale", + "reason": "signal belongs to a superseded process generation", + "policy_version": "2.0.0" + } +} diff --git a/tests/fixtures/issue_5/watcher/restart_midflight.json b/tests/fixtures/issue_5/watcher/restart_midflight.json new file mode 100644 index 0000000..39ef10f --- /dev/null +++ b/tests/fixtures/issue_5/watcher/restart_midflight.json @@ -0,0 +1,61 @@ +{ + "schema_version": "1.0.0", + "scenario_id": "restart_midflight", + "backend": "backend_agnostic", + "provenance": "simulated", + "description": "The pane was restarted mid-flight, so the idle signal comes from a different pane than the one dispatched to.", + "dispatch_context": { + "job_id": "test-job-restart-midflight-01", + "attempt_id": 1, + "agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6", + "pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z", + "dispatch_ack": true, + "execution_anchor_observed": true + }, + "timeline": [ + { + "collector_clock_id": "CLOCK_MONOTONIC", + "relative_ms": 0, + "source_kind": "event", + "source_provenance": "simulated", + "payload": { + "event": "started", + "job_id": "test-job-finalized-01" + } + }, + { + "collector_clock_id": "CLOCK_MONOTONIC", + "relative_ms": 5000, + "source_kind": "filesystem", + "source_provenance": "simulated", + "payload": { + "report_manifest": { + "report_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "attempt_id": 1, + "finalized": true + } + } + }, + { + "collector_clock_id": "CLOCK_MONOTONIC", + "relative_ms": 6000, + "source_kind": "herdr", + "source_provenance": "simulated", + "payload": { + "agent_status": "idle", + "pane_id": "w2X:p7" + } + } + ], + "filesystem": { + "report_path": ".mam/jobs/test-job-finalized-01/report-final.md", + "exists": true, + "manifest_committed": true, + "hash_verified": true + }, + "expected_policy": { + "decision": "reject_stale", + "reason": "signal belongs to a different pane", + "policy_version": "2.0.0" + } +} diff --git a/tests/fixtures/issue_5/watcher/unknown_status.json b/tests/fixtures/issue_5/watcher/unknown_status.json new file mode 100644 index 0000000..76a53db --- /dev/null +++ b/tests/fixtures/issue_5/watcher/unknown_status.json @@ -0,0 +1,61 @@ +{ + "schema_version": "1.0.0", + "scenario_id": "unknown_status", + "backend": "backend_agnostic", + "provenance": "simulated", + "description": "Native status is unknown. An unreadable state is not a quiescent one.", + "dispatch_context": { + "job_id": "test-job-unknown-01", + "attempt_id": 1, + "agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6", + "pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z", + "dispatch_ack": true, + "execution_anchor_observed": true + }, + "timeline": [ + { + "collector_clock_id": "CLOCK_MONOTONIC", + "relative_ms": 0, + "source_kind": "event", + "source_provenance": "simulated", + "payload": { + "event": "started", + "job_id": "test-job-finalized-01" + } + }, + { + "collector_clock_id": "CLOCK_MONOTONIC", + "relative_ms": 5000, + "source_kind": "filesystem", + "source_provenance": "simulated", + "payload": { + "report_manifest": { + "report_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "attempt_id": 1, + "finalized": true + } + } + }, + { + "collector_clock_id": "CLOCK_MONOTONIC", + "relative_ms": 6000, + "source_kind": "herdr", + "source_provenance": "simulated", + "payload": { + "agent_status": "unknown", + "interactive_ready": false + } + } + ], + "filesystem": { + "report_path": ".mam/jobs/test-job-finalized-01/report-final.md", + "exists": true, + "manifest_committed": true, + "hash_verified": true + }, + "expected_policy": { + "decision": "hold", + "reason": "agent status is unknown; fail closed", + "policy_version": "2.0.0" + } +} diff --git a/tests/test_issue5_phase0_contracts.py b/tests/test_issue5_phase0_contracts.py index 1e41e5c..d751e4d 100644 --- a/tests/test_issue5_phase0_contracts.py +++ b/tests/test_issue5_phase0_contracts.py @@ -437,6 +437,10 @@ def test_watcher_manifest_and_all_seven_scenarios_exist(): assert manifest_file.exists(), "watcher manifest.json must exist" manifest = json.loads(manifest_file.read_text(encoding="utf-8")) + # The phase-0 seven are the contract this file guards. Later phases may add + # scenarios (phase 2a adds five), so this asserts the seven still exist and + # are indexed rather than forbidding growth; validate_watcher_manifest keeps + # every entry, old or new, honest against its file. expected_scenarios = { "initial_idle", "tool_running", @@ -447,7 +451,8 @@ def test_watcher_manifest_and_all_seven_scenarios_exist(): "restarted_generation", } actual_scenarios = set(manifest["scenarios"].keys()) - assert actual_scenarios == expected_scenarios, f"Mismatch in watcher scenarios: {actual_scenarios}" + missing = expected_scenarios - actual_scenarios + assert not missing, f"phase-0 watcher scenarios went missing: {missing}" for sid, sinfo in manifest["scenarios"].items(): sfile = WATCHER_ROOT / sinfo["file"] diff --git a/tests/test_issue5_phase2a_loop.py b/tests/test_issue5_phase2a_loop.py index 8182c38..61d0bb1 100644 --- a/tests/test_issue5_phase2a_loop.py +++ b/tests/test_issue5_phase2a_loop.py @@ -537,3 +537,161 @@ def test_attribution_pane_generation_prefix_is_not_a_substring_match(): assert attribution.pane_matches_generation("w2X:p1", "w2X:p1:1234:t") assert not attribution.pane_matches_generation("w2X:p1", "w2X:p10:1234:t") assert not attribution.pane_matches_generation("", "w2X:p1:1:t") + + +# ============================================================================== +# 6. policy — checked against the phase-0 scenario contracts, not fresh rules +# ============================================================================== + +def _scenarios(): + out = {} + for path in sorted(WATCHER_FIXTURES.glob("*.json")): + if path.name == "manifest.json": + continue + data = json.loads(path.read_text(encoding="utf-8")) + out[data["scenario_id"]] = data + return out + + +@pytest.mark.parametrize("scenario_id", [ + # the phase-0 seven ... + "initial_idle", "tool_running", "report_pause_15s", "finalized_candidate", + "blocked_permission", "transport_disconnected", "restarted_generation", + # ... and the five P2A-04 requires, held to the same contract + "race_double_dispatch", "attempt_reuse", "partial_report", + "unknown_status", "restart_midflight", +]) +def test_g7_policy_matches_the_phase0_expected_decision(scenario_id): + """G7: the seven scenarios that survived three-reviewer scrutiny are the + contract. The policy must reproduce their decisions exactly.""" + from lib_py.loop import policy + scenario = _scenarios()[scenario_id] + expected = scenario["expected_policy"]["decision"] + got = policy.decide(scenario) + assert got.decision == expected, ( + "{}: expected {!r}, got {!r} ({})".format(scenario_id, expected, got.decision, got.reason) + ) + + +def test_policy_is_pure_no_io_no_clock(monkeypatch): + """Purity is what makes the fixtures usable as vectors; guard it.""" + import socket + from lib_py.loop import policy + + scenarios = list(_scenarios().values()) # load before the guards go up + + def boom(*_a, **_k): + raise AssertionError("policy.decide must not perform I/O") + + monkeypatch.setattr(subprocess, "run", boom) + monkeypatch.setattr(socket, "socket", boom) + monkeypatch.setattr(Path, "read_text", boom) + monkeypatch.setattr(time, "time", boom) + for scenario in scenarios: + policy.decide(scenario) + + +def test_policy_debounce_alone_never_authorises_completion(): + """A stable mtime is a candidate filter, not an authority to complete.""" + from lib_py.loop import policy + scenario = json.loads((WATCHER_FIXTURES / "finalized_candidate.json").read_text(encoding="utf-8")) + scenario["filesystem"]["manifest_committed"] = False + assert policy.decide(scenario).decision == "hold" + scenario["filesystem"]["manifest_committed"] = True + scenario["filesystem"]["hash_verified"] = False + assert policy.decide(scenario).decision == "hold" + + +def test_policy_holds_when_the_execution_anchor_is_missing(): + """Idle is not evidence of finishing; a reused session looks identical.""" + from lib_py.loop import policy + scenario = json.loads((WATCHER_FIXTURES / "finalized_candidate.json").read_text(encoding="utf-8")) + scenario["dispatch_context"]["execution_anchor_observed"] = False + decision = policy.decide(scenario) + assert decision.decision == "hold" and "anchor" in decision.reason + + +# ============================================================================== +# 7. shadow — cannot write, proven structurally and by hashing the registry +# ============================================================================== + +def test_g4_shadow_run_leaves_the_registry_byte_identical(mam_sandbox, tmp_path, monkeypatch): + """G4: stronger than 'status was not changed' -- nothing under the registry + directory changes at all.""" + monkeypatch.chdir(mam_sandbox) + from lib_py.loop import shadow + + reg = Path(_reg(tmp_path)) + job_ids = [_make_job(str(reg)) for _ in range(3)] + from lib_py.delegation import engine + engine.transition(job_ids[0], "claim", registry_dir=str(reg)) + before = _dir_fingerprint(reg) + assert before, "fixture precondition: the registry has content to protect" + + runner = shadow.ShadowRunner(workspace=str(mam_sandbox)) + results = runner.evaluate_all(list(_scenarios().values())) + + assert _dir_fingerprint(reg) == before, "shadow mode wrote to the registry" + assert any(r["would_complete"] for r in results), "the approved scenario must be reported" + assert all(r["shadow"] is True for r in results) + + +def test_g4_shadow_runner_holds_no_writer_at_all(): + """Structural, not promised: the runner has no route to a write.""" + import ast + + src = (SKILLS / "lib_py" / "loop" / "shadow.py").read_text(encoding="utf-8") + tree = ast.parse(src) + imported = set() + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + for alias in node.names: + imported.add("{}.{}".format(node.module, alias.name)) + elif isinstance(node, ast.Import): + for alias in node.names: + imported.add(alias.name) + assert not any("engine" in name for name in imported), imported + for forbidden in ("update_job_status", "_atomic_write_record", "transition("): + assert forbidden not in src, "shadow must not reference {}".format(forbidden) + + +def test_shadow_records_its_would_complete_decisions(mam_sandbox, monkeypatch): + monkeypatch.chdir(mam_sandbox) + from lib_py.loop import shadow, store + + scenario = _scenarios()["finalized_candidate"] + runner = shadow.ShadowRunner(workspace=str(mam_sandbox)) + entry = runner.evaluate(scenario) + assert entry["would_complete"] is True + logged = store.read_decisions(entry["job_id"], workspace=str(mam_sandbox)) + assert logged and logged[-1]["decision"] == "candidate_approved" + + +def test_g9_loop_package_imports_only_delegation_among_siblings(): + """G9: the rule that caught the engine's original placement.""" + import ast + + offenders = [] + for path in sorted((SKILLS / "lib_py" / "loop").glob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + names = [] + if isinstance(node, ast.Import): + names = [a.name for a in node.names] + elif isinstance(node, ast.ImportFrom) and node.module: + names = [node.module] + for name in names: + if name.startswith("lib_py.") and \ + not name.startswith("lib_py.loop") and \ + not name.startswith("lib_py.delegation"): + offenders.append("{}:{} {}".format(path.name, node.lineno, name)) + assert not offenders, offenders + + +def test_g9_phase2a_spawns_no_daemon_and_calls_no_frozen_helper(): + """The two phase 1 handoffs stay dormant: preexec_fn is unreachable because + nothing here spawns, and no frozen lib.sh helper is invoked.""" + for path in sorted((SKILLS / "lib_py" / "loop").glob("*.py")): + src = path.read_text(encoding="utf-8") + for forbidden in ("ensure_subscriber_daemon", "preexec_fn", "lib.sh"): + assert forbidden not in src, "{} references {}".format(path.name, forbidden)