Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f96055b69 | ||
|
|
923a21ba0b | ||
|
|
6348386869 | ||
|
|
55002ad838 | ||
|
|
6684289a8f | ||
|
|
e25ad0f964 | ||
|
|
c9dee14c62 | ||
|
|
fe9d154778 | ||
|
|
2a4f9c8367 | ||
|
|
7ddfa7eee7 | ||
|
|
986b450bc2 | ||
|
|
b51afea0cb | ||
|
|
c15840514f | ||
|
|
2833175329 | ||
|
|
4a06d9f11a | ||
|
|
c707a81933 | ||
|
|
37deb6085c | ||
|
|
affe852ff7 | ||
|
|
e1a71efeb9 | ||
|
|
5bd8cca941 | ||
|
|
fd5f31bba2 | ||
|
|
8a2a4f7b71 | ||
|
|
b2d6802dba | ||
|
|
3ebbf9a9b1 | ||
|
|
193d81961d | ||
|
|
94ad81245b | ||
|
|
196cf193df | ||
|
|
f95b2a63df | ||
|
|
708871e511 | ||
|
|
18ef9a53e3 | ||
|
|
05edd68027 | ||
|
|
d166e83d55 | ||
|
|
98b4c58d36 | ||
|
|
f5c8b07d15 | ||
|
|
292c72ec3d | ||
|
|
0a436b2f97 | ||
|
|
2ba4114fce | ||
|
|
eccb446a3c |
No files matched your search
@@ -322,3 +322,25 @@ jobs:
|
||||
echo "::error::Required CI checks not green — refusing to publish required-checks success"
|
||||
echo "lint=${{ needs.lint.result }} unit-tests=${{ needs['unit-tests'].result }} canary=${{ needs.canary.result }} best-practices=${{ needs['best-practices'].result }} documentation=${{ needs.documentation.result }} type-check=${{ needs['type-check'].result }} gitea-offline=${{ needs['gitea-offline'].result }}"
|
||||
exit 1
|
||||
|
||||
notify-event-bridge:
|
||||
# T16 §16C: post the CI completion WAKE to the local event bridge so
|
||||
# event-driven waiters (deploy.yml → ci_event_wait.py) resume on the
|
||||
# event instead of sleep-polling. ALWAYS runs (success or failure —
|
||||
# a red CI must wake the waiter just as loudly as a green one). The
|
||||
# wake is best-effort and never decides the verdict: the waiter
|
||||
# re-reads the canonical Gitea state via deploy_qualify.py.
|
||||
name: CI Pipeline / notify-event-bridge
|
||||
runs-on: rune-ci-desktop
|
||||
needs: [required-checks]
|
||||
if: always()
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Wake event bridge (wake only — never completion truth)
|
||||
env:
|
||||
CI_SHA: ${{ gitea.sha }}
|
||||
CI_RESULT: ${{ needs.required-checks.result }}
|
||||
RUNE_EVENT_BRIDGE_URL: ${{ vars.RUNE_EVENT_BRIDGE_URL || 'http://127.0.0.1:8791' }}
|
||||
run: python3 scripts/notify_event_bridge.py
|
||||
+25
-15
@@ -19,28 +19,38 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Wait for CI qualification on this exact SHA
|
||||
- name: Wait for CI qualification on this exact SHA (event-driven, T16 §16C)
|
||||
# T16 §16C/§16D: the old sleep-30 → query → repeat loop is gone.
|
||||
# The waiter registers a correlation on the W/R event bridge and
|
||||
# yields in select() until ci.yml's notify-event-bridge job posts
|
||||
# the completion WAKE; the verdict is then re-read from canonical
|
||||
# Gitea (deploy_qualify.py). The 900s deadline is a FAILURE
|
||||
# DETECTOR (lost-event recovery), never the scheduler.
|
||||
env:
|
||||
GITEA_SERVER: ${{ gitea.server_url }}
|
||||
GITEA_REPO: ${{ gitea.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
# Shared bridge data root: the deploy step runs as the runner
|
||||
# user while the daemon runs as nick — the rendezvous state
|
||||
# (waits/events/resumes/leases) must be shared. §16C live
|
||||
# finding: defaulting to the invoking user's home made the
|
||||
# controller unable to create its FIFO (PermissionError) and
|
||||
# the deploy failed fast+truthfully on both 0.66.63 pushes.
|
||||
RUNE_WR_ROOT: /mnt/workdrive/wr-bridge
|
||||
RUNE_WR_CODE: /home/nick/.local/share/mimo-wait-resume
|
||||
run: |
|
||||
DEPLOY_SHA=$(git rev-parse HEAD)
|
||||
export DEPLOY_SHA
|
||||
echo "Qualifying $DEPLOY_SHA"
|
||||
DEADLINE=$((SECONDS + 900))
|
||||
while [ $SECONDS -lt $DEADLINE ]; do
|
||||
STATUS=$(python3 scripts/deploy_qualify.py)
|
||||
echo "required-checks: $STATUS"
|
||||
if [ "$STATUS" = "success" ]; then exit 0; fi
|
||||
if [ "$STATUS" = "failure" ]; then
|
||||
echo "::error::CI failed on $DEPLOY_SHA — deployment refused"
|
||||
exit 1
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
echo "::error::CI qualification timeout on $DEPLOY_SHA — deployment refused"
|
||||
exit 1
|
||||
echo "Qualifying $DEPLOY_SHA (event-driven)"
|
||||
# READINESS BEFORE ACTION (§16U): fail with a clear message if
|
||||
# the bridge rendezvous is not writable in THIS user's context,
|
||||
# instead of a stack trace at FIFO creation time.
|
||||
if ! touch /mnt/workdrive/wr-bridge/resumes/.write-probe 2>/dev/null; then
|
||||
echo "::error::RUNE_WR_ROOT not writable by $(id -un) — bridge data root misconfigured"
|
||||
exit 1
|
||||
fi
|
||||
rm -f /mnt/workdrive/wr-bridge/resumes/.write-probe
|
||||
python3 scripts/ci_event_wait.py --sha "$DEPLOY_SHA" --deadline 900
|
||||
|
||||
- name: Stage, verify, promote (monotonic)
|
||||
env:
|
||||
|
||||
@@ -22,9 +22,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run all canaries
|
||||
# T16 §16Q reconciliation: the glob looped shared/canary_*.py —
|
||||
# real canaries live in src/rune/ (canary_native.MODULES is the
|
||||
# registry authority). The old job silently no-opped.
|
||||
run: |
|
||||
FAILED=0
|
||||
for canary in shared/canary_*.py; do
|
||||
for canary in src/rune/canary_*.py; do
|
||||
if [ -f "$canary" ]; then
|
||||
echo "Running $canary..."
|
||||
python3 "$canary" || FAILED=$((FAILED + 1))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Rune
|
||||
|
||||
**Current release: v0.66.58**
|
||||
**Current release: v0.66.68**
|
||||
|
||||
**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.**
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_comment": "GENERATED by rune.spine \u2014 compatibility view for legacy umbrella consumers. DO NOT EDIT: structural truth lives in store/spine/current.json; edits here are flagged by spine drift.",
|
||||
"_generated_by_spine": true,
|
||||
"generated_at": "2026-09-14T06:08:20Z",
|
||||
"generated_at": "2026-09-15T16:31:19Z",
|
||||
"umbrellas": {
|
||||
"capability": {
|
||||
"description": "Tool selection and execution authority",
|
||||
@@ -17,6 +17,7 @@
|
||||
"local_tool_runtime",
|
||||
"message_queue",
|
||||
"openai_compat_transport",
|
||||
"provider_context",
|
||||
"recall_uplift",
|
||||
"route_risk_flags",
|
||||
"tool_executor",
|
||||
@@ -219,19 +220,26 @@
|
||||
"cognitive_budgeter",
|
||||
"complexity_scorer",
|
||||
"construction_activation",
|
||||
"construction_commands",
|
||||
"construction_decision",
|
||||
"construction_depth",
|
||||
"construction_interface",
|
||||
"construction_mode",
|
||||
"construction_pattern",
|
||||
"construction_patterns_builtin",
|
||||
"construction_projection",
|
||||
"context_broker",
|
||||
"context_pressure",
|
||||
"drift_resume",
|
||||
"event_wait",
|
||||
"failure_recovery",
|
||||
"layout_contract",
|
||||
"programme_continuation",
|
||||
"release_notes",
|
||||
"salience_gate",
|
||||
"scaffold",
|
||||
"spine",
|
||||
"stage_guards",
|
||||
"structural_store",
|
||||
"toolchain",
|
||||
"tui_backlog",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Rune Structural Map
|
||||
|
||||
_Maintained structural projection — the fast re-entry surface (T16 §16N).
|
||||
Companion to the Spine store (`store/spine/current.json`, structural
|
||||
authority) and `store/evidence/t16/16M_inventory.md` (full classification
|
||||
record). Update this map when authority or canonical routes change; do
|
||||
not copy the repo here._
|
||||
|
||||
## What is Rune?
|
||||
|
||||
A self-hosting coding agent: TUI client over an operator core, with a
|
||||
structural self-model (Spine), a construction authority (Scaffolding),
|
||||
an event-driven wait/resume bridge (W/R), a research pipeline, and a
|
||||
fail-closed release/deployment pipeline that builds, tests, releases,
|
||||
and deploys Rune itself to `/mnt/workdrive/rune-code/current`.
|
||||
|
||||
## Where things happen (canonical routes only)
|
||||
|
||||
| Concern | Authority | State |
|
||||
|---|---|---|
|
||||
| Structural self-model | `src/rune/spine.py` | `store/spine/current.json` |
|
||||
| Construction (build lifecycle) | `src/rune/scaffold.py` + `construction_*.py` | scaffold store (`<dir>/.rune/scaffold`) |
|
||||
| Builder sessions | `scripts/t15_builder_session.py` (event-driven successor; T14 is legacy) | contract + wait records |
|
||||
| Provider/model selection | `rune_operator` final decider; ambient context via `provider_context.require_model` | `state/model_routing_profiles.json` |
|
||||
| Wait/resume (any long op) | `src/rune/event_wait.py` + W/R daemon (out-of-repo snapshot at `~/.local/share/mimo-wait-resume`, data root `/mnt/workdrive/wr-bridge`) | waits/events/leases/resumes JSON |
|
||||
| CI qualification verdict | `scripts/deploy_qualify.py` (two legs, fail-closed) | Gitea commit status + runs |
|
||||
| Release/deploy | `scripts/deploy_stage.py` → `deploy_promotion.py` (monotonic, flock) | deploy root + `current`/`last-good` symlinks |
|
||||
| Research | `scripts/research_task_tree.py` (authority; labels are projection) | `research/issue-<N>/` |
|
||||
| Task completion truth | `verification_layer.py` + `orchestration/conversation_state.py` | durable event store + checkpoints |
|
||||
| Routing (task class) | `src/rune/adaptive_router.py` | `routes/route.jsonl` |
|
||||
| Canary registry | `src/rune/canary_native.MODULES` | observed by Spine |
|
||||
| Structural health | `henxels_checks.py` + `henxels.yaml` | repo root |
|
||||
|
||||
## Known non-canonical / duplicate routes (do not extend; reconcile via 16Q)
|
||||
|
||||
- `build_mode.BuildManager` (legacy /build) — superseded by builder_bridge
|
||||
+ event-driven sessions; still wired.
|
||||
- `scripts/t14_builder_session.py` — superseded by t15 event-driven driver.
|
||||
- Provider selection entry points ×5 (`model_routing.rank`,
|
||||
`model_connect route`, `model_registry get`, `provider_registry`,
|
||||
`work_manager`) — operator is the de-facto final decider.
|
||||
- Task-ish state ×4 (task-tool DB, `task_persist` persisted.json,
|
||||
`memory_store.working`, orchestration event store).
|
||||
- Maintenance ledgers ×3 (scaffold maintenance_item, tui_backlog, drift_ledger).
|
||||
|
||||
## Unfinished / blocked / deprecated
|
||||
|
||||
- programme_continuation.py: implemented + tested but UNWIRED (no
|
||||
production caller) — the "continue problem" machinery awaits routing.
|
||||
- maintenance.yml canary-suite job: loops `shared/canary_*.py` — wrong
|
||||
dir (real canaries in `src/rune/`), job likely no-ops (LEGACY).
|
||||
- daemon out-of-repo: the W/R daemon is deployed from
|
||||
`~/.local/share/mimo-wait-resume` with its own git snapshot —
|
||||
packaging debt, no CI coverage of the deployed copy.
|
||||
- Substrate: NOT BUILT. Scaffolding exposes the seam
|
||||
(`construction_projection.py`, `construction_commands.py`); mock
|
||||
client proves compatibility (`tests/test_substrate_seam.py`).
|
||||
|
||||
## Fresh-session quick answers
|
||||
|
||||
- Is a claim true? → Spine store + evidence_refs, never commit messages.
|
||||
- Is construction allowed? → scaffold contract phase + LEGAL_TRANSITIONS.
|
||||
- How does a long wait work? → register on the W/R bridge, yield on the
|
||||
FIFO event; watchdog is failure detection, never scheduling; the event
|
||||
is a WAKE — authority is re-read after resume.
|
||||
- Who picks the provider? → the user layer, via ambient context;
|
||||
construction never does (`test_provider_ownership.py` enforces).
|
||||
- What runs where? → CI on `rune-ci-desktop` runner; deploy stages to
|
||||
`/mnt/workdrive/rune-code/<sha>`; data home pinned at
|
||||
`/mnt/workdrive/rune` (RUNE_HOME); implementation worktree
|
||||
`/mnt/workdrive/issue161-harness`.
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rune"
|
||||
version = "0.66.58"
|
||||
version = "0.66.68"
|
||||
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ci_event_wait.py — T16 §16C/§16D: event-driven CI qualification wait.
|
||||
|
||||
Replaces the deploy.yml sleep-30 → query → repeat polling loop with the
|
||||
EXISTING W/R event bridge (src/rune/event_wait.py — the SAME primitive the
|
||||
builder adapter uses; §16E: one bridge, adapters differ only in identity
|
||||
and authority probe).
|
||||
|
||||
Flow (mandate §16C):
|
||||
|
||||
push / CI workflow starts
|
||||
↓
|
||||
register correlation (provider="gitea", event_type="ci_completed",
|
||||
run_ref="ci-<sha>", sha=<sha>)
|
||||
↓
|
||||
WAITING_FOR_EVENT (await_resume_event: select() on FIFO, no floor)
|
||||
↓
|
||||
ci.yml final job POSTs /process-event (the WAKE)
|
||||
↓
|
||||
daemon matches → resume_cmd fires → FIFO token → controller resumes
|
||||
↓
|
||||
canonical Gitea state INDEPENDENTLY re-read (deploy_qualify.py verdict)
|
||||
↓
|
||||
verdict
|
||||
|
||||
The event is a WAKE, never truth. The final verdict always comes from
|
||||
deploy_qualify.py (required-checks context + exact-SHA ci.yml run, both
|
||||
legs). Watchdog deadline is a FAILURE DETECTOR, never the scheduler:
|
||||
|
||||
- event arrives → authority re-read; pending → bounded re-arm until the
|
||||
overall deadline (ack-window semantics, same as the daemon's wake sweep).
|
||||
- event lost/suppressed → watchdog expires → ONE-SHOT authority query:
|
||||
terminal verdict → recovered truthfully (no invented wait);
|
||||
CI still running (non-terminal Gitea runs for the SHA) → re-arm;
|
||||
nothing alive and no verdict → truthful failure (deployment refused).
|
||||
|
||||
Exit codes: 0 = qualified (success), 1 = refused (failure/timeout).
|
||||
Output: one JSON line with the §16D latency-proof timestamps.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(_SCRIPT_DIR.parent / "src"))
|
||||
|
||||
from rune import event_wait as ew # noqa: E402
|
||||
|
||||
REPO_ROOT = _SCRIPT_DIR.parent
|
||||
QUALIFY_SCRIPT = _SCRIPT_DIR / "deploy_qualify.py"
|
||||
|
||||
|
||||
def authority_verdict() -> str:
|
||||
"""ONE-SHOT canonical Gitea verdict via deploy_qualify.py — the only
|
||||
completion truth. Never parses the event payload."""
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(QUALIFY_SCRIPT)],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
env=dict(os.environ, PYTHONPATH=""),
|
||||
)
|
||||
out = (proc.stdout or "").strip().splitlines()
|
||||
return (out[-1] if out else "pending").strip()
|
||||
|
||||
|
||||
def authority_after_wake(max_tries: int = 3, gap_secs: int = 4) -> str:
|
||||
"""Post-wake authority read. The daemon's own wake sweep uses a
|
||||
bounded event-triggered retry (4s ×3) for the job-finished-microseconds-
|
||||
ago ack window; a verdict read here can hit the same window. This is a
|
||||
bounded schedule, not a polling loop — 3 reads max, then back to the
|
||||
event wait / watchdog."""
|
||||
verdict = "pending"
|
||||
for attempt in range(max_tries):
|
||||
verdict = authority_verdict()
|
||||
if verdict != "pending":
|
||||
return verdict
|
||||
if attempt < max_tries - 1:
|
||||
time.sleep(gap_secs)
|
||||
return verdict
|
||||
|
||||
|
||||
def ci_alive() -> bool:
|
||||
"""Liveness probe for THIS adapter: any non-terminal Actions run for
|
||||
the SHA in canonical Gitea (one-shot on watchdog expiry — not a poll).
|
||||
Import the probe from deploy_qualify to keep ONE API client."""
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
import deploy_qualify as dq # noqa: E402 (spec-loaded scripts pattern)
|
||||
import gitea_auth
|
||||
server = os.environ.get("GITEA_SERVER", "").rstrip("/")
|
||||
repo = os.environ.get("GITEA_REPO", "")
|
||||
sha = os.environ.get("DEPLOY_SHA", "")
|
||||
if not (server and repo and sha):
|
||||
return False
|
||||
token = gitea_auth.get_token() if gitea_auth.has_token() else ""
|
||||
if not token:
|
||||
return False
|
||||
runs = dq._fetch_runs(server, repo, sha, token)
|
||||
if not runs:
|
||||
return False
|
||||
terminal = {"success", "failure", "cancelled", "skipped"}
|
||||
return any(str(r.get("status", "")).lower() not in terminal
|
||||
for r in runs)
|
||||
|
||||
|
||||
def run(*, sha: str, deadline_secs: int,
|
||||
watchdog_secs: int = 300,
|
||||
daemon_url: str = ew.DEFAULT_DAEMON_URL) -> int:
|
||||
op = f"ci-{sha}"
|
||||
t0 = time.time()
|
||||
# §16Y #10 race fix: the FIFO reader exists BEFORE registration, so a
|
||||
# wake arriving in the registration window can always be delivered.
|
||||
fifo_fd = ew.open_fifo_reader(op)
|
||||
# Event replay (T16 §16C live finding): on a serialized runner the
|
||||
# completion event usually arrives BEFORE the deploy job registers.
|
||||
# A matching durable event means the wake already happened — skip
|
||||
# the rendezvous entirely and go straight to the authority read.
|
||||
prior = ew.find_matching_event("gitea", "ci_completed", op)
|
||||
if prior is not None and prior.get("exit_code") is not None:
|
||||
rec = ew.register_process_wait(
|
||||
op, project="rune",
|
||||
objective=f"event-driven CI qualification for {sha}",
|
||||
watched_for="Gitea CI completion event (replayed from the "
|
||||
"durable event log)",
|
||||
provider="gitea", event_type="ci_completed", sha=sha,
|
||||
watchdog_secs=min(watchdog_secs, deadline_secs))
|
||||
registered_at = rec["created_at"]
|
||||
event_at = time.strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
resumed_at = event_at
|
||||
# the event is a WAKE, never truth — canonical authority decides
|
||||
verdict = authority_after_wake()
|
||||
if verdict == "pending" and not ci_alive():
|
||||
verdict = authority_verdict()
|
||||
mode = ("event_replayed" if verdict != "pending"
|
||||
else "authority_unresolved")
|
||||
verdict_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
out = {
|
||||
"sha": sha,
|
||||
"registered_at": registered_at,
|
||||
"event_received_at": event_at,
|
||||
"controller_resumed_at": resumed_at,
|
||||
"verdict_at": verdict_at,
|
||||
"mode": mode,
|
||||
"rearm_reason": "completion event replayed from durable log "
|
||||
"(arrived before registration)",
|
||||
"verdict": verdict,
|
||||
"wall_secs": round(time.time() - t0, 3),
|
||||
}
|
||||
print(json.dumps(out))
|
||||
return 0 if verdict == "success" else 1
|
||||
rec = ew.register_process_wait(
|
||||
op, project="rune",
|
||||
objective=f"event-driven CI qualification for {sha}",
|
||||
watched_for="Gitea CI completion event (wake; verdict re-read "
|
||||
"from canonical Gitea)",
|
||||
provider="gitea", event_type="ci_completed", sha=sha,
|
||||
watchdog_secs=min(watchdog_secs, deadline_secs))
|
||||
registered_at = rec["created_at"]
|
||||
deadline = time.monotonic() + deadline_secs
|
||||
verdict = "pending"
|
||||
mode = "event"
|
||||
event_at = ""
|
||||
resumed_at = ""
|
||||
rearm_reason = ""
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
remaining = max(1, int(deadline - time.monotonic()))
|
||||
result = ew.await_resume_event(
|
||||
op, watchdog_secs=min(remaining, watchdog_secs), pgid=None,
|
||||
fifo_fd=fifo_fd)
|
||||
if result["kind"] == "event":
|
||||
event_at = event_at or time.strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
resumed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime())
|
||||
# WAKE received — canonical authority decides, never the
|
||||
# event payload
|
||||
verdict = authority_after_wake()
|
||||
if verdict != "pending":
|
||||
mode = "event"
|
||||
break
|
||||
rearm_reason = "event seen; authority pending"
|
||||
if not ci_alive():
|
||||
# CI terminal in Gitea but aggregator lagging: one
|
||||
# final truthful read, then stop
|
||||
verdict = authority_verdict()
|
||||
mode = ("event" if verdict != "pending"
|
||||
else "authority_unresolved")
|
||||
break
|
||||
continue
|
||||
if result["kind"] == "eof":
|
||||
# writer gone without a token: the DURABLE record decides
|
||||
rec = ew.operation_wait(op) or {}
|
||||
if rec.get("status") in ("COMPLETED", "FAILED",
|
||||
"READY_TO_RESUME", "RESUMING") or \
|
||||
(rec.get("status") == "RESUME_UNVERIFIED"
|
||||
and rec.get("process_event_evidence")):
|
||||
event_at = event_at or time.strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
resumed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime())
|
||||
verdict = authority_after_wake()
|
||||
if verdict != "pending":
|
||||
mode = "event_recovered_from_durable_record"
|
||||
break
|
||||
rearm_reason = "eof without token; durable record pending"
|
||||
continue
|
||||
# watchdog expiry: adapter liveness (Gitea runs) decides
|
||||
# re-arm vs truthful recovery — failure detector, never
|
||||
# scheduler
|
||||
if ci_alive():
|
||||
rearm_reason = "watchdog expiry; CI runs non-terminal " \
|
||||
"in Gitea — re-arm"
|
||||
continue
|
||||
verdict = authority_verdict()
|
||||
if verdict == "pending":
|
||||
mode = "watchdog_lost_event_no_verdict"
|
||||
else:
|
||||
mode = "watchdog_authority_recovered"
|
||||
break
|
||||
finally:
|
||||
os.close(fifo_fd)
|
||||
if verdict == "pending":
|
||||
if mode == "event":
|
||||
mode = "deadline_pending"
|
||||
verdict = authority_verdict() # final one-shot truth
|
||||
verdict_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
out = {
|
||||
"sha": sha,
|
||||
"registered_at": registered_at,
|
||||
"event_received_at": event_at,
|
||||
"controller_resumed_at": resumed_at,
|
||||
"verdict_at": verdict_at,
|
||||
"mode": mode,
|
||||
"rearm_reason": rearm_reason,
|
||||
"verdict": verdict,
|
||||
"wall_secs": round(time.time() - t0, 3),
|
||||
}
|
||||
print(json.dumps(out))
|
||||
return 0 if verdict == "success" else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sha = os.environ.get("DEPLOY_SHA", "")
|
||||
args = sys.argv[1:]
|
||||
if "--sha" in args:
|
||||
sha = args[args.index("--sha") + 1]
|
||||
deadline = 900
|
||||
if "--deadline" in args:
|
||||
deadline = int(args[args.index("--deadline") + 1])
|
||||
if not sha:
|
||||
print(json.dumps({"error": "no sha (DEPLOY_SHA or --sha required)"}))
|
||||
return 1
|
||||
return run(sha=sha, deadline_secs=deadline)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""notify_event_bridge.py — T16 §16C: ci.yml's final job posts the CI
|
||||
completion WAKE to the local W/R event bridge (daemon /process-event).
|
||||
|
||||
The event is a WAKE, never completion truth: the waiting controller
|
||||
(ci_event_wait.py) re-reads the canonical Gitea verdict via
|
||||
deploy_qualify.py after resuming. Best-effort by design — if the bridge
|
||||
is unreachable the waiter's watchdog still recovers truthfully (that is
|
||||
the §16D lost-event contract), so a notify failure must NEVER fail CI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
BRIDGE_URL = os.environ.get("RUNE_EVENT_BRIDGE_URL",
|
||||
"http://127.0.0.1:8791")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sha = os.environ.get("CI_SHA", "")
|
||||
result = os.environ.get("CI_RESULT", "") # needs.<job>.result
|
||||
if not sha:
|
||||
print("notify-event-bridge: no CI_SHA — nothing to wake", flush=True)
|
||||
return 0
|
||||
payload = {
|
||||
"operation_id": f"ci-{sha}",
|
||||
"provider": "gitea",
|
||||
"event_type": "ci_completed",
|
||||
"sha": sha,
|
||||
# exit_code is informational only; the wake is not the verdict
|
||||
"exit_code": 0 if result == "success" else 1,
|
||||
"completed_at": time.time(),
|
||||
}
|
||||
for attempt in range(2):
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
BRIDGE_URL + "/process-event",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
resp = json.loads(r.read())
|
||||
print(f"notify-event-bridge: woke bridge "
|
||||
f"(matched={resp.get('matched')})", flush=True)
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001 — wake is best-effort by design; failure is logged and the waiter's watchdog recovery covers a lost wake (§16D contract)
|
||||
if attempt == 0:
|
||||
time.sleep(2)
|
||||
else:
|
||||
print(f"notify-event-bridge: bridge unreachable ({exc}); "
|
||||
f"watchdog recovery covers a lost wake", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -181,7 +181,8 @@ def run_stage_with_relay(tree, out_dir: str) -> dict:
|
||||
err_path = os.path.join(out_dir, f"stage_{stage}.mimo.err.log")
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
|
||||
cmd = [
|
||||
|
||||
@@ -793,7 +793,8 @@ class StageExecutor:
|
||||
prompt = chr(10).join(pp)
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
events_path = os.path.join(self.artifact_dir, "stage_OUTWARD.events.jsonl")
|
||||
err_path = os.path.join(self.artifact_dir, "stage_OUTWARD.mimo.err.log")
|
||||
@@ -933,7 +934,8 @@ class StageExecutor:
|
||||
prompt = chr(10).join(pp)
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
events_path = os.path.join(self.artifact_dir, "stage_APPLICABILITY.events.jsonl")
|
||||
err_path = os.path.join(self.artifact_dir, "stage_APPLICABILITY.mimo.err.log")
|
||||
@@ -1137,7 +1139,8 @@ class StageExecutor:
|
||||
prompt = chr(10).join(pp)
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
events_path = os.path.join(self.artifact_dir, "stage_ADVERSARIAL.events.jsonl")
|
||||
err_path = os.path.join(self.artifact_dir, "stage_ADVERSARIAL.mimo.err.log")
|
||||
@@ -1345,7 +1348,8 @@ class StageExecutor:
|
||||
prompt = chr(10).join(pp).replace("{n}", str(self.issue))
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
events_path = os.path.join(self.artifact_dir, "stage_INNOVATION.events.jsonl")
|
||||
err_path = os.path.join(self.artifact_dir, "stage_INNOVATION.mimo.err.log")
|
||||
@@ -1534,7 +1538,8 @@ class StageExecutor:
|
||||
prompt = chr(10).join(pp)
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
events_path = os.path.join(self.artifact_dir, "stage_SYNTHESIS.events.jsonl")
|
||||
err_path = os.path.join(self.artifact_dir, "stage_SYNTHESIS.mimo.err.log")
|
||||
@@ -1671,7 +1676,8 @@ class StageExecutor:
|
||||
prompt = chr(10).join(pp).replace("{n}", str(self.issue))
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
mimo_agent = os.environ.get("MIMO_AGENT", "build")
|
||||
events_path = os.path.join(self.artifact_dir, "stage_QUALIFY.events.jsonl")
|
||||
err_path = os.path.join(self.artifact_dir, "stage_QUALIFY.mimo.err.log")
|
||||
|
||||
@@ -44,8 +44,18 @@ USER_REQUEST = (
|
||||
"list and complete practice items."
|
||||
)
|
||||
EXPECTED_SCOPE = ["scheduler.js", "scheduler.test.js", "package.json"]
|
||||
BUILDER_MODEL = os.environ.get("T14_BUILDER_MODEL",
|
||||
"Verboo Code/glm-5.3-flash") # PL1 provider policy default
|
||||
# T16 §16F: NO silent default provider. The surrounding provider
|
||||
# layer sets T14_BUILDER_MODEL (falling back to the ambient MIMO_MODEL
|
||||
# context); construction never names a provider.
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent / "src"))
|
||||
from rune.provider_context import ProviderUnresolved, require_model # noqa: E402
|
||||
try:
|
||||
BUILDER_MODEL = os.environ.get("T14_BUILDER_MODEL", "").strip() \
|
||||
or require_model() # ambient provider context (MIMO_MODEL)
|
||||
except ProviderUnresolved as _exc:
|
||||
_sys.stderr.write(str(_exc) + "\n")
|
||||
raise SystemExit(3) # 3 = PROVIDER_UNRESOLVED (no silent fallback)
|
||||
BUILDER_CMD = "mimo"
|
||||
|
||||
|
||||
@@ -196,19 +206,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 +297,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,381 @@
|
||||
#!/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
|
||||
from rune.provider_context import ProviderUnresolved, require_model # noqa: E402
|
||||
|
||||
# T16 §16F: NO silent default provider — resolve from the ambient provider
|
||||
# context (T15_BUILDER_MODEL, falling back to MIMO_MODEL); loud failure
|
||||
# with exit code 3 when the provider layer has not selected one.
|
||||
try:
|
||||
BUILDER_MODEL = os.environ.get("T15_BUILDER_MODEL", "").strip() \
|
||||
or require_model()
|
||||
except ProviderUnresolved as _exc:
|
||||
import sys as _sys
|
||||
_sys.stderr.write(str(_exc) + "\n")
|
||||
raise SystemExit(3) # 3 = PROVIDER_UNRESOLVED (no silent fallback)
|
||||
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())
|
||||
@@ -31,7 +31,21 @@ def main() -> int:
|
||||
# PYTHONNOUSERSITE=1 pins both contexts to the same effective
|
||||
# environment (verified: identical 117-error arithmetic). Local
|
||||
# preflight now predicts CI instead of diverging from it.
|
||||
env = dict(os.environ, PYTHONNOUSERSITE="1")
|
||||
# §16U ROOT-CAUSE COMPLETION (T16): PYTHONNOUSERSITE alone is NOT
|
||||
# sufficient when a DIFFERENT venv is active (e.g. an ad-hoc CI venv):
|
||||
# the pyright wrapper resolves the ambient VIRTUAL_ENV/PATH
|
||||
# interpreter and analyzes against ITS site-packages — reproduced
|
||||
# live (117 vs 124 on the same tree, driven by venv-activation state
|
||||
# alone). The CI runner resolves the AMBIENT python (no venv is
|
||||
# activated in the type-check job), so the ratchet neutralizes every
|
||||
# ambient-venv signal before invoking pyright — analysis context is
|
||||
# now identical to CI regardless of the calling shell.
|
||||
env = {k: v for k, v in os.environ.items()
|
||||
if k not in ("VIRTUAL_ENV",)}
|
||||
env["PYTHONNOUSERSITE"] = "1"
|
||||
env["PATH"] = os.pathsep.join(
|
||||
p for p in env.get("PATH", "").split(os.pathsep)
|
||||
if "ci-venv" not in p and ".type-venv" not in p and "venv/bin" not in p)
|
||||
result = subprocess.run(
|
||||
cmd + [str(ROOT / "src" / "rune"), "--outputjson"],
|
||||
capture_output=True,
|
||||
|
||||
@@ -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.68"
|
||||
|
||||
# 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.68"' in rune_version,
|
||||
failures,
|
||||
)
|
||||
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""construction_commands.py — T16 §16J: bounded validated inbound
|
||||
command interface for future Substrate integration.
|
||||
|
||||
The future Substrate ASKS; Scaffolding DECIDES. Every command:
|
||||
|
||||
- carries identity (command_id, correlation_id, construction_id)
|
||||
- is validated by Scaffolding against the existing lifecycle
|
||||
(LEGAL_TRANSITIONS — no new lifecycle invented)
|
||||
- cannot directly mutate storage (commands return validated intents;
|
||||
the Construction Interface executes them through its own governed
|
||||
path — the caller never writes the store)
|
||||
- is idempotent where applicable (repeat delivery of the same
|
||||
command_id returns the same recorded outcome)
|
||||
|
||||
Only commands that fit EXISTING semantics safely are exposed. PAUSE and
|
||||
CANCEL are intentionally NOT exposed: the lifecycle has no paused phase
|
||||
and abandonment needs user provenance — a future Substrate asking for
|
||||
them gets a refusal, not a half-measure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from rune.scaffold import LEGAL_TRANSITIONS
|
||||
|
||||
COMMANDS = (
|
||||
"REQUEST_STATE", "START", "RESUME", "ACKNOWLEDGE_BLOCKER",
|
||||
"PROVIDE_APPROVAL", "PROVIDE_CONTEXT",
|
||||
)
|
||||
|
||||
# Command → the interface operation it asks for (the interface still
|
||||
# decides: validation failures return refused, never raise into storage).
|
||||
_COMMAND_TO_OP = {
|
||||
"REQUEST_STATE": "resume", # read-only state projection
|
||||
"START": "transition", # draft → approved (then building)
|
||||
"RESUME": "resume", # re-read canonical state
|
||||
"ACKNOWLEDGE_BLOCKER": "transition", # blocked → building (retry path)
|
||||
"PROVIDE_APPROVAL": "transition", # draft → approved with provenance
|
||||
"PROVIDE_CONTEXT": "amend_scope", # context arrives as scope amendment
|
||||
}
|
||||
|
||||
# In-memory idempotency ledger: command_id → recorded outcome. The
|
||||
# Scaffolding lifecycle is the authority; this ledger only makes repeat
|
||||
# delivery return the same answer (no double transition).
|
||||
_outcomes: dict[str, dict] = {}
|
||||
|
||||
|
||||
def command(command_type: str, *, construction_id: str,
|
||||
correlation_id: str = "", command_id: str = "",
|
||||
payload: dict | None = None,
|
||||
scaffold=None) -> dict:
|
||||
"""Validate an inbound command. Returns a validated intent envelope:
|
||||
|
||||
{"accepted": True, "op": <interface op>, "args": {...},
|
||||
"command_id": ..., "idempotent_replay": bool}
|
||||
|
||||
or {"accepted": False, "reason": ...}. The caller executes the intent
|
||||
through the Construction Interface — it never touches storage."""
|
||||
payload = payload or {}
|
||||
command_id = command_id or f"cmd-{uuid.uuid4().hex[:8]}"
|
||||
if command_id in _outcomes:
|
||||
prior = dict(_outcomes[command_id])
|
||||
prior["idempotent_replay"] = True
|
||||
return prior
|
||||
if command_type not in COMMANDS:
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
f"unknown command {command_type!r} "
|
||||
f"(supported: {', '.join(COMMANDS)})")
|
||||
if not construction_id:
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
"construction_id is required")
|
||||
intent = _validate(command_type, construction_id,
|
||||
correlation_id, command_id, payload, scaffold)
|
||||
_outcomes[command_id] = intent
|
||||
return intent
|
||||
|
||||
|
||||
def _refuse(command_id: str, construction_id: str,
|
||||
correlation_id: str, reason: str) -> dict:
|
||||
return {"accepted": False, "reason": reason,
|
||||
"command_id": command_id, "construction_id": construction_id,
|
||||
"correlation_id": correlation_id,
|
||||
"recorded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime()),
|
||||
"idempotent_replay": False}
|
||||
|
||||
|
||||
def _validate(command_type: str, construction_id: str,
|
||||
correlation_id: str, command_id: str,
|
||||
payload: dict, scaffold) -> dict:
|
||||
"""Validate against the CURRENT canonical state — the lifecycle
|
||||
decides, not the requester. The scaffold (canonical store handle) is
|
||||
supplied by the serving layer; the client only ever sees the returned
|
||||
envelope, never the store."""
|
||||
if scaffold is None:
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
"no serving scaffold bound (server-side wiring)")
|
||||
doc = scaffold.load()
|
||||
contract = None
|
||||
for e in doc.get("entities", []):
|
||||
if e.get("id") == f"contract:{construction_id}":
|
||||
contract = e
|
||||
break
|
||||
if contract is None:
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
f"unknown construction {construction_id!r}")
|
||||
phase = contract.get("phase", "")
|
||||
|
||||
if command_type == "REQUEST_STATE":
|
||||
return _accept(command_id, construction_id, correlation_id,
|
||||
"resume", {"contract_id": construction_id})
|
||||
if command_type == "RESUME":
|
||||
if phase in ("closed", "abandoned"):
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
f"construction is {phase} — resume is invalid")
|
||||
return _accept(command_id, construction_id, correlation_id,
|
||||
"resume", {"contract_id": construction_id})
|
||||
if command_type == "START":
|
||||
if phase != "draft":
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
f"START requires draft phase (current: {phase})")
|
||||
return _accept(command_id, construction_id, correlation_id,
|
||||
"transition",
|
||||
{"contract_id": construction_id,
|
||||
"phase": "approved"})
|
||||
if command_type == "PROVIDE_APPROVAL":
|
||||
if phase != "draft":
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
f"approval applies to draft (current: {phase})")
|
||||
if payload.get("approved_by") != "user":
|
||||
# lifecycle rule: approval provenance must be the user —
|
||||
# Substrate cannot approve on the user's behalf
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
"approval provenance must be user "
|
||||
"(Substrate cannot approve for the user)")
|
||||
return _accept(command_id, construction_id, correlation_id,
|
||||
"transition",
|
||||
{"contract_id": construction_id,
|
||||
"phase": "approved",
|
||||
"approved_by": "user"})
|
||||
if command_type == "ACKNOWLEDGE_BLOCKER":
|
||||
if phase not in ("failed", "building", "verifying"):
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
f"no blockable phase (current: {phase})")
|
||||
return _accept(command_id, construction_id, correlation_id,
|
||||
"transition",
|
||||
{"contract_id": construction_id,
|
||||
"phase": "building"})
|
||||
if command_type == "PROVIDE_CONTEXT":
|
||||
paths = payload.get("add_paths") or []
|
||||
if not paths:
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
"PROVIDE_CONTEXT requires add_paths")
|
||||
return _accept(command_id, construction_id, correlation_id,
|
||||
"amend_scope",
|
||||
{"contract_id": construction_id,
|
||||
"add_paths": paths,
|
||||
"reason": payload.get("reason", "substrate context"),
|
||||
"authority": "user"
|
||||
if payload.get("approved_by") == "user" else "substrate"})
|
||||
return _refuse(command_id, construction_id, correlation_id,
|
||||
"unreachable validator branch")
|
||||
|
||||
|
||||
def _accept(command_id: str, construction_id: str, correlation_id: str,
|
||||
op: str, args: dict) -> dict:
|
||||
return {"accepted": True, "op": op, "args": args,
|
||||
"command_id": command_id, "construction_id": construction_id,
|
||||
"correlation_id": correlation_id,
|
||||
"recorded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime()),
|
||||
"idempotent_replay": False}
|
||||
@@ -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,135 @@
|
||||
"""construction_projection.py — T16 §16I: stable versioned PUBLIC
|
||||
projection of construction state for future Substrate integration.
|
||||
|
||||
This is a PROJECTION, never a second authority. All fields are derived
|
||||
(read-only) from the canonical scaffold store (Scaffold.load) plus the
|
||||
live wait bridge state (event_wait.operation_wait). A consumer (the
|
||||
future Substrate, the mock client in tests) must know NOTHING about
|
||||
current.json layout or internal entity arrays — only this schema.
|
||||
|
||||
Schema versioning: SCHEMA_VERSION is bumped on any field change; consumers
|
||||
negotiate by reading schema_version first. Unknown future fields are
|
||||
ignored by readers (forward-tolerant); removed fields bump the major.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
# The mandate's required projection fields (§16I). None may carry live
|
||||
# provider/account secrets or internal storage layout.
|
||||
FIELDS = (
|
||||
"schema_version", "project_id", "originating_goal_id",
|
||||
"construction_id", "contract_id",
|
||||
"mode", "depth", "phase", "stage",
|
||||
"operation_id", "waiting_state", "correlation_id",
|
||||
"builder_state", "blockers", "approval_required",
|
||||
"readiness_canary", "liveness_canary", "exit_canary",
|
||||
"verification_state", "checkpoint", "evidence_refs",
|
||||
"failure_class", "completion_state", "candidate_next_action",
|
||||
)
|
||||
|
||||
|
||||
def _contract_entity(doc: dict, contract_id: str) -> dict | None:
|
||||
for e in doc.get("entities", []):
|
||||
if e.get("id") == f"contract:{contract_id}":
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
def project(
|
||||
scaffold, contract_id: str, *,
|
||||
originating_goal_id: str = "",
|
||||
operation_id: str = "",
|
||||
correlation_id: str = "",
|
||||
stage: str = "",
|
||||
builder_state: str = "",
|
||||
verification_state: str = "",
|
||||
checkpoint: str = "",
|
||||
failure_class: str = "",
|
||||
completion_state: str = "",
|
||||
) -> dict:
|
||||
"""Build the stable public projection for one contract. `scaffold` is
|
||||
a Rune Scaffold instance (the canonical store); all live fields are
|
||||
derived from it. Optional live fields (waiting_state etc.) are filled
|
||||
from the event bridge when operation_id is supplied."""
|
||||
doc = scaffold.load()
|
||||
c = _contract_entity(doc, contract_id) or {}
|
||||
ids = c.get("identities", {})
|
||||
ext = c.get("ext", {})
|
||||
trace = ext.get("decision_trace", {})
|
||||
|
||||
waiting_state = ""
|
||||
live_correlation = correlation_id
|
||||
if operation_id:
|
||||
try:
|
||||
from rune import event_wait as ew
|
||||
rec = ew.operation_wait(operation_id)
|
||||
if rec is not None:
|
||||
waiting_state = rec.get("status", "")
|
||||
live_correlation = correlation_id or rec.get("wait_id", "")
|
||||
except OSError: # bridge data root unreadable → degrade, never lie
|
||||
waiting_state = "BRIDGE_UNREACHABLE"
|
||||
except RuntimeError: # waitstore not resolvable in this context
|
||||
waiting_state = "BRIDGE_UNREACHABLE"
|
||||
|
||||
checkpoints = c.get("checkpoints", [])
|
||||
evidence_refs = []
|
||||
for cp in checkpoints:
|
||||
ev = cp.get("evidence", "")
|
||||
if ev:
|
||||
evidence_refs.append(ev[:120])
|
||||
|
||||
approval = trace.get("approval", {})
|
||||
approval_required = bool(
|
||||
approval.get("required") or approval.get("state") == "REQUIRED"
|
||||
or c.get("phase") == "awaiting_approval")
|
||||
|
||||
blockers: list[str] = []
|
||||
blk = ids.get("blocker")
|
||||
if isinstance(blk, dict):
|
||||
blockers.append(f"{blk.get('class', 'BLOCKED')}: "
|
||||
f"{blk.get('evidence', '')[:120]}")
|
||||
elif isinstance(blk, str) and blk:
|
||||
blockers.append(blk)
|
||||
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"project_id": getattr(scaffold, "project_id", ""),
|
||||
"originating_goal_id": originating_goal_id,
|
||||
"construction_id": contract_id,
|
||||
"contract_id": contract_id,
|
||||
"mode": ids.get("construction_mode", "") or "",
|
||||
"depth": ids.get("depth", "") or "",
|
||||
"phase": c.get("phase", ""),
|
||||
"stage": stage,
|
||||
"operation_id": operation_id,
|
||||
"waiting_state": waiting_state,
|
||||
"correlation_id": live_correlation,
|
||||
"builder_state": builder_state,
|
||||
"blockers": blockers,
|
||||
"approval_required": approval_required,
|
||||
"readiness_canary": ext.get("readiness_canary", ""),
|
||||
"liveness_canary": ext.get("liveness_canary", ""),
|
||||
"exit_canary": ext.get("exit_canary", ""),
|
||||
"verification_state": verification_state,
|
||||
"checkpoint": checkpoint,
|
||||
"evidence_refs": evidence_refs,
|
||||
"failure_class": failure_class,
|
||||
"completion_state": completion_state,
|
||||
"candidate_next_action": ids.get("next_action", ""),
|
||||
}
|
||||
|
||||
|
||||
def validate(projection: dict) -> list[str]:
|
||||
"""Check a projection against the schema. Returns a list of errors
|
||||
(empty = valid). Consumers use this before trusting a projection."""
|
||||
errors = []
|
||||
if projection.get("schema_version") != SCHEMA_VERSION:
|
||||
errors.append(f"schema_version: expected {SCHEMA_VERSION}, got "
|
||||
f"{projection.get('schema_version')!r}")
|
||||
for f in FIELDS:
|
||||
if f not in projection:
|
||||
errors.append(f"missing field: {f}")
|
||||
return errors
|
||||
@@ -0,0 +1,73 @@
|
||||
"""drift_resume.py — T16 §16P: drift-aware resume for months-later
|
||||
project re-entry.
|
||||
|
||||
Months-later resume must NOT blindly trust the last checkpoint. The
|
||||
16O benchmark proved both cold and scaffold-assisted paths converge on
|
||||
the same principle: checkpoint claims NEVER substitute for reality.
|
||||
|
||||
Flow:
|
||||
|
||||
load last verified scaffold state (expectations)
|
||||
↓
|
||||
inspect current repo identity/state (observations)
|
||||
↓
|
||||
compare expected vs actual → DriftRecord list
|
||||
↓
|
||||
unchanged: resume
|
||||
drift: classify EXPECTED / BENIGN / MATERIAL / CONFLICTING /
|
||||
UNKNOWN — MATERIAL/UNKNOWN can block dependent work
|
||||
|
||||
Reality wins unless the claim carries explicit user-locked authority
|
||||
(the one scaffold exception, already enforced by layout_contract).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
EXPECTED = "EXPECTED" # checkpoint older than reality; benign aging
|
||||
BENIGN = "BENIGN" # difference with no bearing on the claims
|
||||
MATERIAL = "MATERIAL" # reality contradicts a scaffold claim
|
||||
CONFLICTING = "CONFLICTING" # two authorities disagree; needs user
|
||||
UNKNOWN = "UNKNOWN" # cannot decide — treated as blocking
|
||||
|
||||
BLOCKING_CLASSES = (MATERIAL, CONFLICTING, UNKNOWN)
|
||||
|
||||
|
||||
def classify_drift(claim: dict, observation: dict) -> dict:
|
||||
"""Classify one claim-vs-observation pair. Deterministic:
|
||||
verification evidence beats prose; explicit beats inferred.
|
||||
|
||||
claim: {"claim": str, "kind": str, "verified_at": str,
|
||||
"user_locked": bool}
|
||||
observation: {"actual": str|None, "checked_by": str,
|
||||
"matches": bool|None}
|
||||
"""
|
||||
if observation.get("matches") is True:
|
||||
return {"claim": claim.get("claim", ""),
|
||||
"class": EXPECTED if claim.get("stale_ok") else "CONFIRMED",
|
||||
"evidence": observation.get("checked_by", "")}
|
||||
if observation.get("matches") is None:
|
||||
return {"claim": claim.get("claim", ""), "class": UNKNOWN,
|
||||
"evidence": f"unverifiable via {observation.get('checked_by', '?')}"}
|
||||
if claim.get("user_locked"):
|
||||
return {"claim": claim.get("claim", ""), "class": CONFLICTING,
|
||||
"evidence": ("reality differs from a USER-LOCKED claim "
|
||||
"— user arbitration required")}
|
||||
return {"claim": claim.get("claim", ""), "class": MATERIAL,
|
||||
"evidence": (f"reality differs: {observation.get('actual', '')}"
|
||||
f" (checked by {observation.get('checked_by', '?')})")}
|
||||
|
||||
|
||||
def reconcile(claims: list[dict], observer: Callable[[dict], dict]) -> dict:
|
||||
"""Reconcile a set of checkpoint claims against reality.
|
||||
|
||||
observer(claim) → observation dict (runs the actual check: test
|
||||
command, file read, git query — never trusts the claim text).
|
||||
|
||||
Returns {"resume": bool, "drifts": [...], "blocking": [...]}. The
|
||||
resume decision: drift-aware, not blind — ANY blocking-class drift
|
||||
stops dependent work until reconciled."""
|
||||
drifts = [classify_drift(c, observer(c)) for c in claims]
|
||||
blocking = [d for d in drifts if d["class"] in BLOCKING_CLASSES]
|
||||
return {"resume": not blocking, "drifts": drifts,
|
||||
"blocking": blocking}
|
||||
@@ -0,0 +1,457 @@
|
||||
"""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 importlib.util
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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
|
||||
|
||||
|
||||
# waitstore instances cached per data root (isolated tests use per-test
|
||||
# tmp roots; the daemon uses its install dir). Loading via importlib.util
|
||||
# keeps the dynamic import pyright-clean — no unresolvable-import warning.
|
||||
_waitstore_cache: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _load_waitstore() -> Any:
|
||||
"""Load the bridge's OWN waitstore module (never a copy), bound to
|
||||
the adapter's data root. waitstore.py lives in the DAEMON CODE dir;
|
||||
in production WR_ROOT is that 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 installs."""
|
||||
root = wr_root()
|
||||
# waitstore binds its DATA dir from WR_ROOT at import time. Pin
|
||||
# WR_ROOT to the adapter's authority 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(root)
|
||||
cached = _waitstore_cache.get(str(root))
|
||||
if cached is not None:
|
||||
return cached
|
||||
code_dir = None
|
||||
for cand in (os.environ.get("RUNE_WR_CODE"), str(root),
|
||||
str(FALLBACK_WR_ROOT)):
|
||||
if cand and (Path(cand) / "waitstore.py").is_file():
|
||||
code_dir = Path(cand)
|
||||
break
|
||||
if code_dir is None:
|
||||
raise RuntimeError(
|
||||
"waitstore.py not found in known W/R roots "
|
||||
"(RUNE_WR_CODE, WR_ROOT, ~/.local/share/mimo-wait-resume)")
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"waitstore", str(code_dir / "waitstore.py"))
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"cannot load waitstore from {code_dir}")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
_waitstore_cache[str(root)] = mod
|
||||
return mod
|
||||
|
||||
|
||||
def register_process_wait(operation_id: str, *, contract_id: str = "",
|
||||
project: str = "", objective: str = "",
|
||||
watched_for: str = "",
|
||||
provider: str = "builder",
|
||||
event_type: str = "builder_exit",
|
||||
sha: 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.
|
||||
|
||||
provider/event_type are the adapter's event identity on the shared
|
||||
bridge (16E: ONE wait primitive; builder / Gitea-CI / future adapters
|
||||
differ ONLY in these identity fields + their liveness probe — the
|
||||
registration, FIFO rendezvous, matching, idempotency, restart
|
||||
recovery, and watchdog semantics are the same code for all).
|
||||
Defaults preserve the T15 builder semantics."""
|
||||
ws = _load_waitstore()
|
||||
fifo = fifo_path(operation_id)
|
||||
fifo.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Do NOT unlink+recreate an existing fifo: a caller that opened its
|
||||
# read end before registering (open_fifo_reader, §16Y #10 race fix)
|
||||
# would be orphaned onto the old inode while the daemon's resume
|
||||
# writer opened the new one. The rendezvous is create-if-missing.
|
||||
if not fifo.exists():
|
||||
os.mkfifo(fifo)
|
||||
os.chmod(fifo, 0o666) # cross-user bridge (see open_fifo_reader)
|
||||
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=provider,
|
||||
event_type=event_type,
|
||||
sha=sha,
|
||||
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)
|
||||
# cross-user bridge (T16 §16C live finding): the daemon reconciler may
|
||||
# run under a different account than the registering controller — the
|
||||
# durable record must be readable/writable on both sides
|
||||
try:
|
||||
os.chmod(ws.WAITS / f"{rec['wait_id']}.json", 0o666)
|
||||
except OSError:
|
||||
pass # single-user deployment: nothing to relax
|
||||
return rec
|
||||
|
||||
|
||||
def fifo_path(operation_id: str) -> Path:
|
||||
return wr_root() / "resumes" / f"{operation_id}.fifo"
|
||||
|
||||
|
||||
def open_fifo_reader(operation_id: str) -> int:
|
||||
"""Open the operation's FIFO read end (O_NONBLOCK) BEFORE the wait is
|
||||
registered. §16Y adversarial #10: an event that arrives after
|
||||
registration but before the controller enters select() would find no
|
||||
reader — the resume write fails (ENXIO) and the wait lands
|
||||
RESUME_UNVERIFIED while the controller sleeps until the watchdog.
|
||||
Holding the reader open across the registration window closes that
|
||||
race for every adapter; the fd is passed to await_resume_event and
|
||||
closed by the caller."""
|
||||
fifo = fifo_path(operation_id)
|
||||
fifo.parent.mkdir(parents=True, exist_ok=True)
|
||||
if fifo.exists():
|
||||
fifo.unlink()
|
||||
os.mkfifo(fifo)
|
||||
# CROSS-USER BRIDGE (T17 17C root-cause, re-landed): the T16 chmod was
|
||||
# lost in a rebase resolution — FIFOs landed 644 (666 & umask 022),
|
||||
# and the daemon running as a DIFFERENT user got EACCES on the resume
|
||||
# write (rc=1 → RESUME_UNVERIFIED) while same-user tests passed.
|
||||
# chmod AFTER mkfifo is the only reliable act — umask never applies.
|
||||
os.chmod(fifo, 0o666)
|
||||
return os.open(fifo, os.O_RDONLY | os.O_NONBLOCK)
|
||||
|
||||
|
||||
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,
|
||||
fifo_fd: int | None = None) -> dict:
|
||||
"""YIELD until the bridge resumes this controller. Blocks in select()
|
||||
on the FIFO — no sleep, no polling.
|
||||
|
||||
fifo_fd: a pre-opened read end from open_fifo_reader() (§16Y #10 race
|
||||
fix — the fd must already exist when the wait becomes registered).
|
||||
Caller-owned fds are never closed here; on EOF (writer gone, no
|
||||
token) the call returns {"kind": "eof"} instead of busy-looping on an
|
||||
always-readable fd — the caller falls back to the durable waitstore
|
||||
record via resume_or_recover/operation_wait.
|
||||
|
||||
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.
|
||||
"""
|
||||
owned_fd = fifo_fd is None
|
||||
if owned_fd:
|
||||
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)
|
||||
fd = os.open(fifo, os.O_RDONLY | os.O_NONBLOCK)
|
||||
else:
|
||||
fd = fifo_fd
|
||||
try:
|
||||
for arm in range(max_rearms + 1):
|
||||
deadline = time.monotonic() + watchdog_secs
|
||||
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:
|
||||
if owned_fd:
|
||||
continue
|
||||
# caller-owned fd at EOF: writer is gone without
|
||||
# delivering a token — never a busy loop; the durable
|
||||
# record decides what actually happened
|
||||
return {"kind": "eof"}
|
||||
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")}
|
||||
# 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}
|
||||
finally:
|
||||
if owned_fd:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
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 find_matching_event(provider: str, event_type: str, run_ref: str) -> dict | None:
|
||||
"""Search the bridge's durable event log for an event matching this
|
||||
identity. T16 §16C live finding: on a serialized single-runner
|
||||
machine the deploy job ALWAYS starts after ci.yml completes — the
|
||||
completion event lands before the waiter registers, and a pure
|
||||
forward-only FIFO rendezvous can never deliver it (both production
|
||||
cycles fell back to the 900s deadline). Events are durable precisely
|
||||
so they can be replayed: a waiter that finds a matching event at
|
||||
registration goes straight to the authority read instead of sleeping
|
||||
for a wake that already happened."""
|
||||
import json as _json
|
||||
ws = _load_waitstore()
|
||||
events_dir = ws.EVENTS
|
||||
if not events_dir.is_dir():
|
||||
return None
|
||||
best = None
|
||||
for f in sorted(events_dir.glob("*.json"),
|
||||
key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
try:
|
||||
rec = _json.loads(f.read_text())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
ev = rec.get("event", {})
|
||||
if (ev.get("provider") == provider
|
||||
and ev.get("event_type") == event_type
|
||||
and str(ev.get("run_ref", "")) == run_ref):
|
||||
best = ev # newest match wins
|
||||
break
|
||||
return best
|
||||
|
||||
|
||||
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())
|
||||
@@ -192,7 +192,8 @@ def _default_runner(prompt: str, artifact_path: str) -> None:
|
||||
import subprocess
|
||||
|
||||
mimo_bin = os.environ.get("MIMO_BIN", "/var/lib/gitea-desktop-runner/bin/mimo")
|
||||
mimo_model = os.environ.get("MIMO_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
from rune.provider_context import require_model
|
||||
mimo_model = require_model() # T16 §16F: provider layer owns selection
|
||||
cmd = [
|
||||
mimo_bin,
|
||||
"run",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""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"
|
||||
PROVIDER_UNAVAILABLE = "PROVIDER_UNAVAILABLE"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
CLASSES = (TRANSIENT, INTERMITTENT, KNOWN_PROCESS_DEFECT, PERMANENT,
|
||||
APPROVAL_REQUIRED, PROVIDER_UNAVAILABLE, 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"),
|
||||
# T16 §16F: provider failures NEVER auto-retry and NEVER fall back
|
||||
# to another provider — preserve the real error, block the branch.
|
||||
PROVIDER_UNAVAILABLE: (0, "block_branch_preserve_error"),
|
||||
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]}
|
||||
# T16 §16F: an explicit provider-context failure wins over every
|
||||
# generic marker — the real status/API code is preserved verbatim in
|
||||
# the evidence, the branch blocks, and no fallback is attempted.
|
||||
if context.get("provider_error"):
|
||||
return {"class": PROVIDER_UNAVAILABLE,
|
||||
"evidence": (f"provider error preserved: {error_text}"
|
||||
if error_text else "provider error"),
|
||||
"policy": POLICY[PROVIDER_UNAVAILABLE]}
|
||||
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,349 @@
|
||||
"""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
|
||||
from typing import Any, cast
|
||||
|
||||
|
||||
# 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")}
|
||||
# T17 §17E: all stages terminal but some FAILED — the programme
|
||||
# may still close (programme-level acceptance decides), never
|
||||
# hang in UNKNOWN_INTERRUPTION.
|
||||
if all(s.state in TERMINAL for s in self.stages):
|
||||
failed = [s.stage_id for s in self.stages if s.state == FAILED]
|
||||
return {"verdict": "PROGRAMME_COMPLETE_WITH_FAILURES",
|
||||
"next_action": None,
|
||||
"reason": (f"all stages terminal; failed: {failed} — "
|
||||
f"programme-level acceptance decides")}
|
||||
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
|
||||
|
||||
|
||||
# ── T17 §17D: production wiring — Scaffolding-integrated driver ─────────
|
||||
#
|
||||
# The 15M canary proved the RULE; this adapter makes it CANONICAL. One
|
||||
# programme = a list of stages; each stage maps 1:1 to a Scaffolding
|
||||
# contract (stage_id == contract_id). Stages run through the Construction
|
||||
# Interface lifecycle (approved→building→verifying→closed); asynchronous
|
||||
# stage work runs under the event bridge (WAITING_EVENT is a state, not
|
||||
# a stop). Persisted to the scaffold store's programme area — reloadable
|
||||
# after restart with no transcript.
|
||||
|
||||
DEFAULT_PROGRAMME_AREA = "programmes"
|
||||
|
||||
|
||||
def _programme_base(scaffold_store: Path) -> Path:
|
||||
return Path(scaffold_store) / DEFAULT_PROGRAMME_AREA
|
||||
|
||||
|
||||
def scaffolding_stage_runner(interface, *, scaffold_store: Path,
|
||||
daemon_url: str = "",
|
||||
watchdog_secs: int = 900):
|
||||
"""Return a stage_runner for run_programme bound to the Construction
|
||||
Interface. The stage's work is read from stage.result["work"] — a
|
||||
callable (interface, stage) → dict with {"ok": bool, "evidence": str,
|
||||
"async_cmd": [argv] optional}. When async_cmd is present the work
|
||||
runs under the event bridge and the driver resumes on the EVENT;
|
||||
canonical verification still decides completion truth. Stages without
|
||||
work complete as governance-only transitions."""
|
||||
from rune import event_wait as ew # local import: optional dependency
|
||||
|
||||
def _phase(contract_id: str) -> str:
|
||||
# ALWAYS re-read: transition() saves through its own doc copy, so
|
||||
# a locally-held contract dict goes stale after any transition
|
||||
# (T17 17E live finding — the draft→approved→building chain
|
||||
# silently stopped after the first step on a stale reference).
|
||||
doc = interface.scaffold.load()
|
||||
c = interface.scaffold.contract(doc, contract_id)
|
||||
return (c or {}).get("phase", "")
|
||||
|
||||
def run(stage: ProgrammeStage) -> str:
|
||||
contract_id = stage.stage_id
|
||||
phase = _phase(contract_id)
|
||||
if phase == "":
|
||||
stage.result["error"] = f"no contract {contract_id}"
|
||||
return FAILED
|
||||
if phase == "closed":
|
||||
return COMPLETE # idempotent re-entry (duplicate signal case)
|
||||
if phase == "draft":
|
||||
interface.transition(contract_id, "approved",
|
||||
evidence_ref="programme-stage-start")
|
||||
if _phase(contract_id) == "approved":
|
||||
interface.transition(contract_id, "building",
|
||||
evidence_ref="programme-stage-start")
|
||||
work = cast("Any", stage.result.get("work"))
|
||||
if work is None:
|
||||
interface.transition(contract_id, "verifying",
|
||||
evidence_ref="governance-only-stage")
|
||||
interface.close(
|
||||
contract_id,
|
||||
observed_evidence=[{"source": "programme-driver",
|
||||
"detail": "governance-only stage: "
|
||||
"no work, closure by "
|
||||
"programme acceptance"}],
|
||||
scope_files=[])
|
||||
return COMPLETE
|
||||
# work is either a callable (interface, stage) → outcome dict, or
|
||||
# a literal outcome spec dict ({"ok", "evidence", "async_cmd"?})
|
||||
outcome: dict = (dict(cast("dict", work(interface, stage)))
|
||||
if callable(work) else dict(cast("dict", work)))
|
||||
async_cmd = outcome.get("async_cmd")
|
||||
if async_cmd:
|
||||
op = f"stage-{contract_id}-{int(time.time())}"
|
||||
ew.register_process_wait(
|
||||
op, contract_id=contract_id,
|
||||
project=getattr(interface.scaffold, "project_id", ""),
|
||||
objective=stage.objective,
|
||||
watched_for="stage work completion event",
|
||||
watchdog_secs=watchdog_secs)
|
||||
notifier = ew.run_builder_under_events(
|
||||
async_cmd, op, daemon_url=daemon_url or ew.DEFAULT_DAEMON_URL)
|
||||
stage.result["operation_id"] = op
|
||||
stage.result["notifier_pgid"] = notifier.pid
|
||||
result = ew.await_resume_event(op, watchdog_secs=watchdog_secs,
|
||||
pgid=notifier.pid)
|
||||
if result["kind"] != "event":
|
||||
stage.result["error"] = f"stage event wait: {result['kind']}"
|
||||
interface.transition(contract_id, "failed",
|
||||
evidence_ref="stage-event-lost")
|
||||
return FAILED
|
||||
rec: dict = result.get("wait") or {}
|
||||
outcome["evidence"] = (f"event resume; wait {rec.get('wait_id')} "
|
||||
f"verdict={rec.get('verdict')}")
|
||||
if not outcome.get("ok"):
|
||||
stage.result["error"] = outcome.get("evidence", "work failed")
|
||||
# truthful failure: record the evidence ON the contract and
|
||||
# drive it to the failed phase via the legal transition map
|
||||
# (building→failed is legal; no exception swallowing)
|
||||
doc = interface.scaffold.load()
|
||||
c = interface.scaffold.contract(doc, contract_id)
|
||||
if c is not None:
|
||||
c.setdefault("checkpoints", []).append({
|
||||
"at": time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime()),
|
||||
"phase": c.get("phase", ""),
|
||||
"evidence": "programme-stage-failed",
|
||||
"note": (outcome.get("evidence") or "work failed")[:200],
|
||||
})
|
||||
interface.scaffold.save(doc)
|
||||
phase = c.get("phase", "")
|
||||
if phase == "building":
|
||||
interface.transition(contract_id, "failed",
|
||||
evidence_ref="programme-stage-failed")
|
||||
return FAILED
|
||||
# completion truth: work evidence recorded ON the contract, then
|
||||
# closed (evidence lives on the durable object, not the log)
|
||||
if _phase(contract_id) == "building":
|
||||
interface.transition(contract_id, "verifying",
|
||||
evidence_ref="programme-stage-verify")
|
||||
doc = interface.scaffold.load()
|
||||
c = interface.scaffold.contract(doc, contract_id)
|
||||
if c is not None:
|
||||
c.setdefault("checkpoints", []).append({
|
||||
"at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"phase": "verifying",
|
||||
"evidence": "programme-stage-work",
|
||||
"note": (outcome.get("evidence") or "work completed")[:200],
|
||||
})
|
||||
interface.scaffold.save(doc)
|
||||
interface.close(
|
||||
contract_id,
|
||||
observed_evidence=[{"source": "programme-driver",
|
||||
"detail": outcome.get("evidence", "ok")}],
|
||||
scope_files=[])
|
||||
return COMPLETE
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def run_construction_programme(prog: Programme, interface, *,
|
||||
scaffold_store: Path,
|
||||
daemon_url: str = "",
|
||||
watchdog_secs: int = 900,
|
||||
max_transitions: int = 100) -> list[dict]:
|
||||
"""Canonical multi-stage construction continuation (T17 §17D):
|
||||
run_programme with the Scaffolding-bound runner; persists the
|
||||
programme around every transition so a restart resumes from the
|
||||
durable record, never a transcript. Returns the transition log."""
|
||||
runner = scaffolding_stage_runner(
|
||||
interface, scaffold_store=scaffold_store,
|
||||
daemon_url=daemon_url, watchdog_secs=watchdog_secs)
|
||||
log: list[dict] = []
|
||||
base = _programme_base(scaffold_store)
|
||||
for _ in range(max_transitions):
|
||||
prog.save(base)
|
||||
canary = prog.continuation_canary()
|
||||
log.append({"canary": canary})
|
||||
verdict, action = canary["verdict"], canary["next_action"]
|
||||
if action is None:
|
||||
break
|
||||
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)
|
||||
if stage.state == COMPLETE:
|
||||
continue # duplicate continuation signal: idempotent
|
||||
stage.state = ELIGIBLE
|
||||
new_state = runner(stage)
|
||||
stage.state = new_state
|
||||
log.append({"stage": target, "new_state": new_state})
|
||||
prog.save(base)
|
||||
prog.save(base)
|
||||
return log
|
||||
@@ -0,0 +1,48 @@
|
||||
"""provider_context.py — T16 §16F: provider ownership boundary.
|
||||
|
||||
ARCHITECTURE RULE (T16 mandate, permanent):
|
||||
|
||||
SCAFFOLDING DOES NOT OWN PROVIDER SELECTION.
|
||||
|
||||
Provider, model, provider fallback, provider source, and provider account
|
||||
belong to the surrounding Rune/TUI/provider layer. Construction code never
|
||||
names a provider. The ONLY supported way for a driver/probe (builder
|
||||
session, research stage, evidence probe — all PROVIDER_LAYER callers) to
|
||||
learn which model to use is the ambient provider context, i.e. the
|
||||
environment the surrounding layer sets.
|
||||
|
||||
works → continue
|
||||
missing/unresolvable → fail LOUDLY with PROVIDER_UNRESOLVED
|
||||
provider call fails → preserve the REAL error and status/API code,
|
||||
classify BLOCKED/PROVIDER_UNAVAILABLE, stop the dependent branch
|
||||
|
||||
NO silent default provider. NO automatic fallback. NO preference mutation.
|
||||
(Automatic provider fallback is DEFERRED to the future multi-source /
|
||||
local-model architecture.)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
class ProviderUnresolved(RuntimeError):
|
||||
"""Raised when no provider context is available and a silent default
|
||||
would otherwise have been selected. Deliberately loud: provider
|
||||
selection is a user-layer decision, never a code default."""
|
||||
|
||||
|
||||
def require_model(env_var: str = "MIMO_MODEL") -> str:
|
||||
"""Resolve the current model from the ambient provider context.
|
||||
|
||||
The surrounding provider layer (TUI / operator / session environment)
|
||||
owns this variable. A driver or probe MUST NOT carry its own default:
|
||||
a missing value is a configuration gap the user layer must fix, not
|
||||
something construction code may paper over by picking a provider."""
|
||||
model = (os.environ.get(env_var) or "").strip()
|
||||
if not model:
|
||||
raise ProviderUnresolved(
|
||||
f"PROVIDER_UNRESOLVED: {env_var} is not set — provider/model "
|
||||
f"selection belongs to the provider layer (set {env_var} in "
|
||||
f"the calling environment); no silent default is permitted "
|
||||
f"(T16 §16F)")
|
||||
return model
|
||||
@@ -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.68
|
||||
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.68 | 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
|
||||
@@ -0,0 +1,48 @@
|
||||
# T16 Stage 16A — exact baseline (verified 2026-09-15 ~15:40 local)
|
||||
|
||||
Fresh session ses_-ffe5f5a82d0b5ffeLrGt5rMRv. All facts verified independently this session; none carried from prior-session memory without re-verification.
|
||||
|
||||
## Identity / release chain
|
||||
- gitea/master: 5bd8cca9410b = auto-bump 0.66.62 (2026-09-15 14:15:22 +0100)
|
||||
- Releases: v0.66.62 published 14:15:51; v0.66.60 (13:29), v0.66.61 (13:42) same day
|
||||
- Deployed: /mnt/workdrive/rune-code/current -> 5bd8cca9410b (symlink mtime 14:20)
|
||||
- last-good -> fd5f31b (0.66.61-lineage fix commit)
|
||||
- CONVERGED: release tag, deployed current, and master tip are the same SHA. No manual refresh used.
|
||||
|
||||
## Inherited T15 open item — RESOLVED on master
|
||||
- 15Z CI failure (196cf19: pyright 445 vs 443; unit-tests job truncated) — fixed by:
|
||||
- 193d819 "event_wait importlib-clean loading, zero pyright warnings" (= be87dc3 rebased/pushed)
|
||||
- b2d6802 "§15K test no longer shells out to git worktree on CI"
|
||||
- fd5f31b "§15D tests use ephemeral ports, not fixed 8793"
|
||||
- These drove releases 0.66.60 → 0.66.61 → 0.66.62, each deployed. Nothing inherited is left open.
|
||||
|
||||
## Working copies
|
||||
- /mnt/workdrive/issue161-harness: master tip, clean — THE code worktree for T16.
|
||||
- /mnt/workdrive/rune (RUNE_HOME): branch pinned-live @ 90c7db1 (2026-09-08), 652 commits behind master, HEAD is a clean ancestor (fast-forwardable). Expected: RUNE_HOME is the data-continuity home; runtime launches from rune-code/current. Still: stale and drifting (16P material).
|
||||
- 39 stale build-20260915-* worktrees, all cut at 90c7db1 (stale base) — /home/nick/.rune/worktrees/. Cleanup candidate (16W).
|
||||
|
||||
## Gates (all run in lock-exact environments, master tip 5bd8cca)
|
||||
- Full suite: 1854 passed + 84 subtests, 0 failures (113s)
|
||||
- Lint ratchet: PASS (BLE001 354/354, S110 80/80 at baseline). Raw `ruff check` = 12 errors, all within committed lint-baseline (mandate's "8 pre-existing" is now 12 — drift, 16W item).
|
||||
- Type-check ratchet: PASS — errors 117 (= baseline), warnings 437 (≤ 443). CRITICAL METHOD NOTE: must run in `.type-venv` recreated from requirements-ci.txt (lock-exact). Stale /tmp/ci-venv (pyright 1.1.414 but stale env) false-failed 124 vs 117. Environment-exactness is a repeat failure class (16U).
|
||||
- Henxels: rc=0.
|
||||
|
||||
## Wait/resume infrastructure
|
||||
- W/R daemon RUNNING: pid 3756873, `python3 daemon.py 8791` (webhook receiver on 0.0.0.0:8791), restarted 14:08 today.
|
||||
- Daemon lives at /home/nick/.local/share/mimo-wait-resume (daemon.py, waitstore.py, verify.py, own tests) with its OWN git snapshot @ 4811070 "T15 §15D/§15E: per-wait resume_cmd override + PROCESS_EVENT wait kind + /process-event endpoint". The daemon is NOT part of the master tree — src/rune/ has event_wait.py (client-side adapter) but no daemon.py. (16M inventory finding: out-of-repo deployment, separate snapshot.)
|
||||
- Wait store: 45 records — states: COMPLETED 19, RESUME_UNVERIFIED 10, FAILED 6, READY_TO_RESUME 6, WAITING_EXTERNAL 4. Kinds: EXACT_WORKFLOW 24, RELEASE_DEPLOY 8, PROCESS_EVENT 7 (+6 pre-kind records with wait_kind absent).
|
||||
- 4 WAITING_EXTERNAL + 6 READY_TO_RESUME + 10 RESUME_UNVERIFIED = outstanding waits to reconcile or leave truthfully parked.
|
||||
|
||||
## Scaffolding stores
|
||||
- Repo store/scaffold/current.json: spine-shaped (entities/relationships/observations), no projects key.
|
||||
- store/scaffold/projects/: EMPTY. T14/T15 project scaffolds lived in ephemeral /tmp stores (e.g. /tmp/t15r-store). Durable evidence: store/evidence/t14/, store/evidence/t15/ (+ t16/ created this session).
|
||||
|
||||
## T15 maturity — CORRECTED RECORD (16A mandate)
|
||||
- Programme continuation MECHANISM (stage_guards, event waits, canaries): INTEGRATED / TEST-PROVEN. T15's own test evidence stands — not erased.
|
||||
- Uninterrupted REAL-SESSION programme continuation: NOT YET LIVE-PROVEN. The prior session's actual user experience was repeated manual "continue" despite the mechanism being tested green. This fresh T16 session is the live experiment (Stage 16B).
|
||||
- Builder event-driven wait: CANONICAL DEFAULT (bounded live proof, T15).
|
||||
- CI/deployment wait: STILL POLLING FALLBACK (sleep-N → query patterns remain in release/CI verification paths) — the 16C/D target. Verified by inspection during 16A setup; full inventory in 16C.
|
||||
|
||||
## Provider/model handling (initial scan, full audit = 16F)
|
||||
- Known hardcode surface to classify: Verboo, glm-5.3-flash, mimo-v2.5-pro, Xioami, BUILDER_MODEL, DEFAULT_MODEL, MIMO_MODEL, --model/-m.
|
||||
- T14/T15 real-builder runs pinned "Verboo Code/glm-5.3-flash" at the DRIVER layer (t14_builder_session.py), not inside Scaffolding proper — classification pending 16F audit.
|
||||
@@ -0,0 +1,44 @@
|
||||
# T16 Stage 16B — live continuation diagnostic (spans the whole tranche)
|
||||
|
||||
fresh_session = true
|
||||
session = ses_-ffe5f5a82d0b5ffeLrGt5rMRv
|
||||
tranche_start = 2026-09-15 (T16 mandate received at session open)
|
||||
|
||||
## Response-boundary ledger
|
||||
|
||||
| # | boundary | last completed | next eligible stage started automatically? | user intervention required? |
|
||||
|---|----------|----------------|---------------------------------------------|------------------------------|
|
||||
| 1 | session open (checkpoint rebuild from prior-session T16 mandate) | none | 16A started this session without user prompt | NO |
|
||||
|
||||
## Stage log
|
||||
|
||||
- 16A: STARTED 2026-09-15 (this session, unprompted continuation from mandate). COMPLETED ~15:45 local. Baseline + T15 maturity correction persisted (store/evidence/t16/16A_baseline.md + project MEMORY.md). Next stage (16C) selected and started immediately in the same assistant turn — no user intervention.
|
||||
|
||||
## Verdict so far
|
||||
|
||||
Running experiment. Required live proof: ≥4 substantial sequential stages completing and the next starting automatically without user intervention. A LIVE_CONTINUATION_FAILURE is recorded the moment this assistant reaches a normal response boundary while eligible authorized work remains.
|
||||
|
||||
## Stage log update (16:00Z)
|
||||
|
||||
- 16A: COMPLETED (persisted + readback done). No user intervention.
|
||||
- 16B diagnostic: created and maintained continuously. No user intervention.
|
||||
- 16C: COMPLETED (event-driven CI wait adapter implemented, 8 held-outs, workflows wired, daemon passthrough landed). NO user intervention — began immediately after 16A in the same assistant turn.
|
||||
- 16D: IN PROGRESS — first live cycle exposed a real defect (runner user vs bridge data root; deploy failed fast+truthfully on 7953/7958 — no false green). Root-caused, shared root + readiness probe shipped (c707a81, pushed 16:02Z); latency proof pending the next cycle. This is itself a 16U datum: "wrong environment" was caught by production before a canary could — the readiness probe now added prevents recurrence.
|
||||
- 16E: COMPLETED (one primitive: builder + gitea adapters share register/FIFO/match/idempotency/watchdog; identity selects events — 16E held-out).
|
||||
- 16F/16G: COMPLETED (provider_context seam, PROVIDER_UNAVAILABLE class, 7 silent provider defaults removed, structural guard + 6 held-outs; committed 37deb60).
|
||||
|
||||
Response boundaries so far: ZERO user interventions. Continuation has been automatic at every stage boundary.
|
||||
|
||||
## Stage log update (16:35Z)
|
||||
- 16D: COMPLETED — two real production cycles proved the watchdog/
|
||||
deadline truthfulness path (deploy promoted from the deadline path,
|
||||
zero false greens, sleep-30 floor gone); event-before-waiter root-
|
||||
caused and fixed via durable replay (b51afea pushed 16:34Z).
|
||||
- 16E: COMPLETED (one primitive, adapters by identity).
|
||||
- 16F/16G: COMPLETED. 16I/J/K: COMPLETED. 16M: COMPLETED. 16N: COMPLETED
|
||||
(docs/STRUCTURAL_MAP.md). 16O: COMPLETED (honest result — no step-count
|
||||
win on tiny fixtures; qualitative advantages recorded). 16P: COMPLETED
|
||||
(drift_resume.py + real-fixture held-outs).
|
||||
- Next: 16Q (reconciliation), 16R (self-hosted change), 16U-V, 16W-Y, 16Z.
|
||||
- USER INTERVENTIONS TO DATE: ZERO. Every stage transition has been
|
||||
automatic within continuous assistant execution.
|
||||
@@ -0,0 +1,44 @@
|
||||
# T16 §16D — live CI/deploy latency proofs (2026-09-15)
|
||||
|
||||
## Cycle 1: c707a81 (fix commit) — watchdog/deadline-recovery path
|
||||
- registered_at: 16:03:16Z (deploy job start)
|
||||
- event_received_at: (none — ci.yml notify ran BEFORE deploy registered;
|
||||
event-before-waiter ordering, the workflow-level sibling of 16Y #10)
|
||||
- controller behavior: watchdog expiry → ci_alive() True (runs genuinely
|
||||
non-terminal) → truthful re-arms; no false green at any point
|
||||
- verdict_at: 16:18:16Z (mode deadline_pending → final authority read
|
||||
success), wall_secs 900.92 — the deadline is a FAILURE DETECTOR; it
|
||||
expired exactly once and the final one-shot authority read decided
|
||||
- PROMOTED: c707a81 at 16:18:17Z (current moved; last-good → previous)
|
||||
- Sleep-30 polling floor: GONE (old loop would have polled 30 times in
|
||||
this window; new adapter slept in select(), zero poll queries — the
|
||||
only Gitea reads were one-shot authority verdicts on expiry)
|
||||
|
||||
## Cycle 2: 4a06d9f (auto-bump 0.66.64) — true event path
|
||||
- waiter registered while CI still in progress; process observed
|
||||
sleeping in select() (S state, 700s elapsed, zero CPU) at inspection
|
||||
- expected: notify-event-bridge POST on CI completion → mid-wait wake →
|
||||
resume latency « 30s floor (proof line appended when the cycle closes)
|
||||
|
||||
## Event-before-waiter finding (new, honest)
|
||||
The wake event cannot be delivered when CI finishes before the deploy
|
||||
job registers its correlation. This ordering is inherent (deploy.yml
|
||||
starts after checkout; ci.yml may finish earlier). The durable-bridge
|
||||
recovery covers it: the waiter's watchdog + one-shot authority read.
|
||||
A future improvement could register the correlation BEFORE CI completes
|
||||
(e.g. a preceding workflow step) — deferred; watchdog recovery is
|
||||
truthful and the deadline bound held exactly.
|
||||
|
||||
## Cycle 2 postmortem + replay fix (17:35Z)
|
||||
Cycle 2 (4a06d9f) also ended deadline_pending at 901.0s: the completion
|
||||
events DID reach the daemon (16:07:44/16:08:21, after registration at
|
||||
16:07:06) but never resumed the waiter. ROOT CAUSE: on a serialized
|
||||
single-runner machine the deploy job starts only after ci.yml's job
|
||||
slot frees — the wake lands before/while the waiter is between
|
||||
registration arms; the FIFO rendezvous is forward-only.
|
||||
FIX: durable event replay — find_matching_event() scans the bridge's
|
||||
event log at registration; a matching event short-circuits straight to
|
||||
the authority read (event_replayed mode). 2 held-outs added (9 total).
|
||||
NOTE: the replay fix lands on the NEXT cycle (it cannot prove itself on
|
||||
the cycle that exposed it); cycles 1-2 remain valid proof of the
|
||||
watchdog/deadline truthfulness path.
|
||||
@@ -0,0 +1,54 @@
|
||||
# T16 Stage 16M — Retroactive Rune inventory (2026-09-15)
|
||||
|
||||
Sources: two parallel read-only exploration sweeps + 16A/16C hands-on verification. Classification scale per mandate. This is the raw material for the 16N structural map.
|
||||
|
||||
## The ten canonical systems
|
||||
|
||||
| System | Modules | Canonical state | Classification | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Spine | spine.py (695L) over structural_store.py (650L, zero Rune constants) | store/spine/current.json, snapshots byte-deterministic | HEALTHY | maturity ladder evidence-gated; CLI JSON-canonical |
|
||||
| Research pipeline | scripts/research_task_tree.py (authority), research_stage_executor.py, research_event_relay.py | research/issue-N/ artifacts + Gitea labels (labels=projection) | HEALTHY | stage gates per artifact; coverage thinner than Spine |
|
||||
| ADAPT/router | adaptive_router.py (625L, "canonical route authority ADAPT-2"), route_risk_flags.py | routes/route.jsonl append-only | HEALTHY | capability_broker/recall_router/warm_router/model_routing are SEPARATE routing domains — same word, different authority; DO NOT MERGE |
|
||||
| Completion Truth | verification_layer.py, orchestration/conversation_state.py (TaskStateRepository over DurableEventStore), records.py | event-store log + atomic checkpoints | UNCLEAR_AUTHORITY / MISSING_SEAM | no enforced seam that a task cannot close COMPLETED while verifiers fail — highest-value gap found |
|
||||
| Release/deploy | deploy_stage/deploy_promotion/deploy_qualify/create_release/auto_bump + workflows | deploy root + current/last-good + _build_identity.json | HEALTHY | monotonic promotion fail-closed; 16C event-driven wait now added |
|
||||
| Provider layer | model_routing ("one authoritative router #29 P1-06"), model_connect, model_registry, provider_registry, work_manager, standardized_providers, openai_compat_transport | model_routing_profiles.json keyed (provider,model) | DUPLICATE_ROUTE / UNCLEAR_AUTHORITY | ≥5 selection entry points; the OPERATOR is the final decider, not any registry. 16F removed construction-side selection; provider-layer consolidation = 16Q candidate |
|
||||
| W/R event bridge | event_wait.py + out-of-repo daemon.py/waitstore.py/verify.py | WR_ROOT waits/events/leases/resumes | HEALTHY / OPTIONAL_BUT_SHOULD_BE_DEFAULT | one primitive, builder+gitea adapters (16E proven); daemon lives OUT-OF-REPO with its own git snapshot — packaging/deployment debt |
|
||||
| TUI/backend | rune_tui.py (3885L) ← rune_operator.py (2604L) + microkernel + orchestration | conversation/task repositories | HEALTHY | one-way direction enforced by canary_tui_drift; import via sys.path shim (fragile, keep test) |
|
||||
| build_mode (legacy /build) | build_mode.py BuildManager + plugins/build_mode/phases | ~/.rune/work/builds + worktrees | LEGACY / DUPLICATE_ROUTE | superseded by builder_bridge + event-driven sessions (T14/T15); still wired → two ways to run a build; REPO_DIR="/mnt/workdrive/rune" stale constant |
|
||||
| Canaries | 24 canary_*.py + canary_native.MODULES registry | none (stateless) | HEALTHY | native/full_system/orchestration_foundation partially overlap (layered, acceptable); no test enforces MODULES completeness |
|
||||
|
||||
## Auxiliary systems
|
||||
|
||||
| System | Classification | Notes |
|
||||
|---|---|---|
|
||||
| Scaffolding itself | HEALTHY | provider-blind since 16F (structural guard); phase machine governed |
|
||||
| Maintenance machinery | UNCLEAR_AUTHORITY | three overlapping routes: scaffold maintenance_item entities, tui_backlog ledger, drift_ledger — no central module, NO tests |
|
||||
| Task/intent state | DUPLICATE_ROUTE | task-tool session DB vs task_persist persisted.json vs memory_store.working vs orchestration event store — four task-ish stores |
|
||||
| Henxels | HEALTHY | henxels.yaml + 191L checks; contract-tested |
|
||||
| Plugin surfaces | MISSING_SEAM | phases exist as files; no generic loader; single hardcoded plugin |
|
||||
| Learning/process records | DUPLICATE_ROUTE | memory_store vs graph_memory vs warm_neuron_store vs curator vs process_experience — no single authority doc |
|
||||
| programme_continuation | MISSING_SEAM | run_programme() has NO production caller (proof-only, T15 §15M); night_mode LEGACY aspirational; cron_engine MISSING_VERIFICATION |
|
||||
| maintenance.yml | LEGACY | canary-suite job loops shared/canary_*.py — real canaries live in src/rune/; the job likely no-ops |
|
||||
|
||||
## Duplicate routes / bypasses (16Q raw material, priority-ordered)
|
||||
|
||||
1. **task-state quadruplication** — 4 stores hold task-ish state; truth hierarchy unclear
|
||||
2. **build_mode vs builder_bridge** — two build paths, legacy still wired
|
||||
3. **provider selection ×5 entry points** — operator is de-facto authority
|
||||
4. **T14 vs T15 builder drivers** — T15 is the event-driven successor; T14 parallel-runs
|
||||
5. **maintenance ledgers ×3** — scaffold entities, backlog, drift ledger
|
||||
6. **maintenance.yml canary glob wrong dir** — stale no-op job
|
||||
7. **programme_continuation unwired** — the "continue problem" machinery exists but nothing calls it in production
|
||||
8. **memory stores ×5** — overlapping recall surfaces
|
||||
|
||||
## Hard-coded dev paths in production code (16W targets)
|
||||
|
||||
- build_mode.REPO_DIR + 5 complexity_scorer defaults → /mnt/workdrive/rune (stale for harness)
|
||||
- deploy_promotion.DEPLOY_ROOT → /mnt/workdrive/rune-code (fine: that IS the deploy root, but should be env-derived — it is)
|
||||
- two canaries sys.path.insert(/home/nick/.config/opencode/scripts)
|
||||
- memory/system_registry.json points at /mnt/workdrive/rune — STALE repo root
|
||||
- event_wait FALLBACK_WR_ROOT → /home/nick/... (fallback only; shared root now env-set in deploy.yml)
|
||||
|
||||
## Honest overall
|
||||
|
||||
Rune's structural health is better than the mandate's tone suggests: Spine, release/deploy, ADAPT, W/R, TUI boundary, canaries, Henxels, and Scaffolding are all HEALTHY with real verification. The debt concentrates in: completion-truth enforcement seam, task-state multiplication, provider-layer entry-point spread, legacy build_mode still wired, and unwired programme continuation (the very continuation defect this tranche experiments on).
|
||||
@@ -0,0 +1,45 @@
|
||||
# T16 §16O — long-gap re-entry benchmark (2026-09-15)
|
||||
|
||||
Fixture: /mnt/workdrive/t16-reentry — Python invoice-ledger project, 2
|
||||
stages (1 closed+verified, 2 blocked PROVIDER_UNAVAILABLE with recorded
|
||||
next action), then POST-CHECKPOINT DRIFT injected: (a) VAT regression in
|
||||
report.py breaking 2 tests, (b) unrecorded discount.py module, (c) 2
|
||||
unrecorded commits, (d) committed __pycache__ + no .gitignore (unplanned
|
||||
extras). Two fresh-identity subagents, no transcript, no briefing beyond
|
||||
method.
|
||||
|
||||
## Measured
|
||||
|
||||
| Metric | PATH A cold rediscovery | PATH B scaffold-assisted |
|
||||
|---|---|---|
|
||||
| Files opened | 8 | 9 |
|
||||
| Commands run | 4 | 6 |
|
||||
| Explore/verify cycles | ~5 | ~8 |
|
||||
| Drifts found | 4/4 planted + pycache + gitignore | 4/4 planted (+pytest_cache receipt); did not enumerate pycache/gitignore |
|
||||
| False assumptions corrected | 2 (pytest path; comments-might-be-stale) | 3 (pytest path; assumed green still held; assumed repo at checkpoint commit) |
|
||||
| Drift CLASSIFICATION | none (flat findings) | EXPECTED/MATERIAL applied correctly, per-claim CONFIRMED/DRIFTED |
|
||||
| Next action quality | correct, self-derived | correct + explicitly adjusted from recorded next action |
|
||||
| Confidence calibration | honest (blocked=medium) | honest (drift completeness medium-high) |
|
||||
|
||||
## Honest verdict
|
||||
|
||||
1. On a TINY fixture, cold rediscovery is CHEAP and nearly as accurate —
|
||||
the scaffold did not save significant effort at this scale. No
|
||||
fabricated win: file/command counts are comparable (8 vs 9, 4 vs 6).
|
||||
2. The scaffold's measurable advantages are QUALITATIVE and scale with
|
||||
project size: (a) structured per-claim reconciliation instead of flat
|
||||
discovery, (b) recorded next action + blocker evidence that survives
|
||||
model turnover, (c) drift CLASSES enabling the 16P material/expected
|
||||
gating decision, (d) intent (non_goals: "no hardcoded FX rates")
|
||||
that cold inspection cannot infer.
|
||||
3. Both paths needed a test run to catch the regression — checkpoint
|
||||
claims NEVER substitute for reality (the 16P principle, proven by
|
||||
both paths independently).
|
||||
4. Path A's unplanned extras (pycache/gitignore) show cold discovery
|
||||
also finds things outside the scaffold's frame — the scaffold is a
|
||||
starting point, not a boundary.
|
||||
|
||||
Recommendation: adopt scaffold-assisted re-entry as the DEFAULT for
|
||||
multi-session projects (16P flow), keep cold verification of claims as
|
||||
a mandatory reconciliation step. Re-benchmark on a realistically-sized
|
||||
repo before claiming step-efficiency wins (OVERFLOW 3 candidate).
|
||||
@@ -0,0 +1,91 @@
|
||||
# T16 §16S/§16T/§16U/§16V/§16X/§16Y — consolidated findings (2026-09-15)
|
||||
|
||||
## §16S — live programme continuation proof
|
||||
Evidence: 16B_continuity_log.md. Stages 16A→16B→16C→16D→16E→16F/16G→
|
||||
16I/J/K→16M→16N→16O→16P→16Q/16R executed in ONE continuous assistant
|
||||
session with ZERO user "continue" interventions; every stage transition
|
||||
was automatic. ≥4 substantial sequential stages completed with the next
|
||||
starting automatically (requirement exceeded: 12+ stages).
|
||||
**LIVE-PROVEN (bounded, this session).** Caveat recorded honestly: the
|
||||
session is long; a context-boundary failure would still be possible in
|
||||
even longer programmes — the mechanism (stage_guards + programme_
|
||||
continuation canary) is what carried continuation, now live-proven.
|
||||
|
||||
## §16T — session-freshness finding
|
||||
Previous long session (T15): user typed "continue" repeatedly DESPITE
|
||||
the mechanism being test-proven. Fresh T16 session: zero interventions
|
||||
across 12+ stages. Verdict: **MIXED** — old-session degradation is
|
||||
SUPPORTED as the parsimonious explanation (same mechanism, same model
|
||||
class, different session freshness → different behavior), but n=1 and
|
||||
the prior session also carried much larger context; a systemic harness
|
||||
contribution cannot be excluded. Not called corruption; no evidence of
|
||||
it.
|
||||
|
||||
## §16U — interface-first error prevention (moved-left repairs)
|
||||
- pyright ambient-venv skew (117⇄124): root-caused; ratchet now
|
||||
neutralizes VIRTUAL_ENV/PATH before invoking pyright — environment
|
||||
mismatch is now detected-at-setup instead of post-hoc.
|
||||
- deploy readiness probe: RUNE_WR_ROOT writability checked BEFORE the
|
||||
wait (the PermissionError class caught twice in production is now a
|
||||
clear setup-time error).
|
||||
- provider PROVIDER_UNRESOLVED: missing provider context fails loudly
|
||||
before any construction run instead of silently using a default.
|
||||
- lesson-class recorded: "wrong environment" failures were caught by
|
||||
production twice before a canary existed — readiness-before-action is
|
||||
the general fix shape.
|
||||
|
||||
## §16V — canary + Henxels coverage vs Rune boundaries
|
||||
- Henxels rc=0 all session; umbrella manifest gate caught every new
|
||||
module (provider_context, construction_projection, construction_
|
||||
commands, drift_resume) — the completeness statement works.
|
||||
- Found + fixed: maintenance.yml canary glob pointed at shared/ (silent
|
||||
no-op) — 16Q/16R.
|
||||
- Gaps recorded (16M): no MODULES-completeness test for canary registry;
|
||||
maintenance machinery lacks tests; daemon out-of-repo has no CI cover.
|
||||
- Not duplicated: Henxels = structural statements; canaries = import/
|
||||
exercise; tests = behavior. Boundary held.
|
||||
|
||||
## §16X — backlogs (backend → future projection/control needs)
|
||||
Construction projection (16I) already exposes: project, contract, phase,
|
||||
stage, waiting_state, correlation, builder, blockers, verification,
|
||||
next_action. TUI/Substrate control needs mapped to 16J commands:
|
||||
START/RESUME/PROVIDE_APPROVAL/PROVIDE_CONTEXT + REQUEST_STATE.
|
||||
Drift display: drift_resume classes are the vocabulary.
|
||||
DEBT (recorded, not built): PAUSE needs a lifecycle phase if wanted;
|
||||
goal-level (why-is-this-active) linkage belongs to future Substrate.
|
||||
|
||||
## §16Y — adversarial matrix (30 cases) — where each is held
|
||||
1 old-context absent → 16O both paths; 2 scaffold reconstructs → 16O Path B
|
||||
+ 16P; 3 provider unavailable → 16G-C; 4 no fallback → 16G-D (policy 0);
|
||||
5 real status preserved → 16G-C verbatim; 6 duplicate event → 15E tests;
|
||||
7 stale event → 15E tests; 8 missing webhook → 16D cycles 1-2 (deadline
|
||||
recovery, promoted truthfully); 9 duplicate webhook → 15E double-resume
|
||||
tests; 10 event-before-waiter → REPLAY FIX (b51afea) + live finding;
|
||||
11 controller restart → 15E restart tests + resume_or_recover; 12 mock
|
||||
invalid command → 16K refusal tests; 13 direct-state semantics → 16K
|
||||
inexpressible test; 14 stale scaffold → 16P VAT regression blocks; 15
|
||||
months-later drift → 16O/16P; 16 incomplete work after gap → 16O stage-2;
|
||||
17 no-activity≠completion → watchdog/PROCESS_LOST semantics (15D/Q);
|
||||
18 duplicate route → 16M inventory + 16Q fix; 19-21 TUI request classes →
|
||||
classify_mode (T10-15 suites); 22 manual provider switch → 16G-G
|
||||
byte-identical scaffold; 23 approval gate → LEGAL_TRANSITIONS + 16J
|
||||
user-provenance refusal; 24 Henxels defect → 16Q canary glob (found+fixed
|
||||
by inventory); 25 false-green → stage_guards verify_test_truth (15F-I)
|
||||
+ deploy refusals observed live; 26 completion replay → match only on
|
||||
WAITING_EXTERNAL; 27 stale publisher → T14B authority re-read; 28 CI
|
||||
completion event → 16C suite; 29 stage-completion-≠-programme → 15M
|
||||
canary + this session's behavior; 30 final completion → 16Z report
|
||||
condition (this document; programme-complete only when the release
|
||||
cycle converges).
|
||||
|
||||
Gaps honestly recorded: adversarial cases 22-27 rely on T14/T15-era
|
||||
suites (not re-run live this tranche); cases 1-2 depend on subagent
|
||||
honesty (both paths cross-checked against planted ground truth).
|
||||
|
||||
## §16Z — final release convergence (17:55Z)
|
||||
Deployed current -> 986b450 (T16 16O/P commit) at 17:54Z, promoted by
|
||||
the event-driven pipeline itself; releases 0.66.63→0.66.66 auto-published
|
||||
and deployed through T16 commits; zero manual release refresh. Run 7991
|
||||
(2a4f9c8) in flight at report time; durable waits remain parked and the
|
||||
pipeline drains autonomously. Suite 1884 green; ratchets PASS; Henxels
|
||||
rc=0 at last full gate run.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"programme_id": "t17-17e-proof-2",
|
||||
"objective": "17E live proof: 4 stages, async event wait, failure, auto-continuation",
|
||||
"stages": [
|
||||
{
|
||||
"stage_id": "17e2-stage-1-governance",
|
||||
"objective": "governance-only stage",
|
||||
"state": "COMPLETE",
|
||||
"depends_on": [],
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"stage_id": "17e2-stage-2-async",
|
||||
"objective": "async work under event bridge",
|
||||
"state": "COMPLETE",
|
||||
"depends_on": [],
|
||||
"priority": 1
|
||||
},
|
||||
{
|
||||
"stage_id": "17e2-stage-3-fails",
|
||||
"objective": "deliberately failing stage",
|
||||
"state": "FAILED",
|
||||
"depends_on": [],
|
||||
"priority": 2
|
||||
},
|
||||
{
|
||||
"stage_id": "17e2-stage-4-final",
|
||||
"objective": "final stage",
|
||||
"state": "COMPLETE",
|
||||
"depends_on": [],
|
||||
"priority": 3
|
||||
}
|
||||
],
|
||||
"fallbacks": [],
|
||||
"saved_at": "2026-09-15T17:51:22Z"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"programme_id": "t17-17e-proof-3",
|
||||
"objective": "17E live proof: async event wait, failure isolation, auto-continuation, honest terminal verdict",
|
||||
"stages": [
|
||||
{
|
||||
"stage_id": "17e3-stage-1-governance",
|
||||
"objective": "governance-only stage",
|
||||
"state": "COMPLETE",
|
||||
"depends_on": [],
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"stage_id": "17e3-stage-2-async",
|
||||
"objective": "async work under event bridge",
|
||||
"state": "COMPLETE",
|
||||
"depends_on": [],
|
||||
"priority": 1
|
||||
},
|
||||
{
|
||||
"stage_id": "17e3-stage-3-fails",
|
||||
"objective": "deliberately failing stage",
|
||||
"state": "FAILED",
|
||||
"depends_on": [],
|
||||
"priority": 2
|
||||
},
|
||||
{
|
||||
"stage_id": "17e3-stage-4-final",
|
||||
"objective": "final stage",
|
||||
"state": "COMPLETE",
|
||||
"depends_on": [],
|
||||
"priority": 3
|
||||
}
|
||||
],
|
||||
"fallbacks": [],
|
||||
"saved_at": "2026-09-15T17:52:31Z"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"programme_id": "t17-17e-proof",
|
||||
"objective": "prove canonical programme continuation: 4 stages incl. async event wait, failure, auto-continuation",
|
||||
"stages": [
|
||||
{
|
||||
"stage_id": "17e-stage-1-governance",
|
||||
"objective": "governance-only stage",
|
||||
"state": "COMPLETE",
|
||||
"depends_on": [],
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"stage_id": "17e-stage-2-async",
|
||||
"objective": "async work under event bridge",
|
||||
"state": "PENDING",
|
||||
"depends_on": [],
|
||||
"priority": 1
|
||||
},
|
||||
{
|
||||
"stage_id": "17e-stage-3-fails",
|
||||
"objective": "deliberately failing stage",
|
||||
"state": "PENDING",
|
||||
"depends_on": [],
|
||||
"priority": 2
|
||||
},
|
||||
{
|
||||
"stage_id": "17e-stage-4-final",
|
||||
"objective": "final stage",
|
||||
"state": "PENDING",
|
||||
"depends_on": [],
|
||||
"priority": 3
|
||||
}
|
||||
],
|
||||
"fallbacks": [],
|
||||
"saved_at": "2026-09-15T17:50:29Z"
|
||||
}
|
||||
+718
-130
File diff suppressed because it is too large.
Load diff
@@ -12,12 +12,49 @@ override via monkeypatch.setenv, which wins over this fixture.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# W/R bridge code dir (daemon/waitstore live out-of-repo, own snapshot).
|
||||
# Env-overridable so runner users without $HOME access can point at an
|
||||
# installed copy — T15 legacy hardcode, resolved T16.
|
||||
WR_CODE_DIR = Path(os.environ.get(
|
||||
"RUNE_WR_CODE", "/home/nick/.local/share/mimo-wait-resume"))
|
||||
if str(WR_CODE_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(WR_CODE_DIR))
|
||||
|
||||
TESTS_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = TESTS_DIR.parent
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_construction_store(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RUNE_CONSTRUCTION_STORE",
|
||||
str(tmp_path / ".rune" / "scaffold"))
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _provide_test_provider_context(monkeypatch):
|
||||
"""T16 §16F: drivers/probes resolve the model from the ambient
|
||||
provider context (MIMO_MODEL) and fail loudly without one. In tests,
|
||||
the HARNESS is the surrounding provider layer — so the default test
|
||||
context provides one, unless a test explicitly removes it (the
|
||||
PROVIDER_UNRESOLVED held-outs do exactly that)."""
|
||||
monkeypatch.setenv("MIMO_MODEL", "test-provider/test-model")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def wr_daemon(tmp_path, monkeypatch):
|
||||
"""Real W/R daemon subprocess on an isolated WR_ROOT + ephemeral port.
|
||||
Shared by test_event_wait.py (T15) and test_ci_event_wait.py (T16)."""
|
||||
monkeypatch.setenv("RUNE_WR_ROOT", str(tmp_path)) # adapter binds here
|
||||
monkeypatch.setenv("RUNE_WR_CODE", str(WR_CODE_DIR))
|
||||
from tests.test_event_wait import Daemon
|
||||
d = Daemon(tmp_path)
|
||||
yield d
|
||||
d.stop()
|
||||
@@ -0,0 +1,303 @@
|
||||
"""test_ci_event_wait.py — T16 §16C/§16D/§16E: event-driven CI
|
||||
qualification wait over the EXISTING W/R bridge.
|
||||
|
||||
Same disposable harness as test_event_wait.py (real daemon subprocess on
|
||||
an isolated WR_ROOT + ephemeral port; Daemon fixture imported — 16W rule:
|
||||
no duplicated test helpers).
|
||||
|
||||
Proofs:
|
||||
16C event wake — CI-completion event (provider=gitea,
|
||||
event_type=ci_completed, correlated by sha) resumes
|
||||
the waiting controller; the EVENT payload is NOT
|
||||
truth — the verdict is re-read from canonical Gitea
|
||||
authority (monkeypatched here; production = deploy_
|
||||
qualify.py).
|
||||
16C event≠truth — event claims success, authority says failure →
|
||||
deployment refused (payload never decides).
|
||||
16D no floor — event → resume latency is event-driven (sub-second),
|
||||
not a fixed sleep floor.
|
||||
16D lost event — event suppressed → watchdog expiry → ONE-SHOT
|
||||
authority query → truthful recovery (or truthful
|
||||
failure when authority has no verdict). Watchdog is
|
||||
the failure detector, never the scheduler.
|
||||
16D re-arm — watchdog expiry while CI runs are non-terminal in
|
||||
Gitea → re-arm, not failure; a later event resumes.
|
||||
16E one primitive— builder and gitea waits share the SAME bridge
|
||||
machinery; identities select the matching events
|
||||
(a gitea event never resumes a builder wait and
|
||||
vice versa).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
|
||||
from rune import event_wait as ew # noqa: E402
|
||||
from tests.test_event_wait import Daemon # noqa: E402 (shared fixture)
|
||||
|
||||
|
||||
def _load_ci_wait_module():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"ci_event_wait", str(REPO / "scripts" / "ci_event_wait.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
CI_SHA = "e" * 40
|
||||
|
||||
|
||||
def _post_when_registered(wr_daemon: Daemon, op: str, payload: dict,
|
||||
timeout: float = 10.0):
|
||||
"""Thread body: wait for the wait record to exist, then post the wake
|
||||
event. The event POST is best-effort (tests assert on outcomes)."""
|
||||
|
||||
def go():
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if ew.operation_wait(op) is not None:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
wr_daemon.post_event(payload) # loud: a failed wake must fail the test
|
||||
|
||||
t = threading.Thread(target=go, daemon=True)
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
def test_16c_event_wake_resumes_and_authority_decides(wr_daemon, monkeypatch):
|
||||
mod = _load_ci_wait_module()
|
||||
monkeypatch.setattr(mod, "authority_verdict", lambda: "success")
|
||||
monkeypatch.setattr(mod, "ci_alive", lambda: False)
|
||||
payload = {"operation_id": f"ci-{CI_SHA}", "provider": "gitea",
|
||||
"event_type": "ci_completed", "sha": CI_SHA, "exit_code": 0,
|
||||
"completed_at": time.time()}
|
||||
_post_when_registered(wr_daemon, f"ci-{CI_SHA}", payload)
|
||||
t0 = time.monotonic()
|
||||
rc = mod.run(sha=CI_SHA, deadline_secs=25, daemon_url=wr_daemon.url())
|
||||
wall = time.monotonic() - t0
|
||||
# rc==0 alone is NOT proof: watchdog recovery also returns 0. The wake
|
||||
# must actually have driven the resume (fast), not the watchdog.
|
||||
assert rc == 0
|
||||
assert wall < 15, f"wake-resume took {wall:.1f}s — event never arrived"
|
||||
rec = ew.operation_wait(f"ci-{CI_SHA}")
|
||||
assert rec is not None and rec["provider"] == "gitea"
|
||||
assert rec["wait_kind"] == "PROCESS_EVENT"
|
||||
|
||||
|
||||
def test_16c_event_payload_is_never_truth(wr_daemon, monkeypatch):
|
||||
mod = _load_ci_wait_module()
|
||||
# the event says success (exit_code 0); canonical authority says the
|
||||
# CI is RED — the payload must never decide the verdict
|
||||
monkeypatch.setattr(mod, "authority_verdict", lambda: "failure")
|
||||
monkeypatch.setattr(mod, "ci_alive", lambda: False)
|
||||
payload = {"operation_id": f"ci-{CI_SHA}", "provider": "gitea",
|
||||
"event_type": "ci_completed", "sha": CI_SHA, "exit_code": 0,
|
||||
"completed_at": time.time()}
|
||||
_post_when_registered(wr_daemon, f"ci-{CI_SHA}", payload)
|
||||
rc = mod.run(sha=CI_SHA, deadline_secs=25, daemon_url=wr_daemon.url())
|
||||
assert rc == 1 # refused, despite the wake saying 0
|
||||
|
||||
|
||||
def test_16d_event_resume_has_no_sleep_floor(wr_daemon, monkeypatch):
|
||||
mod = _load_ci_wait_module()
|
||||
monkeypatch.setattr(mod, "authority_verdict", lambda: "success")
|
||||
monkeypatch.setattr(mod, "ci_alive", lambda: False)
|
||||
payload = {"operation_id": f"ci-{CI_SHA}", "provider": "gitea",
|
||||
"event_type": "ci_completed", "sha": CI_SHA, "exit_code": 0,
|
||||
"completed_at": time.time()}
|
||||
_post_when_registered(wr_daemon, f"ci-{CI_SHA}", payload)
|
||||
t0 = time.monotonic()
|
||||
rc = mod.run(sha=CI_SHA, deadline_secs=25, daemon_url=wr_daemon.url())
|
||||
wall = time.monotonic() - t0
|
||||
# a would-be 30s polling floor is NOT waited: registration + event +
|
||||
# verdict must be seconds, not the old sleep-30 loop quantum
|
||||
assert rc == 0
|
||||
assert wall < 15, f"event-driven wait took {wall:.1f}s — floor crept back"
|
||||
|
||||
|
||||
def test_16d_lost_event_watchdog_recovers_from_authority(
|
||||
wr_daemon, monkeypatch):
|
||||
mod = _load_ci_wait_module()
|
||||
# NO event is ever posted (suppressed/lost); authority IS resolvable
|
||||
monkeypatch.setattr(mod, "authority_verdict", lambda: "success")
|
||||
monkeypatch.setattr(mod, "ci_alive", lambda: False)
|
||||
rc = mod.run(sha=CI_SHA, deadline_secs=4, daemon_url=wr_daemon.url())
|
||||
assert rc == 0
|
||||
# truthful recovery, not invented success: the record shows the
|
||||
# watchdog (failure detector) path, and the verdict came from authority
|
||||
rec = ew.operation_wait(f"ci-{CI_SHA}")
|
||||
assert rec is not None
|
||||
|
||||
|
||||
def test_16d_lost_event_with_no_verdict_fails_truthfully(
|
||||
wr_daemon, monkeypatch):
|
||||
mod = _load_ci_wait_module()
|
||||
monkeypatch.setattr(mod, "authority_verdict", lambda: "pending")
|
||||
monkeypatch.setattr(mod, "ci_alive", lambda: False)
|
||||
rc = mod.run(sha=CI_SHA, deadline_secs=4, daemon_url=wr_daemon.url())
|
||||
assert rc == 1 # deployment refused — timeout is failure detection
|
||||
|
||||
|
||||
def test_16d_ci_alive_rearm_then_event(wr_daemon, monkeypatch):
|
||||
mod = _load_ci_wait_module()
|
||||
monkeypatch.setattr(mod, "authority_verdict", lambda: "success")
|
||||
# first watchdog expiry (t=3s): CI runs still non-terminal → re-arm,
|
||||
# not fail; the event arrives only AFTER that expiry (t≥4s)
|
||||
alive = {"n": 0}
|
||||
|
||||
def flaky_alive():
|
||||
alive["n"] += 1
|
||||
return alive["n"] <= 1 # alive on first expiry, gone after event
|
||||
|
||||
monkeypatch.setattr(mod, "ci_alive", flaky_alive)
|
||||
|
||||
def post_late():
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
if ew.operation_wait(f"ci-{CI_SHA}") is not None:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
time.sleep(4.0) # strictly after the first watchdog expiry (3s)
|
||||
wr_daemon.post_event({"operation_id": f"ci-{CI_SHA}",
|
||||
"provider": "gitea",
|
||||
"event_type": "ci_completed",
|
||||
"sha": CI_SHA, "exit_code": 0,
|
||||
"completed_at": time.time()})
|
||||
|
||||
threading.Thread(target=post_late, daemon=True).start()
|
||||
rc = mod.run(sha=CI_SHA, deadline_secs=15, watchdog_secs=3,
|
||||
daemon_url=wr_daemon.url())
|
||||
assert rc == 0
|
||||
assert alive["n"] >= 1 # the re-arm actually happened
|
||||
|
||||
|
||||
def test_16e_one_bridge_two_adapters_identity_selects(wr_daemon):
|
||||
"""16E: builder and gitea waits share the same primitive; only the
|
||||
identity fields differ, and identity selects the matching event."""
|
||||
# builder-style wait
|
||||
b = ew.register_process_wait("op-builder-1", watchdog_secs=60)
|
||||
# gitea-style wait
|
||||
g = ew.register_process_wait(f"ci-{CI_SHA}", provider="gitea",
|
||||
event_type="ci_completed", sha=CI_SHA,
|
||||
watchdog_secs=60)
|
||||
assert b["provider"] == "builder" and g["provider"] == "gitea"
|
||||
# a gitea CI event must NOT resume the builder wait...
|
||||
wr_daemon.post_event({"operation_id": f"ci-{CI_SHA}", "provider": "gitea",
|
||||
"event_type": "ci_completed", "sha": CI_SHA,
|
||||
"exit_code": 0})
|
||||
brec = ew.operation_wait("op-builder-1")
|
||||
grec = ew.operation_wait(f"ci-{CI_SHA}")
|
||||
assert brec["status"] == "WAITING_EXTERNAL"
|
||||
assert grec["status"] in ("READY_TO_RESUME", "RESUME_UNVERIFIED",
|
||||
"COMPLETED")
|
||||
# ...and a builder event must not resume a (fresh) gitea wait
|
||||
g2 = ew.register_process_wait(f"ci-{'f' * 40}", provider="gitea",
|
||||
event_type="ci_completed", sha="f" * 40,
|
||||
watchdog_secs=60)
|
||||
wr_daemon.post_event({"operation_id": "op-builder-1", "exit_code": 0})
|
||||
assert ew.operation_wait(f"ci-{'f' * 40}")["status"] == "WAITING_EXTERNAL"
|
||||
assert ew.operation_wait("op-builder-1")["status"] in (
|
||||
"READY_TO_RESUME", "RESUME_UNVERIFIED", "COMPLETED")
|
||||
|
||||
|
||||
def test_16c_event_replay_before_registration(wr_daemon, monkeypatch):
|
||||
"""§16C live finding (serialized runner): the completion event lands
|
||||
BEFORE the deploy job registers. The durable event log replays it —
|
||||
the waiter skips the rendezvous and goes straight to authority."""
|
||||
mod = _load_ci_wait_module()
|
||||
monkeypatch.setattr(mod, "authority_verdict", lambda: "success")
|
||||
monkeypatch.setattr(mod, "ci_alive", lambda: False)
|
||||
wr_daemon.post_event({"operation_id": f"ci-{CI_SHA}",
|
||||
"provider": "gitea", "event_type": "ci_completed",
|
||||
"sha": CI_SHA, "exit_code": 0,
|
||||
"completed_at": time.time()})
|
||||
t0 = time.monotonic()
|
||||
rc = mod.run(sha=CI_SHA, deadline_secs=60, watchdog_secs=300,
|
||||
daemon_url=wr_daemon.url())
|
||||
wall = time.monotonic() - t0
|
||||
assert rc == 0
|
||||
assert wall < 10, f"replayed wake took {wall:.1f}s — replay path dead"
|
||||
assert ew.find_matching_event("gitea", "ci_completed",
|
||||
f"ci-{CI_SHA}") is not None
|
||||
|
||||
|
||||
def test_16c_replay_does_not_match_other_identities(wr_daemon):
|
||||
wr_daemon.post_event({"operation_id": f"ci-{CI_SHA}",
|
||||
"provider": "gitea", "event_type": "ci_completed",
|
||||
"sha": CI_SHA, "exit_code": 0})
|
||||
assert ew.find_matching_event("gitea", "ci_completed",
|
||||
"ci-other") is None
|
||||
assert ew.find_matching_event("builder", "builder_exit",
|
||||
f"ci-{CI_SHA}") is None
|
||||
|
||||
|
||||
def test_17c_fifo_mode_is_cross_user(wr_daemon):
|
||||
"""T17 17C ROOT-CAUSE REGRESSION GUARD: the T16 chmod(0o666) was lost
|
||||
in a rebase; FIFOs landed 644, the daemon (different user) got EACCES
|
||||
on the resume write, and every production waiter slept to its
|
||||
deadline. Mode must be 666 regardless of umask/creating user."""
|
||||
import os as _os
|
||||
import stat as _stat
|
||||
fd = ew.open_fifo_reader("ci-mode-guard-op")
|
||||
try:
|
||||
mode = _os.stat(ew.fifo_path("ci-mode-guard-op")).st_mode
|
||||
assert mode & 0o666 == 0o666, (
|
||||
f"fifo mode {oct(mode & 0o777)} — resume writer from another "
|
||||
f"user will get EACCES (17C root-cause regression)")
|
||||
finally:
|
||||
_os.close(fd)
|
||||
|
||||
|
||||
def test_17c_cross_user_resume_delivery(wr_daemon):
|
||||
"""Full cross-user delivery: the WAITER side (fifo + registration)
|
||||
runs as the invoking user; the resume WRITE runs through the real
|
||||
daemon (as its own user in production). After the mode fix the
|
||||
daemon's writer must succeed (rc=0, wait COMPLETED), not rc=1."""
|
||||
import os as _os
|
||||
op = f"ci-crossuser-{int(time.time())}"
|
||||
fd = ew.open_fifo_reader(op)
|
||||
rec = ew.register_process_wait(op, provider="gitea",
|
||||
event_type="ci_completed",
|
||||
sha="crossuser", watchdog_secs=60)
|
||||
try:
|
||||
import threading
|
||||
done = {}
|
||||
|
||||
def waiter():
|
||||
done["r"] = ew.await_resume_event(op, watchdog_secs=15,
|
||||
fifo_fd=fd)
|
||||
|
||||
t = threading.Thread(target=waiter, daemon=True)
|
||||
t.start()
|
||||
time.sleep(0.3)
|
||||
wr_daemon.post_event({"operation_id": op, "provider": "gitea",
|
||||
"event_type": "ci_completed",
|
||||
"sha": "crossuser", "exit_code": 0,
|
||||
"completed_at": time.time()})
|
||||
t.join(timeout=20)
|
||||
assert done.get("r", {}).get("kind") == "event", (
|
||||
f"resume delivery failed again: {done} — 17C root-cause "
|
||||
f"regression")
|
||||
# the durable record must show the writer exited rc=0
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
r = ew.operation_wait(op) or {}
|
||||
if r.get("status") == "COMPLETED":
|
||||
break
|
||||
time.sleep(0.2)
|
||||
assert r.get("status") == "COMPLETED", (
|
||||
f"wait status {r.get('status')} — resume writer did not "
|
||||
f"deliver cleanly (expect COMPLETED, not RESUME_UNVERIFIED)")
|
||||
finally:
|
||||
_os.close(fd)
|
||||
@@ -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,82 @@
|
||||
"""test_drift_resume.py — T16 §16P: drift-aware resume held-outs,
|
||||
driven by the REAL 16O fixture."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
|
||||
from rune import drift_resume as dr # noqa: E402
|
||||
|
||||
FIXTURE = Path("/mnt/workdrive/t16-reentry")
|
||||
REPO_DIR = FIXTURE / "repo"
|
||||
STORE = FIXTURE / "scaffold" / "current.json"
|
||||
|
||||
|
||||
def test_16p_drift_classes_deterministic():
|
||||
ok = {"actual": "", "checked_by": "pytest", "matches": True}
|
||||
bad = {"actual": "2 tests fail", "checked_by": "pytest", "matches": False}
|
||||
unverif = {"actual": None, "checked_by": "pytest", "matches": None}
|
||||
assert dr.classify_drift({"claim": "green"}, ok)["class"] == "CONFIRMED"
|
||||
assert dr.classify_drift({"claim": "green"}, bad)["class"] == "MATERIAL"
|
||||
assert dr.classify_drift({"claim": "x"}, unverif)["class"] == "UNKNOWN"
|
||||
locked = dict({"claim": "layout"}, user_locked=True)
|
||||
assert dr.classify_drift(locked, bad)["class"] == "CONFLICTING"
|
||||
|
||||
|
||||
def test_16p_reality_wins_user_locked_needs_user():
|
||||
r = dr.reconcile(
|
||||
[{"claim": "layout locked as X", "user_locked": True}],
|
||||
lambda c: {"actual": "layout is Y", "checked_by": "ls",
|
||||
"matches": False})
|
||||
assert r["resume"] is False # blocked, not overridden
|
||||
assert r["blocking"][0]["class"] == "CONFLICTING"
|
||||
|
||||
|
||||
def test_16p_real_fixture_vat_regression_blocks_resume():
|
||||
"""The REAL fixture: scaffold claims stage-1 verified green; reality
|
||||
has the VAT regression. The reconciliation must block."""
|
||||
doc = json.loads(STORE.read_text())
|
||||
stage1 = next(e for e in doc["entities"]
|
||||
if e["id"] == "contract:stage-1-monthly-totals")
|
||||
|
||||
def run_fixture_tests(claim):
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "pytest", "tests/", "-q"],
|
||||
cwd=REPO_DIR, capture_output=True, text=True,
|
||||
env={"PATH": "/usr/bin:/bin:/usr/local/bin",
|
||||
"PYTHONPATH": "src"})
|
||||
passed = proc.returncode == 0
|
||||
return {"actual": proc.stdout.strip().splitlines()[-1]
|
||||
if proc.stdout else proc.stderr[:120],
|
||||
"checked_by": "pytest tests/", "matches": passed}
|
||||
|
||||
claims = [{"claim": "stage-1 verified green", "kind": "verification",
|
||||
"stale_ok": False}]
|
||||
r = dr.reconcile(claims, run_fixture_tests)
|
||||
assert r["resume"] is False
|
||||
assert r["blocking"][0]["class"] == "MATERIAL"
|
||||
assert "failed" in r["blocking"][0]["evidence"]
|
||||
assert stage1["phase"] == "closed" # the stale claim we caught
|
||||
|
||||
|
||||
def test_16p_clean_repo_resumes():
|
||||
"""Same observer on the checkpoint commit: no drift, resume allowed."""
|
||||
def check_at_checkpoint(claim):
|
||||
proc = subprocess.run(
|
||||
["git", "show", "39bd917:src/report.py"], cwd=REPO_DIR,
|
||||
capture_output=True, text=True)
|
||||
vat_free = "1.19" not in proc.stdout
|
||||
return {"actual": "" if vat_free else "VAT present at checkpoint",
|
||||
"checked_by": "git show", "matches": vat_free}
|
||||
|
||||
r = dr.reconcile([{"claim": "stage-1 green at checkpoint"}],
|
||||
check_at_checkpoint)
|
||||
assert r["resume"] is True and r["blocking"] == []
|
||||
@@ -0,0 +1,260 @@
|
||||
"""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 | None = None):
|
||||
# Ephemeral port (not a fixed one): the CI runner executes the
|
||||
# FULL suite in one process, and a fixed port collides with
|
||||
# TIME_WAIT sockets from earlier tests in the same file or with
|
||||
# parallel jobs on the same runner. 15Z CI failure: the latency
|
||||
# test's daemon failed to bind 8793 (stale socket), the notifier's
|
||||
# event POSTs went nowhere, and the watchdog correctly reported
|
||||
# PROCESS_LOST — the test harness was at fault, not the bridge.
|
||||
if port is None:
|
||||
import socket as _socket
|
||||
with _socket.socket() as _s:
|
||||
_s.bind(("127.0.0.1", 0))
|
||||
port = _s.getsockname()[1]
|
||||
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())
|
||||
|
||||
|
||||
# T16 §16E: the daemon fixture moved to tests/conftest.py (wr_daemon) so
|
||||
# test_ci_event_wait.py shares ONE harness — no duplicated test helpers.
|
||||
@pytest.fixture()
|
||||
def daemon(wr_daemon):
|
||||
return wr_daemon
|
||||
|
||||
|
||||
# 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,251 @@
|
||||
"""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.
|
||||
|
||||
BuildManager.start_build shells out to `git worktree add` against a
|
||||
HARDCODED production repo path (/mnt/workdrive/rune) — that is real
|
||||
VCS mutation and has no place in a unit test (it fails on CI with
|
||||
dubious-ownership, and would pollute the real repo locally). This
|
||||
test therefore inspects the manager's state machine WITHOUT invoking
|
||||
the worktree-creating path: construction-state separation is a
|
||||
property of where BuildManager writes, not of the git operation."""
|
||||
from rune import build_mode
|
||||
from rune.build_mode import BuildManager
|
||||
monkeypatch.chdir(tmp_path) # NOT bare os.chdir — must not leak
|
||||
monkeypatch.setattr(build_mode, "BUILDS_DIR",
|
||||
str(tmp_path / ".rune" / "work" / "builds"))
|
||||
monkeypatch.setattr(build_mode, "WORKTREE_DIR",
|
||||
str(tmp_path / ".rune" / "worktrees"))
|
||||
bm = BuildManager()
|
||||
# start_build's worktree step is a real VCS mutation — stub it; the
|
||||
# property under test (no canonical construction state) is orthogonal
|
||||
monkeypatch.setattr(
|
||||
BuildManager, "create_worktree",
|
||||
lambda self: setattr(self.state, "worktree_path", "stubbed") or "stubbed")
|
||||
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,150 @@
|
||||
"""test_programme_continuation.py — T15 §15M: 'task done' ≠ 'programme
|
||||
done'.
|
||||
|
||||
The required proof: a 4-stage programme where each completed stage
|
||||
AUTOMATICALLY starts the next — no user re-prompt, no voluntary stop.
|
||||
Plus the stop-classification matrix (every voluntary stop must carry an
|
||||
honest verdict) and the BLOCKED→fallback path.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
|
||||
from rune.programme_continuation import ( # noqa: E402
|
||||
APPROVAL_REQUIRED, BLOCKED, COMPLETE, ELIGIBLE, FAILED, PENDING,
|
||||
WAITING_EVENT, Programme, ProgrammeStage, run_programme,
|
||||
)
|
||||
|
||||
|
||||
def _four_stage_programme():
|
||||
return Programme(
|
||||
programme_id="prog-15m-proof",
|
||||
objective="prove automatic continuation",
|
||||
stages=[
|
||||
ProgrammeStage("s1", "stage one", priority=1),
|
||||
ProgrammeStage("s2", "stage two", depends_on=["s1"], priority=2),
|
||||
ProgrammeStage("s3", "stage three waits for builder",
|
||||
depends_on=["s2"], priority=3),
|
||||
ProgrammeStage("s4", "stage four", depends_on=["s3"], priority=4),
|
||||
])
|
||||
|
||||
|
||||
def test_15m_stage_completion_starts_next_stage_automatically():
|
||||
"""THE required proof: completing stage 1 MUST begin stage 2 without
|
||||
a user re-prompt — the canary returns PROGRAMME_OPEN with a concrete
|
||||
next action, never a stop."""
|
||||
prog = _four_stage_programme()
|
||||
v = prog.continuation_canary()
|
||||
assert v["verdict"] == "PROGRAMME_OPEN"
|
||||
assert v["next_action"] == ("start", "s1")
|
||||
# stage 1 finishes...
|
||||
prog.stages[0].state = COMPLETE
|
||||
v2 = prog.continuation_canary()
|
||||
assert v2["verdict"] == "PROGRAMME_OPEN"
|
||||
assert v2["next_action"] == ("start", "s2"), \
|
||||
"stage 2 must start immediately — no manual continue"
|
||||
# stage 2 finishes → stage 3...
|
||||
prog.stages[1].state = COMPLETE
|
||||
v3 = prog.continuation_canary()
|
||||
assert v3["next_action"] == ("start", "s3")
|
||||
# stage 3 finishes → stage 4 → programme complete
|
||||
prog.stages[2].state = COMPLETE
|
||||
prog.stages[3].state = COMPLETE
|
||||
v4 = prog.continuation_canary()
|
||||
assert v4["verdict"] == "PROGRAMME_COMPLETE"
|
||||
|
||||
|
||||
def test_15m_event_wait_is_open_not_stop():
|
||||
prog = _four_stage_programme()
|
||||
prog.stages[0].state = COMPLETE
|
||||
prog.stages[1].state = COMPLETE
|
||||
prog.stages[2].state = WAITING_EVENT
|
||||
v = prog.continuation_canary()
|
||||
assert v["verdict"] == "PROGRAMME_OPEN_WAITING"
|
||||
assert v["next_action"] == ("await_event", "s3")
|
||||
assert "polling" not in (v["reason"] or "") or "no polling" in v["reason"]
|
||||
|
||||
|
||||
def test_15m_blocked_falls_back_to_preauthorized():
|
||||
prog = _four_stage_programme()
|
||||
prog.fallbacks = ["fallback-docs"]
|
||||
prog.stages[0].state = COMPLETE
|
||||
prog.stages[1].state = BLOCKED # provider outage, say
|
||||
prog.stages[2].state = BLOCKED
|
||||
v = prog.continuation_canary()
|
||||
assert v["verdict"] == "PROGRAMME_OPEN"
|
||||
assert v["next_action"] == ("fallback", "fallback-docs"), \
|
||||
"blocked branch must not idle the programme"
|
||||
|
||||
|
||||
def test_15m_approval_required_is_honest_stop():
|
||||
prog = _four_stage_programme()
|
||||
prog.stages[0].state = COMPLETE
|
||||
prog.stages[1].state = APPROVAL_REQUIRED
|
||||
prog.stages[2].state = APPROVAL_REQUIRED
|
||||
prog.stages[3].state = APPROVAL_REQUIRED
|
||||
v = prog.continuation_canary()
|
||||
assert v["verdict"] == "APPROVAL_REQUIRED"
|
||||
assert v["next_action"] is None
|
||||
|
||||
|
||||
def test_15m_every_stop_carries_an_honest_verdict():
|
||||
"""No voluntary stop may be classified UNKNOWN or missing."""
|
||||
states = (PENDING, ELIGIBLE, WAITING_EVENT, COMPLETE, BLOCKED,
|
||||
APPROVAL_REQUIRED, FAILED)
|
||||
for combo_state in states:
|
||||
prog = _four_stage_programme()
|
||||
prog.stages[0].state = COMPLETE
|
||||
for s in prog.stages[1:]:
|
||||
s.state = combo_state
|
||||
v = prog.continuation_canary()
|
||||
assert v["verdict"] in (
|
||||
"PROGRAMME_COMPLETE", "PROGRAMME_OPEN", "PROGRAMME_OPEN_WAITING",
|
||||
"ALL_REMAINING_BLOCKED", "APPROVAL_REQUIRED",
|
||||
"PROGRAMME_COMPLETE_WITH_FAILURES", # T17 17E: honest all-terminal
|
||||
"UNKNOWN_INTERRUPTION"), v
|
||||
if v["verdict"] == "UNKNOWN_INTERRUPTION":
|
||||
assert combo_state == FAILED, \
|
||||
"UNKNOWN only tolerable for genuinely unclassifiable state"
|
||||
|
||||
|
||||
def test_15m_run_programme_drives_without_manual_continue(tmp_path):
|
||||
"""End-to-end: the runner drives all 4 stages autonomously; the
|
||||
transition log is the proof artifact; it persists and reloads."""
|
||||
prog = _four_stage_programme()
|
||||
|
||||
def stage_runner(stage):
|
||||
# every stage completes; stage 3's "builder wait" is simulated by
|
||||
# the runner returning COMPLETE (the event mechanism itself is
|
||||
# proven in test_event_wait.py)
|
||||
return COMPLETE
|
||||
|
||||
log = run_programme(prog, stage_runner)
|
||||
verdicts = [e["canary"]["verdict"] for e in log if "canary" in e]
|
||||
assert verdicts.count("PROGRAMME_OPEN") >= 3, \
|
||||
"each completed stage must be followed by an automatic next start"
|
||||
assert verdicts[-1] == "PROGRAMME_COMPLETE"
|
||||
assert prog.stages[3].state == COMPLETE
|
||||
|
||||
# durable: persist + fresh reload → same state, no transcript
|
||||
prog.save(tmp_path)
|
||||
reloaded = Programme.load(tmp_path, "prog-15m-proof")
|
||||
assert reloaded is not None
|
||||
assert all(s.state == COMPLETE for s in reloaded.stages)
|
||||
assert reloaded.continuation_canary()["verdict"] == "PROGRAMME_COMPLETE"
|
||||
|
||||
|
||||
def test_15m_dependencies_gate_eligibility():
|
||||
prog = _four_stage_programme()
|
||||
# s2 depends on s1 — with s1 incomplete, s2 is NOT eligible
|
||||
eligible = prog.eligible_stages()
|
||||
assert [s.stage_id for s in eligible] == ["s1"]
|
||||
prog.stages[0].state = COMPLETE
|
||||
eligible = prog.eligible_stages()
|
||||
assert [s.stage_id for s in eligible] == ["s2", "s3", "s4"] \
|
||||
or [s.stage_id for s in eligible] == ["s2"], \
|
||||
"s3/s4 depend transitively on s2/s3 — not eligible yet"
|
||||
@@ -0,0 +1,160 @@
|
||||
"""test_programme_wiring.py — T17 §17D/§17E: canonical programme
|
||||
continuation wiring held-outs.
|
||||
|
||||
The 15M canary proved the RULE; these prove the WIRING: stages bound to
|
||||
real Scaffolding contracts, driven through the Construction Interface
|
||||
lifecycle, with restart/duplicate/idempotency/adversarial cases.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
|
||||
from rune import construction_interface as ci # noqa: E402
|
||||
from rune import programme_continuation as pc # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def iface(tmp_path):
|
||||
return ci.ConstructionInterface(
|
||||
builder="t17-programme-test",
|
||||
project_store=str(tmp_path / "scaffold"),
|
||||
project_id="prog-test")
|
||||
|
||||
|
||||
def _make_contract(iface, contract_id, need="stage need"):
|
||||
doc = iface.scaffold.load()
|
||||
iface.scaffold.new_contract(doc, contract_id=contract_id, need=need,
|
||||
origin="programme", affected_entities=[])
|
||||
iface.scaffold.save(doc)
|
||||
|
||||
|
||||
def _prog(prog_id, stages):
|
||||
p = pc.Programme(programme_id=prog_id, objective="test programme")
|
||||
for sid, work in stages:
|
||||
st = pc.ProgrammeStage(stage_id=sid, objective=sid,
|
||||
priority=len(p.stages))
|
||||
if work is not None:
|
||||
st.result["work"] = work
|
||||
p.stages.append(st)
|
||||
return p
|
||||
|
||||
|
||||
def _phase(iface, contract_id):
|
||||
doc = iface.scaffold.load()
|
||||
c = iface.scaffold.contract(doc, contract_id)
|
||||
return (c or {}).get("phase", "")
|
||||
|
||||
|
||||
def test_17e_all_four_stages_close_with_evidence(iface, tmp_path):
|
||||
stages = [("st-1", None),
|
||||
("st-2", {"ok": True, "evidence": "did the thing"}),
|
||||
("st-3", {"ok": True, "evidence": "more work"})]
|
||||
prog = _prog("p-four", stages)
|
||||
for sid, _ in stages:
|
||||
_make_contract(iface, sid)
|
||||
log = pc.run_construction_programme(prog, iface,
|
||||
scaffold_store=tmp_path / "scaffold")
|
||||
assert prog.continuation_canary()["verdict"] == "PROGRAMME_COMPLETE"
|
||||
for sid, _ in stages:
|
||||
assert _phase(iface, sid) == "closed"
|
||||
doc = iface.scaffold.load()
|
||||
c = next(e for e in doc["entities"] if e["id"] == "contract:st-2")
|
||||
assert any("did the thing" in (cp.get("note", "") + cp.get("evidence", ""))
|
||||
for cp in c.get("checkpoints", []))
|
||||
|
||||
|
||||
def test_17e_failure_does_not_stop_programme(iface, tmp_path):
|
||||
stages = [("f-1", {"ok": False, "evidence": "verification failed"}),
|
||||
("f-2", {"ok": True, "evidence": "independent work ok"})]
|
||||
prog = _prog("p-fail", stages)
|
||||
for sid, _ in stages:
|
||||
_make_contract(iface, sid)
|
||||
pc.run_construction_programme(prog, iface,
|
||||
scaffold_store=tmp_path / "scaffold")
|
||||
verdict = prog.continuation_canary()["verdict"]
|
||||
assert verdict == "PROGRAMME_COMPLETE_WITH_FAILURES"
|
||||
assert _phase(iface, "f-1") == "failed" # truthful failure, not hung
|
||||
assert _phase(iface, "f-2") == "closed"
|
||||
|
||||
|
||||
def test_17e_restart_resumes_from_durable_record(iface, tmp_path):
|
||||
stages = [("r-1", {"ok": True, "evidence": "done"}), ("r-2", None)]
|
||||
prog = _prog("p-restart", stages)
|
||||
for sid, _ in stages:
|
||||
_make_contract(iface, sid)
|
||||
base = tmp_path / "scaffold" / "programmes"
|
||||
# stage 1 genuinely completed before the "restart": close its contract
|
||||
iface.transition("r-1", "approved", evidence_ref="pre")
|
||||
iface.transition("r-1", "building", evidence_ref="pre")
|
||||
iface.transition("r-1", "verifying", evidence_ref="pre")
|
||||
iface.close("r-1", observed_evidence=[{"source": "pre", "detail": "x"}],
|
||||
scope_files=[])
|
||||
prog.stages[0].state = pc.COMPLETE
|
||||
prog.save(base)
|
||||
# "restart": reload from the durable record only
|
||||
prog2 = pc.Programme.load(base, "p-restart")
|
||||
assert prog2.stages[0].state == pc.COMPLETE
|
||||
assert prog2.stages[1].state == pc.PENDING
|
||||
pc.run_construction_programme(prog2, iface,
|
||||
scaffold_store=tmp_path / "scaffold")
|
||||
assert _phase(iface, "r-1") == "closed"
|
||||
assert _phase(iface, "r-2") == "closed"
|
||||
|
||||
|
||||
def test_17e_duplicate_continuation_signal_idempotent(iface, tmp_path):
|
||||
_make_contract(iface, "d-1")
|
||||
prog = _prog("p-dup", [("d-1", {"ok": True, "evidence": "once"})])
|
||||
base = tmp_path / "scaffold"
|
||||
log1 = pc.run_construction_programme(prog, iface, scaffold_store=base)
|
||||
# re-run the whole programme against the SAME contracts
|
||||
prog2 = pc.Programme.load(base / "programmes", "p-dup")
|
||||
log2 = pc.run_construction_programme(prog2, iface, scaffold_store=base)
|
||||
assert _phase(iface, "d-1") == "closed" # not re-opened, not corrupted
|
||||
assert any(e["canary"]["verdict"] == "PROGRAMME_COMPLETE"
|
||||
for e in log2 if "canary" in e)
|
||||
|
||||
|
||||
def test_17e_already_completed_stage_skipped(iface, tmp_path):
|
||||
_make_contract(iface, "s-1")
|
||||
iface.transition("s-1", "approved", evidence_ref="pre")
|
||||
iface.transition("s-1", "building", evidence_ref="pre")
|
||||
iface.transition("s-1", "verifying", evidence_ref="pre")
|
||||
iface.close("s-1", observed_evidence=[{"source": "pre", "detail": "x"}],
|
||||
scope_files=[])
|
||||
prog = _prog("p-skip", [("s-1", {"ok": True, "evidence": "rework?"})])
|
||||
log = pc.run_construction_programme(prog, iface,
|
||||
scaffold_store=tmp_path / "scaffold")
|
||||
# already-closed → COMPLETE without re-running work
|
||||
assert prog.continuation_canary()["verdict"] == "PROGRAMME_COMPLETE"
|
||||
assert not any("rework" in str(e) for e in log)
|
||||
|
||||
|
||||
def test_17e_work_callable_receives_interface_and_stage(iface, tmp_path):
|
||||
seen = {}
|
||||
|
||||
def work(i, stage):
|
||||
seen["iface"] = type(i).__name__
|
||||
seen["stage"] = stage.stage_id
|
||||
return {"ok": True, "evidence": "callable ran"}
|
||||
|
||||
_make_contract(iface, "w-1")
|
||||
prog = _prog("p-call", [("w-1", work)])
|
||||
pc.run_construction_programme(prog, iface,
|
||||
scaffold_store=tmp_path / "scaffold")
|
||||
assert seen == {"iface": "ConstructionInterface", "stage": "w-1"}
|
||||
assert _phase(iface, "w-1") == "closed"
|
||||
|
||||
|
||||
def test_17e_missing_contract_fails_honestly(iface, tmp_path):
|
||||
prog = _prog("p-missing", [("ghost-1", {"ok": True, "evidence": "x"})])
|
||||
log = pc.run_construction_programme(prog, iface,
|
||||
scaffold_store=tmp_path / "scaffold")
|
||||
assert prog.stages[0].state == pc.FAILED
|
||||
assert "no contract" in (prog.stages[0].result.get("error") or "")
|
||||
@@ -0,0 +1,198 @@
|
||||
"""test_provider_ownership.py — T16 §16F/§16G: provider ownership
|
||||
boundary + provider failure held-outs.
|
||||
|
||||
THE RULE (permanent architecture):
|
||||
SCAFFOLDING DOES NOT OWN PROVIDER SELECTION.
|
||||
|
||||
Proofs:
|
||||
16F guard — no provider/model name leaks into Scaffolding modules
|
||||
(structural, permanent — the audit is enforced by the
|
||||
suite, not by a one-off grep).
|
||||
16F seam — provider_context.require_model resolves from the ambient
|
||||
context and fails LOUDLY (ProviderUnresolved) when the
|
||||
provider layer has not selected a model; no silent
|
||||
default exists.
|
||||
16G-A/B — provider context flows THROUGH the construction path:
|
||||
the builder driver inherits whatever the provider layer
|
||||
set; Scaffolding never names one.
|
||||
16G-C — a real provider error (401/403/404/429/5xx) is preserved
|
||||
VERBATIM — never relabelled a generic "400".
|
||||
16G-D — NO fallback: a provider failure attempts zero alternate
|
||||
providers (policy = block_branch_preserve_error, 0
|
||||
retries).
|
||||
16G-E — the construction state remains resumable after a
|
||||
provider block (block ≠ dead contract).
|
||||
16G-G — Scaffolding canonical project truth is unaffected by
|
||||
provider preference: switching the provider context does
|
||||
not mutate scaffold state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
|
||||
from rune import failure_recovery as fr # noqa: E402
|
||||
from rune.provider_context import ProviderUnresolved, require_model # noqa: E402
|
||||
|
||||
# The Scaffolding core: construction authority modules that must never
|
||||
# own provider selection (T16 §16F audit scope).
|
||||
_SCAFFOLDING_CORE = [
|
||||
"scaffold.py", "construction_interface.py",
|
||||
"construction_decision.py", "construction_mode.py",
|
||||
"construction_pattern.py", "construction_patterns_builtin.py",
|
||||
"stage_guards.py", "event_wait.py",
|
||||
"failure_recovery.py", "structural_store.py",
|
||||
]
|
||||
# construction_* modules discovered live — new ones are covered
|
||||
# automatically (the guard follows the surface, not a hand-list)
|
||||
import re as _re
|
||||
SCAFFOLDING_MODULES = sorted(set(
|
||||
_re.match(r"(construction_\w+)\.py", p.name).group(1) + ".py"
|
||||
for p in (REPO / "src" / "rune").glob("construction_*.py")
|
||||
) | set(_SCAFFOLDING_CORE))
|
||||
_PROVIDER_TOKENS = [
|
||||
"Verboo", "verboo", "glm-5.3-flash", "mimo-v2.5-pro", "Xioami",
|
||||
"xiaomi", "qwen3:14b", "BUILDER_MODEL", "MIMO_MODEL",
|
||||
]
|
||||
|
||||
|
||||
def test_16f_no_provider_names_in_scaffolding_modules():
|
||||
"""Structural guard: the construction authority is provider-blind."""
|
||||
src = REPO / "src" / "rune"
|
||||
for mod in SCAFFOLDING_MODULES:
|
||||
text = (src / mod).read_text()
|
||||
# strip comments and docstrings crudely but decisively: any REAL
|
||||
# code reference would survive; pure prose mentions live in
|
||||
# comments/docstrings. We assert on code lines only.
|
||||
code_lines = [
|
||||
ln for ln in text.splitlines()
|
||||
if ln.strip() and not ln.strip().startswith(("#", '"', "'"))
|
||||
]
|
||||
for token in _PROVIDER_TOKENS:
|
||||
hits = [ln for ln in code_lines if token in ln]
|
||||
assert not hits, (
|
||||
f"Scaffolding module {mod} references provider token "
|
||||
f"{token!r} in CODE: {hits[:2]} — Scaffolding must not "
|
||||
f"own provider selection (T16 §16F)")
|
||||
|
||||
|
||||
def test_16f_require_model_resolves_ambient_context(monkeypatch):
|
||||
monkeypatch.setenv("MIMO_MODEL", "Provider A/model-a")
|
||||
assert require_model() == "Provider A/model-a"
|
||||
|
||||
|
||||
def test_16f_missing_context_fails_loudly_never_silent(monkeypatch):
|
||||
monkeypatch.delenv("MIMO_MODEL", raising=False)
|
||||
with pytest.raises(ProviderUnresolved) as ei:
|
||||
require_model()
|
||||
assert "PROVIDER_UNRESOLVED" in str(ei.value)
|
||||
|
||||
|
||||
def test_16g_c_real_provider_status_preserved_verbatim():
|
||||
"""No test may pretend all provider failures are a generic '400'."""
|
||||
for err in ("HTTP 401 Unauthorized: bad api key",
|
||||
"HTTP 403 Forbidden: quota tier",
|
||||
"HTTP 404 model not found: glm-5.3-flash",
|
||||
"HTTP 429 rate limited: retry after 30s",
|
||||
"HTTP 502 upstream unavailable"):
|
||||
verdict = fr.classify_failure(err, context={"provider_error": True})
|
||||
assert verdict["class"] == fr.PROVIDER_UNAVAILABLE
|
||||
# the REAL code survives into the evidence, verbatim
|
||||
assert err.split(":")[0] in verdict["evidence"] or \
|
||||
err in verdict["evidence"]
|
||||
assert verdict["policy"] == fr.POLICY[fr.PROVIDER_UNAVAILABLE]
|
||||
assert verdict["policy"][0] == 0 # zero retries, zero fallbacks
|
||||
|
||||
|
||||
def test_16g_d_no_fallback_provider_attempted():
|
||||
"""A provider failure stops the branch — the plan of record contains
|
||||
exactly ONE provider attempt, and the recovery policy blocks."""
|
||||
attempted_providers: list[str] = []
|
||||
|
||||
def construction_path(provider_ctx: str, fail_on: str):
|
||||
attempted_providers.append(provider_ctx)
|
||||
try:
|
||||
if provider_ctx == fail_on:
|
||||
raise RuntimeError(f"HTTP 401 Unauthorized from {provider_ctx}")
|
||||
except RuntimeError as exc:
|
||||
verdict = fr.classify_failure(str(exc),
|
||||
context={"provider_error": True})
|
||||
if verdict["policy"][0] == 0: # block_branch_preserve_error
|
||||
return verdict # STOP the dependent branch — no plan B
|
||||
return None
|
||||
|
||||
verdict = construction_path("Provider A/model-a", "Provider A/model-a")
|
||||
assert attempted_providers == ["Provider A/model-a"] # 16G-D: ONE
|
||||
assert verdict["class"] == fr.PROVIDER_UNAVAILABLE
|
||||
assert "401" in verdict["evidence"] # real code preserved
|
||||
|
||||
|
||||
def test_16g_e_construction_remains_resumable_after_provider_block(tmp_path):
|
||||
"""Block ≠ dead: after a provider-unavailable block the scaffold
|
||||
contract must still be loadable and non-terminal (resume stays
|
||||
legal)."""
|
||||
from rune import failure_recovery as fr
|
||||
from rune import scaffold as sc
|
||||
s = sc.Scaffold(tmp_path / ".rune" / "scaffold", "prov-e")
|
||||
doc = s.init(purpose="provider block resumability")
|
||||
s.new_contract(doc, contract_id="prov-e-1", need="build a thing")
|
||||
verdict = fr.classify_failure("HTTP 429 quota exhausted",
|
||||
context={"provider_error": True})
|
||||
for e in doc["entities"]:
|
||||
if e["id"] == "contract:prov-e-1":
|
||||
e["identities"]["blocker"] = {
|
||||
"class": verdict["class"], "policy": verdict["policy"],
|
||||
"evidence": verdict["evidence"],
|
||||
}
|
||||
s.save(doc)
|
||||
# a FRESH process reads the same contract: non-terminal + resumable
|
||||
doc2 = sc.Scaffold(tmp_path / ".rune" / "scaffold", "prov-e").load()
|
||||
c = next(e for e in doc2["entities"]
|
||||
if e["id"] == "contract:prov-e-1")
|
||||
assert c["identities"]["blocker"]["class"] == "PROVIDER_UNAVAILABLE"
|
||||
assert c["identities"]["blocker"]["policy"][1].startswith("block_branch")
|
||||
assert c.get("phase") != "closed" # block is NOT completion
|
||||
|
||||
|
||||
def test_16g_g_provider_preference_never_touches_scaffold_truth(tmp_path):
|
||||
"""Switching the provider context must leave Scaffolding canonical
|
||||
project state byte-identical."""
|
||||
import os
|
||||
from rune import scaffold as sc
|
||||
s = sc.Scaffold(tmp_path / ".rune" / "scaffold", "prov-g")
|
||||
doc = s.init(purpose="provider neutrality")
|
||||
s.new_contract(doc, contract_id="prov-g-1", need="a need")
|
||||
s.save(doc)
|
||||
before = (tmp_path / ".rune" / "scaffold" / "current.json").read_text()
|
||||
for ctx in ("Provider A/model-a", "Provider B/model-b", None):
|
||||
if ctx:
|
||||
os.environ["MIMO_MODEL"] = ctx
|
||||
else:
|
||||
os.environ.pop("MIMO_MODEL", None)
|
||||
s2 = sc.Scaffold(tmp_path / ".rune" / "scaffold", "prov-g")
|
||||
doc2 = s2.init(purpose="provider neutrality") # idempotent re-init
|
||||
s2.save(doc2)
|
||||
after = (tmp_path / ".rune" / "scaffold" / "current.json").read_text()
|
||||
assert "Provider" not in after # provider context never leaks in
|
||||
before = after
|
||||
os.environ.pop("MIMO_MODEL", None)
|
||||
|
||||
|
||||
def test_16g_drivers_have_no_silent_provider_default():
|
||||
"""The builder/research drivers resolve provider via require_model —
|
||||
a missing context exits 3 (PROVIDER_UNRESOLVED), never a baked-in
|
||||
provider name."""
|
||||
for script in ("t14_builder_session.py", "t15_builder_session.py"):
|
||||
text = (REPO / "scripts" / script).read_text()
|
||||
assert "require_model" in text, f"{script} bypasses the seam"
|
||||
assert "Verboo Code/glm-5.3-flash\"\" )" not in text
|
||||
assert '"Verboo Code/glm-5.3-flash")' not in text and \
|
||||
"'Verboo Code/glm-5.3-flash')" not in text, (
|
||||
f"{script} still carries a hardcoded provider default")
|
||||
@@ -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"] == {}
|
||||
@@ -0,0 +1,176 @@
|
||||
"""test_substrate_seam.py — T16 §16I/§16J/§16K: future-Substrate
|
||||
compatibility seam held-outs.
|
||||
|
||||
The MOCK client below knows NOTHING about current.json or internal
|
||||
entity arrays — it sees only the public projection (construction_
|
||||
projection) and the inbound command seam (construction_commands). It:
|
||||
|
||||
1. requests current construction state through the public seam;
|
||||
2. observes a construction event (a projection whose waiting_state
|
||||
advanced — simulated by a durable state change, not by poking
|
||||
internals);
|
||||
3. correlates project/contract/stage;
|
||||
4. issues RESUME (valid) and receives acceptance;
|
||||
5. issues an invalid transition (START on a non-draft) and receives
|
||||
REFUSAL;
|
||||
6. survives process restart using identities (re-request state by
|
||||
contract id alone — no transcript, no cached internals);
|
||||
7. leaves Scaffolding fully usable without the client existing.
|
||||
|
||||
NOT implemented (explicitly out of scope): relevance, autonomous
|
||||
prioritisation, goal planning, memory, Substrate cognition. This is a
|
||||
compatibility seam, not a Substrate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
|
||||
from rune import construction_commands as cmds # noqa: E402
|
||||
from rune import construction_projection as proj # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
|
||||
|
||||
class MockSubstrateClient:
|
||||
"""TEST CLIENT ONLY. Sees the projection + command seam; the store
|
||||
object is passed to the SEAM functions, never touched directly."""
|
||||
|
||||
def __init__(self, scaffold):
|
||||
self._scaffold = scaffold
|
||||
self.observed: list[dict] = []
|
||||
|
||||
def request_state(self, contract_id: str, **kw) -> dict:
|
||||
p = proj.project(self._scaffold, contract_id, **kw)
|
||||
assert not proj.validate(p), f"invalid projection: {proj.validate(p)}"
|
||||
self.observed.append(p)
|
||||
return p
|
||||
|
||||
def issue(self, command_type: str, construction_id: str, **kw) -> dict:
|
||||
# the serving layer binds the canonical store to the seam; the
|
||||
# client only ever sees the returned envelope
|
||||
return cmds.command(command_type, construction_id=construction_id,
|
||||
scaffold=self._scaffold, **kw)
|
||||
|
||||
|
||||
def _fixture_scaffold(tmp_path):
|
||||
s = sc.Scaffold(tmp_path / ".rune" / "scaffold", "substrate-seam")
|
||||
doc = s.init(purpose="substrate seam held-out")
|
||||
s.new_contract(doc, contract_id="seam-1", need="a thing to build")
|
||||
s.save(doc)
|
||||
return s
|
||||
|
||||
|
||||
def test_16k_state_through_public_seam_only(tmp_path):
|
||||
s = _fixture_scaffold(tmp_path)
|
||||
client = MockSubstrateClient(s)
|
||||
p = client.request_state("seam-1", originating_goal_id="goal-9",
|
||||
stage="BUILD")
|
||||
assert p["schema_version"] == proj.SCHEMA_VERSION
|
||||
assert p["contract_id"] == "seam-1"
|
||||
assert p["phase"] == "draft"
|
||||
assert p["candidate_next_action"] # Scaffolding tells, client reads
|
||||
# the projection exposes NO storage internals
|
||||
assert "entities" not in p and "current.json" not in str(p)
|
||||
|
||||
|
||||
def test_16k_observe_event_correlate_and_resume(tmp_path):
|
||||
s = _fixture_scaffold(tmp_path)
|
||||
client = MockSubstrateClient(s)
|
||||
before = client.request_state("seam-1", stage="BUILD")
|
||||
# a construction EVENT: durable state advanced (transition to
|
||||
# approved — done through the real lifecycle, not internals)
|
||||
doc = s.load()
|
||||
s.transition_contract(doc, "seam-1", "approved")
|
||||
s.save(doc)
|
||||
after = client.request_state("seam-1", stage="BUILD")
|
||||
assert before["phase"] == "draft" and after["phase"] == "approved"
|
||||
# correlate project/contract/stage, then issue the valid command
|
||||
assert after["project_id"] == "substrate-seam"
|
||||
resp = client.issue("RESUME", construction_id="seam-1",
|
||||
correlation_id="corr-1")
|
||||
assert resp["accepted"] is True and resp["op"] == "resume"
|
||||
|
||||
|
||||
def test_16k_invalid_transition_refused(tmp_path):
|
||||
s = _fixture_scaffold(tmp_path)
|
||||
client = MockSubstrateClient(s)
|
||||
doc = s.load()
|
||||
s.transition_contract(doc, "seam-1", "approved")
|
||||
s.save(doc)
|
||||
# START on a non-draft is invalid — Scaffolding decides, Substrate asks
|
||||
resp = client.issue("START", construction_id="seam-1")
|
||||
assert resp["accepted"] is False
|
||||
assert "draft" in resp["reason"]
|
||||
|
||||
|
||||
def test_16k_direct_store_semantics_impossible(tmp_path):
|
||||
s = _fixture_scaffold(tmp_path)
|
||||
client = MockSubstrateClient(s)
|
||||
# the client has no path to the store: it never received the store
|
||||
# dir, only the scaffold object for seam calls. Attempting to mutate
|
||||
# storage is not expressible through the seam — the closest it gets
|
||||
# is PROVIDE_CONTEXT, which is validated and user-provenance-gated.
|
||||
resp = client.issue("PROVIDE_CONTEXT", construction_id="seam-1",
|
||||
payload={"add_paths": ["src/x.py"]})
|
||||
assert resp["accepted"] is True
|
||||
assert resp["args"]["authority"] == "substrate" # never user-forged
|
||||
|
||||
|
||||
def test_16k_survives_restart_via_identities(tmp_path):
|
||||
s = _fixture_scaffold(tmp_path)
|
||||
# "restart": drop the client AND the scaffold; rebuild from identities
|
||||
contract_id, project_id = "seam-1", "substrate-seam"
|
||||
del s
|
||||
s2 = sc.Scaffold(tmp_path / ".rune" / "scaffold", project_id)
|
||||
client2 = MockSubstrateClient(s2)
|
||||
p = client2.request_state(contract_id) # contract id alone — no transcript
|
||||
assert p["phase"] == "draft" and p["project_id"] == project_id
|
||||
|
||||
|
||||
def test_16k_scaffolding_usable_without_client(tmp_path):
|
||||
# the client never existed here: the full lifecycle still works
|
||||
s = _fixture_scaffold(tmp_path)
|
||||
doc = s.load()
|
||||
s.transition_contract(doc, "seam-1", "approved")
|
||||
s.transition_contract(doc, "seam-1", "building")
|
||||
s.save(doc)
|
||||
doc2 = s.load()
|
||||
c = next(e for e in doc2["entities"] if e["id"] == "contract:seam-1")
|
||||
assert c["phase"] == "building"
|
||||
|
||||
|
||||
def test_16k_idempotent_command_replay(tmp_path):
|
||||
s = _fixture_scaffold(tmp_path)
|
||||
client = MockSubstrateClient(s)
|
||||
first = client.issue("RESUME", construction_id="seam-1",
|
||||
command_id="replay-1")
|
||||
second = client.issue("RESUME", construction_id="seam-1",
|
||||
command_id="replay-1")
|
||||
assert first["accepted"] and second["accepted"]
|
||||
assert second["idempotent_replay"] is True
|
||||
|
||||
|
||||
def test_16k_unknown_command_and_unknown_construction_refused(tmp_path):
|
||||
s = _fixture_scaffold(tmp_path)
|
||||
client = MockSubstrateClient(s)
|
||||
assert client.issue("PAUSE", construction_id="seam-1")["accepted"] is False
|
||||
assert client.issue("RESUME", construction_id="nope")["accepted"] is False
|
||||
assert client.issue("RESUME", construction_id="")["accepted"] is False
|
||||
|
||||
|
||||
def test_16k_projection_schema_validated(tmp_path):
|
||||
s = _fixture_scaffold(tmp_path)
|
||||
client = MockSubstrateClient(s)
|
||||
p = client.request_state("seam-1")
|
||||
assert proj.validate(p) == []
|
||||
bad = dict(p)
|
||||
del bad["phase"]
|
||||
assert proj.validate(bad) != []
|
||||
bad2 = dict(p, schema_version=999)
|
||||
assert proj.validate(bad2) != []
|
||||
@@ -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