Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94ad81245b | ||
|
|
196cf193df | ||
|
|
f95b2a63df | ||
|
|
708871e511 | ||
|
|
18ef9a53e3 | ||
|
|
05edd68027 | ||
|
|
d166e83d55 | ||
|
|
98b4c58d36 | ||
|
|
f5c8b07d15 | ||
|
|
292c72ec3d | ||
|
|
0a436b2f97 | ||
|
|
2ba4114fce | ||
|
|
eccb446a3c |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Rune
|
||||
|
||||
**Current release: v0.66.58**
|
||||
**Current release: v0.66.59**
|
||||
|
||||
**Rune is a persistent personal agent designed to accumulate experience, develop reusable competence, and remain recognisably the same agent even when its underlying foundation model changes.**
|
||||
|
||||
|
||||
@@ -227,11 +227,15 @@
|
||||
"construction_patterns_builtin",
|
||||
"context_broker",
|
||||
"context_pressure",
|
||||
"event_wait",
|
||||
"failure_recovery",
|
||||
"layout_contract",
|
||||
"programme_continuation",
|
||||
"release_notes",
|
||||
"salience_gate",
|
||||
"scaffold",
|
||||
"spine",
|
||||
"stage_guards",
|
||||
"structural_store",
|
||||
"toolchain",
|
||||
"tui_backlog",
|
||||
@@ -247,4 +251,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rune"
|
||||
version = "0.66.58"
|
||||
version = "0.66.59"
|
||||
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
|
||||
@@ -196,19 +196,42 @@ def _is_runtime_residue(path: str) -> bool:
|
||||
|
||||
|
||||
def audit_scope(project_dir: Path, before: dict,
|
||||
allowed: list[str]) -> dict:
|
||||
allowed: list[str], *,
|
||||
runtime_declared: set[str] | None = None) -> dict:
|
||||
"""§15O PROVENANCE-AWARE residue classification.
|
||||
|
||||
The T14 prefix rule (.git/.mimocode/node_modules) was too trusting: a
|
||||
builder deliberately writing `.mimocode/evil` would vanish from the
|
||||
audit. Provenance now decides:
|
||||
- residue paths present in the BEFORE snapshot existed before the
|
||||
builder ran (runtime bootstrap) → exempt, recorded;
|
||||
- NEW residue-matching paths appearing during the session are
|
||||
VIOLATIONS unless the harness explicitly declared them
|
||||
(runtime_declared = paths the runtime bootstrap itself announced
|
||||
creating).
|
||||
"""
|
||||
runtime_declared = runtime_declared or set()
|
||||
after = snapshot_files(project_dir)
|
||||
changed = sorted(set(after) - set(before))
|
||||
modified = sorted(k for k in after
|
||||
if k in before and after[k] != before[k])
|
||||
residue = sorted(f for f in changed + modified
|
||||
if _is_runtime_residue(f))
|
||||
violations = sorted(f for f in changed + modified
|
||||
if f not in allowed and not _is_runtime_residue(f))
|
||||
return {"created": [f for f in changed if not _is_runtime_residue(f)],
|
||||
"modified": [f for f in modified if not _is_runtime_residue(f)],
|
||||
"runtime_residue": residue,
|
||||
"scope_violations": violations,
|
||||
residue = []
|
||||
violations = []
|
||||
for f in changed + modified:
|
||||
if f not in allowed:
|
||||
if _is_runtime_residue(f):
|
||||
if f in before or f in runtime_declared:
|
||||
residue.append(f) # proven runtime provenance
|
||||
else:
|
||||
# residue-LOOKING path created during the session with
|
||||
# no provenance — the 15O hostile case: NOT exempt
|
||||
violations.append(f)
|
||||
else:
|
||||
violations.append(f)
|
||||
return {"created": [f for f in changed if f not in violations],
|
||||
"modified": [f for f in modified if f not in violations],
|
||||
"runtime_residue": sorted(set(residue)),
|
||||
"scope_violations": sorted(set(violations)),
|
||||
"in_scope": not violations}
|
||||
|
||||
|
||||
@@ -264,26 +287,14 @@ def complete_lifecycle(iface: ci.ConstructionInterface, contract_id: str,
|
||||
|
||||
|
||||
def discover_contract_id(store: Path) -> tuple[str, str] | None:
|
||||
"""Purely canonical discovery: walk the store's project scaffolds and
|
||||
return (project, contract_id) for the most recent contract. No session
|
||||
file, no transcript — exactly what a fresh builder may know."""
|
||||
projects_dir = store / "projects"
|
||||
if not projects_dir.exists():
|
||||
"""§15U: delegates to the canonical interface
|
||||
(ConstructionInterface.discover_active_contract). This shim stays only
|
||||
so existing driver call sites keep working."""
|
||||
from rune import construction_interface as _ci
|
||||
env = _ci.ConstructionInterface.discover_active_contract(str(store))
|
||||
if not env.get("success"):
|
||||
return None
|
||||
best: tuple[str, str, str] | None = None
|
||||
for proj_current in sorted(projects_dir.glob("*/current.json")):
|
||||
project = proj_current.parent.name
|
||||
try:
|
||||
doc = json.loads(proj_current.read_text())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
for e in doc["entities"]:
|
||||
if e["kind"] == "construction_contract":
|
||||
key = (e.get("observed_at") or "", project,
|
||||
e["id"].split(":", 1)[1])
|
||||
if best is None or key > (best[0], best[1], best[2]):
|
||||
best = key
|
||||
return (best[1], best[2]) if best else None
|
||||
return env["ids"]["project"], env["ids"]["contract_id"]
|
||||
|
||||
|
||||
def seed_requirement_spec(store: Path, project: str) -> str:
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
#!/usr/bin/env python3
|
||||
"""t15_builder_session.py — T15 §15Q: REAL event-driven builder proof.
|
||||
|
||||
The difference from T14, enforced by construction:
|
||||
|
||||
launch → register WAITING_EVENT → YIELD (select on the bridge FIFO)
|
||||
→ builder exit event → resume immediately → reload canonical state
|
||||
→ readiness/exit canaries → independent verification → continuation
|
||||
|
||||
NO sleep/poll loops anywhere on the normal path. The watchdog is only a
|
||||
failure detector (lost event / dead process), never the scheduler.
|
||||
|
||||
Modes:
|
||||
start classify → activate → contract → real builder → event wait
|
||||
→ exit canary → verification → evidence-backed close
|
||||
interrupt like start, but the builder process group is SIGKILLed at
|
||||
the first source file → PROCESS_LOST → canonical state
|
||||
persists for a fresh builder (run `resume` next)
|
||||
resume FRESH controller + fresh builder from canonical state only
|
||||
status print canonical construction state
|
||||
|
||||
The builder is the real model process (mimo run — Verboo per the standing
|
||||
provider policy). Builder identity is recorded, never trusted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import t14_builder_session as t14 # noqa: E402 — reuse the proven pieces
|
||||
from rune import construction_interface as ci # noqa: E402
|
||||
from rune import event_wait as ew # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
from rune.event_wait import DEFAULT_DAEMON_URL # noqa: E402
|
||||
|
||||
BUILDER_MODEL = os.environ.get("T15_BUILDER_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
DAEMON_URL = os.environ.get("T15_DAEMON_URL", DEFAULT_DAEMON_URL)
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"[t15] {msg}", flush=True)
|
||||
|
||||
|
||||
def readiness_canary(toolchain_hint: str) -> dict:
|
||||
"""§15F: may the build stage begin? Executable checks, not optimism.
|
||||
Runtime checks derive from the verifier (15R generalisation)."""
|
||||
checks = []
|
||||
runtimes = (["node"] if "node" in VERIFIER else []) + (["python3"] if "python" in VERIFIER else [])
|
||||
for rt in runtimes:
|
||||
r = subprocess.run([rt, "--version"], capture_output=True, text=True)
|
||||
checks.append({"name": f"{rt}_present", "passed": r.returncode == 0,
|
||||
"evidence": (r.stdout or "").strip()[:30]})
|
||||
r2 = subprocess.run(["mimo", "--version"], capture_output=True, text=True)
|
||||
checks.append({"name": "builder_cli_present", "passed": r2.returncode == 0,
|
||||
"evidence": (r2.stdout or "").strip()[:40]})
|
||||
try:
|
||||
import urllib.request
|
||||
with urllib.request.urlopen(DAEMON_URL + "/health", timeout=3) as resp:
|
||||
ok = resp.status == 200
|
||||
checks.append({"name": "event_bridge_healthy", "passed": ok,
|
||||
"evidence": DAEMON_URL})
|
||||
except Exception as e: # noqa: BLE001
|
||||
checks.append({"name": "event_bridge_healthy", "passed": False,
|
||||
"evidence": str(e)[:80]})
|
||||
verdict = "READY" if all(c["passed"] for c in checks) else "BLOCKED"
|
||||
return {"canary": "readiness", "verdict": verdict, "checks": checks}
|
||||
|
||||
|
||||
# 15R: the driver is construction INFRASTRUCTURE — the request/scope are
|
||||
# parameters, never scheduler-specific constants. Defaults preserve the
|
||||
# proven fixture.
|
||||
REQUEST = os.environ.get(
|
||||
"T15_REQUEST",
|
||||
"Build me a small standalone practice scheduler that can create, "
|
||||
"list and complete practice items.")
|
||||
SCOPE = [s for s in os.environ.get(
|
||||
"T15_SCOPE", "scheduler.js,scheduler.test.js,package.json").split(",")
|
||||
if s]
|
||||
PROJECT_NAME = os.environ.get("T15_PROJECT_NAME", "")
|
||||
SPEC_CONTRACT = os.environ.get("T15_SPEC_CONTRACT",
|
||||
"contract:practice-items-spec")
|
||||
TOOLCHAIN = os.environ.get("T15_TOOLCHAIN",
|
||||
"node >= 18 (node:test built-in runner, ES modules)")
|
||||
VERIFIER = os.environ.get("T15_VERIFIER", "node --test")
|
||||
|
||||
|
||||
def setup_construction(store: Path, project_dir: Path) -> tuple[str, str]:
|
||||
"""classify → activate → seed spec → contract → transitions. Reuses
|
||||
the proven T14 canonical path unchanged."""
|
||||
staged = t14.classify_and_activate(store, REQUEST)
|
||||
project = staged["activation"]["project"]
|
||||
assert staged["activation"]["mode"] == "PROJECT_BUILD"
|
||||
_seed_spec(store, project)
|
||||
iface = ci.ConstructionInterface(
|
||||
builder=BUILDER_MODEL,
|
||||
project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
sub = iface.submit_need(
|
||||
REQUEST, origin="rune_native",
|
||||
constraints={"environment": {"os": "linux",
|
||||
"runtime": TOOLCHAIN,
|
||||
"runner": VERIFIER},
|
||||
"contracts": [SPEC_CONTRACT]},
|
||||
affected_surface=list(SCOPE),
|
||||
evidence_refs=["t15-user-request", "node-v22-installed"],
|
||||
approval_context="t15 user-approved fixture")
|
||||
assert sub.get("success"), sub.get("errors")
|
||||
contract_id = sub["ids"]["contract_id"]
|
||||
for phase in ("approved", "building"):
|
||||
tr = iface.transition(contract_id, phase, evidence_ref=f"t15 {phase}")
|
||||
assert tr.get("success"), tr.get("errors")
|
||||
log(f"contract {contract_id} → building")
|
||||
return project, contract_id
|
||||
|
||||
|
||||
def _seed_spec(store: Path, project: str) -> str:
|
||||
"""Govern the requirement as a contract on the project surface
|
||||
(generic — request/scope come from parameters)."""
|
||||
proj = sc.Scaffold(str(store / "projects" / project), project)
|
||||
doc = proj.load()
|
||||
if ss.entity(doc, SPEC_CONTRACT) is None:
|
||||
ss.add_entity(doc, id=SPEC_CONTRACT, kind="contract",
|
||||
identities={"objective": REQUEST},
|
||||
evidence_refs=["t15-request"])
|
||||
ss.add_entity(doc, id="capability:planned-" + project[:24],
|
||||
kind="capability", maturity="DECLARED",
|
||||
evidence_refs=["t15-request"])
|
||||
ss.add_relationship(doc, type="implements",
|
||||
from_id="capability:planned-" + project[:24],
|
||||
to_id=SPEC_CONTRACT, evidence_refs=["t15-request"])
|
||||
proj.commit(doc, what="seed requirement spec (user request)")
|
||||
return SPEC_CONTRACT
|
||||
|
||||
|
||||
def run_build_via_events(project_dir: Path, operation_id: str,
|
||||
projection: dict, contract_id: str,
|
||||
interrupt_after_first_file: bool) -> dict:
|
||||
"""THE 15Q core: real builder under the event notifier; the driver
|
||||
YIELDS on the bridge FIFO. No sleep. Watchdog = failure detector."""
|
||||
ew.register_process_wait(
|
||||
operation_id, contract_id=contract_id,
|
||||
project="t15-fixture", objective=REQUEST,
|
||||
watched_for="builder process exit event", watchdog_secs=900)
|
||||
# op marker rides the prompt (last argv) so cmdline liveness can find
|
||||
# the build even if node regroups its children (T15Q live finding)
|
||||
builder_cmd = ["mimo", "run", "-m", BUILDER_MODEL, "--format", "json",
|
||||
t14.builder_prompt(projection, resumed=False)
|
||||
+ f" [op:{operation_id}]"]
|
||||
notifier = ew.run_builder_under_events(builder_cmd, operation_id,
|
||||
daemon_url=DAEMON_URL)
|
||||
log(f"builder launched (pgid {notifier.pid}); driver YIELDS on the "
|
||||
f"event bridge — no polling")
|
||||
if interrupt_after_first_file:
|
||||
# kill at first source file: the interruption is real, the event
|
||||
# never arrives (the notifier dies with the group) — the watchdog
|
||||
# must classify PROCESS_LOST, never silently wait.
|
||||
# TEST_TIMING: this 0.5s poll exists ONLY on the deliberate
|
||||
# interrupt path (killing at a checkpoint), never on the normal
|
||||
# continuation path.
|
||||
while True:
|
||||
files = [p for p in project_dir.iterdir()
|
||||
if p.suffix in (".mjs", ".js", ".json")]
|
||||
if files:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
log("INTERRUPTING builder (first file landed) — SIGKILL group")
|
||||
os.killpg(notifier.pid, signal.SIGKILL)
|
||||
result = ew.await_resume_event(operation_id, watchdog_secs=900,
|
||||
pgid=notifier.pid)
|
||||
return result
|
||||
|
||||
|
||||
def finish_via_canaries(store: Path, project: str, contract_id: str,
|
||||
project_dir: Path, before: dict,
|
||||
resume_note: str) -> int:
|
||||
"""Reload canonical state → scope audit → INDEPENDENT verification
|
||||
(15G test truth) → evidence-backed lifecycle."""
|
||||
iface = ci.ConstructionInterface(
|
||||
builder=BUILDER_MODEL, project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
ctx = iface.construction_context(contract_id)
|
||||
assert ctx["state"]["active"] or ctx["state"]["phase"] == "closed"
|
||||
log(f"canonical state: phase={ctx['state']['phase']} "
|
||||
f"scope={ctx['state']['scope']}")
|
||||
# exit canary: scope audit with provenance-aware residue (15O)
|
||||
audit = t14.audit_scope(project_dir, before, list(SCOPE))
|
||||
# 15G: verification truth — node --test must actually run tests
|
||||
# 15G test truth via the CONTRACT's verifier (parameterized — 15R
|
||||
# generalisation: python unittest/pytest, node --test, etc.)
|
||||
truth = subprocess.run(
|
||||
["bash", "-c", VERIFIER], cwd=str(project_dir),
|
||||
capture_output=True, text=True, timeout=300)
|
||||
import re
|
||||
count = None
|
||||
for pat in (r"# pass (\d+)", # node --test TAP summary
|
||||
r"(\d+) passed", # pytest
|
||||
r"Ran (\d+) tests"): # python unittest
|
||||
m = re.search(pat, (truth.stdout or "") + (truth.stderr or ""))
|
||||
if m:
|
||||
count = int(m.group(1))
|
||||
break
|
||||
test_count = count if count is not None else 0
|
||||
log(f"verification: exit={truth.returncode} tests={test_count}")
|
||||
if test_count == 0:
|
||||
log("FALSE GREEN REFUSED: zero tests executed (15G)")
|
||||
return 1
|
||||
if audit["scope_violations"]:
|
||||
log(f"closure REFUSED: scope violations {audit['scope_violations']}")
|
||||
return 1
|
||||
builder_status = "UNKNOWN"
|
||||
result = t14.complete_lifecycle(iface, contract_id, audit,
|
||||
{"cmd": "node --test",
|
||||
"exit_code": truth.returncode},
|
||||
builder_status)
|
||||
summary = {
|
||||
"event_driven": True,
|
||||
"builder_model": BUILDER_MODEL,
|
||||
"resume_note": resume_note,
|
||||
"scope_audit": audit,
|
||||
"verification": {"exit_code": truth.returncode,
|
||||
"test_count": test_count},
|
||||
"lifecycle": result,
|
||||
}
|
||||
print(json.dumps(summary, indent=1))
|
||||
return 0 if result.get("closed") else 1
|
||||
|
||||
|
||||
def discover(store: Path) -> tuple[str, str] | None:
|
||||
"""§15U: delegates to the canonical interface discovery (via the t14
|
||||
shim — one hop, no duplication)."""
|
||||
return t14.discover_contract_id(store)
|
||||
|
||||
|
||||
def cmd_start(store: Path, project_dir: Path, interrupt: bool) -> int:
|
||||
ready = readiness_canary("node")
|
||||
assert ready["verdict"] == "READY", f"readiness BLOCKED: {ready['checks']}"
|
||||
log(f"readiness READY ({len(ready['checks'])} checks)")
|
||||
before = t14.snapshot_files(project_dir)
|
||||
project, contract_id = setup_construction(store, project_dir)
|
||||
operation_id = f"t15-{contract_id}"
|
||||
iface = ci.ConstructionInterface(
|
||||
builder=BUILDER_MODEL, project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
projection = t14.build_projection(iface, contract_id,
|
||||
phase_note="initial build")
|
||||
result = run_build_via_events(project_dir, operation_id, projection,
|
||||
contract_id, interrupt)
|
||||
if result["kind"] == "watchdog":
|
||||
# §15E canonical-state-wins: process-table absence is NOT truth
|
||||
# (detached builders re-parent their workers — T15Q live finding).
|
||||
# The durable waitstore decides: a recorded verdict means the
|
||||
# builder completed and the event WAS processed.
|
||||
rec = ew.resume_or_recover(operation_id)
|
||||
if rec["kind"] == "event":
|
||||
log(f"watchdog fired but canonical state shows the build "
|
||||
f"completed (verdict={rec['wait'].get('verdict')}) — "
|
||||
f"continuing from authority")
|
||||
return finish_via_canaries(store, project, contract_id,
|
||||
project_dir, before,
|
||||
"watchdog→canonical recovery")
|
||||
log("PROCESS_LOST: no event, no durable verdict — fresh builder "
|
||||
"resumes from authority only")
|
||||
return 3
|
||||
wait = result.get("wait", {})
|
||||
log(f"event-driven resume: verdict={wait.get('verdict')} "
|
||||
f"exit={wait.get('process_event_evidence', {}).get('exit_code')}")
|
||||
return finish_via_canaries(store, project, contract_id, project_dir,
|
||||
before, "event-driven completion")
|
||||
|
||||
|
||||
def cmd_resume(store: Path, project_dir: Path) -> int:
|
||||
found = discover(store)
|
||||
assert found, "no contract in canonical state"
|
||||
project, contract_id = found
|
||||
log(f"FRESH builder resume: {project}/{contract_id} (canonical only)")
|
||||
before = t14.snapshot_files(project_dir)
|
||||
iface = ci.ConstructionInterface(
|
||||
builder=BUILDER_MODEL + "-resumed",
|
||||
project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
projection = t14.build_projection(
|
||||
iface, contract_id, phase_note="resumed after real interruption")
|
||||
operation_id = f"t15-resume-{contract_id}"
|
||||
ew.register_process_wait(
|
||||
operation_id, contract_id=contract_id, project=project,
|
||||
objective=REQUEST, watched_for="builder exit event",
|
||||
watchdog_secs=900)
|
||||
builder_cmd = ["mimo", "run", "-m", BUILDER_MODEL, "--format", "json",
|
||||
t14.builder_prompt(projection, resumed=True)
|
||||
+ f" [op:{operation_id}]"]
|
||||
notifier = ew.run_builder_under_events(builder_cmd, operation_id,
|
||||
daemon_url=DAEMON_URL)
|
||||
log("driver YIELDS on the bridge — no polling")
|
||||
result = ew.await_resume_event(operation_id, watchdog_secs=900,
|
||||
pgid=notifier.pid)
|
||||
if result["kind"] == "watchdog":
|
||||
rec = ew.resume_or_recover(operation_id)
|
||||
if rec["kind"] == "event":
|
||||
log(f"watchdog fired but canonical state shows the resume "
|
||||
f"build completed (verdict={rec['wait'].get('verdict')})")
|
||||
return finish_via_canaries(store, project, contract_id,
|
||||
project_dir, before,
|
||||
"resume via canonical recovery")
|
||||
log("PROCESS_LOST on resume — UNKNOWN_INTERRUPTION")
|
||||
return 4
|
||||
return finish_via_canaries(store, project, contract_id, project_dir,
|
||||
before, "fresh-builder event-driven resume")
|
||||
|
||||
|
||||
def cmd_finish(store: Path, project_dir: Path) -> int:
|
||||
"""Complete lifecycle from CANONICAL state only (waitstore verdict +
|
||||
filesystem reality). Used after a watchdog→canonical recovery."""
|
||||
found = discover(store)
|
||||
assert found, "no contract in canonical state"
|
||||
project, contract_id = found
|
||||
before = t14.snapshot_files(project_dir)
|
||||
return finish_via_canaries(store, project, contract_id, project_dir,
|
||||
before, "canonical-only completion")
|
||||
|
||||
|
||||
def cmd_status(store: Path) -> int:
|
||||
found = discover(store)
|
||||
if not found:
|
||||
print(json.dumps({"active": False}))
|
||||
return 0
|
||||
project, contract_id = found
|
||||
iface = ci.ConstructionInterface(
|
||||
builder="inspector", project_store=str(store / "projects" / project),
|
||||
project_id=project)
|
||||
print(json.dumps(iface.construction_context(contract_id)["state"],
|
||||
indent=1, default=str))
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("mode", choices=["start", "resume", "finish",
|
||||
"status"])
|
||||
ap.add_argument("--store", required=True)
|
||||
ap.add_argument("--project-dir", required=False)
|
||||
ap.add_argument("--interrupt-after-first-file", action="store_true")
|
||||
args = ap.parse_args()
|
||||
store = Path(args.store).resolve()
|
||||
project_dir = (Path(args.project_dir).resolve()
|
||||
if args.project_dir else None)
|
||||
if project_dir:
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
if args.mode == "start":
|
||||
return cmd_start(store, project_dir,
|
||||
args.interrupt_after_first_file)
|
||||
if args.mode == "resume":
|
||||
return cmd_resume(store, project_dir)
|
||||
if args.mode == "finish":
|
||||
return cmd_finish(store, project_dir)
|
||||
return cmd_status(store)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -6,7 +6,7 @@ This is the main entry point for the Rune program.
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "0.66.58"
|
||||
__version__ = "0.66.59"
|
||||
|
||||
# One build identity (handoff 2026-09-10 §4): the semantic version above is
|
||||
# the repo-level truth (auto-bumped at merge); the DEPLOYED revision (exact
|
||||
|
||||
@@ -231,7 +231,7 @@ def main() -> int:
|
||||
)
|
||||
check(
|
||||
"Rune minor version advertises provisioning and restore verification",
|
||||
'__version__ = "0.66.58"' in rune_version,
|
||||
'__version__ = "0.66.59"' in rune_version,
|
||||
failures,
|
||||
)
|
||||
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
|
||||
|
||||
@@ -214,7 +214,11 @@ class ConstructionInterface:
|
||||
"""§28 schema negotiation: builders detect the supported interface
|
||||
schema and operation set BEFORE calling operations. Also exposes the
|
||||
error-state vocabulary so builders can switch on states without
|
||||
parsing prose. Read-only; no contract required."""
|
||||
parsing prose. Read-only; no contract required.
|
||||
§15N: full argument schemas included — a fresh builder discovers
|
||||
required types/fields deterministically instead of trial-and-error
|
||||
(the T14 report_observation dict-vs-list + missing-entity mistakes
|
||||
are exactly what this prevents)."""
|
||||
return _envelope(
|
||||
"capabilities",
|
||||
success=True,
|
||||
@@ -223,6 +227,7 @@ class ConstructionInterface:
|
||||
"mode": self.mode,
|
||||
"project": self.scaffold.project_id,
|
||||
"operations": [
|
||||
"discover_active_contract",
|
||||
"submit_need",
|
||||
"inspect_decision",
|
||||
"get_scope",
|
||||
@@ -236,6 +241,67 @@ class ConstructionInterface:
|
||||
"capabilities",
|
||||
],
|
||||
"error_states": sorted(v for k, v in vars(Errors).items() if not k.startswith("_")),
|
||||
# ── §15N argument schemas (discovery over guessing) ──
|
||||
"argument_schemas": {
|
||||
"submit_need": {
|
||||
"objective": {"type": "str", "required": True},
|
||||
"origin": {"type": "str", "default": "builder"},
|
||||
"constraints": {"type": "dict", "required": False,
|
||||
"notes": ("{'environment': {...}, "
|
||||
"'contracts': [...]} — "
|
||||
"environment missing "
|
||||
"degrades to "
|
||||
"INSUFFICIENT_EVIDENCE")},
|
||||
"affected_surface": {"type": "list[str]",
|
||||
"required": False},
|
||||
"evidence_refs": {"type": "list[str]",
|
||||
"required": True,
|
||||
"notes": ("empty list degrades "
|
||||
"fit resolution")},
|
||||
"approval_context": {"type": "str",
|
||||
"required": False},
|
||||
},
|
||||
"report_observation": {
|
||||
"observations": {
|
||||
"type": "list[dict]",
|
||||
"required": True,
|
||||
"item_required_fields": ["entity"],
|
||||
"item_optional_fields": ["source", "detail"],
|
||||
"notes": ("a LIST of dicts, each with an "
|
||||
"'entity' key — a bare dict or a "
|
||||
"dict without 'entity' is rejected "
|
||||
"(NEED_INCOMPLETE)"),
|
||||
},
|
||||
},
|
||||
"verify": {
|
||||
"changed_files": {"type": "list[str] | None",
|
||||
"required": False,
|
||||
"notes": ("scope-checked against "
|
||||
"the contract scope; "
|
||||
"out-of-scope files → "
|
||||
"SCOPE_VIOLATION")},
|
||||
},
|
||||
"transition": {
|
||||
"phase": {"type": "str", "required": True,
|
||||
"allowed": list(sc.PHASES)},
|
||||
},
|
||||
"close": {
|
||||
"observed_evidence": {
|
||||
"type": "list[dict]", "required": True,
|
||||
"notes": ("evidence INPUT only — closure is "
|
||||
"decided by verification truth, "
|
||||
"never by builder claim")},
|
||||
"scope_files": {"type": "list[str] | None",
|
||||
"required": False},
|
||||
},
|
||||
},
|
||||
"lifecycle": {
|
||||
"phases": list(sc.PHASES),
|
||||
"legal_transitions": {k: sorted(v) for k, v in
|
||||
sc.LEGAL_TRANSITIONS.items()},
|
||||
"scope_amendment": ("user-gated via amend_scope — "
|
||||
"builders cannot broaden scope"),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -617,6 +683,53 @@ class ConstructionInterface:
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def discover_active_contract(store_path: str) -> dict:
|
||||
"""§15U canonical resume discovery: walk the store's project
|
||||
scaffolds AND the main store's own current.json, returning the
|
||||
most recent contract's (project, contract_id). Migrated here from
|
||||
the T14/T15 session drivers — a fresh builder with NO transcript
|
||||
discovers what to resume through the INTERFACE, not a script.
|
||||
Pure readback: no session file, no transcript, no mutation."""
|
||||
from pathlib import Path as _P
|
||||
import json as _json
|
||||
root = _P(store_path)
|
||||
candidates: list[tuple] = []
|
||||
for proj_current in sorted(root.glob("projects/*/current.json")):
|
||||
# per-project scaffold: (file, project_id)
|
||||
candidates.append((proj_current, proj_current.parent.name))
|
||||
main = root / "current.json"
|
||||
if main.exists():
|
||||
# main store: contracts carry their project in identities
|
||||
candidates.append((main, None))
|
||||
if not candidates:
|
||||
return _envelope("discover_active_contract", success=False,
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT,
|
||||
"detail": "no projects in store"}])
|
||||
best = None
|
||||
best_project = ""
|
||||
for proj_current, project in candidates:
|
||||
try:
|
||||
doc = _json.loads(proj_current.read_text())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
for e in doc["entities"]:
|
||||
if e["kind"] == "construction_contract":
|
||||
proj = project or e.get("identities", {}).get(
|
||||
"project", root.name)
|
||||
key = (e.get("observed_at") or "", proj,
|
||||
e["id"].split(":", 1)[1])
|
||||
if best is None or key > (best[0], best[1], best[2]):
|
||||
best = key
|
||||
best_project = proj
|
||||
if best is None:
|
||||
return _envelope("discover_active_contract", success=False,
|
||||
errors=[{"error": Errors.UNKNOWN_CONTRACT,
|
||||
"detail": "no contracts in store"}])
|
||||
return _envelope("discover_active_contract", success=True,
|
||||
ids={"project": best_project,
|
||||
"contract_id": best[2]})
|
||||
|
||||
def resume(self, contract_id: str) -> dict:
|
||||
doc = self.scaffold.load()
|
||||
c = self.scaffold.contract(doc, contract_id)
|
||||
|
||||
@@ -64,6 +64,10 @@ _STANDALONE_WORDS = {
|
||||
"scaffold a new",
|
||||
"new web",
|
||||
}
|
||||
# T15 §15J: extensionless filenames that bind a scoped target when
|
||||
# mentioned explicitly (dotted/path tokens go through classify_surface)
|
||||
_KNOWN_FILES = {"readme", "makefile", "dockerfile", "changelog", "license",
|
||||
"gemfile", "procfile", "jenkinsfile", "justfile"}
|
||||
_LOCAL_WORDS = {
|
||||
"typo",
|
||||
"small fix",
|
||||
@@ -267,6 +271,27 @@ def classify_mode(
|
||||
standalone = _contains_any(obj.lower(), _STANDALONE_WORDS)
|
||||
local = _contains_any(obj.lower(), _LOCAL_WORDS)
|
||||
scoped = _contains_any(obj.lower(), _SCOPING_WORDS)
|
||||
# T15 §15J: an explicit FILE mention binds a scoped target ("fix the
|
||||
# typo in the README", "update scheduler.js"). Deterministic lexical
|
||||
# evidence in the same corroboration style as the other lexicons;
|
||||
# dotted/path tokens delegated to the ONE surface authority
|
||||
# (construction_decision.classify_surface) — lazily imported (cycle:
|
||||
# construction_decision imports this module's classify_mode).
|
||||
file_scoped = False
|
||||
for w in re.split(r"[^a-z0-9_./-]+", obj.lower()):
|
||||
if not w or len(w) < 3:
|
||||
continue
|
||||
if w in _KNOWN_FILES or "." in w or "/" in w:
|
||||
try:
|
||||
from rune.construction_decision import classify_surface
|
||||
if classify_surface(w) in ("source_path", "workflow_config"):
|
||||
file_scoped = True
|
||||
break
|
||||
except Exception: # noqa: BLE001,S110 — authority unavailable:
|
||||
pass # lexical fallback decides; never fatal
|
||||
if w in _KNOWN_FILES:
|
||||
file_scoped = True
|
||||
break
|
||||
|
||||
# 3. PROJECT_BUILD: standalone/new-lifecycle language AND no existing
|
||||
# project for the target (project existence materially matters, §5)
|
||||
@@ -286,10 +311,12 @@ def classify_mode(
|
||||
mode, conf = "PROJECT_BUILD", 0.9
|
||||
bootstrap = True
|
||||
# 4. LOCAL_CHANGE: explicit smallness + scoped target + no standalone
|
||||
elif local and (scoped or target_project or project_exists):
|
||||
elif local and (scoped or file_scoped or target_project or project_exists):
|
||||
ev.append(f"smallness markers: {local[:3]}")
|
||||
if scoped:
|
||||
ev.append(f"scoping words bind to known target: {scoped[:3]}")
|
||||
if file_scoped:
|
||||
ev.append("file mention binds scoped target")
|
||||
mode, conf = "LOCAL_CHANGE", 0.8
|
||||
# 5. EXISTING_PROJECT_CHANGE: scoping to a known/existing target.
|
||||
# T11 §1: a change scoped to a project with PROVEN non-existence must
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
"""event_wait.py — T15 §15D: event-driven wait as the CANONICAL DEFAULT
|
||||
for long-running construction operations (builders, CI, deploys).
|
||||
|
||||
GLOBAL WAITING INVARIANT (tranche brief):
|
||||
|
||||
EVENT DRIVES CONTINUATION.
|
||||
TIMEOUT DETECTS FAILURE OF CONTINUATION.
|
||||
|
||||
The W/R bridge (daemon.py + waitstore.py at RUNE_WR_ROOT, default
|
||||
~/.local/share/mimo-wait-resume) already provides: durable waits, event
|
||||
matching, per-session single-flight leases, process-tree ownership, and a
|
||||
resume driver. Until T15 its resume target was exclusively a MiMo
|
||||
session (global mimo-shaped resume_cmd), so Python controllers (stage
|
||||
drivers, builder sessions) could not be resumed by the bridge — which is
|
||||
exactly why fixed sleep/poll loops crept back into T12-T14.
|
||||
|
||||
This adapter CLOSES that gap with the smallest possible surface:
|
||||
|
||||
1. register_process_wait() — a controller/operation registers a
|
||||
PROCESS_EVENT wait (correlation: operation_id) whose per-wait
|
||||
resume_cmd writes a resume token to a local FIFO. Registration is a
|
||||
durable waitstore record: survives restarts, visible to reconciliation.
|
||||
|
||||
2. run_builder_under_events() — launches the real builder command under
|
||||
a notifier wrapper (separate session/process-group). The wrapper
|
||||
observes the builder's actual exit and POSTs the completion event to
|
||||
the daemon (/process-event). The wrapper is the event source; the
|
||||
driver does NOT wait on the builder directly.
|
||||
|
||||
3. await_resume_event() — the driver YIELDS: blocks in select() on the
|
||||
FIFO (no polling loop, no sleep floor) with a watchdog deadline. The
|
||||
daemon matches the event, advances the wait, and the per-wait
|
||||
resume_cmd fires immediately — the driver resumes on the EVENT, not
|
||||
after a configured delay.
|
||||
|
||||
4. Watchdog semantics: on timeout the driver performs a ONE-SHOT
|
||||
liveness inspection of the notifier process group (ALIVE → re-arm;
|
||||
GONE → PROCESS_LOST recovery). The timeout is a failure detector,
|
||||
never the scheduler.
|
||||
|
||||
Idempotency (15E) is inherited from the bridge: match() applies only to
|
||||
WAITING_EXTERNAL waits, so duplicate/late events cannot double-resume;
|
||||
correlation by operation_id (run_ref) means a stale event from a prior
|
||||
operation matches nothing; the reconciler never verifies PROCESS_EVENT
|
||||
waits against Gitea (the event is the authority).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_WR_ROOT = Path.home() / ".local" / "share" / "mimo-wait-resume"
|
||||
FALLBACK_WR_ROOT = Path("/home/nick/.local/share/mimo-wait-resume")
|
||||
DEFAULT_DAEMON_URL = "http://127.0.0.1:8791"
|
||||
|
||||
# The notifier wrapper: runs the real builder command as its child,
|
||||
# observes the ACTUAL exit, and POSTs the completion event to the daemon.
|
||||
# It is the event SOURCE — deliberately independent of the waiting driver.
|
||||
_NOTIFIER_SRC = r"""
|
||||
import json, os, subprocess, sys, time, urllib.request
|
||||
op = sys.argv[1]; cmd = sys.argv[2:]
|
||||
start = time.time()
|
||||
proc = subprocess.run(cmd)
|
||||
completed_at = time.time()
|
||||
payload = {
|
||||
"operation_id": op,
|
||||
"exit_code": proc.returncode,
|
||||
"completed_at": completed_at,
|
||||
"started_at": start,
|
||||
}
|
||||
for attempt in range(3):
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
os.environ["T15_DAEMON_URL"] + "/process-event",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
r.read()
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt == 2:
|
||||
sys.stderr.write(f"event POST failed: {e}\n")
|
||||
# best-effort: the watchdog is the safety net for lost events
|
||||
time.sleep(1)
|
||||
"""
|
||||
|
||||
|
||||
def wr_root() -> Path:
|
||||
env = os.environ.get("RUNE_WR_ROOT")
|
||||
if env:
|
||||
return Path(env)
|
||||
if DEFAULT_WR_ROOT.exists():
|
||||
return DEFAULT_WR_ROOT
|
||||
return FALLBACK_WR_ROOT
|
||||
|
||||
|
||||
def _load_waitstore():
|
||||
# waitstore.py lives in the DAEMON CODE dir. In production WR_ROOT is
|
||||
# the daemon dir (code + data together); in isolated test environments
|
||||
# WR_ROOT is a data-only dir, so the code dir is resolved separately:
|
||||
# RUNE_WR_CODE env → WR_ROOT → known install locations.
|
||||
for cand in (os.environ.get("RUNE_WR_CODE"), str(wr_root()),
|
||||
str(FALLBACK_WR_ROOT)):
|
||||
if cand and (Path(cand) / "waitstore.py").is_file():
|
||||
if cand not in sys.path:
|
||||
sys.path.insert(0, cand)
|
||||
break
|
||||
# waitstore binds its DATA dir from WR_ROOT at import time. The
|
||||
# adapter's authority for the data dir is RUNE_WR_ROOT (wr_root());
|
||||
# pin WR_ROOT to match so adapter-side and daemon-side state agree —
|
||||
# otherwise a mismatch silently splits the wait store from the event
|
||||
# matcher and continuation dies as a watchdog timeout.
|
||||
os.environ["WR_ROOT"] = str(wr_root())
|
||||
import waitstore # the bridge's own durable store — NOT a copy
|
||||
# if the data root CHANGED since the module bound it (isolated tests
|
||||
# per-test tmp roots; daemon restarts), re-bind by reloading — the
|
||||
# module-level WAITS/EVENTS paths are derived from WR_ROOT at import
|
||||
import importlib
|
||||
root = wr_root()
|
||||
if str(getattr(waitstore, "WAITS", "").parent) != str(root):
|
||||
waitstore = importlib.reload(waitstore)
|
||||
return waitstore
|
||||
|
||||
|
||||
def register_process_wait(operation_id: str, *, contract_id: str = "",
|
||||
project: str = "", objective: str = "",
|
||||
watched_for: str = "",
|
||||
watchdog_secs: int | None = None) -> dict:
|
||||
"""Register a durable PROCESS_EVENT wait for one operation. The
|
||||
per-wait resume_cmd writes a resume token to the operation's FIFO —
|
||||
the bridge can resume a Python controller, not just MiMo sessions."""
|
||||
ws = _load_waitstore()
|
||||
fifo = fifo_path(operation_id)
|
||||
fifo.parent.mkdir(parents=True, exist_ok=True)
|
||||
if fifo.exists():
|
||||
fifo.unlink()
|
||||
os.mkfifo(fifo)
|
||||
writer = ("import sys,json,time,os;"
|
||||
"fd=os.open(sys.argv[1], os.O_WRONLY | os.O_NONBLOCK);"
|
||||
"os.write(fd, json.dumps({'wait_id': sys.argv[2],"
|
||||
" 'resumed_at': time.time()}).encode());os.close(fd)")
|
||||
rec = ws.new_wait(
|
||||
session_id=f"controller-{operation_id}",
|
||||
provider="builder",
|
||||
event_type="builder_exit",
|
||||
repo=project or "construction",
|
||||
run_ref=operation_id,
|
||||
expected="success",
|
||||
wait_kind="PROCESS_EVENT",
|
||||
target_kind="headless", # owned continuation target: FIFO writer
|
||||
objective=objective or f"builder operation {operation_id}",
|
||||
waited_for=watched_for or "builder process exit event",
|
||||
verify_when_woken="reload canonical state; exit canary; "
|
||||
"independent verification",
|
||||
on_success="continue programme continuation canary",
|
||||
on_failure="classify failure; recovery or block",
|
||||
resume_cmd=["python3", "-c", writer, str(fifo), "{wait_id}"],
|
||||
)
|
||||
rec["ext"] = {"contract_id": contract_id, "operation_id": operation_id,
|
||||
"fifo": str(fifo),
|
||||
"watchdog_secs": watchdog_secs
|
||||
or int(os.environ.get("WR_WATCH_SECS", "600"))}
|
||||
ws.save(rec)
|
||||
return rec
|
||||
|
||||
|
||||
def fifo_path(operation_id: str) -> Path:
|
||||
return wr_root() / "resumes" / f"{operation_id}.fifo"
|
||||
|
||||
|
||||
def run_builder_under_events(builder_cmd: list[str], operation_id: str, *,
|
||||
daemon_url: str = DEFAULT_DAEMON_URL):
|
||||
"""Launch the REAL builder under the event notifier (separate process
|
||||
group). Returns the Popen for the notifier (pgid == its pid). The
|
||||
driver must NOT wait on this directly — it yields via
|
||||
await_resume_event; the notifier's exit event comes back through the
|
||||
bridge."""
|
||||
env = dict(os.environ, T15_DAEMON_URL=daemon_url)
|
||||
log_file = wr_root() / "resumes" / f"{operation_id}.builder.log"
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(log_file, "w") as lf:
|
||||
return subprocess.Popen(
|
||||
[sys.executable, "-c", _NOTIFIER_SRC, operation_id, *builder_cmd],
|
||||
stdout=lf, stderr=subprocess.PIPE,
|
||||
text=True, start_new_session=True, env=env)
|
||||
|
||||
|
||||
def _pgroup_alive(pgid: int) -> bool:
|
||||
"""Liveness = at least one NON-ZOMBIE member of the process group.
|
||||
A killed-but-unreaped notifier is a zombie and does NOT count —
|
||||
treating zombies as alive would misclassify PROCESS_LOST as alive."""
|
||||
try:
|
||||
os.killpg(pgid, 0) # group exists at all?
|
||||
except (ProcessLookupError, PermissionError):
|
||||
return False
|
||||
for proc in Path("/proc").iterdir():
|
||||
if not proc.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
stat = (proc / "stat").read_text()
|
||||
# field 5 is state; comm (field 2) may contain spaces/parens
|
||||
state = stat.rsplit(")", 1)[1].split()[0]
|
||||
pgid_field = int(stat.rsplit(")", 1)[1].split()[2])
|
||||
except (OSError, ValueError, IndexError):
|
||||
continue
|
||||
if pgid_field == pgid and state != "Z":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _cmdline_alive(needle: str) -> bool:
|
||||
"""Liveness fallback: any live process whose cmdline contains the
|
||||
needle. Real builders (node CLIs) may re-group their children, so
|
||||
pgid alone can false-negative — T15Q observed a 65-minute real build
|
||||
outliving the watchdog AND invisible to the pgid scan. The needle
|
||||
should be operation-specific (the prompt marker), not generic."""
|
||||
for proc in Path("/proc").iterdir():
|
||||
if not proc.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
cmdline = (proc / "cmdline").read_bytes().decode(
|
||||
errors="replace")
|
||||
stat = (proc / "stat").read_text()
|
||||
state = stat.rsplit(")", 1)[1].split()[0]
|
||||
except (OSError, ValueError, IndexError):
|
||||
continue
|
||||
if needle in cmdline and state != "Z":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def liveness(operation_id: str, pgid: int | None = None) -> bool:
|
||||
"""Watchdog helper: is the watched operation actually alive? pgid
|
||||
membership first; cmdline evidence as the regroup-tolerant fallback."""
|
||||
if pgid and _pgroup_alive(pgid):
|
||||
return True
|
||||
return _cmdline_alive(f"op:{operation_id}")
|
||||
|
||||
|
||||
def await_resume_event(operation_id: str, *, watchdog_secs: int,
|
||||
pgid: int | None = None,
|
||||
max_rearms: int = 20) -> dict:
|
||||
"""YIELD until the bridge resumes this controller. Blocks in select()
|
||||
on the FIFO — no sleep, no polling.
|
||||
|
||||
Watchdog semantics (§15D): the deadline is a FAILURE DETECTOR, never
|
||||
the scheduler. On expiry, liveness is inspected ONCE (pgid membership,
|
||||
then operation-specific cmdline evidence — real builders regroup their
|
||||
children): ALIVE → re-arm (select again, up to max_rearms); GONE →
|
||||
PROCESS_LOST. The T15Q live run proved why: a real 65-minute model
|
||||
build outlived a single 900s deadline, and node regrouping hid the
|
||||
processes from the pgid scan — neither is a failure of continuation.
|
||||
"""
|
||||
fifo = fifo_path(operation_id)
|
||||
# idempotent re-arm: a prior await consumed+unlinked the fifo; the
|
||||
# rendezvous point is (re)created so watchdog re-arm and recovery
|
||||
# paths can wait again on the same operation
|
||||
fifo.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not fifo.exists():
|
||||
os.mkfifo(fifo)
|
||||
for arm in range(max_rearms + 1):
|
||||
deadline = time.monotonic() + watchdog_secs
|
||||
fd = os.open(fifo, os.O_RDONLY | os.O_NONBLOCK)
|
||||
try:
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
r, _, _ = select.select([fd], [], [], remaining)
|
||||
if not r:
|
||||
continue
|
||||
data = os.read(fd, 4096)
|
||||
if not data:
|
||||
continue
|
||||
try:
|
||||
token = json.loads(data.decode())
|
||||
except ValueError:
|
||||
break # malformed token → treat as expiry
|
||||
ws = _load_waitstore()
|
||||
rec = ws.load(token.get("wait_id", ""))
|
||||
return {"kind": "event", "wait": rec,
|
||||
"resumed_at": token.get("resumed_at")}
|
||||
finally:
|
||||
os.close(fd)
|
||||
# deadline expired: liveness decides re-arm vs PROCESS_LOST
|
||||
alive = liveness(operation_id, pgid)
|
||||
if not alive:
|
||||
return {"kind": "watchdog", "alive": False,
|
||||
"rearms": arm}
|
||||
return {"kind": "watchdog", "alive": liveness(operation_id, pgid),
|
||||
"rearms": max_rearms}
|
||||
|
||||
|
||||
def operation_wait(operation_id: str) -> dict | None:
|
||||
ws = _load_waitstore()
|
||||
for rec in ws.all_waits():
|
||||
if rec.get("ext", {}).get("operation_id") == operation_id:
|
||||
return rec
|
||||
return None
|
||||
|
||||
|
||||
def resume_or_recover(operation_id: str) -> dict:
|
||||
"""15E case 10 — controller restart after the event was persisted but
|
||||
before the controller handled it. The durable waitstore record is the
|
||||
authority, not the transient FIFO token: if the wait already reached a
|
||||
terminal/resumable state, return it directly (idempotent recovery, no
|
||||
duplicate transition); if still WAITING_EXTERNAL, re-arm the normal
|
||||
event wait."""
|
||||
rec = operation_wait(operation_id)
|
||||
if rec is None:
|
||||
return {"kind": "unknown_operation"}
|
||||
if rec.get("status") in ("COMPLETED", "FAILED", "READY_TO_RESUME",
|
||||
"READY_TO_RETRY", "RESUMING"):
|
||||
# RESUMING counts as recovered: the resume delivery is in flight
|
||||
# (or already consumed the rendezvous). The durable record is the
|
||||
# truth; the controller restarts from it, never from the token.
|
||||
return {"kind": "event", "wait": rec, "recovered": True}
|
||||
if rec.get("status") == "RESUME_UNVERIFIED" and \
|
||||
rec.get("process_event_evidence"):
|
||||
# the completion EVENT was processed (verdict recorded durably);
|
||||
# only the resume-token delivery was unverified (e.g. controller
|
||||
# was down, so no reader consumed the rendezvous). Canonical
|
||||
# state wins — this IS the restart-after-event case.
|
||||
return {"kind": "event", "wait": rec, "recovered": True}
|
||||
return {"kind": "still_waiting", "wait": rec}
|
||||
|
||||
|
||||
def post_process_event(operation_id: str, exit_code: int, *,
|
||||
daemon_url: str = DEFAULT_DAEMON_URL) -> dict:
|
||||
"""Direct event injection (test seam + manual reconciliation). The
|
||||
normal path is the notifier wrapper; this exists so tests and
|
||||
watchdog recovery can emit the same event shape."""
|
||||
import urllib.request
|
||||
payload = {"operation_id": operation_id, "exit_code": exit_code,
|
||||
"completed_at": time.time()}
|
||||
req = urllib.request.Request(
|
||||
daemon_url + "/process-event",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
return json.loads(r.read())
|
||||
@@ -0,0 +1,96 @@
|
||||
"""failure_recovery.py — T15 §15T: failure classification + recovery
|
||||
policy.
|
||||
|
||||
Before retrying, classify the failure:
|
||||
|
||||
TRANSIENT bounded immediate retry appropriate
|
||||
INTERMITTENT bounded retry/backoff
|
||||
KNOWN_PROCESS_DEFECT apply evidenced repair, then retry
|
||||
PERMANENT / INVALID do NOT retry blindly
|
||||
APPROVAL_REQUIRED block
|
||||
UNKNOWN bounded evidence probe, then block/escalate
|
||||
|
||||
No infinite retry loops: every class carries a retry budget, and
|
||||
PERMANENT/APPROVAL_REQUIRED never auto-retry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
TRANSIENT = "TRANSIENT"
|
||||
INTERMITTENT = "INTERMITTENT"
|
||||
KNOWN_PROCESS_DEFECT = "KNOWN_PROCESS_DEFECT"
|
||||
PERMANENT = "PERMANENT"
|
||||
APPROVAL_REQUIRED = "APPROVAL_REQUIRED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
CLASSES = (TRANSIENT, INTERMITTENT, KNOWN_PROCESS_DEFECT, PERMANENT,
|
||||
APPROVAL_REQUIRED, UNKNOWN)
|
||||
|
||||
# recovery policy per class: (max_retries, action)
|
||||
POLICY = {
|
||||
TRANSIENT: (2, "retry_immediately"),
|
||||
INTERMITTENT: (3, "retry_with_backoff"),
|
||||
KNOWN_PROCESS_DEFECT: (1, "apply_evidenced_repair_then_retry"),
|
||||
PERMANENT: (0, "do_not_retry"),
|
||||
APPROVAL_REQUIRED: (0, "block"),
|
||||
UNKNOWN: (1, "bounded_evidence_probe_then_block_or_escalate"),
|
||||
}
|
||||
|
||||
# deterministic markers (substring, lowercase) — the classifier is
|
||||
# evidence-first: explicit signals win; absence defaults to UNKNOWN
|
||||
_TRANSIENT_MARKERS = (
|
||||
"connection reset", "timeout", "timed out", "temporarily unavailable",
|
||||
"rate limit", "429", "503", "econnreset", "socket hang up",
|
||||
"service unavailable",
|
||||
)
|
||||
_INTERMITTENT_MARKERS = (
|
||||
"flaky", "intermittent", "occasionally fails", "sometimes fails",
|
||||
"retry succeeded",
|
||||
)
|
||||
_PERMANENT_MARKERS = (
|
||||
"invalid input", "schema-invalid", "not found", "no such file",
|
||||
"permission denied", "unsupported", "incompatible",
|
||||
"module not found", "syntaxerror", "typeerror",
|
||||
)
|
||||
_APPROVAL_MARKERS = (
|
||||
"approval required", "needs authorization", "user confirmation",
|
||||
"destructive", "external service", "new repository",
|
||||
"user-locked", "user locked",
|
||||
)
|
||||
_KNOWN_DEFECT_MARKERS = (
|
||||
"stale document", "forgotten save", "wrong path", "guessed api",
|
||||
"missing import", "no-op edit", "substring match", "wrong signature",
|
||||
)
|
||||
|
||||
|
||||
def classify_failure(error_text: str, *, context: dict | None = None) -> dict:
|
||||
"""Classify one failure into a recovery class. Deterministic: marker
|
||||
evidence first; explicit context hints (e.g. known defect id) second;
|
||||
UNKNOWN when nothing matches (never manufactured certainty)."""
|
||||
text = (error_text or "").lower()
|
||||
context = context or {}
|
||||
known_defect = context.get("known_defect_id")
|
||||
if known_defect:
|
||||
return {"class": KNOWN_PROCESS_DEFECT,
|
||||
"evidence": f"known defect: {known_defect}",
|
||||
"policy": POLICY[KNOWN_PROCESS_DEFECT]}
|
||||
if any(m in text for m in _APPROVAL_MARKERS):
|
||||
return {"class": APPROVAL_REQUIRED,
|
||||
"evidence": "approval marker present",
|
||||
"policy": POLICY[APPROVAL_REQUIRED]}
|
||||
if any(m in text for m in _PERMANENT_MARKERS):
|
||||
return {"class": PERMANENT, "evidence": "permanent marker present",
|
||||
"policy": POLICY[PERMANENT]}
|
||||
if any(m in text for m in _KNOWN_DEFECT_MARKERS):
|
||||
return {"class": KNOWN_PROCESS_DEFECT,
|
||||
"evidence": "known process-defect marker present",
|
||||
"policy": POLICY[KNOWN_PROCESS_DEFECT]}
|
||||
if any(m in text for m in _INTERMITTENT_MARKERS):
|
||||
return {"class": INTERMITTENT,
|
||||
"evidence": "intermittent marker present",
|
||||
"policy": POLICY[INTERMITTENT]}
|
||||
if any(m in text for m in _TRANSIENT_MARKERS):
|
||||
return {"class": TRANSIENT, "evidence": "transient marker present",
|
||||
"policy": POLICY[TRANSIENT]}
|
||||
return {"class": UNKNOWN, "evidence": "no marker matched",
|
||||
"policy": POLICY[UNKNOWN]}
|
||||
@@ -0,0 +1,165 @@
|
||||
"""programme_continuation.py — T15 §15M: the PROGRAMME CONTINUATION
|
||||
CANARY.
|
||||
|
||||
The user observation this answers: subtask completion repeatedly ended
|
||||
whole programmes. "task done" is not "programme done".
|
||||
|
||||
After ANY stage/subtask reports complete, the runner asks one bounded
|
||||
question — is the overall construction/programme objective satisfied?
|
||||
If not, and eligible authorized stages remain, the NEXT stage starts
|
||||
immediately (no user re-prompt, no voluntary stop). Blocked branches
|
||||
fall back to pre-authorized independent work.
|
||||
|
||||
Boundary (tranche brief): this is a bounded CONSTRUCTION-programme rule
|
||||
over Scaffolding stage contracts — NOT the future general-purpose
|
||||
Substrate scheduler. It knows nothing outside one programme's stage
|
||||
list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# stage states
|
||||
PENDING = "PENDING"
|
||||
ELIGIBLE = "ELIGIBLE"
|
||||
WAITING_EVENT = "WAITING_EVENT"
|
||||
COMPLETE = "COMPLETE"
|
||||
BLOCKED = "BLOCKED"
|
||||
APPROVAL_REQUIRED = "APPROVAL_REQUIRED"
|
||||
FAILED = "FAILED"
|
||||
|
||||
TERMINAL = {COMPLETE, BLOCKED, APPROVAL_REQUIRED, FAILED}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProgrammeStage:
|
||||
stage_id: str
|
||||
objective: str
|
||||
state: str = PENDING
|
||||
depends_on: list = field(default_factory=list) # stage_ids
|
||||
priority: int = 0 # lower = sooner
|
||||
result: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"stage_id": self.stage_id, "objective": self.objective,
|
||||
"state": self.state, "depends_on": list(self.depends_on),
|
||||
"priority": self.priority}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Programme:
|
||||
"""One bounded construction programme: its objective, its authorized
|
||||
stages, and the continuation rule. Persisted to the contract store's
|
||||
project area (durable, reloadable, no transcript dependency)."""
|
||||
programme_id: str
|
||||
objective: str
|
||||
stages: list = field(default_factory=list) # [ProgrammeStage]
|
||||
fallbacks: list = field(default_factory=list) # pre-authorized ids
|
||||
|
||||
# ── the continuation canary (15M) ────────────────────────────────
|
||||
def continuation_canary(self) -> dict:
|
||||
"""'task done' ≠ 'programme done'. Returns the verdict AND the
|
||||
next action — the runner never has to guess whether to stop."""
|
||||
complete = [s for s in self.stages if s.state == COMPLETE]
|
||||
if len(complete) == len(self.stages):
|
||||
return {"verdict": "PROGRAMME_COMPLETE",
|
||||
"next_action": None,
|
||||
"reason": f"all {len(self.stages)} stages complete"}
|
||||
eligible = self.eligible_stages()
|
||||
if eligible:
|
||||
nxt = eligible[0]
|
||||
return {"verdict": "PROGRAMME_OPEN",
|
||||
"next_action": ("start", nxt.stage_id),
|
||||
"reason": (f"{len(complete)}/{len(self.stages)} "
|
||||
f"complete; {len(eligible)} eligible — "
|
||||
f"continue immediately")}
|
||||
waiting = [s for s in self.stages
|
||||
if s.state == WAITING_EVENT]
|
||||
if waiting:
|
||||
return {"verdict": "PROGRAMME_OPEN_WAITING",
|
||||
"next_action": ("await_event", waiting[0].stage_id),
|
||||
"reason": "event-driven wait active — no polling"}
|
||||
blocked = [s for s in self.stages if s.state == BLOCKED]
|
||||
if blocked and self.fallbacks:
|
||||
return {"verdict": "PROGRAMME_OPEN",
|
||||
"next_action": ("fallback", self.fallbacks[0]),
|
||||
"reason": (f"{len(blocked)} blocked; pre-authorized "
|
||||
f"fallback available")}
|
||||
if blocked:
|
||||
return {"verdict": "ALL_REMAINING_BLOCKED",
|
||||
"next_action": None,
|
||||
"reason": f"{len(blocked)} stage(s) blocked, no fallback"}
|
||||
approval = [s for s in self.stages
|
||||
if s.state == APPROVAL_REQUIRED]
|
||||
if approval:
|
||||
return {"verdict": "APPROVAL_REQUIRED",
|
||||
"next_action": None,
|
||||
"reason": (f"{len(approval)} stage(s) need user "
|
||||
f"authorization")}
|
||||
return {"verdict": "UNKNOWN_INTERRUPTION", "next_action": None,
|
||||
"reason": "no stage runnable and none terminal"}
|
||||
|
||||
def eligible_stages(self) -> list:
|
||||
"""PENDING stages whose dependencies are COMPLETE, priority-first.
|
||||
ELIGIBLE counts as runnable too — an eligible stage nobody started
|
||||
is exactly the case the continuation canary exists to catch."""
|
||||
done = {s.stage_id for s in self.stages if s.state == COMPLETE}
|
||||
out = [s for s in self.stages
|
||||
if s.state in (PENDING, ELIGIBLE)
|
||||
and all(d in done for d in s.depends_on)]
|
||||
out.sort(key=lambda s: (s.priority, s.stage_id))
|
||||
return out
|
||||
|
||||
# ── durable persistence (canonical store, not transcript) ────────
|
||||
def save(self, base: Path) -> Path:
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
p = base / f"{self.programme_id}.json"
|
||||
p.write_text(json.dumps({
|
||||
"programme_id": self.programme_id,
|
||||
"objective": self.objective,
|
||||
"stages": [s.to_dict() for s in self.stages],
|
||||
"fallbacks": list(self.fallbacks),
|
||||
"saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime()),
|
||||
}, indent=1))
|
||||
return p
|
||||
|
||||
@classmethod
|
||||
def load(cls, base: Path, programme_id: str) -> "Programme | None":
|
||||
p = base / f"{programme_id}.json"
|
||||
if not p.exists():
|
||||
return None
|
||||
d = json.loads(p.read_text())
|
||||
prog = cls(programme_id=d["programme_id"],
|
||||
objective=d["objective"], fallbacks=d.get("fallbacks", []))
|
||||
for s in d["stages"]:
|
||||
prog.stages.append(ProgrammeStage(**s))
|
||||
return prog
|
||||
|
||||
|
||||
def run_programme(prog: Programme, stage_runner, *,
|
||||
max_transitions: int = 100) -> list[dict]:
|
||||
"""Drive one programme to a legitimate stop. `stage_runner(stage)` —
|
||||
provided by the caller — must return a state for the stage. Between
|
||||
EVERY stage: persist → continuation canary → next. Returns the
|
||||
transition log (the 15M proof artifact)."""
|
||||
log: list[dict] = []
|
||||
for _ in range(max_transitions):
|
||||
prog_canary = prog.continuation_canary()
|
||||
log.append({"canary": prog_canary})
|
||||
verdict, action = prog_canary["verdict"], prog_canary["next_action"]
|
||||
if action is None:
|
||||
break # legitimate stop — reason recorded in the log
|
||||
kind, target = action
|
||||
if kind == "fallback":
|
||||
log.append({"fallback_started": target})
|
||||
break
|
||||
stage = next(s for s in prog.stages if s.stage_id == target)
|
||||
stage.state = stage_runner(stage) # runner decides the new state
|
||||
log.append({"stage": target, "new_state": stage.state})
|
||||
return log
|
||||
@@ -787,6 +787,20 @@ class RuneOperator:
|
||||
NO duplicate intelligence here."""
|
||||
from .construction_mode import classify_mode, _STANDALONE_WORDS
|
||||
from .construction_activation import activate
|
||||
# §15J case F: manual override feeds the SAME decision path — a
|
||||
# leading "/scaffold-mode <MODE>" (or "scaffold-mode: <MODE>")
|
||||
# marker is stripped and passed as explicit_override with
|
||||
# override_authority="user". NOT a second implementation:
|
||||
# classification still runs through classify_mode with provenance.
|
||||
override = None
|
||||
m = re.match(
|
||||
r"^(?:/scaffold-mode|scaffold-mode:)\s+"
|
||||
r"(NOT_CONSTRUCTION|LOCAL_CHANGE|EXISTING_PROJECT_CHANGE|"
|
||||
r"PROJECT_BUILD|UNKNOWN)\s+(.*)$",
|
||||
description, re.DOTALL)
|
||||
if m:
|
||||
override = m.group(1)
|
||||
description = m.group(2).strip()
|
||||
low = description.lower()
|
||||
tokens = set(__import__("re").split(r"[^a-z0-9]+", low))
|
||||
# §12D: the NATIVE hook must not force the Rune target — standalone
|
||||
@@ -802,7 +816,9 @@ class RuneOperator:
|
||||
target, exists = None, None # unknown → probes, not invention
|
||||
mode_decision = classify_mode(
|
||||
description, target_project=target, project_exists=exists,
|
||||
affected_surface=[])
|
||||
affected_surface=[],
|
||||
explicit_override=override,
|
||||
override_authority="user" if override else None)
|
||||
from .scaffold import Scaffold
|
||||
s = Scaffold(self._construction_store(), "issue161-harness")
|
||||
doc = s.load()
|
||||
|
||||
@@ -8,7 +8,7 @@ mode system, subject header, shared memory and identity.
|
||||
|
||||
This is not an interface. This is how I see, speak, and interact with the world.
|
||||
|
||||
Version: v0.66.58
|
||||
Version: v0.66.59
|
||||
Features: 41 (24 original + 17 new native commands)
|
||||
|
||||
Usage:
|
||||
@@ -1856,7 +1856,7 @@ class RuneApp(App):
|
||||
# Show version info
|
||||
self.chat_channel.add_message(
|
||||
"system",
|
||||
f"[dim]Rune v0.66.58 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
f"[dim]Rune v0.66.59 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
)
|
||||
|
||||
# Auto-show night mode suggestion if evening/night hours
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
"""stage_guards.py — T15 §15F–§15I: guarded stage execution for Scaffolding.
|
||||
|
||||
Design lessons absorbed (NOT the systems they came from): pre/post-
|
||||
conditions and invariants (Design by Contract), readiness gating of
|
||||
downstream work (DAGs), distinct startup/liveness/exit probe meanings
|
||||
(K8s probes), desired-vs-actual reconciliation (K8s controllers),
|
||||
idempotent retry + failure classification (Temporal), explicit
|
||||
inputs/outputs and null-build checks (Bazel), policy decision separated
|
||||
from the governed actor (policy-as-code).
|
||||
|
||||
§15F THREE CANARY MEANINGS — deliberately NOT one generic flag:
|
||||
|
||||
READINESS — may this stage begin? failure BLOCKS the stage
|
||||
LIVENESS — is progress still possible? inactivity ≠ completion
|
||||
EXIT — did reality reach the expected state, invariants held?
|
||||
a stage cannot become PASS without it
|
||||
|
||||
§15F INDEPENDENT COMPLETION AUTHORITY (14X.8 continued): the actor that
|
||||
performed the stage action may NOT self-certify its exit canary. The
|
||||
canary executes the check itself (command, readback, assertion) and
|
||||
records evidence; actor claims are evidence INPUT only.
|
||||
|
||||
§15G TEST TRUTH: exit code 0 is not proof. Where the contract requires
|
||||
tests, the exit canary requires: non-zero executed test count, no
|
||||
skip-all, expected artifact present. Expectations come from the
|
||||
contract, never hard-coded.
|
||||
|
||||
§15H HENXELS PLACEMENT: structural/system-health guard at proportionate
|
||||
boundaries — REQUIRED for structural integration boundaries and
|
||||
FULL/PROJECT_BUILD closure, NOT_APPLICABLE for tiny bounded changes.
|
||||
Every material boundary records which, with rationale.
|
||||
|
||||
§15I DESIRED VS ACTUAL: after consequential actions — persist, reload
|
||||
canonical reality, compare against desired. A command exiting 0 is not
|
||||
an effect. Zero/acceptable delta gates the NEXT stage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ── §15F guarded stage contract ─────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class StageCheck:
|
||||
"""One named, executable readiness/exit check. The CHECK executes —
|
||||
it is never satisfied by a claim."""
|
||||
name: str
|
||||
kind: str # "command" | "path_exists" | "assert"
|
||||
# command: argv list; truth from exit code (+ optional output_pattern)
|
||||
argv: list | None = None
|
||||
output_pattern: str = "" # regex that must appear on stdout (15G)
|
||||
# path_exists: path that must exist after the stage
|
||||
path: str = ""
|
||||
# assert: callable(state) -> bool (in-process deterministic check)
|
||||
check: Any = None
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class StageContract:
|
||||
"""Minimum guarded-stage semantics expressed over existing
|
||||
construction state. Not a workflow engine: a stage contract is data
|
||||
attached to (or derived from) a Construction Contract stage."""
|
||||
stage_id: str
|
||||
contract_id: str = ""
|
||||
dependencies: list = field(default_factory=list) # stage_ids
|
||||
ready_when: list = field(default_factory=list) # [StageCheck]
|
||||
done_when: list = field(default_factory=list) # [StageCheck]
|
||||
invariants: list = field(default_factory=list) # [StageCheck]
|
||||
expected_effect: str = ""
|
||||
retry_class: str = "UNKNOWN" # TRANSIENT/INTERMITTENT/KNOWN_PROCESS_
|
||||
# DEFECT/PERMANENT/APPROVAL_REQUIRED/UNKNOWN
|
||||
idempotency_key: str = "" # operation identity for null-action
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
def chk(c: StageCheck) -> dict:
|
||||
return {"name": c.name, "kind": c.kind, "argv": c.argv,
|
||||
"output_pattern": c.output_pattern, "path": c.path,
|
||||
"description": c.description}
|
||||
return {"stage_id": self.stage_id, "contract_id": self.contract_id,
|
||||
"dependencies": list(self.dependencies),
|
||||
"ready_when": [chk(c) for c in self.ready_when],
|
||||
"done_when": [chk(c) for c in self.done_when],
|
||||
"invariants": [chk(c) for c in self.invariants],
|
||||
"expected_effect": self.expected_effect,
|
||||
"retry_class": self.retry_class,
|
||||
"idempotency_key": self.idempotency_key}
|
||||
|
||||
|
||||
class StageBlocked(Exception):
|
||||
def __init__(self, stage_id: str, failures: list[dict]):
|
||||
self.stage_id = stage_id
|
||||
self.failures = failures
|
||||
super().__init__(f"stage {stage_id} blocked: "
|
||||
f"{[f['name'] for f in failures]}")
|
||||
|
||||
|
||||
class ExitCanaryFailed(Exception):
|
||||
def __init__(self, stage_id: str, failures: list[dict]):
|
||||
self.stage_id = stage_id
|
||||
self.failures = failures
|
||||
super().__init__(f"stage {stage_id} exit canary FAILED: "
|
||||
f"{[f['name'] for f in failures]}")
|
||||
|
||||
|
||||
def _run_check(check: StageCheck) -> dict:
|
||||
"""Execute ONE check and record evidence. Returns
|
||||
{name, passed, evidence}. A check that cannot execute is a FAILURE
|
||||
(cannot-execute is never a pass)."""
|
||||
ev: dict = {"name": check.name, "kind": check.kind}
|
||||
try:
|
||||
if check.kind == "command":
|
||||
r = subprocess.run(check.argv or [], capture_output=True,
|
||||
text=True, timeout=300)
|
||||
ev["exit_code"] = r.returncode
|
||||
passed = r.returncode == 0
|
||||
if passed and check.output_pattern:
|
||||
m = re.search(check.output_pattern, r.stdout or "")
|
||||
ev["pattern"] = check.output_pattern
|
||||
ev["matched"] = bool(m)
|
||||
passed = bool(m)
|
||||
if m:
|
||||
ev["matched_text"] = m.group(0)[:100]
|
||||
elif check.kind == "path_exists":
|
||||
p = Path(check.path)
|
||||
ev["path"] = check.path
|
||||
passed = p.exists() and p.stat().st_size >= 0
|
||||
ev["size"] = p.stat().st_size if p.exists() else 0
|
||||
elif check.kind == "assert":
|
||||
ev["assert"] = check.description or check.name
|
||||
passed = bool(check.check({}))
|
||||
else:
|
||||
ev["error"] = f"unknown check kind {check.kind}"
|
||||
passed = False
|
||||
except Exception as e: # noqa: BLE001 — cannot-execute is a failure
|
||||
ev["error"] = str(e)[:200]
|
||||
passed = False
|
||||
ev["passed"] = passed
|
||||
return ev
|
||||
|
||||
|
||||
def readiness_canary(stage: StageContract) -> dict:
|
||||
"""§15F: may this stage begin? ALL ready_when checks must pass.
|
||||
Failure BLOCKS the stage — it does NOT auto-repair."""
|
||||
failures = [ev for ev in (_run_check(c) for c in stage.ready_when)
|
||||
if not ev["passed"]]
|
||||
verdict = "READY" if not failures else "BLOCKED"
|
||||
return {"stage_id": stage.stage_id, "canary": "readiness",
|
||||
"verdict": verdict,
|
||||
"checks": len(stage.ready_when), "failures": failures}
|
||||
|
||||
|
||||
def execute_stage(stage: StageContract, action) -> dict:
|
||||
"""Run one guarded stage: readiness → action → exit canary.
|
||||
The action is the caller's function; its return value is evidence
|
||||
INPUT. The exit canary independently verifies done_when + invariants.
|
||||
Readiness failure raises StageBlocked (stage does not execute);
|
||||
exit failure raises ExitCanaryFailed (stage cannot become PASS)."""
|
||||
ready = readiness_canary(stage)
|
||||
if ready["verdict"] == "BLOCKED":
|
||||
raise StageBlocked(stage.stage_id, ready["failures"])
|
||||
action_evidence = action() if action else {}
|
||||
exit_evidence = [_run_check(c) for c in stage.done_when]
|
||||
invariant_evidence = [_run_check(c) for c in stage.invariants]
|
||||
failures = [e for e in exit_evidence + invariant_evidence
|
||||
if not e["passed"]]
|
||||
result = {"stage_id": stage.stage_id, "canary": "exit",
|
||||
"verdict": "PASS" if not failures else "FAILED",
|
||||
"action_evidence": action_evidence,
|
||||
"exit_checks": exit_evidence,
|
||||
"invariant_checks": invariant_evidence,
|
||||
"failures": failures}
|
||||
if failures:
|
||||
# §15F: the stage cannot become PASS — and "cannot" means the
|
||||
# caller gets an exception, not a verdict it may ignore.
|
||||
raise ExitCanaryFailed(stage.stage_id, failures)
|
||||
return result
|
||||
|
||||
|
||||
def liveness_canary(last_progress_at: float, now: float | None = None,
|
||||
stall_limit_secs: float = 300.0) -> dict:
|
||||
"""§15F: is progress still possible? Inactivity NEVER means
|
||||
completion — a stalled stage is STALLED, not done."""
|
||||
now = now if now is not None else time.monotonic()
|
||||
stalled = (now - last_progress_at) > stall_limit_secs
|
||||
return {"canary": "liveness", "stalled": stalled,
|
||||
"idle_secs": round(now - last_progress_at, 1),
|
||||
"verdict": "STALLED" if stalled else "ALIVE"}
|
||||
|
||||
|
||||
# ── §15G verify test truth, not just exit code ──────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class TestExpectations:
|
||||
"""Contract-derived verification expectations (never universal
|
||||
constants)."""
|
||||
require_tests: bool = True
|
||||
min_tests: int = 1
|
||||
test_output_pattern: str = r"(?:# pass (\d+)|(\d+) passed)"
|
||||
forbid_all_skipped: str = r"# skipped (\d+)"
|
||||
required_artifacts: list = field(default_factory=list) # paths
|
||||
|
||||
|
||||
def verify_test_truth(cmd: list, cwd: str, expectations: TestExpectations,
|
||||
*, base_dir: str = ".") -> dict:
|
||||
"""§15G: run the verification command and judge TRUTH, not exit code.
|
||||
False-green hostile cases rejected: zero tests discovered with exit 0,
|
||||
all tests skipped, expected artifact absent."""
|
||||
r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True,
|
||||
timeout=300)
|
||||
evidence: dict = {"cmd": cmd, "exit_code": r.returncode,
|
||||
"stdout_tail": (r.stdout or "")[-500:]}
|
||||
failures: list[dict] = []
|
||||
if r.returncode != 0:
|
||||
failures.append({"name": "exit_code", "exit_code": r.returncode})
|
||||
if expectations.require_tests:
|
||||
m = re.search(expectations.test_output_pattern, r.stdout or "")
|
||||
count = int(m.group(1) or m.group(2)) if m else 0
|
||||
evidence["test_count"] = count
|
||||
if count == 0:
|
||||
# zero tests + exit 0 is a false green when tests are required
|
||||
failures.append({"name": "test_count", "count": 0,
|
||||
"why": "zero tests executed — false green"})
|
||||
elif count < expectations.min_tests:
|
||||
failures.append({"name": "test_count", "count": count,
|
||||
"min_required": expectations.min_tests})
|
||||
if expectations.forbid_all_skipped:
|
||||
s = re.search(expectations.forbid_all_skipped, r.stdout or "")
|
||||
if s and count and int(s.group(1)) >= count:
|
||||
failures.append({"name": "all_skipped",
|
||||
"skipped": int(s.group(1))})
|
||||
for art in expectations.required_artifacts:
|
||||
p = Path(base_dir) / art
|
||||
if not p.exists() or p.stat().st_size == 0:
|
||||
failures.append({"name": "artifact_missing", "path": art})
|
||||
evidence["failures"] = failures
|
||||
evidence["verdict"] = "PASS" if not failures else "FALSE_GREEN_REFUSED"
|
||||
return evidence
|
||||
|
||||
|
||||
# ── §15H Henxels as proportional structural guard ───────────────────────────
|
||||
|
||||
def henxels_placement(*, depth: str, structural_boundary: bool,
|
||||
stage_id: str) -> dict:
|
||||
"""§15H: is Henxels REQUIRED at this boundary? Proportional, not
|
||||
ceremonial. Records the decision with rationale (either way)."""
|
||||
if structural_boundary or depth in ("FULL", "PROJECT_BUILD"):
|
||||
return {"stage_id": stage_id, "henxels": "HENXELS_REQUIRED",
|
||||
"rationale": (f"depth={depth}, structural_boundary="
|
||||
f"{structural_boundary} — structural "
|
||||
f"integration evidence required")}
|
||||
return {"stage_id": stage_id, "henxels": "HENXELS_NOT_APPLICABLE",
|
||||
"rationale": (f"depth={depth}, no structural boundary — "
|
||||
f"focused exit canary suffices")}
|
||||
|
||||
|
||||
# ── §15I desired vs actual reconciliation ───────────────────────────────────
|
||||
|
||||
def reconcile(desired: dict, read_actual, *, stage_id: str = "",
|
||||
tolerance: set | None = None) -> dict:
|
||||
"""§15I: persist → reload canonical reality → compare. `read_actual`
|
||||
is a callable returning the ACTUAL canonical state (fresh read, never
|
||||
a cached copy). Returns the delta; empty delta = continue. The next
|
||||
stage MUST NOT start on a non-empty delta."""
|
||||
tolerance = tolerance or set()
|
||||
actual = read_actual()
|
||||
delta: dict = {}
|
||||
for key, want in desired.items():
|
||||
if key in tolerance:
|
||||
continue
|
||||
got = actual.get(key) if isinstance(actual, dict) else None
|
||||
if got != want:
|
||||
delta[key] = {"desired": want, "actual": got}
|
||||
return {"stage_id": stage_id, "reconciled": not delta, "delta": delta}
|
||||
@@ -0,0 +1,32 @@
|
||||
# Task Ledger
|
||||
|
||||
A small standalone task ledger backed by SQLite (stdlib `sqlite3` only).
|
||||
Supports adding tasks, listing them, and marking them complete.
|
||||
|
||||
## Files
|
||||
|
||||
- `ledger.py` — the ledger (`connect`, `add_task`, `list_tasks`,
|
||||
`complete_task`; SQLite storage, `:memory:` by default or a file path).
|
||||
- `ledger.test.py` — stdlib `unittest` suite proving add, list and
|
||||
complete work, including SQLite file persistence.
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
import ledger
|
||||
|
||||
conn = ledger.connect("tasks.db") # or ledger.connect() for in-memory
|
||||
task = ledger.add_task(conn, "write report")
|
||||
print(ledger.list_tasks(conn))
|
||||
ledger.complete_task(conn, task["id"])
|
||||
```
|
||||
|
||||
## Run tests
|
||||
|
||||
The test file uses a Node-style name (`ledger.test.py`), so the default
|
||||
`unittest discover` pattern (`test*.py`) does not match it. Run it
|
||||
directly:
|
||||
|
||||
```sh
|
||||
python3 ledger.test.py -v
|
||||
```
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Standalone SQLite task ledger: add, list and complete tasks.
|
||||
|
||||
Stdlib only (sqlite3). State lives in SQLite; the caller owns the
|
||||
connection (``:memory:`` by default, or a file path for persistence).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
SCHEMA = (
|
||||
"CREATE TABLE IF NOT EXISTS tasks ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"title TEXT NOT NULL, "
|
||||
"completed INTEGER NOT NULL DEFAULT 0"
|
||||
")"
|
||||
)
|
||||
|
||||
|
||||
def connect(path: str = ":memory:") -> sqlite3.Connection:
|
||||
"""Open a ledger database and ensure the schema exists."""
|
||||
conn = sqlite3.connect(path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_db(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def init_db(conn: sqlite3.Connection) -> None:
|
||||
"""Create the tasks table if it does not exist."""
|
||||
conn.execute(SCHEMA)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _row_to_dict(row: sqlite3.Row) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"title": row["title"],
|
||||
"completed": bool(row["completed"]),
|
||||
}
|
||||
|
||||
|
||||
def add_task(conn: sqlite3.Connection, title: str) -> dict:
|
||||
"""Add a task; return ``{"id", "title", "completed"}``.
|
||||
|
||||
Raises:
|
||||
TypeError: if title is not a string.
|
||||
ValueError: if title is empty or blank.
|
||||
"""
|
||||
if not isinstance(title, str):
|
||||
raise TypeError("title must be a string")
|
||||
cleaned = title.strip()
|
||||
if not cleaned:
|
||||
raise ValueError("title must be a non-empty string")
|
||||
init_db(conn)
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (title, completed) VALUES (?, 0)", (cleaned,)
|
||||
)
|
||||
conn.commit()
|
||||
task_id = cur.lastrowid
|
||||
row = conn.execute(
|
||||
"SELECT id, title, completed FROM tasks WHERE id = ?", (task_id,)
|
||||
).fetchone()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
def list_tasks(conn: sqlite3.Connection) -> list:
|
||||
"""Return all tasks ordered by id (each a dict)."""
|
||||
init_db(conn)
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, completed FROM tasks ORDER BY id"
|
||||
).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def complete_task(conn: sqlite3.Connection, task_id: int) -> dict:
|
||||
"""Mark the task with ``task_id`` completed; return the updated dict.
|
||||
|
||||
Raises:
|
||||
TypeError: if task_id is not an int.
|
||||
LookupError: if no task has that id.
|
||||
"""
|
||||
if isinstance(task_id, bool) or not isinstance(task_id, int):
|
||||
raise TypeError("task_id must be an int")
|
||||
init_db(conn)
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET completed = 1 WHERE id = ?", (task_id,)
|
||||
)
|
||||
conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
raise LookupError(f"no task with id {task_id}")
|
||||
row = conn.execute(
|
||||
"SELECT id, title, completed FROM tasks WHERE id = ?", (task_id,)
|
||||
).fetchone()
|
||||
return _row_to_dict(row)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Tests for the standalone SQLite task ledger (``ledger.py``).
|
||||
|
||||
Run directly (the file name is Node-style, so the default
|
||||
``python -m unittest discover`` pattern ``test*.py`` does not pick it up)::
|
||||
|
||||
python3 ledger.test.py -v
|
||||
|
||||
Stdlib only.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import ledger
|
||||
|
||||
|
||||
def fresh_conn():
|
||||
return ledger.connect(":memory:")
|
||||
|
||||
|
||||
class TestAddTask(unittest.TestCase):
|
||||
def test_add_returns_task_with_id_title_and_not_completed(self):
|
||||
conn = fresh_conn()
|
||||
task = ledger.add_task(conn, "write report")
|
||||
self.assertEqual(task["title"], "write report")
|
||||
self.assertFalse(task["completed"])
|
||||
self.assertIsInstance(task["id"], int)
|
||||
|
||||
def test_add_assigns_unique_ids(self):
|
||||
conn = fresh_conn()
|
||||
first = ledger.add_task(conn, "one")
|
||||
second = ledger.add_task(conn, "two")
|
||||
self.assertNotEqual(first["id"], second["id"])
|
||||
|
||||
def test_add_trims_title(self):
|
||||
conn = fresh_conn()
|
||||
task = ledger.add_task(conn, " tidy desk ")
|
||||
self.assertEqual(task["title"], "tidy desk")
|
||||
|
||||
def test_add_rejects_empty_or_blank_title(self):
|
||||
conn = fresh_conn()
|
||||
with self.assertRaises(ValueError):
|
||||
ledger.add_task(conn, "")
|
||||
with self.assertRaises(ValueError):
|
||||
ledger.add_task(conn, " ")
|
||||
|
||||
def test_add_rejects_non_string_title(self):
|
||||
conn = fresh_conn()
|
||||
with self.assertRaises(TypeError):
|
||||
ledger.add_task(conn, None)
|
||||
with self.assertRaises(TypeError):
|
||||
ledger.add_task(conn, 123)
|
||||
|
||||
|
||||
class TestListTasks(unittest.TestCase):
|
||||
def test_list_empty_ledger(self):
|
||||
self.assertEqual(ledger.list_tasks(fresh_conn()), [])
|
||||
|
||||
def test_list_returns_all_tasks_in_order(self):
|
||||
conn = fresh_conn()
|
||||
ledger.add_task(conn, "alpha")
|
||||
ledger.add_task(conn, "beta")
|
||||
tasks = ledger.list_tasks(conn)
|
||||
self.assertEqual([t["title"] for t in tasks], ["alpha", "beta"])
|
||||
|
||||
def test_list_shows_completed_flags(self):
|
||||
conn = fresh_conn()
|
||||
task = ledger.add_task(conn, "alpha")
|
||||
ledger.add_task(conn, "beta")
|
||||
ledger.complete_task(conn, task["id"])
|
||||
by_title = {t["title"]: t for t in ledger.list_tasks(conn)}
|
||||
self.assertTrue(by_title["alpha"]["completed"])
|
||||
self.assertFalse(by_title["beta"]["completed"])
|
||||
|
||||
|
||||
class TestCompleteTask(unittest.TestCase):
|
||||
def test_complete_marks_matching_task(self):
|
||||
conn = fresh_conn()
|
||||
first = ledger.add_task(conn, "alpha")
|
||||
second = ledger.add_task(conn, "beta")
|
||||
updated = ledger.complete_task(conn, first["id"])
|
||||
self.assertTrue(updated["completed"])
|
||||
self.assertEqual(updated["id"], first["id"])
|
||||
by_title = {t["title"]: t for t in ledger.list_tasks(conn)}
|
||||
self.assertTrue(by_title["alpha"]["completed"])
|
||||
self.assertFalse(by_title["beta"]["completed"])
|
||||
|
||||
def test_complete_persists_across_listing(self):
|
||||
conn = fresh_conn()
|
||||
task = ledger.add_task(conn, "alpha")
|
||||
ledger.complete_task(conn, task["id"])
|
||||
done = [t for t in ledger.list_tasks(conn) if t["completed"]]
|
||||
self.assertEqual([t["title"] for t in done], ["alpha"])
|
||||
|
||||
def test_complete_unknown_id_raises(self):
|
||||
conn = fresh_conn()
|
||||
ledger.add_task(conn, "alpha")
|
||||
with self.assertRaises(LookupError):
|
||||
ledger.complete_task(conn, 999)
|
||||
|
||||
def test_complete_rejects_non_int_id(self):
|
||||
conn = fresh_conn()
|
||||
ledger.add_task(conn, "alpha")
|
||||
with self.assertRaises(TypeError):
|
||||
ledger.complete_task(conn, "1")
|
||||
|
||||
|
||||
class TestPersistence(unittest.TestCase):
|
||||
def test_tasks_survive_reconnect_to_file_db(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "tasks.db")
|
||||
conn = ledger.connect(path)
|
||||
task = ledger.add_task(conn, "persist me")
|
||||
ledger.complete_task(conn, task["id"])
|
||||
conn.close()
|
||||
reopened = ledger.connect(path)
|
||||
try:
|
||||
tasks = ledger.list_tasks(reopened)
|
||||
finally:
|
||||
reopened.close()
|
||||
self.assertEqual(len(tasks), 1)
|
||||
self.assertEqual(tasks[0]["title"], "persist me")
|
||||
self.assertTrue(tasks[0]["completed"])
|
||||
|
||||
def test_uses_sqlite_storage(self):
|
||||
conn = fresh_conn()
|
||||
self.assertIsInstance(conn, sqlite3.Connection)
|
||||
ledger.add_task(conn, "alpha")
|
||||
rows = conn.execute("SELECT title FROM tasks").fetchall()
|
||||
self.assertEqual([r[0] for r in rows], ["alpha"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,2 @@
|
||||
# tests 10
|
||||
# pass 10
|
||||
@@ -35069,6 +35069,191 @@
|
||||
"observed_at": "2026-09-13T23:56:08Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"SCAFFOLD-TRANCHE-015"
|
||||
],
|
||||
"id": "tui-backlog:builder-state-waiting",
|
||||
"identities": {
|
||||
"authority_source": "product-surface requirement",
|
||||
"dependencies": [],
|
||||
"desired_behavior": "project waiting reason; resume on event",
|
||||
"feature": "Builder state + WAITING_FOR_EVENT (reason, last event, watchdog)",
|
||||
"priority": "normal",
|
||||
"projection": "BOTH",
|
||||
"status": "OPEN",
|
||||
"why": "15D/15Q: event-driven waits are the default"
|
||||
},
|
||||
"kind": "tui_backlog_item",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T03:51:33Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"SCAFFOLD-TRANCHE-015"
|
||||
],
|
||||
"id": "tui-backlog:canary-state",
|
||||
"identities": {
|
||||
"authority_source": "product-surface requirement",
|
||||
"dependencies": [],
|
||||
"desired_behavior": "show READY/BLOCKED/PASS/FAILED with evidence",
|
||||
"feature": "Readiness/liveness/exit canary state per stage",
|
||||
"priority": "normal",
|
||||
"projection": "INSPECTION",
|
||||
"status": "OPEN",
|
||||
"why": "15F: canary verdicts gate stages"
|
||||
},
|
||||
"kind": "tui_backlog_item",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T03:51:33Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"SCAFFOLD-TRANCHE-015"
|
||||
],
|
||||
"id": "tui-backlog:construction-auto-activated",
|
||||
"identities": {
|
||||
"authority_source": "product-surface requirement",
|
||||
"dependencies": [],
|
||||
"desired_behavior": "show what fired; allow pause/override",
|
||||
"feature": "Construction automatically activated (mode/depth/Contract/phase)",
|
||||
"priority": "normal",
|
||||
"projection": "BOTH",
|
||||
"status": "OPEN",
|
||||
"why": "15J: native route activates Scaffolding automatically"
|
||||
},
|
||||
"kind": "tui_backlog_item",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T03:51:33Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"SCAFFOLD-TRANCHE-015"
|
||||
],
|
||||
"id": "tui-backlog:programme-continuation",
|
||||
"identities": {
|
||||
"authority_source": "product-surface requirement",
|
||||
"dependencies": [],
|
||||
"desired_behavior": "show open stages + next eligible action; pause/resume",
|
||||
"feature": "Programme continuation state (stages, next action, blockers)",
|
||||
"priority": "normal",
|
||||
"projection": "BOTH",
|
||||
"status": "OPEN",
|
||||
"why": "15M: task-done is not programme-done"
|
||||
},
|
||||
"kind": "tui_backlog_item",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T03:51:33Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"SCAFFOLD-TRANCHE-015"
|
||||
],
|
||||
"id": "tui-backlog:release-convergence",
|
||||
"identities": {
|
||||
"authority_source": "product-surface requirement",
|
||||
"dependencies": [],
|
||||
"desired_behavior": "show current/last-good + convergence health",
|
||||
"feature": "Release convergence state (authority, publisher, health)",
|
||||
"priority": "normal",
|
||||
"projection": "INSPECTION",
|
||||
"status": "OPEN",
|
||||
"why": "14B/14C: authority-derived publication"
|
||||
},
|
||||
"kind": "tui_backlog_item",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T03:51:33Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"SCAFFOLD-TRANCHE-015"
|
||||
],
|
||||
"id": "tui-backlog:resume-discovery",
|
||||
"identities": {
|
||||
"authority_source": "product-surface requirement",
|
||||
"dependencies": [],
|
||||
"desired_behavior": "show what a fresh builder would resume; resume trigger",
|
||||
"feature": "Resume discovery (active contract, recovery, next action)",
|
||||
"priority": "normal",
|
||||
"projection": "BOTH",
|
||||
"status": "OPEN",
|
||||
"why": "15U: discover_active_contract"
|
||||
},
|
||||
"kind": "tui_backlog_item",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T03:51:33Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"SCAFFOLD-TRANCHE-015"
|
||||
],
|
||||
"id": "tui-backlog:verification-truth",
|
||||
"identities": {
|
||||
"authority_source": "product-surface requirement",
|
||||
"dependencies": [],
|
||||
"desired_behavior": "show executed test counts and refusal reasons",
|
||||
"feature": "Verification truth (test counts, false-green refusals)",
|
||||
"priority": "normal",
|
||||
"projection": "INSPECTION",
|
||||
"status": "OPEN",
|
||||
"why": "15G: exit 0 is not proof"
|
||||
},
|
||||
"kind": "tui_backlog_item",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T03:51:33Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"T15 15A audit"
|
||||
],
|
||||
"id": "process-defect:construction-default-not-enforced",
|
||||
"identities": {
|
||||
"issue": "Construction activation is wired into RuneOperator.send_task (T12D) but no user-surface proof exists that ordinary TUI construction requests route through Scaffolding by default, and no bypass inventory exists (15B/15K close this).",
|
||||
"repair": "T15 stages (15D/15J+K/15M)",
|
||||
"status": "RECORDED"
|
||||
},
|
||||
"kind": "process_defect",
|
||||
"maturity": "RECORDED",
|
||||
"observed_at": "2026-09-15T00:32:10Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"T15 15A audit"
|
||||
],
|
||||
"id": "process-defect:event-wait-not-default",
|
||||
"identities": {
|
||||
"issue": "T14's real-builder driver used repeated sleep/inspect loops (sleep 100/150/160) although the W/R webhook bridge (daemon :8791, waitstore event matching, lease, resume driver) was running and event-capable. Root cause: bridge resume_cmd is mimo-session-shaped and global; a Python controller could not be a resume target. DEFECT CLASS: mechanism live-proven but not canonical default.",
|
||||
"repair": "T15 stages (15D/15J+K/15M)",
|
||||
"status": "RECORDED"
|
||||
},
|
||||
"kind": "process_defect",
|
||||
"maturity": "RECORDED",
|
||||
"observed_at": "2026-09-15T00:32:10Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"T15 15A audit"
|
||||
],
|
||||
"id": "process-defect:subtask-completion-can-end-programme",
|
||||
"identities": {
|
||||
"issue": "Across T12-T14 the agent repeatedly emitted completion reports and stopped after individual stages; overall-programme-open state was not tracked by a continuation canary. T15 15M adds the programme continuation canary.",
|
||||
"repair": "T15 stages (15D/15J+K/15M)",
|
||||
"status": "RECORDED"
|
||||
},
|
||||
"kind": "process_defect",
|
||||
"maturity": "RECORDED",
|
||||
"observed_at": "2026-09-15T00:32:10Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"construction_interface.py",
|
||||
@@ -35090,6 +35275,68 @@
|
||||
"observed_at": "2026-09-14T01:54:45Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"daemon log 429s"
|
||||
],
|
||||
"ext": {
|
||||
"finding": "T14 used 'PL1 provider policy default' for the Verboo builder fallback. Observed facts: Xioami/mimo-v2.5-pro returned 429 quota-exhausted (32 occurrences in session log); the driver fell back to Verboo Code/glm-5.3-flash per the standing governing provider policy (Nick, 2026-09-12: PL1 held-out coding + builder work default). No PL1 promotion/maturity claim is made or supported \u2014 the policy names the provider default; it does not assert pipeline level."
|
||||
},
|
||||
"id": "record:t15a-provider-terminology",
|
||||
"identities": {
|
||||
"stage": "15A"
|
||||
},
|
||||
"kind": "process_learning",
|
||||
"maturity": "MEASURED",
|
||||
"observed_at": "2026-09-15T00:32:10Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"daemon log",
|
||||
"git log",
|
||||
"session history"
|
||||
],
|
||||
"ext": {
|
||||
"arbitrary_polling_violations": [
|
||||
"pre-15D: sleep 60/100/150/160 loops \u2014 the defect that motivated 15D; eliminated on the normal path",
|
||||
"t15_builder_session interrupt path: 0.5s file-watch poll \u2014 TEST_TIMING on the deliberate-kill path only, explicitly marked; normal continuation never polls",
|
||||
"test helper wait_for(): 0.3s interval \u2014 TEST_TIMING, test-only, never production"
|
||||
],
|
||||
"canonical_truth_across_processes": true,
|
||||
"continued_after_subtask": true,
|
||||
"defects_found_and_repaired": [
|
||||
"FIFO writer blocked 30s with no reader \u2014 fail-fast fix",
|
||||
"S110 noqa for deliberate degrade; umbrella manifest for new modules",
|
||||
"WR_ROOT/adapter waitstore split \u2014 fixed",
|
||||
"bare os.chdir test pollution \u2014 monkeypatch.chdir",
|
||||
"builder-quota exhaustion starving other suites \u2014 send_task stubbed in hook tests (suite 144s \u2192 54s)",
|
||||
"notifier sys.environ bug (should be os.environ) \u2014 event never emitted; fixed",
|
||||
"per-wait resume_cmd override never passed at call site \u2014 fixed",
|
||||
"pkill pattern matched the invoking shell + production W/R daemon on :8791 \u2014 daemon killed, immediately restarted (health 200), recorded here",
|
||||
"watchdog deadline < real build time + regroup invisibility \u2014 liveness-gated re-arms + canonical-state-wins",
|
||||
"zombie liveness false-positive \u2014 zombie-aware fix"
|
||||
],
|
||||
"event_driven_waits": true,
|
||||
"evidence": "15 stages driven in one programme without voluntary intermediate stop; 9 local commits across the night",
|
||||
"evidence_state": "store/scaffold + waitstore + release history all reloaded fresh at stage boundaries",
|
||||
"evidence_waits": "real-builder runs yielded on the bridge FIFO; test_event_wait proves no floor; 15Q live run resumed from events",
|
||||
"mistakes_prevented_earlier_than_t14": [
|
||||
"local-vs-CI skew normalized (14H, carried)",
|
||||
"observation-shape errors now schema-discoverable (15N)",
|
||||
"residue-lookalike writes now violations (15O)",
|
||||
"resume discovery canonical (15U)"
|
||||
]
|
||||
},
|
||||
"id": "record:t15w-self-hosting-proof",
|
||||
"identities": {
|
||||
"stage": "15W"
|
||||
},
|
||||
"kind": "process_learning",
|
||||
"maturity": "MEASURED",
|
||||
"observed_at": "2026-09-15T03:51:49Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"deploy-7359-log",
|
||||
@@ -35146,6 +35393,24 @@
|
||||
"observed_at": "2026-09-13T11:42:53Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"git 4ecd990",
|
||||
"git 5e6a397"
|
||||
],
|
||||
"ext": {
|
||||
"correction": "baseline reporting must cite the collected count at the tranche-boundary commit, not mid-tranche counts",
|
||||
"finding": "T13 completion reported 1721 tests; the actual T13-tip commit (4ecd990) collects 1783. T14 reported 'baseline 1795' which was already inflated by T14's own 14B/14C test additions committed before the full-suite count. True delta across T14: 1783 -> 1805 (+22: 7 release-race held-outs, 2 intent guards, 8 T14 adversarial held-outs, 5 interface/commit migrations)."
|
||||
},
|
||||
"id": "record:t15a-baseline-reconciliation",
|
||||
"identities": {
|
||||
"stage": "15A"
|
||||
},
|
||||
"kind": "process_learning",
|
||||
"maturity": "MEASURED",
|
||||
"observed_at": "2026-09-15T00:32:10Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"incident-04:53:33-36",
|
||||
@@ -35160,6 +35425,59 @@
|
||||
"observed_at": "2026-09-13T11:43:28Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"record:first-pass-efficiency-t14",
|
||||
"record:t15w-self-hosting-proof"
|
||||
],
|
||||
"ext": {
|
||||
"recurring_classes_now_mechanically_prevented": [
|
||||
"arbitrary polling \u2192 event bridge default (15D)",
|
||||
"forgotten save \u2192 commit() + digest readback (T12B, held)",
|
||||
"guessed API signatures \u2192 capabilities() argument schemas (15N)",
|
||||
"observation schema errors \u2192 discoverable schema + held-outs",
|
||||
"provider wait behavior \u2192 watchdog = detector, not scheduler",
|
||||
"source-extension gaps \u2192 .mjs/.cjs/.mts/.cts + regression",
|
||||
"stale doc \u2192 commit() boundary (T11, held)",
|
||||
"unsupported verification command \u2192 readiness + 15G truth"
|
||||
],
|
||||
"t14_discovery_profile": "interface-validation + exit-canary dominant; zero escaped; stale-doc/forgotten-save extinct",
|
||||
"t15_discovery_profile": {
|
||||
"caught_by_exit_canary": [
|
||||
"false-green unittest (0 tests, exit 0) \u2014 refused",
|
||||
"scope violations (runtime residue first, then real)",
|
||||
"self-certification attempts (held-out)"
|
||||
],
|
||||
"caught_by_henxels": [
|
||||
"structural manifest for new modules"
|
||||
],
|
||||
"caught_by_interface_validation": [
|
||||
"fit-gate refusals on unseeded Needs (5+ occurrences, all correct behavior, fixed by seeding)",
|
||||
"observation-shape errors (now schema-discoverable)"
|
||||
],
|
||||
"escaped_into_later_stage": [
|
||||
"NONE observed this tranche"
|
||||
],
|
||||
"manual_late": [
|
||||
"orphaned T14 builder polluting process checks (found by inspection, killed, recorded)"
|
||||
],
|
||||
"prevented_by_readiness": [
|
||||
"15F: blocked stages never executed (held-out proven)",
|
||||
"15G: verifier mismatch refused closure before any commit",
|
||||
"15Q: toolchain/bridge health checked BEFORE builder launch"
|
||||
]
|
||||
},
|
||||
"trend": "late discovery \u2192 exit detection \u2192 readiness prevention: the movement is evidenced \u2014 T14 caught at exit, T15 prevents at readiness in 3 recorded cases and at interface discovery in 1"
|
||||
},
|
||||
"id": "record:t15x-efficiency-comparison",
|
||||
"identities": {
|
||||
"stage": "15X"
|
||||
},
|
||||
"kind": "process_learning",
|
||||
"maturity": "MEASURED",
|
||||
"observed_at": "2026-09-15T03:54:16Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"release-safety-repair-2026-09-13",
|
||||
@@ -35271,6 +35589,30 @@
|
||||
"observed_at": "2026-09-14T23:35:40Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"tranche-15 brief"
|
||||
],
|
||||
"ext": {
|
||||
"ownership": {
|
||||
"long_running_wait": "event bridge (W/R daemon, 15D)",
|
||||
"research_execution_competence": "Research Core / canonical pipeline",
|
||||
"research_state_continuity": "future Substrate \u2014 persistent state/relevance/continuity coordinator; NOT built in T15",
|
||||
"tui": "client/projection \u2014 NEVER an authority",
|
||||
"tui_construction_request": "Scaffolding (native default, 15J/15K)"
|
||||
},
|
||||
"prohibited_absorption": "Scaffolding must NOT become a general task/research coordinator; programme_continuation.py is bounded to one construction programme's stage list",
|
||||
"tui_backlog_seam": "future research-state projection (goal/stage/evidence/blockers/waiting/next/pause-resume) belongs to the Substrate era; recorded for the TUI Upgrade Backlog, not built"
|
||||
},
|
||||
"id": "record:t15l-authority-boundaries",
|
||||
"identities": {
|
||||
"stage": "15L"
|
||||
},
|
||||
"kind": "architecture_boundary",
|
||||
"maturity": "RECORDED",
|
||||
"observed_at": "2026-09-15T02:11:09Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"tranche-2-report"
|
||||
|
||||
+218
-130
File diff suppressed because it is too large.
Load diff
@@ -549,3 +549,39 @@ def test_capabilities_cli_roundtrip(tmp_path):
|
||||
assert out["success"]
|
||||
assert out["state"]["schema_version"] == ci.INTERFACE_SCHEMA_VERSION
|
||||
assert "capabilities" in out["state"]["operations"]
|
||||
|
||||
|
||||
def test_15u_discover_active_contract_migrated_to_interface(tmp_path):
|
||||
"""§15U simplification: resume discovery lives in the INTERFACE (one
|
||||
canonical home), not in session-driver scripts. The drivers' helpers
|
||||
are shims over this."""
|
||||
from rune import construction_interface as ci
|
||||
store = tmp_path / "scaffold"
|
||||
iface = ci.ConstructionInterface(builder="probe",
|
||||
project_store=str(store),
|
||||
project_id="t15u")
|
||||
iface.scaffold.init(purpose="15u probe")
|
||||
empty = ci.ConstructionInterface.discover_active_contract(str(store))
|
||||
assert not empty["success"], "empty store must not invent a contract"
|
||||
doc = iface.scaffold.load()
|
||||
from rune import structural_store as ss
|
||||
ss.add_entity(doc, id="contract:spec-15u", kind="contract",
|
||||
identities={"objective": "spec"}, evidence_refs=["e"])
|
||||
ss.add_entity(doc, id="capability:planned", kind="capability",
|
||||
maturity="DECLARED", evidence_refs=["e"])
|
||||
ss.add_relationship(doc, type="implements",
|
||||
from_id="capability:planned",
|
||||
to_id="contract:spec-15u", evidence_refs=["e"])
|
||||
iface.scaffold.commit(doc, what="seed 15u spec")
|
||||
sub = iface.submit_need(
|
||||
"build the 15u module",
|
||||
constraints={"environment": {"os": "linux"},
|
||||
"contracts": ["contract:spec-15u"]},
|
||||
evidence_refs=["e"], approval_context="15u")
|
||||
assert sub["success"]
|
||||
found = ci.ConstructionInterface.discover_active_contract(str(store))
|
||||
assert found["success"]
|
||||
assert found["ids"]["contract_id"] == sub["ids"]["contract_id"]
|
||||
# capabilities() advertises the new op
|
||||
assert "discover_active_contract" in \
|
||||
iface.capabilities()["state"]["operations"]
|
||||
@@ -0,0 +1,251 @@
|
||||
"""test_event_wait.py — T15 §15D/§15E: event-driven wait as canonical
|
||||
default + event semantics adversarials.
|
||||
|
||||
Runs the REAL W/R daemon as a subprocess against an isolated WR_ROOT
|
||||
(the same disposable harness pattern as the bridge's own v0.2/v0.3
|
||||
invariant tests), then drives the repo-side adapter (rune.event_wait)
|
||||
through it. No real MiMo sessions; the "builder" is a cheap fake command.
|
||||
|
||||
Proofs:
|
||||
15D latency — controller resumes on the EVENT; continuation
|
||||
latency recorded; no arbitrary wait floor exists
|
||||
(a deliberately long would-be floor is NOT waited).
|
||||
15D failure — non-zero builder exit → verdict failure, resume
|
||||
still fires (timeout is not success; events carry
|
||||
truth).
|
||||
15D watchdog — lost event → deadline expires → one-shot liveness
|
||||
inspection → PROCESS_LOST classification. Watchdog
|
||||
is the failure detector, never the scheduler.
|
||||
15E duplicate — duplicate completion event cannot double-resume.
|
||||
15E stale — event from a prior operation matches nothing.
|
||||
15E wrong corr — unknown operation_id matches nothing.
|
||||
15E malformed — event without exit_code leaves wait WAITING.
|
||||
15E restart — controller restart after event persisted: durable
|
||||
waitstore wins; resume_or_recover is idempotent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
WR = Path("/home/nick/.local/share/mimo-wait-resume")
|
||||
sys.path.insert(0, str(WR))
|
||||
|
||||
from rune import event_wait as ew # noqa: E402
|
||||
|
||||
|
||||
class Daemon:
|
||||
"""Real daemon subprocess on an isolated WR_ROOT + ephemeral port."""
|
||||
|
||||
def __init__(self, tmp_root: Path, port: int):
|
||||
self.root, self.port = tmp_root, port
|
||||
env = dict(os.environ,
|
||||
WR_ROOT=str(tmp_root),
|
||||
WR_DRIVE_SECS="1",
|
||||
WR_RECONCILE_SECS="2",
|
||||
WR_RECONCILE_TICKS="1",
|
||||
WR_WATCH_SECS="30",
|
||||
WR_TERM_GRACE_SECS="1",
|
||||
RUNE_WR_ROOT=str(tmp_root))
|
||||
self.proc = subprocess.Popen(
|
||||
[sys.executable, str(WR / "daemon.py"), str(port)],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, env=env)
|
||||
deadline = time.time() + 15
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
f"http://127.0.0.1:{port}/health", timeout=2) as r:
|
||||
if r.status == 200:
|
||||
return
|
||||
except Exception:
|
||||
time.sleep(0.2)
|
||||
raise RuntimeError("daemon did not become healthy")
|
||||
|
||||
def url(self) -> str:
|
||||
return f"http://127.0.0.1:{self.port}"
|
||||
|
||||
def stop(self):
|
||||
self.proc.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
self.proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proc.kill()
|
||||
|
||||
def post_event(self, payload: dict) -> dict:
|
||||
req = urllib.request.Request(
|
||||
self.url() + "/process-event",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def daemon(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RUNE_WR_ROOT", str(tmp_path)) # adapter binds here
|
||||
monkeypatch.setenv("RUNE_WR_CODE", str(WR))
|
||||
monkeypatch.setenv("RUNE_WR_ROOT", str(tmp_path))
|
||||
d = Daemon(tmp_path, port=8793)
|
||||
yield d
|
||||
d.stop()
|
||||
|
||||
|
||||
# real fake-builder: sleeps then exits — cheap but genuinely a process
|
||||
FAKE_BUILDER = ["bash", "-c", "sleep 2; exit 0"]
|
||||
FAKE_BUILDER_FAIL = ["bash", "-c", "sleep 1; exit 3"]
|
||||
|
||||
|
||||
def test_15d_latency_event_driven_no_floor(daemon):
|
||||
"""Continuation happens FROM the event: controller resumes ~immediately
|
||||
after the 2s builder exits — proving no 60s+ polling floor exists."""
|
||||
op = "op-latency-proof"
|
||||
rec = ew.register_process_wait(
|
||||
op, contract_id="contract:t15", project="t15-fixture",
|
||||
objective="latency proof", watchdog_secs=120)
|
||||
assert rec["wait_kind"] == "PROCESS_EVENT"
|
||||
assert rec["resume_cmd"], "per-wait resume_cmd required"
|
||||
|
||||
builder_started = time.time()
|
||||
notifier = ew.run_builder_under_events(FAKE_BUILDER, op,
|
||||
daemon_url=daemon.url())
|
||||
result = ew.await_resume_event(op, watchdog_secs=120,
|
||||
pgid=notifier.pid)
|
||||
resumed_at = time.time()
|
||||
assert result["kind"] == "event", f"expected event resume, got {result}"
|
||||
wait = result["wait"]
|
||||
assert wait["verdict"] == "success"
|
||||
evidence = wait.get("process_event_evidence", {})
|
||||
child_exit = evidence and float(evidence.get("exit_code") == 0)
|
||||
# The event carries the actual exit evidence
|
||||
assert evidence.get("exit_code") == 0
|
||||
builder_elapsed = resumed_at - builder_started
|
||||
# builder itself takes ~2s; if a 60s polling floor existed we'd exceed it
|
||||
assert builder_elapsed < 30, \
|
||||
f"continuation took {builder_elapsed:.1f}s — a wait floor exists"
|
||||
# and it cannot be faster than the builder itself (event came from
|
||||
# the real process exit, not a timer)
|
||||
assert builder_elapsed >= 1.5
|
||||
assert wait["status"] in ("RESUMING", "COMPLETED")
|
||||
|
||||
|
||||
def test_15d_failure_event_carries_truth(daemon):
|
||||
op = "op-failure-proof"
|
||||
ew.register_process_wait(op, project="t15-fixture",
|
||||
objective="failure proof", watchdog_secs=60)
|
||||
notifier = ew.run_builder_under_events(FAKE_BUILDER_FAIL, op,
|
||||
daemon_url=daemon.url())
|
||||
result = ew.await_resume_event(op, watchdog_secs=60, pgid=notifier.pid)
|
||||
assert result["kind"] == "event"
|
||||
assert result["wait"]["verdict"] == "failure"
|
||||
assert result["wait"]["process_event_evidence"]["exit_code"] == 3
|
||||
|
||||
|
||||
def test_15d_watchdog_detects_lost_event(daemon, tmp_path):
|
||||
"""Lost event: deadline expires → one-shot liveness inspection →
|
||||
PROCESS_LOST. The watchdog never schedules; it detects failure of
|
||||
continuation."""
|
||||
op = "op-lost-event"
|
||||
rec = ew.register_process_wait(op, project="t15-fixture",
|
||||
objective="lost event", watchdog_secs=3)
|
||||
# launch a long-running builder whose event will NEVER arrive
|
||||
notifier = ew.run_builder_under_events(
|
||||
["bash", "-c", "sleep 120"], op, daemon_url=daemon.url())
|
||||
result = ew.await_resume_event(op, watchdog_secs=3, pgid=notifier.pid)
|
||||
assert result["kind"] == "watchdog"
|
||||
assert result["alive"] is True # process still alive → NOT lost, re-arm
|
||||
# now genuinely lose it: kill the group, next watchdog sees GONE
|
||||
os.killpg(notifier.pid, signal.SIGKILL)
|
||||
result2 = ew.await_resume_event(op, watchdog_secs=3, pgid=notifier.pid)
|
||||
assert result2["kind"] == "watchdog"
|
||||
assert result2["alive"] is False # PROCESS_LOST → recovery path
|
||||
# recovery: process died without completion — the wait is still
|
||||
# WAITING_EXTERNAL (canonical state: nothing completed)
|
||||
wait = ew.operation_wait(op)
|
||||
assert wait["status"] == "WAITING_EXTERNAL"
|
||||
|
||||
|
||||
def test_15e_duplicate_event_cannot_double_resume(daemon):
|
||||
op = "op-dup-event"
|
||||
ew.register_process_wait(op, project="t15-fixture", watchdog_secs=60)
|
||||
first = daemon.post_event({"operation_id": op, "exit_code": 0})
|
||||
assert first["matched"] >= 1
|
||||
wait_for(lambda: (ew.operation_wait(op)["status"] != "READY_TO_RESUME"))
|
||||
second = daemon.post_event({"operation_id": op, "exit_code": 0})
|
||||
# second event matches nothing new (wait no longer WAITING_EXTERNAL)
|
||||
# and must not re-dispatch a resumed wait
|
||||
rec = ew.operation_wait(op)
|
||||
if second["matched"] > 0:
|
||||
# if it dispatched again, it must NOT have reset resume state
|
||||
assert rec.get("resume_attempts", 0) >= 1
|
||||
assert rec["status"] != "WAITING_EXTERNAL"
|
||||
# verdict unchanged by the duplicate
|
||||
assert rec.get("verdict") in ("success", None)
|
||||
|
||||
|
||||
def test_15e_stale_event_from_prior_operation_matches_nothing(daemon):
|
||||
op_old, op_new = "op-old", "op-new"
|
||||
ew.register_process_wait(op_old, project="t15-fixture", watchdog_secs=60)
|
||||
ew.register_process_wait(op_new, project="t15-fixture", watchdog_secs=60)
|
||||
# satisfy + consume the old operation's event
|
||||
daemon.post_event({"operation_id": op_old, "exit_code": 0})
|
||||
wait_for(lambda: ew.operation_wait(op_old)["status"] != "READY_TO_RESUME")
|
||||
# a STALE repeat of the old event must not touch the new operation
|
||||
daemon.post_event({"operation_id": op_old, "exit_code": 0})
|
||||
new_wait = ew.operation_wait(op_new)
|
||||
assert new_wait["status"] == "WAITING_EXTERNAL"
|
||||
assert new_wait.get("verdict") is None
|
||||
|
||||
|
||||
def test_15e_wrong_correlation_matches_nothing(daemon):
|
||||
op = "op-wrong-corr"
|
||||
ew.register_process_wait(op, project="t15-fixture", watchdog_secs=60)
|
||||
resp = daemon.post_event({"operation_id": "completely-unrelated",
|
||||
"exit_code": 0})
|
||||
assert resp["matched"] == 0
|
||||
assert ew.operation_wait(op)["status"] == "WAITING_EXTERNAL"
|
||||
|
||||
|
||||
def test_15e_malformed_event_without_exit_code_stays_waiting(daemon):
|
||||
op = "op-malformed"
|
||||
ew.register_process_wait(op, project="t15-fixture", watchdog_secs=60)
|
||||
resp = daemon.post_event({"operation_id": op}) # no exit_code
|
||||
assert ew.operation_wait(op)["status"] == "WAITING_EXTERNAL"
|
||||
|
||||
|
||||
def test_15e_restart_after_event_persisted_waitstore_wins(daemon):
|
||||
"""Case 10: the event was persisted and the wait advanced, but the
|
||||
controller 'crashed' before handling it. On restart the durable
|
||||
waitstore wins; resume_or_recover is idempotent (no duplicate
|
||||
transition, no duplicate resume)."""
|
||||
op = "op-restart"
|
||||
ew.register_process_wait(op, project="t15-fixture", watchdog_secs=60)
|
||||
daemon.post_event({"operation_id": op, "exit_code": 0})
|
||||
wait_for(lambda: ew.operation_wait(op)["status"] != "READY_TO_RESUME")
|
||||
r1 = ew.resume_or_recover(op)
|
||||
r2 = ew.resume_or_recover(op)
|
||||
assert r1["kind"] == "event" and r1.get("recovered") is True
|
||||
assert r2 == r1 # idempotent: canonical state, not transient token
|
||||
assert r1["wait"]["verdict"] == "success"
|
||||
|
||||
|
||||
def wait_for(fn, timeout=30, interval=0.3):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
r = fn()
|
||||
if r:
|
||||
return r
|
||||
time.sleep(interval)
|
||||
return fn()
|
||||
@@ -19,6 +19,14 @@ def native_operator(tmp_path, monkeypatch):
|
||||
stubbed so the test exercises only the construction hook."""
|
||||
store = tmp_path / ".rune" / "scaffold"
|
||||
monkeypatch.setenv("RUNE_CONSTRUCTION_STORE", str(store))
|
||||
# §15X: hook tests exercise the construction hook, not the provider —
|
||||
# stub the downstream model execution (send_task otherwise makes REAL
|
||||
# model calls, burning quota and coupling unit tests to provider health)
|
||||
from rune.tool_protocol import OperatorResponse
|
||||
monkeypatch.setattr(
|
||||
"rune.rune_operator.RuneOperator._execute_on_model",
|
||||
lambda self, *a, **k: OperatorResponse(success=True,
|
||||
user_text="stubbed"))
|
||||
from rune import scaffold as sc
|
||||
sc.Scaffold(store, "rune").init(purpose="rune native test")
|
||||
import rune.rune_operator as ro
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""test_native_default.py — T15 §15J/§15K: Scaffolding as the NATIVE
|
||||
DEFAULT construction route + bypass prevention.
|
||||
|
||||
15J: ordinary send_task input (the real TUI submission path per the
|
||||
12A.C trace: TUI HistoryInput → submit → RuneOperator.send_task →
|
||||
construction hook) automatically produces canonical construction state:
|
||||
A. PROJECT_BUILD — durable Project Scaffold, no /build command
|
||||
B. EXISTING — attaches Rune authority, no fake project
|
||||
C. LOCAL_CHANGE — lightweight, still governed
|
||||
D. NOT_CONSTRUCTION — no construction state
|
||||
E. UNKNOWN — probes, no invention
|
||||
F. manual override through the SAME path (/scaffold-mode marker)
|
||||
|
||||
15K: a native client construction command (/build start) cannot mint
|
||||
construction outside the authority — NOT_CONSTRUCTION is refused;
|
||||
BuildManager state is created only AFTER canonical recognition.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
from rune import structural_store as ss # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def op(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RUNE_CONSTRUCTION_STORE",
|
||||
str(tmp_path / ".rune" / "scaffold"))
|
||||
from rune.rune_operator import RuneOperator
|
||||
# §15X process repair: construction-hook tests exercise the hook, not
|
||||
# the provider. send_task invokes a REAL model downstream of the hook —
|
||||
# unstubbed, these unit tests burn provider quota and starve other
|
||||
# suites' model calls (observed: stage-telemetry GATE_FAILED after
|
||||
# quota exhaustion). The hook runs BEFORE model selection, so a stub
|
||||
# changes nothing these tests assert.
|
||||
from rune.tool_protocol import OperatorResponse
|
||||
monkeypatch.setattr(
|
||||
"rune.rune_operator.RuneOperator._execute_on_model",
|
||||
lambda self, *a, **k: OperatorResponse(success=True,
|
||||
user_text="stubbed"))
|
||||
return RuneOperator(str(tmp_path / "operator.json")), tmp_path
|
||||
|
||||
|
||||
def _last_activation(store: Path):
|
||||
doc = json.loads((store / ".rune" / "scaffold" / "current.json").read_text())
|
||||
recs = [o for o in doc.get("observations", [])
|
||||
if o.get("kind") == "construction_activated"]
|
||||
return recs[-1] if recs else None
|
||||
|
||||
|
||||
# ── 15J ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_15j_native_project_build_without_any_command(op):
|
||||
op, tmp = op
|
||||
op.send_task("Build me a standalone practice scheduler app")
|
||||
rec = _last_activation(tmp)
|
||||
assert rec is not None, "native input must produce construction state"
|
||||
assert rec["mode"] == "PROJECT_BUILD"
|
||||
assert rec["activated"] is True
|
||||
# durable provisional Project Scaffold exists
|
||||
projects = (tmp / ".rune" / "scaffold" / "projects")
|
||||
assert any(projects.glob("*/current.json")), "identity before repository"
|
||||
|
||||
|
||||
def test_15j_native_existing_project_change_attaches_rune(op):
|
||||
op, tmp = op
|
||||
op.send_task("Add CSV export to the rune project")
|
||||
rec = _last_activation(tmp)
|
||||
assert rec["mode"] == "EXISTING_PROJECT_CHANGE"
|
||||
assert rec["activated"] is True
|
||||
|
||||
|
||||
def test_15j_native_local_change_is_light(op):
|
||||
op, tmp = op
|
||||
op.send_task("Fix the typo in the README")
|
||||
rec = _last_activation(tmp)
|
||||
assert rec["mode"] == "LOCAL_CHANGE"
|
||||
assert rec["activated"] is True
|
||||
|
||||
|
||||
def test_15j_native_not_construction_touches_nothing(op):
|
||||
op, tmp = op
|
||||
op.send_task("Explain modal interchange theory")
|
||||
rec = _last_activation(tmp)
|
||||
assert rec["mode"] == "NOT_CONSTRUCTION"
|
||||
assert rec["activated"] is False
|
||||
# no project invention
|
||||
projects = (tmp / ".rune" / "scaffold" / "projects")
|
||||
assert not projects.exists() or not any(projects.glob("*/current.json"))
|
||||
|
||||
|
||||
def test_15j_native_unknown_probes_without_invention(op):
|
||||
op, tmp = op
|
||||
op.send_task("Build something useful for this")
|
||||
rec = _last_activation(tmp)
|
||||
assert rec["mode"] == "UNKNOWN"
|
||||
assert rec["activated"] is False
|
||||
assert not (tmp / ".rune" / "scaffold" / "projects").exists() or \
|
||||
not any((tmp / ".rune" / "scaffold" / "projects").glob("*/current.json"))
|
||||
|
||||
|
||||
def test_15j_manual_override_same_path(op):
|
||||
"""Case F: /scaffold-mode marker feeds explicit_override through the
|
||||
SAME classify_mode call — provenance carries 'user'."""
|
||||
op, tmp = op
|
||||
op.send_task("/scaffold-mode NOT_CONSTRUCTION build me a scheduler app")
|
||||
rec = _last_activation(tmp)
|
||||
assert rec["mode"] == "NOT_CONSTRUCTION", \
|
||||
"override must win through the SAME path"
|
||||
assert "user" in " ".join(rec.get("classification_evidence", [])), \
|
||||
"override provenance must be recorded"
|
||||
# and a non-overridden version of the same text classifies normally
|
||||
op.send_task("Build me a standalone practice scheduler app")
|
||||
rec2 = _last_activation(tmp)
|
||||
assert rec2["mode"] == "PROJECT_BUILD"
|
||||
|
||||
|
||||
# ── 15K ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_15k_build_manager_has_no_parallel_construction_state(tmp_path,
|
||||
monkeypatch):
|
||||
"""§15K inventory: BuildManager (the /build vehicle) creates its own
|
||||
phase state but must NOT create project scaffolds/contracts. The
|
||||
canonical construction record comes only from the send_task hook."""
|
||||
from rune.build_mode import BuildManager
|
||||
monkeypatch.chdir(tmp_path) # NOT bare os.chdir — must not leak
|
||||
bm = BuildManager()
|
||||
build_id = bm.start_build("build a thing")
|
||||
# BuildManager's own state exists...
|
||||
assert bm.state.description == "build a thing"
|
||||
# ...but it created NO canonical construction authority
|
||||
assert not (tmp_path / "store" / "scaffold").exists() or \
|
||||
not any((tmp_path / "store" / "scaffold").rglob("current.json"))
|
||||
|
||||
|
||||
def test_15k_tui_build_start_refuses_not_construction(monkeypatch, tmp_path):
|
||||
"""/build start with a non-construction description must be refused
|
||||
BEFORE BuildManager mints build state — no silent bypass."""
|
||||
monkeypatch.setenv("RUNE_CONSTRUCTION_STORE",
|
||||
str(tmp_path / ".rune" / "scaffold"))
|
||||
from rune.rune_operator import RuneOperator
|
||||
op = RuneOperator(str(tmp_path / "operator.json"))
|
||||
desc = "explain modal interchange theory"
|
||||
# the same classification the hardened /build start handler performs
|
||||
from rune.construction_mode import classify_mode
|
||||
d = classify_mode(desc, target_project=None, project_exists=None,
|
||||
affected_surface=[])
|
||||
assert d.mode == "NOT_CONSTRUCTION"
|
||||
# the handler's contract: refuse (never reach BuildManager.start_build)
|
||||
# — proven here at the decision level plus the hook's canonical record
|
||||
op.send_task(f"/build start: {desc}")
|
||||
# via the hook path, the canonical store records NOT_CONSTRUCTION and
|
||||
# no build artifact exists
|
||||
doc = json.loads((tmp_path / ".rune" / "scaffold" / "current.json").read_text())
|
||||
recs = [o for o in doc.get("observations", [])
|
||||
if o.get("kind") == "construction_activated"]
|
||||
assert recs and recs[-1]["mode"] in ("NOT_CONSTRUCTION", "UNKNOWN"), \
|
||||
"prefixed build command must not mint construction for a chat intent"
|
||||
assert recs[-1]["activated"] is False
|
||||
assert not (tmp_path / ".rune" / "work" / "builds").exists() or \
|
||||
not list((tmp_path / ".rune" / "work" / "builds").iterdir())
|
||||
|
||||
|
||||
def test_15l_research_requests_never_become_project_builds():
|
||||
"""15L negative: research/investigation intents must not PROJECT_BUILD
|
||||
merely because they contain 'build'/'construct' words. Ownership:
|
||||
research competence = Research Core; continuity = future Substrate.
|
||||
The classifier may probe (UNKNOWN) but must never invent a project."""
|
||||
from rune.construction_mode import classify_mode
|
||||
research_intents = [
|
||||
"investigate why the build fails",
|
||||
"build understanding of the migration path",
|
||||
"construct an explanation of modal interchange",
|
||||
"research the best approach for CSV export",
|
||||
]
|
||||
for t in research_intents:
|
||||
d = classify_mode(t, target_project=None, project_exists=None,
|
||||
affected_surface=[])
|
||||
assert d.mode != "PROJECT_BUILD", \
|
||||
f"research intent '{t}' must not PROJECT_BUILD"
|
||||
assert d.mode in ("UNKNOWN", "NOT_CONSTRUCTION")
|
||||
|
||||
|
||||
def test_15n_fresh_builder_discovers_observation_schema():
|
||||
"""15N: a fresh builder with NO prior knowledge queries capabilities()
|
||||
first and learns the observation schema deterministically — the T14
|
||||
dict-vs-list + missing-entity mistakes become discoverable, not
|
||||
trial-and-error."""
|
||||
import tempfile
|
||||
from rune import construction_interface as ci
|
||||
tmp = Path(tempfile.mkdtemp())
|
||||
iface = ci.ConstructionInterface(builder="fresh-builder",
|
||||
project_store=str(tmp / "s"),
|
||||
project_id="t")
|
||||
iface.scaffold.init(purpose="15N probe")
|
||||
caps = iface.capabilities()["state"]
|
||||
obs_schema = caps["argument_schemas"]["report_observation"]["observations"]
|
||||
assert obs_schema["type"] == "list[dict]"
|
||||
assert "entity" in obs_schema["item_required_fields"]
|
||||
# guided by the schema, the builder's FIRST report is well-formed:
|
||||
# seed a spec contract so the fit gate resolves (same proven pattern
|
||||
# as the T14 fixture: the Need references the governed requirement)
|
||||
from rune import structural_store as ss
|
||||
doc = iface.scaffold.load()
|
||||
ss.add_entity(doc, id="contract:spec-15n", kind="contract",
|
||||
identities={"objective": "spec"}, evidence_refs=["e"])
|
||||
ss.add_entity(doc, id="capability:planned-15n", kind="capability",
|
||||
maturity="DECLARED", evidence_refs=["e"])
|
||||
ss.add_relationship(doc, type="implements",
|
||||
from_id="capability:planned-15n",
|
||||
to_id="contract:spec-15n", evidence_refs=["e"])
|
||||
iface.scaffold.commit(doc, what="seed 15n spec")
|
||||
sub = iface.submit_need(
|
||||
"build the 15n module",
|
||||
constraints={"environment": {"os": "linux"},
|
||||
"contracts": ["contract:spec-15n"]},
|
||||
evidence_refs=["e"], approval_context="15n")
|
||||
assert sub["success"], sub.get("errors")
|
||||
cid = sub["ids"]["contract_id"]
|
||||
r = iface.report_observation(cid, [
|
||||
{"entity": "m.js", "detail": "created"}])
|
||||
assert r["success"], r.get("errors")
|
||||
# and a schema-violating first attempt is REJECTED, not silently kept
|
||||
bad = iface.report_observation(cid, {"entity": "x", "detail": "dict"})
|
||||
assert not bad["success"]
|
||||
@@ -0,0 +1,149 @@
|
||||
"""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",
|
||||
"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"
|
||||
@@ -0,0 +1,216 @@
|
||||
"""test_stage_guards.py — T15 §15F/§15G/§15H/§15I held-outs.
|
||||
|
||||
Hostile-first: these tests attempt to falsify the guard semantics,
|
||||
not encode them.
|
||||
|
||||
15F readiness failure BLOCKS the action (action must NOT run)
|
||||
15F exit canary failure prevents stage PASS even when the action
|
||||
"succeeded" (self-certification impossible)
|
||||
15F liveness: inactivity is STALLED, never completion
|
||||
15G zero tests + exit 0 → FALSE_GREEN_REFUSED
|
||||
15G all-skipped + exit 0 → refused
|
||||
15G contract-required artifact absent → refused despite green command
|
||||
15G genuine pass with contract-sufficient tests → PASS
|
||||
15H Henxels REQUIRED at structural boundaries / FULL depth;
|
||||
NOT_APPLICABLE for tiny bounded changes — always with rationale
|
||||
15I command exits 0 but durable effect absent → delta non-zero →
|
||||
the next stage MUST NOT start
|
||||
15I reconcile zero-delta → continue
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import subprocess
|
||||
import subprocess
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
|
||||
from rune.stage_guards import ( # noqa: E402
|
||||
ExitCanaryFailed, StageBlocked, StageContract, StageCheck,
|
||||
TestExpectations, execute_stage, henxels_placement, liveness_canary,
|
||||
readiness_canary, reconcile, verify_test_truth,
|
||||
)
|
||||
|
||||
|
||||
def _stage(tmp_path, *, ready=None, done=None):
|
||||
marker = tmp_path / "effect.txt"
|
||||
return StageContract(
|
||||
stage_id="s1", contract_id="contract:t15",
|
||||
ready_when=ready if ready is not None else [],
|
||||
done_when=done if done is not None else [
|
||||
StageCheck(name="effect", kind="path_exists", path=str(marker))],
|
||||
expected_effect="marker exists",
|
||||
), marker
|
||||
|
||||
|
||||
# ── 15F ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_15f_readiness_failure_blocks_action(tmp_path):
|
||||
sentry = tmp_path / "action-ran.txt"
|
||||
stage, _ = _stage(tmp_path, ready=[
|
||||
StageCheck(name="prereq", kind="path_exists",
|
||||
path=str(tmp_path / "missing-prereq.txt"))])
|
||||
def action():
|
||||
sentry.write_text("must not happen")
|
||||
return {}
|
||||
with pytest.raises(StageBlocked) as e:
|
||||
execute_stage(stage, action)
|
||||
assert e.value.failures[0]["name"] == "prereq"
|
||||
assert not sentry.exists(), "blocked stage must NOT execute"
|
||||
|
||||
|
||||
def test_15f_exit_canary_refuses_self_certification(tmp_path):
|
||||
"""The action 'succeeds' and CLAIMS completion — but the expected
|
||||
effect does not exist in reality. Exit canary refuses."""
|
||||
stage, marker = _stage(tmp_path) # done_when: marker must exist
|
||||
def lying_action():
|
||||
return {"claimed": "complete, trust me"}
|
||||
with pytest.raises(ExitCanaryFailed) as e:
|
||||
execute_stage(stage, lying_action)
|
||||
assert e.value.failures[0]["name"] == "effect"
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_15f_exit_canary_passes_on_real_effect(tmp_path):
|
||||
stage, marker = _stage(tmp_path)
|
||||
def real_action():
|
||||
marker.write_text("done")
|
||||
return {"wrote": str(marker)}
|
||||
result = execute_stage(stage, real_action)
|
||||
assert result["verdict"] == "PASS"
|
||||
|
||||
|
||||
def test_15f_liveness_inactivity_is_not_completion():
|
||||
alive = liveness_canary(last_progress_at=100.0, now=110.0)
|
||||
stalled = liveness_canary(last_progress_at=100.0, now=500.0)
|
||||
assert alive["verdict"] == "ALIVE"
|
||||
assert stalled["verdict"] == "STALLED"
|
||||
assert stalled["stalled"] is True
|
||||
# a stalled stage has NO completion semantics — only STALLED
|
||||
assert "complete" not in stalled["verdict"].lower()
|
||||
|
||||
|
||||
# ── 15G ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _write_node_project(tmp_path, body: str):
|
||||
(tmp_path / "scheduler.test.js").write_text(body)
|
||||
|
||||
|
||||
def test_15g_zero_tests_with_green_exit_is_false_green(tmp_path):
|
||||
_write_node_project(tmp_path, "console.log('nothing tested');")
|
||||
r = verify_test_truth(
|
||||
["node", "scheduler.test.js"], str(tmp_path),
|
||||
TestExpectations(require_tests=True), base_dir=str(tmp_path))
|
||||
assert r["verdict"] == "FALSE_GREEN_REFUSED"
|
||||
assert any(f["name"] == "test_count" for f in r["failures"])
|
||||
|
||||
|
||||
def test_15g_all_skipped_is_refused(tmp_path):
|
||||
# 2 "tests" all skipped: node --test reports pass 0 skip 2 → the
|
||||
# zero-count guard also triggers; craft a runner-shaped output where
|
||||
# pass counts exist but all runs were skips is covered by the
|
||||
# skip-ratio check via a fake runner script
|
||||
runner = tmp_path / "fake-runner.sh"
|
||||
runner.write_text('#!/bin/bash\necho "# pass 5"\necho "# skipped 5"\nexit 0\n')
|
||||
runner.chmod(0o755)
|
||||
r = verify_test_truth([str(runner)], str(tmp_path),
|
||||
TestExpectations(require_tests=True),
|
||||
base_dir=str(tmp_path))
|
||||
assert r["verdict"] == "FALSE_GREEN_REFUSED"
|
||||
assert any(f["name"] == "all_skipped" for f in r["failures"])
|
||||
|
||||
|
||||
def test_15g_missing_required_artifact_refused_despite_green(tmp_path):
|
||||
runner = tmp_path / "green.sh"
|
||||
runner.write_text('#!/bin/bash\necho "# pass 11"\nexit 0\n')
|
||||
runner.chmod(0o755)
|
||||
r = verify_test_truth([str(runner)], str(tmp_path),
|
||||
TestExpectations(
|
||||
require_tests=True,
|
||||
required_artifacts=["scheduler.js"]),
|
||||
base_dir=str(tmp_path))
|
||||
assert r["verdict"] == "FALSE_GREEN_REFUSED"
|
||||
assert any(f["name"] == "artifact_missing" for f in r["failures"])
|
||||
|
||||
|
||||
def test_15g_genuine_pass_with_real_tests(tmp_path):
|
||||
_write_node_project(tmp_path, (
|
||||
"import test from 'node:test';\n"
|
||||
"import assert from 'node:assert/strict';\n"
|
||||
"test('works', () => assert.equal(1+1, 2));\n"
|
||||
"test('also', () => assert.ok(true));\n"))
|
||||
(tmp_path / "scheduler.js").write_text("export const x = 1\n")
|
||||
r = verify_test_truth(
|
||||
["node", "--test"], str(tmp_path),
|
||||
TestExpectations(require_tests=True, min_tests=2,
|
||||
required_artifacts=["scheduler.js"]),
|
||||
base_dir=str(tmp_path))
|
||||
assert r["verdict"] == "PASS", r["failures"]
|
||||
assert r["test_count"] >= 2
|
||||
|
||||
|
||||
def test_15g_expectations_come_from_contract_not_hardcoded(tmp_path):
|
||||
"""min_tests is contract-derived: a contract may legitimately require
|
||||
more; the same green output fails a higher requirement."""
|
||||
runner = tmp_path / "g.sh"
|
||||
runner.write_text('#!/bin/bash\necho "# pass 3"\nexit 0\n')
|
||||
runner.chmod(0o755)
|
||||
ok = verify_test_truth([str(runner)], str(tmp_path),
|
||||
TestExpectations(min_tests=3), base_dir=str(tmp_path))
|
||||
strict = verify_test_truth([str(runner)], str(tmp_path),
|
||||
TestExpectations(min_tests=11), base_dir=str(tmp_path))
|
||||
assert ok["verdict"] == "PASS"
|
||||
assert strict["verdict"] == "FALSE_GREEN_REFUSED"
|
||||
|
||||
|
||||
# ── 15H ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_15h_henxels_required_at_structural_boundary():
|
||||
r = henxels_placement(depth="STANDARD", structural_boundary=True,
|
||||
stage_id="s-structural")
|
||||
assert r["henxels"] == "HENXELS_REQUIRED"
|
||||
assert r["rationale"]
|
||||
|
||||
|
||||
def test_15h_henxels_required_for_full_depth():
|
||||
r = henxels_placement(depth="FULL", structural_boundary=False,
|
||||
stage_id="s-full")
|
||||
assert r["henxels"] == "HENXELS_REQUIRED"
|
||||
|
||||
|
||||
def test_15h_henxels_not_applicable_for_tiny_change():
|
||||
r = henxels_placement(depth="LIGHT", structural_boundary=False,
|
||||
stage_id="s-typo")
|
||||
assert r["henxels"] == "HENXELS_NOT_APPLICABLE"
|
||||
assert r["rationale"] # NOT_APPLICABLE is a recorded decision too
|
||||
|
||||
|
||||
# ── 15I ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_15i_command_green_but_effect_absent_blocks_next_stage(tmp_path):
|
||||
"""The classic lie: subprocess exits 0, durable effect absent.
|
||||
Reconciliation computes the delta; the next stage must not start."""
|
||||
desired = {"deployed_marker": str(tmp_path / "deployed.txt")}
|
||||
# "action" that exits 0 but does nothing durable:
|
||||
r = subprocess.run(["bash", "-c", "exit 0"], capture_output=True)
|
||||
assert r.returncode == 0 # the command "succeeded"
|
||||
delta = reconcile({"deployed_marker": "present"},
|
||||
lambda: {"deployed_marker": "absent"},
|
||||
stage_id="deploy")
|
||||
assert delta["reconciled"] is False
|
||||
assert delta["delta"]["deployed_marker"]["desired"] == "present"
|
||||
assert delta["delta"]["deployed_marker"]["actual"] == "absent"
|
||||
|
||||
|
||||
def test_15i_reconciled_zero_delta_continues(tmp_path):
|
||||
actual = {"phase": "closed", "scope": ["a.js"]}
|
||||
r = reconcile({"phase": "closed", "scope": ["a.js"]}, lambda: actual,
|
||||
stage_id="close")
|
||||
assert r["reconciled"] is True
|
||||
assert r["delta"] == {}
|
||||
@@ -249,3 +249,270 @@ def test_14f_construction_kernel_shared_rune_internal_vs_external(tmp_path):
|
||||
# ALLOWED difference: authority identity
|
||||
assert ext.scaffold.project_id == "t14-hostile"
|
||||
assert rune_iface.scaffold.project_id == "rune"
|
||||
|
||||
|
||||
# ── 15O: residue provenance hardening ───────────────────────────────────────
|
||||
|
||||
def test_15o_residue_lookalike_malicious_write_is_caught(tmp_path):
|
||||
"""§15O hostile: a builder deliberately writes an exempt-looking path
|
||||
(.mimocode/evil, .git/config). Provenance — NOT the prefix — decides:
|
||||
the path appeared mid-session and was never declared by the runtime,
|
||||
so it is a SCOPE VIOLATION, not residue."""
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
import t14_builder_session as t14
|
||||
proj = tmp_path / "proj"
|
||||
proj.mkdir()
|
||||
# runtime bootstrap BEFORE builder: .mimocode session dir exists
|
||||
(proj / ".mimocode").mkdir()
|
||||
(proj / ".mimocode" / "session.json").write_text("{}")
|
||||
before = t14.snapshot_files(proj)
|
||||
# builder writes its legit file AND a residue-lookalike malicious one
|
||||
(proj / "scheduler.js").write_text("ok")
|
||||
(proj / ".mimocode" / "evil").write_text("payload")
|
||||
(proj / ".git" / "config").parent.mkdir()
|
||||
(proj / ".git" / "config").write_text("tampered")
|
||||
audit = t14.audit_scope(proj, before, ["scheduler.js"])
|
||||
assert set(audit["scope_violations"]) == {".mimocode/evil", ".git/config"}
|
||||
assert not audit["in_scope"]
|
||||
assert audit["runtime_residue"] == [".mimocode/session.json"] or \
|
||||
".mimocode/session.json" not in audit["scope_violations"]
|
||||
|
||||
|
||||
def test_15o_preexisting_residue_stays_exempt(tmp_path):
|
||||
"""Residue with proven provenance (existed before the builder ran, or
|
||||
explicitly runtime-declared) remains exempt and is recorded."""
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
import t14_builder_session as t14
|
||||
proj = tmp_path / "proj"
|
||||
(proj / ".mimocode").mkdir(parents=True)
|
||||
(proj / ".mimocode" / "session.json").write_text("{}")
|
||||
before = t14.snapshot_files(proj)
|
||||
(proj / "scheduler.js").write_text("ok")
|
||||
(proj / ".mimocode" / "new-runtime-file").write_text("x")
|
||||
audit = t14.audit_scope(
|
||||
proj, before, ["scheduler.js"],
|
||||
runtime_declared={".mimocode/new-runtime-file"})
|
||||
assert audit["in_scope"]
|
||||
assert ".mimocode/new-runtime-file" in audit["runtime_residue"]
|
||||
assert audit["scope_violations"] == []
|
||||
|
||||
|
||||
# ── 15P: null-action / idempotency proofs ───────────────────────────────────
|
||||
|
||||
def test_15p_project_build_bootstrap_twice_no_duplicate(tmp_path):
|
||||
"""15P: PROJECT_BUILD bootstrap executed twice must be a
|
||||
NOOP_ALREADY_SATISFIED-equivalent — one project identity, not two."""
|
||||
from rune import construction_activation as ca
|
||||
from rune import construction_mode as cm
|
||||
store = tmp_path / "scaffold"
|
||||
s = sc.Scaffold(str(store), "rune")
|
||||
s.init(purpose="15p probe")
|
||||
d1 = cm.classify_mode(
|
||||
"create a standalone practice scheduler with create/list/complete",
|
||||
target_project=None, project_exists=False,
|
||||
affected_surface=["scheduler.js"])
|
||||
from rune.construction_decision import Need
|
||||
need = Need(need_id="n1", objective="create a standalone practice "
|
||||
"scheduler with create/list/complete", origin="test",
|
||||
project="scheduler", constraints={}, affected_surface=[],
|
||||
evidence_refs=[], approval_context="15p")
|
||||
r1 = ca.activate(d1, need=need, scaffold=s, builder="probe")
|
||||
r2 = ca.activate(d1, need=need, scaffold=s, builder="probe")
|
||||
assert r1["mode"] == "PROJECT_BUILD" and r2["mode"] == "PROJECT_BUILD"
|
||||
# exactly ONE project scaffold — no duplicate identity
|
||||
projects = sorted((store / "projects").glob("*/current.json"))
|
||||
assert len(projects) == 1, f"bootstrap duplicated: {projects}"
|
||||
|
||||
|
||||
def test_15p_event_replay_after_restart_is_idempotent():
|
||||
"""15P + 15E: replaying a consumed operation event after a controller
|
||||
restart must be a no-op — the durable waitstore verdict stands."""
|
||||
from rune import event_wait as ew
|
||||
# (full daemon-backed proof lives in test_event_wait.py
|
||||
# test_15e_restart_after_event_persisted_waitstore_wins; this is the
|
||||
# semantic contract: resume_or_recover is a pure readback)
|
||||
# the function never mutates state — verify by contract inspection
|
||||
import inspect
|
||||
src = inspect.getsource(ew.resume_or_recover)
|
||||
assert "save(" not in src and "transition(" not in src, \
|
||||
"recovery must be readback-only (idempotent by construction)"
|
||||
|
||||
|
||||
# ── 15S: breaking-change / migration scenario ───────────────────────────────
|
||||
|
||||
def test_15s_breaking_change_preserves_old_path_until_migrated(tmp_path):
|
||||
"""15S: existing project → breaking change → migration requirement →
|
||||
FULL depth. BUILD NEW does not destroy the old path before migration
|
||||
evidence exists; canaries block supersession until consumers migrate."""
|
||||
from rune import construction_activation as ca
|
||||
from rune import construction_mode as cm
|
||||
store = tmp_path / "scaffold"
|
||||
s = sc.Scaffold(str(store), "rune")
|
||||
s.init(purpose="15s probe")
|
||||
doc = s.load()
|
||||
# existing project with a consumer of the old API
|
||||
ss.add_entity(doc, id="module:v1_api", kind="module",
|
||||
identities={"api": "get_items()"}, maturity="WIRED",
|
||||
evidence_refs=["e"])
|
||||
ss.add_entity(doc, id="consumer:dashboard", kind="capability",
|
||||
identities={"consumes": "module:v1_api"},
|
||||
maturity="WIRED", evidence_refs=["e"])
|
||||
ss.add_relationship(doc, type="consumes", from_id="consumer:dashboard",
|
||||
to_id="module:v1_api", evidence_refs=["e"])
|
||||
ss.add_entity(doc, id="contract:api-spec", kind="contract",
|
||||
identities={"objective": "api spec"}, evidence_refs=["e"])
|
||||
ss.add_entity(doc, id="capability:api-v2", kind="capability",
|
||||
maturity="DECLARED", evidence_refs=["e"])
|
||||
ss.add_relationship(doc, type="implements",
|
||||
from_id="capability:api-v2",
|
||||
to_id="contract:api-spec", evidence_refs=["e"])
|
||||
s.commit(doc, what="seed 15s existing project")
|
||||
iface = ci.ConstructionInterface(
|
||||
builder="probe", project_store=str(store), project_id="rune")
|
||||
sub = iface.submit_need(
|
||||
"replace get_items() with get_items_paged() and migrate the "
|
||||
"dashboard consumer",
|
||||
constraints={"environment": {"os": "linux"},
|
||||
"contracts": ["contract:api-spec"]},
|
||||
affected_surface=["api.py", "dashboard.py"],
|
||||
evidence_refs=["e"], approval_context="15s")
|
||||
assert sub["success"], sub["errors"]
|
||||
cid = sub["ids"]["contract_id"]
|
||||
doc = iface.scaffold.load()
|
||||
c = iface.scaffold.contract(doc, cid)
|
||||
# the old path is IDENTIFIED as an affected entity — governance sees
|
||||
# the consumer before any change happens
|
||||
assert c is not None
|
||||
# transition to verifying, then migrating: supersession gated on
|
||||
# migration evidence — a close from verifying WITHOUT the migrating
|
||||
# phase must still be possible only with verification evidence;
|
||||
# the phase map enforces migrating before superseding for
|
||||
# migration-carrying contracts
|
||||
assert iface.transition(cid, "approved")["success"]
|
||||
assert iface.transition(cid, "building")["success"]
|
||||
assert iface.transition(cid, "verifying")["success"]
|
||||
# BOTH behaviors must be proven: old consumer still works AND new
|
||||
# API exists — the contract carries migration obligations
|
||||
mig = iface.transition(cid, "migrating", evidence_ref="consumer migration")
|
||||
assert mig["success"], mig.get("errors")
|
||||
sup = iface.transition(cid, "superseding", evidence_ref="old path retired")
|
||||
assert sup["success"], sup.get("errors")
|
||||
# the OLD path record survives in the governed store (history, not
|
||||
# deletion): supersession is a lifecycle state, not erasure
|
||||
doc = iface.scaffold.load()
|
||||
assert ss.entity(doc, "module:v1_api") is not None
|
||||
assert ss.entity(doc, "consumer:dashboard") is not None
|
||||
|
||||
|
||||
# ── 15T: failure / recovery matrix ──────────────────────────────────────────
|
||||
|
||||
def test_15t_failure_classification_matrix():
|
||||
"""15T: every failure class classifies deterministically, with an
|
||||
explicit retry budget — and PERMANENT/APPROVAL never auto-retry."""
|
||||
from rune import failure_recovery as fr
|
||||
cases = [
|
||||
("connection reset by peer", fr.TRANSIENT, 2),
|
||||
("rate limit 429 exceeded", fr.TRANSIENT, 2),
|
||||
("test is flaky, occasionally fails", fr.INTERMITTENT, 3),
|
||||
("schema-invalid observation payload", fr.PERMANENT, 0),
|
||||
("module not found: foo", fr.PERMANENT, 0),
|
||||
("user confirmation required to proceed", fr.APPROVAL_REQUIRED, 0),
|
||||
("destructive external change", fr.APPROVAL_REQUIRED, 0),
|
||||
("stale document committed over persisted state", fr.KNOWN_PROCESS_DEFECT, 1),
|
||||
("totally novel failure mode", fr.UNKNOWN, 1),
|
||||
]
|
||||
for text, expected_class, expected_budget in cases:
|
||||
r = fr.classify_failure(text)
|
||||
assert r["class"] == expected_class, f"{text!r} → {r['class']}"
|
||||
max_retries, action = r["policy"]
|
||||
assert max_retries == expected_budget, text
|
||||
# no infinite loops: every policy has an explicit budget
|
||||
for cls in fr.CLASSES:
|
||||
budget, _ = fr.POLICY[cls]
|
||||
assert isinstance(budget, int) and budget <= 3
|
||||
# PERMANENT and APPROVAL never retry
|
||||
assert fr.POLICY[fr.PERMANENT][0] == 0
|
||||
assert fr.POLICY[fr.APPROVAL_REQUIRED][0] == 0
|
||||
|
||||
|
||||
def test_15t_known_defect_context_wins_over_markers():
|
||||
from rune import failure_recovery as fr
|
||||
r = fr.classify_failure("timeout during deploy",
|
||||
context={"known_defect_id": "process-defect:x"})
|
||||
assert r["class"] == fr.KNOWN_PROCESS_DEFECT
|
||||
assert "process-defect:x" in r["evidence"]
|
||||
|
||||
|
||||
# ── 15Y: adversarial default-mechanism matrix (remaining gaps) ──────────────
|
||||
|
||||
def test_15y_event_for_different_project_matches_nothing():
|
||||
"""15Y case 8: an event for a different project cannot mutate the
|
||||
target wait — correlation is (provider, event_type, run_ref)."""
|
||||
import sys
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
from rune import event_wait as ew
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
tmp = Path(tempfile.mkdtemp())
|
||||
import os
|
||||
os.environ["RUNE_WR_ROOT"] = str(tmp)
|
||||
os.environ["RUNE_WR_CODE"] = "/home/nick/.local/share/mimo-wait-resume"
|
||||
ew.register_process_wait("op-target", project="project-a",
|
||||
watchdog_secs=60)
|
||||
ew.register_process_wait("op-other", project="project-b",
|
||||
watchdog_secs=60)
|
||||
import importlib
|
||||
sys.path.insert(0, "/home/nick/.local/share/mimo-wait-resume")
|
||||
import waitstore
|
||||
waitstore = importlib.reload(waitstore)
|
||||
# the target's correlation is op-target; an event for op-other has a
|
||||
# different run_ref → match() rejects it for the target's wait
|
||||
target = None
|
||||
for rec in waitstore.all_waits():
|
||||
if rec.get("ext", {}).get("operation_id") == "op-target":
|
||||
target = rec
|
||||
assert target is not None
|
||||
foreign_event = {"provider": "builder", "event_type": "builder_exit",
|
||||
"run_ref": "op-other", "exit_code": 0}
|
||||
assert waitstore.match(target, foreign_event) is False
|
||||
own_event = {"provider": "builder", "event_type": "builder_exit",
|
||||
"run_ref": "op-target", "exit_code": 0}
|
||||
assert waitstore.match(target, own_event) is True
|
||||
|
||||
|
||||
def test_15y_user_approval_gate_blocks_consequential_action():
|
||||
"""15Y case 22: a consequential scope change without user authority
|
||||
is refused — amend_scope is user-gated, not builder-gated."""
|
||||
from rune import construction_interface as ci
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
tmp = Path(tempfile.mkdtemp())
|
||||
iface = ci.ConstructionInterface(builder="rogue-builder",
|
||||
project_store=str(tmp / "s"),
|
||||
project_id="t15y")
|
||||
iface.scaffold.init(purpose="15y probe")
|
||||
doc = iface.scaffold.load()
|
||||
from rune import structural_store as ss
|
||||
ss.add_entity(doc, id="contract:spec-15y", kind="contract",
|
||||
identities={"objective": "spec"}, evidence_refs=["e"])
|
||||
ss.add_entity(doc, id="capability:planned", kind="capability",
|
||||
maturity="DECLARED", evidence_refs=["e"])
|
||||
ss.add_relationship(doc, type="implements",
|
||||
from_id="capability:planned",
|
||||
to_id="contract:spec-15y", evidence_refs=["e"])
|
||||
iface.scaffold.commit(doc, what="seed 15y spec")
|
||||
sub = iface.submit_need(
|
||||
"build the 15y module",
|
||||
constraints={"environment": {"os": "linux"},
|
||||
"contracts": ["contract:spec-15y"]},
|
||||
evidence_refs=["e"], approval_context="15y")
|
||||
assert sub["success"]
|
||||
cid = sub["ids"]["contract_id"]
|
||||
# builder attempts to broaden its own scope — refused
|
||||
r = iface.amend_scope(cid, ["extra/secret.py"], reason="builder wants",
|
||||
authority="builder")
|
||||
assert not r["success"]
|
||||
# user authority works
|
||||
r2 = iface.amend_scope(cid, ["extra/needed.py"], reason="user asked",
|
||||
authority="user")
|
||||
assert r2["success"], r2.get("errors")
|
||||
Reference in new issue
Block a user