diff --git a/src/rune/construction_interface.py b/src/rune/construction_interface.py index 299de2ae..2ec617c5 100644 --- a/src/rune/construction_interface.py +++ b/src/rune/construction_interface.py @@ -36,6 +36,7 @@ from typing import Any import json import sys +import uuid from pathlib import Path from rune import structural_store as ss @@ -421,7 +422,8 @@ class ConstructionInterface: errors.append({"error": Errors.INSUFFICIENT_EVIDENCE, "detail": trace["rationale"][:200]}) contract = None if generate and trace["resolution"] not in ("INSUFFICIENT_EVIDENCE",): - cid = contract_id or f"BUILDER-{ss.now()}-{self.builder}" + cid = (contract_id + or f"BUILDER-{ss.now()}-{uuid.uuid4().hex[:6]}-{self.builder}") cd.generate_contract( self.scaffold, doc, @@ -768,21 +770,62 @@ class ConstructionInterface: material_closure = self._auto_material_change( scope_files or [], note=f"contract {contract_id} closed (material change)") + continuation = self._programme_continuation(contract_id) + state = { + "phase": "closed", + "material_change_closure": material_closure, + "contract_quality": { + k: q[k] + for k in ("correctly_predicted", "expected_new_observed", "genuinely_unexpected_new", "scope_check") + if k in q + }, + } + if continuation is not None: + state["programme_continuation"] = continuation return _envelope( "close", success=True, ids={"contract_id": contract_id}, - state={ - "phase": "closed", - "material_change_closure": material_closure, - "contract_quality": { - k: q[k] - for k in ("correctly_predicted", "expected_new_observed", "genuinely_unexpected_new", "scope_check") - if k in q - }, - }, + state=state, ) + def _programme_continuation(self, contract_id: str) -> dict | None: + """T5.15 slice 3 (the programme_continuation wire): after a legal + close, any persisted Programme stage bound to this contract is + marked COMPLETE and the continuation canary's verdict rides the + close envelope — 'task done' ≠ 'programme done', and the next + eligible stage is SURFACED, never guessed. No programme bound → + None (no continuation block in the envelope). Failures degrade: + a broken programme ledger never fails a real closure.""" + try: + from pathlib import Path as _Path + + from rune import programme_continuation as pc + + base = _Path(self.scaffold.current).parent / pc.DEFAULT_PROGRAMME_AREA + if not base.is_dir(): + return None + for prog_file in sorted(base.glob("*.json")): + prog = pc.Programme.load(base, prog_file.stem) + if prog is None: + continue + stage = next((s for s in prog.stages + if s.stage_id == contract_id + and s.state != pc.COMPLETE), None) + if stage is None: + continue + stage.state = pc.COMPLETE + stage.result["closed_at_contract"] = contract_id + prog.save(base) + return { + "programme_id": prog.programme_id, + "stage_marked_complete": contract_id, + "canary": prog.continuation_canary(), + } + except Exception as exc: # noqa: BLE001 — ledger failure never fails a closure + return {"error": f"programme continuation degraded: {exc}"[:200]} + return None + def _auto_material_change(self, scope_files: list[str], *, note: str) -> dict: """T21.4 §21.4H (rule 19Q.2): governed construction closes → the diff --git a/tests/test_construction_interface.py b/tests/test_construction_interface.py index 8da4fcd8..75e6728a 100644 --- a/tests/test_construction_interface.py +++ b/tests/test_construction_interface.py @@ -6,6 +6,7 @@ import json import os import subprocess import sys +from pathlib import Path import pytest @@ -671,3 +672,86 @@ def test_15f_transition_to_building_runs_no_guards(tmp_path): iface.scaffold.commit(doc, what="attach guard") env = iface.transition(cid, "approved") # NOT a completion edge assert env["success"] + + +# ── T5.15 slice 3: programme_continuation wired into close() ──────────────── + + +def _seed_programme(iface, programme_id, stage_contract_ids, tmp_path): + """Persist a real Programme bound to the interface's scaffold store.""" + from rune import programme_continuation as pc + + base = Path(iface.scaffold.current).parent / pc.DEFAULT_PROGRAMME_AREA + prog = pc.Programme(programme_id=programme_id, objective="wave test") + for i, cid in enumerate(stage_contract_ids): + prog.stages.append(pc.ProgrammeStage( + stage_id=cid, objective=f"stage {i}", depends_on=[])) + prog.save(base) + return prog, base + + +def test_close_marks_programme_stage_complete_and_surfaces_continuation(tmp_path): + """Closing a contract that is a stage of a persisted Programme marks + that stage COMPLETE and the envelope carries the continuation canary — + the programme's next eligible stage is surfaced, not guessed.""" + from rune import programme_continuation as pc + + iface = _iface(tmp_path) + env1 = _submit(iface, objective="wave stage one") + cid1 = env1["ids"]["contract_id"] + env2 = _submit(iface, objective="wave stage two") + cid2 = env2["ids"]["contract_id"] + # drive both through to verifying so close is legal + for cid in (cid1, cid2): + iface.transition(cid, "approved") + iface.transition(cid, "building") + iface.transition(cid, "verifying") + prog, base = _seed_programme(iface, "wave-x", [cid1, cid2], tmp_path) + env = iface.close(cid1, observed_evidence=[{"entity": "capability:exporter", "detail": "done"}]) + assert env["success"], env["errors"] + cont = env["state"].get("programme_continuation") + assert cont, "close envelope must carry the programme continuation" + assert cont["programme_id"] == "wave-x" + assert cont["stage_marked_complete"] == cid1 + reloaded = pc.Programme.load(base, "wave-x") + stage1 = next(s for s in reloaded.stages if s.stage_id == cid1) + assert stage1.state == pc.COMPLETE + assert cont["canary"]["verdict"] == "PROGRAMME_OPEN" + assert cont["canary"]["next_action"] == ("start", cid2) + + +def test_close_without_programme_has_no_continuation_block(tmp_path): + iface = _iface(tmp_path) + env = _submit(iface) + cid = env["ids"]["contract_id"] + iface.transition(cid, "approved") + iface.transition(cid, "building") + iface.transition(cid, "verifying") + env = iface.close(cid, observed_evidence=[{"entity": "capability:exporter", "detail": "done"}]) + assert env["success"] + assert "programme_continuation" not in env["state"] + + +def test_close_programme_completion_verdict_surfaces(tmp_path): + """Closing the LAST stage yields PROGRAMME_COMPLETE — the canary says + stop, the runner never guesses.""" + iface = _iface(tmp_path) + env = _submit(iface) + cid = env["ids"]["contract_id"] + iface.transition(cid, "approved") + iface.transition(cid, "building") + iface.transition(cid, "verifying") + prog, base = _seed_programme(iface, "solo", [cid], tmp_path) + env = iface.close(cid, observed_evidence=[{"entity": "capability:exporter", "detail": "done"}]) + cont = env["state"]["programme_continuation"] + assert cont["canary"]["verdict"] == "PROGRAMME_COMPLETE" + + +def test_two_submits_same_second_get_distinct_contract_ids(tmp_path): + """Second-granularity timestamps collide when two needs are filed in + the same second (real builder-pair scenario) — ids must be distinct.""" + iface = _iface(tmp_path) + e1 = _submit(iface, objective="wave stage one") + e2 = _submit(iface, objective="wave stage two") + assert e1["success"] and e2["success"], (e1["errors"], e2["errors"]) + assert e1["ids"]["contract_id"] != e2["ids"]["contract_id"]