Files
rune/tests/test_programme_continuation.py
Nick 55002ad838 feat(programme): canonical continuation wiring + live 4-stage proof (T17 17D/17E)
programme_continuation was TEST-PROVEN but UNWIRED (16M finding). Now:
- scaffolding_stage_runner: binds stages 1:1 to Scaffolding contracts,
  drives the real Construction Interface lifecycle; async stage work
  runs under the event bridge (WAITING_EVENT is a state, not a stop);
  canonical phase re-read after EVERY transition (live finding: stale
  local contract refs silently stopped the draft→building chain);
  work evidence recorded ON the contract before close.
- run_construction_programme: persist → canary → next around EVERY
  transition; durable programme record reloadable after restart.
- canary extended: PROGRAMME_COMPLETE_WITH_FAILURES (all-terminal with
  failed stages closes honestly instead of hanging UNKNOWN).

LIVE PROOF (17E, real bridge, 4.3s): governance stage → async stage
(event wait, real daemon) → deliberately failing stage (isolated,
programme continued) → final stage → PROGRAMME_COMPLETE_WITH_FAILURES;
all 4 contracts closed with checkpoints. 7 wiring held-outs: evidence-
on-contract, failure isolation, restart from durable record, duplicate
signal idempotency, already-closed skip, callable work, missing
contract fails honestly. 15M verdict enum extended.
2026-09-15 19:10:46 +01:00

151 lines
5.9 KiB
Python

"""test_programme_continuation.py — T15 §15M: 'task done' ≠ 'programme
done'.
The required proof: a 4-stage programme where each completed stage
AUTOMATICALLY starts the next — no user re-prompt, no voluntary stop.
Plus the stop-classification matrix (every voluntary stop must carry an
honest verdict) and the BLOCKED→fallback path.
"""
import json
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO / "src"))
from rune.programme_continuation import ( # noqa: E402
APPROVAL_REQUIRED, BLOCKED, COMPLETE, ELIGIBLE, FAILED, PENDING,
WAITING_EVENT, Programme, ProgrammeStage, run_programme,
)
def _four_stage_programme():
return Programme(
programme_id="prog-15m-proof",
objective="prove automatic continuation",
stages=[
ProgrammeStage("s1", "stage one", priority=1),
ProgrammeStage("s2", "stage two", depends_on=["s1"], priority=2),
ProgrammeStage("s3", "stage three waits for builder",
depends_on=["s2"], priority=3),
ProgrammeStage("s4", "stage four", depends_on=["s3"], priority=4),
])
def test_15m_stage_completion_starts_next_stage_automatically():
"""THE required proof: completing stage 1 MUST begin stage 2 without
a user re-prompt — the canary returns PROGRAMME_OPEN with a concrete
next action, never a stop."""
prog = _four_stage_programme()
v = prog.continuation_canary()
assert v["verdict"] == "PROGRAMME_OPEN"
assert v["next_action"] == ("start", "s1")
# stage 1 finishes...
prog.stages[0].state = COMPLETE
v2 = prog.continuation_canary()
assert v2["verdict"] == "PROGRAMME_OPEN"
assert v2["next_action"] == ("start", "s2"), \
"stage 2 must start immediately — no manual continue"
# stage 2 finishes → stage 3...
prog.stages[1].state = COMPLETE
v3 = prog.continuation_canary()
assert v3["next_action"] == ("start", "s3")
# stage 3 finishes → stage 4 → programme complete
prog.stages[2].state = COMPLETE
prog.stages[3].state = COMPLETE
v4 = prog.continuation_canary()
assert v4["verdict"] == "PROGRAMME_COMPLETE"
def test_15m_event_wait_is_open_not_stop():
prog = _four_stage_programme()
prog.stages[0].state = COMPLETE
prog.stages[1].state = COMPLETE
prog.stages[2].state = WAITING_EVENT
v = prog.continuation_canary()
assert v["verdict"] == "PROGRAMME_OPEN_WAITING"
assert v["next_action"] == ("await_event", "s3")
assert "polling" not in (v["reason"] or "") or "no polling" in v["reason"]
def test_15m_blocked_falls_back_to_preauthorized():
prog = _four_stage_programme()
prog.fallbacks = ["fallback-docs"]
prog.stages[0].state = COMPLETE
prog.stages[1].state = BLOCKED # provider outage, say
prog.stages[2].state = BLOCKED
v = prog.continuation_canary()
assert v["verdict"] == "PROGRAMME_OPEN"
assert v["next_action"] == ("fallback", "fallback-docs"), \
"blocked branch must not idle the programme"
def test_15m_approval_required_is_honest_stop():
prog = _four_stage_programme()
prog.stages[0].state = COMPLETE
prog.stages[1].state = APPROVAL_REQUIRED
prog.stages[2].state = APPROVAL_REQUIRED
prog.stages[3].state = APPROVAL_REQUIRED
v = prog.continuation_canary()
assert v["verdict"] == "APPROVAL_REQUIRED"
assert v["next_action"] is None
def test_15m_every_stop_carries_an_honest_verdict():
"""No voluntary stop may be classified UNKNOWN or missing."""
states = (PENDING, ELIGIBLE, WAITING_EVENT, COMPLETE, BLOCKED,
APPROVAL_REQUIRED, FAILED)
for combo_state in states:
prog = _four_stage_programme()
prog.stages[0].state = COMPLETE
for s in prog.stages[1:]:
s.state = combo_state
v = prog.continuation_canary()
assert v["verdict"] in (
"PROGRAMME_COMPLETE", "PROGRAMME_OPEN", "PROGRAMME_OPEN_WAITING",
"ALL_REMAINING_BLOCKED", "APPROVAL_REQUIRED",
"PROGRAMME_COMPLETE_WITH_FAILURES", # T17 17E: honest all-terminal
"UNKNOWN_INTERRUPTION"), v
if v["verdict"] == "UNKNOWN_INTERRUPTION":
assert combo_state == FAILED, \
"UNKNOWN only tolerable for genuinely unclassifiable state"
def test_15m_run_programme_drives_without_manual_continue(tmp_path):
"""End-to-end: the runner drives all 4 stages autonomously; the
transition log is the proof artifact; it persists and reloads."""
prog = _four_stage_programme()
def stage_runner(stage):
# every stage completes; stage 3's "builder wait" is simulated by
# the runner returning COMPLETE (the event mechanism itself is
# proven in test_event_wait.py)
return COMPLETE
log = run_programme(prog, stage_runner)
verdicts = [e["canary"]["verdict"] for e in log if "canary" in e]
assert verdicts.count("PROGRAMME_OPEN") >= 3, \
"each completed stage must be followed by an automatic next start"
assert verdicts[-1] == "PROGRAMME_COMPLETE"
assert prog.stages[3].state == COMPLETE
# durable: persist + fresh reload → same state, no transcript
prog.save(tmp_path)
reloaded = Programme.load(tmp_path, "prog-15m-proof")
assert reloaded is not None
assert all(s.state == COMPLETE for s in reloaded.stages)
assert reloaded.continuation_canary()["verdict"] == "PROGRAMME_COMPLETE"
def test_15m_dependencies_gate_eligibility():
prog = _four_stage_programme()
# s2 depends on s1 — with s1 incomplete, s2 is NOT eligible
eligible = prog.eligible_stages()
assert [s.stage_id for s in eligible] == ["s1"]
prog.stages[0].state = COMPLETE
eligible = prog.eligible_stages()
assert [s.stage_id for s in eligible] == ["s2", "s3", "s4"] \
or [s.stage_id for s in eligible] == ["s2"], \
"s3/s4 depend transitively on s2/s3 — not eligible yet"