Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5c69de06f | ||
|
|
fcde0b2ce3 | ||
|
|
1f96055b69 | ||
|
|
923a21ba0b | ||
|
|
6348386869 | ||
|
|
55002ad838 | ||
|
|
6684289a8f | ||
|
|
e25ad0f964 | ||
|
|
c9dee14c62 | ||
|
|
fe9d154778 | ||
|
|
2a4f9c8367 | ||
|
|
7ddfa7eee7 | ||
|
|
986b450bc2 | ||
|
|
b51afea0cb | ||
|
|
c15840514f | ||
|
|
2833175329 | ||
|
|
4a06d9f11a | ||
|
|
c707a81933 | ||
|
|
37deb6085c | ||
|
|
affe852ff7 | ||
|
|
e1a71efeb9 | ||
|
|
5bd8cca941 | ||
|
|
fd5f31bba2 | ||
|
|
8a2a4f7b71 | ||
|
|
b2d6802dba |
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.60**
|
||||
**Current release: v0.66.69**
|
||||
|
||||
**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-15T18:24:01Z",
|
||||
"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,14 +220,18 @@
|
||||
"cognitive_budgeter",
|
||||
"complexity_scorer",
|
||||
"construction_activation",
|
||||
"construction_commands",
|
||||
"construction_decision",
|
||||
"construction_depth",
|
||||
"construction_intent",
|
||||
"construction_interface",
|
||||
"construction_mode",
|
||||
"construction_pattern",
|
||||
"construction_patterns_builtin",
|
||||
"construction_projection",
|
||||
"context_broker",
|
||||
"context_pressure",
|
||||
"drift_resume",
|
||||
"event_wait",
|
||||
"failure_recovery",
|
||||
"layout_contract",
|
||||
@@ -251,4 +256,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.60"
|
||||
version = "0.66.69"
|
||||
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"
|
||||
|
||||
|
||||
|
||||
@@ -43,8 +43,18 @@ 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
|
||||
|
||||
BUILDER_MODEL = os.environ.get("T15_BUILDER_MODEL", "Verboo Code/glm-5.3-flash")
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
@@ -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.60"
|
||||
__version__ = "0.66.69"
|
||||
|
||||
# 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.60"' in rune_version,
|
||||
'__version__ = "0.66.69"' 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}
|
||||
@@ -0,0 +1,410 @@
|
||||
"""construction_intent.py — T17 §17J-§17N: the conversational
|
||||
interpretation layer of contextual construction.
|
||||
|
||||
AUTHORITY BOUNDARY (§17O):
|
||||
|
||||
THIS LAYER owns: meaning, referent resolution, target resolution,
|
||||
action-vs-discussion, conversational authorization, user constraints.
|
||||
|
||||
SCAFFOLDING (construction_mode.classify_mode) owns: construction
|
||||
classification, depth, contracts, scope, lifecycle.
|
||||
|
||||
This module NEVER deepens or reclassifies — it resolves what the user
|
||||
MEANT and whether they AUTHORIZED it, then hands a structured
|
||||
ConstructionIntent to Scaffolding, which runs the ONE canonical
|
||||
classifier (classify_mode). No second classifier lives here.
|
||||
|
||||
Not a phrase table: explicit demands are recognized by imperative
|
||||
grammar + construction verbs; questions are recognized by
|
||||
interrogative structure; referents are resolved against the live
|
||||
conversation context (pending proposals, recency, topic drift).
|
||||
Paraphrases resolve because the criteria are structural, not lexical
|
||||
incantations.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# ── conversation context (the layer's memory, NOT Scaffolding state) ────
|
||||
|
||||
@dataclass
|
||||
class Proposal:
|
||||
"""A pending construction proposal awaiting user authorization.
|
||||
Rune suggesting a change is NOT authorization to mutate (§17L)."""
|
||||
proposal_id: str
|
||||
summary: str
|
||||
created_turn: int
|
||||
superseded: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationContext:
|
||||
"""Active conversational/project context for referent resolution.
|
||||
Recency-aware, proposal-aware, stale-context-resistant (§17M)."""
|
||||
turn: int = 0
|
||||
recent_turns: list = field(default_factory=list) # [{role, text}]
|
||||
pending_proposals: list = field(default_factory=list) # [Proposal]
|
||||
active_project: str | None = None
|
||||
active_task_id: str | None = None
|
||||
|
||||
def next_turn(self) -> int:
|
||||
self.turn += 1
|
||||
return self.turn
|
||||
|
||||
def record_turn(self, role: str, text: str) -> None:
|
||||
self.recent_turns.append({"role": role, "text": text,
|
||||
"turn": self.turn})
|
||||
self.turn += 1 # each recorded exchange advances the context
|
||||
|
||||
def add_proposal(self, summary: str) -> Proposal:
|
||||
p = Proposal(proposal_id=f"prop-{uuid.uuid4().hex[:8]}",
|
||||
summary=summary,
|
||||
created_turn=self.turn)
|
||||
self.pending_proposals.append(p)
|
||||
return p
|
||||
|
||||
def supersede_proposal(self, proposal_id: str) -> None:
|
||||
for p in self.pending_proposals:
|
||||
if p.proposal_id == proposal_id:
|
||||
p.superseded = True
|
||||
|
||||
def live_proposals(self) -> list:
|
||||
return [p for p in self.pending_proposals if not p.superseded]
|
||||
|
||||
|
||||
# ── the structured intent (§17N) ─────────────────────────────────────────
|
||||
|
||||
APPROVAL_STATES = ("DISCUSSION", "PROPOSAL", "USER_APPROVED",
|
||||
"CONSTRUCTION_STARTED")
|
||||
|
||||
# approval-state lifecycle legality (§17L): authorization may only be
|
||||
# granted from PROPOSAL or DISCUSSION (direct demand); CONSTRUCTION_STARTED
|
||||
# and mutations are Scaffolding's side of the boundary.
|
||||
LEGAL_APPROVAL_TRANSITIONS = {
|
||||
"DISCUSSION": {"PROPOSAL", "USER_APPROVED"},
|
||||
"PROPOSAL": {"USER_APPROVED"},
|
||||
"USER_APPROVED": {"CONSTRUCTION_STARTED"},
|
||||
"CONSTRUCTION_STARTED": set(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConstructionIntent:
|
||||
"""Structured meaning-resolution output (§17N). Scaffolding decides
|
||||
mode/depth from this; this layer never does."""
|
||||
intent_id: str
|
||||
objective: str
|
||||
requested_effect: str
|
||||
resolved_target: str | None
|
||||
resolved_referent: str | None
|
||||
origin_context_id: str | None
|
||||
action_intent: str # ACTION | DISCUSSION
|
||||
approval_state: str # DISCUSSION | PROPOSAL | USER_APPROVED
|
||||
confidence: float
|
||||
ambiguity: str # NONE | REFERENT | TARGET | SCOPE
|
||||
consequence_level: str # LOW | MEDIUM | HIGH
|
||||
relevant_constraints: list = field(default_factory=list)
|
||||
classification_evidence: list = field(default_factory=list)
|
||||
probe_questions: list = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"intent_id": self.intent_id,
|
||||
"objective": self.objective,
|
||||
"requested_effect": self.requested_effect,
|
||||
"resolved_target": self.resolved_target,
|
||||
"resolved_referent": self.resolved_referent,
|
||||
"origin_context_id": self.origin_context_id,
|
||||
"action_intent": self.action_intent,
|
||||
"approval_state": self.approval_state,
|
||||
"confidence": self.confidence,
|
||||
"ambiguity": self.ambiguity,
|
||||
"consequence_level": self.consequence_level,
|
||||
"relevant_constraints": self.relevant_constraints,
|
||||
"classification_evidence": self.classification_evidence,
|
||||
"probe_questions": self.probe_questions,
|
||||
}
|
||||
|
||||
|
||||
# ── structural lexical criteria (grammar classes, not incantations) ─────
|
||||
|
||||
# §17M stale-context resistance: a proposal older than this many turns
|
||||
# is stale unless the user's text explicitly references its content.
|
||||
STALE_GAP_TURNS = 2
|
||||
|
||||
_NEW_ARTICLE = re.compile(r"\b(me a|me an|a|an|another)\b")
|
||||
_CREATION_VERBS = {"build", "create", "make", "write", "develop"}
|
||||
|
||||
_CONSTRUCTION_VERBS = {
|
||||
"build", "implement", "add", "replace", "fix", "create", "make",
|
||||
"write", "port", "migrate", "refactor", "integrate", "remove",
|
||||
"extend", "wire", "ship",
|
||||
}
|
||||
_DEMONSTRATIVES = {"that", "this", "it", "those", "these"}
|
||||
_OPTION_REFERENTS = re.compile(
|
||||
r"\b(first|second|third|other|latter|former)\s+(option|approach|one)\b")
|
||||
_QUESTION_MARKERS = re.compile(
|
||||
r"^\s*(how|what|why|when|where|who|which|should|could|would|can|"
|
||||
r"is|are|did|do you|do we|do they|does it|does this|does that)\b|"
|
||||
r"\?\s*$", re.IGNORECASE)
|
||||
_ANALYSIS_MARKERS = re.compile(
|
||||
r"\b(think|opinion|view|recommend|suggest|assess|evaluate|review|"
|
||||
r"compare|consider|worth it|worth it\?|needs? improvement|any "
|
||||
r"ideas?)\b", re.IGNORECASE)
|
||||
_RELATIVE_THAT = re.compile(
|
||||
r"\bthat\s+(?!the\b|a\b|an\b|is\b|was\b|will\b|can\b|should\b)"
|
||||
r"[a-z]+(?:s|ing)\b")
|
||||
|
||||
_GO_AHEAD = re.compile(
|
||||
r"^\s*(yes|yeah|yep|ok|okay|sure|go ahead|please do|do it|do that|"
|
||||
r"carry on|proceed|approved|lgtm|ship it)\b[\s.,!]*$", re.IGNORECASE)
|
||||
_AFFIRMATION_OPENERS = re.compile(
|
||||
r"^\s*(yes|yeah|yep|yup|ok|okay|sure|agreed|absolutely|indeed|"
|
||||
r"alright|right)\b", re.IGNORECASE)
|
||||
_APPROVAL_IDIOM = re.compile(
|
||||
r"\b(go ahead|do it|do that|carry on|build that|implement that|"
|
||||
r"make it so|make it happen|that's the one|go with|let's go|"
|
||||
r"let's do|proceed with|approved|ship it)\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def _tokens(text: str) -> set[str]:
|
||||
return set(re.split(r"[^a-z0-9]+", text.lower()))
|
||||
|
||||
|
||||
def _is_question(text: str) -> bool:
|
||||
"""Interrogative STRUCTURE, not vocabulary: leading wh/auxiliary or
|
||||
terminal '?'. 'How would you build this?' is a question regardless
|
||||
of the construction verb inside it (§17K)."""
|
||||
if "?" in text and not re.match(r"^\s*(build|implement|add|fix|make|"
|
||||
r"create|write|replace|remove)\b",
|
||||
text, re.IGNORECASE):
|
||||
return True
|
||||
return bool(_QUESTION_MARKERS.match(text))
|
||||
|
||||
|
||||
def _imperative_verb(text: str) -> str | None:
|
||||
"""Leading imperative construction verb — the strong/direct demand
|
||||
signal (§17K). 'Build me a program...' → 'build'."""
|
||||
m = re.match(r"^\s*(\w+)", text, re.IGNORECASE)
|
||||
if m and m.group(1).lower() in _CONSTRUCTION_VERBS:
|
||||
return m.group(1).lower()
|
||||
return None
|
||||
|
||||
|
||||
def resolve_construction_intent(
|
||||
text: str,
|
||||
context: ConversationContext,
|
||||
*,
|
||||
origin_context_id: str | None = None,
|
||||
) -> ConstructionIntent:
|
||||
"""Resolve one user turn into a structured ConstructionIntent.
|
||||
|
||||
Criteria (§17J), in precedence order:
|
||||
1. interrogative structure → DISCUSSION (never mutation); analysis
|
||||
of an improvement question may RECORD a proposal for later.
|
||||
2. bare approval ("go ahead") or demonstrative authorization
|
||||
("build that") → resolve referent against LIVE proposals,
|
||||
recency-first; multiple equally plausible → clarification;
|
||||
none live → clarification (stale-context resistant).
|
||||
3. explicit imperative with resolved target → USER_APPROVED
|
||||
direct demand — no unnecessary re-asking (§17K).
|
||||
4. everything else → DISCUSSION; construction language without
|
||||
resolved target → ambiguity flagged, never guessed (§17M).
|
||||
"""
|
||||
context.next_turn()
|
||||
context.record_turn("user", text)
|
||||
t = text.strip()
|
||||
low = t.lower()
|
||||
ev: list[str] = []
|
||||
probes: list[str] = []
|
||||
|
||||
# 1. questions are discussion — even with construction verbs inside
|
||||
if _is_question(t):
|
||||
ev.append("interrogative structure → discussion, not authorization")
|
||||
analysis = bool(_ANALYSIS_MARKERS.search(t))
|
||||
if analysis:
|
||||
ev.append("analysis/advice request — a proposal may be recorded "
|
||||
"for later authorization")
|
||||
return ConstructionIntent(
|
||||
intent_id=f"intent-{uuid.uuid4().hex[:8]}",
|
||||
objective=t, requested_effect="analysis/discussion",
|
||||
resolved_target=context.active_project,
|
||||
resolved_referent=None, origin_context_id=origin_context_id,
|
||||
action_intent="DISCUSSION", approval_state="DISCUSSION",
|
||||
confidence=0.85, ambiguity="NONE",
|
||||
consequence_level="LOW", classification_evidence=ev)
|
||||
|
||||
# 2. authorization of a prior proposal (bare approval or demonstrative)
|
||||
bare_approval = bool(_GO_AHEAD.match(t))
|
||||
imperative = _imperative_verb(t)
|
||||
dem_tokens = _tokens(low) & _DEMONSTRATIVES
|
||||
if "that" in dem_tokens and _RELATIVE_THAT.search(low):
|
||||
dem_tokens.discard("that") # relative clause, not a demonstrative
|
||||
ev.append("'that' is a relative pronoun here, not a referent")
|
||||
demonstrative = bool(dem_tokens) or \
|
||||
bool(_OPTION_REFERENTS.search(low))
|
||||
option_ref = bool(_OPTION_REFERENTS.search(low))
|
||||
approval_idiom = bool(_APPROVAL_IDIOM.search(low))
|
||||
affirmation = bool(_AFFIRMATION_OPENERS.match(t))
|
||||
authorized = (bare_approval or approval_idiom or
|
||||
(affirmation and (demonstrative or option_ref)) or
|
||||
(imperative and (demonstrative or option_ref)) or
|
||||
(option_ref and imperative is not None))
|
||||
|
||||
if authorized:
|
||||
referent = _resolve_referent(low, context)
|
||||
if referent is None:
|
||||
ev.append("authorization language but NO live resolvable "
|
||||
"referent — never guessed")
|
||||
probes.append("what should that refer to? I don't have an "
|
||||
"active proposal matching it.")
|
||||
return _intent(t, "ACTION", "DISCUSSION", 0.4, "REFERENT",
|
||||
ev, probes, context, origin_context_id)
|
||||
if referent.get("ambiguous"):
|
||||
ev.append("multiple equally plausible referents — asking, "
|
||||
"not guessing (§17M)")
|
||||
probes.append("which one — " + " or ".join(
|
||||
p.summary[:40] for p in referent["candidates"]) + "?")
|
||||
return _intent(t, "ACTION", "DISCUSSION", 0.4, "REFERENT",
|
||||
ev, probes, context, origin_context_id)
|
||||
target_prop = referent["proposal"]
|
||||
ev.append(f"referent resolved: {target_prop.proposal_id} "
|
||||
f"(recency-first among live proposals)")
|
||||
ev.append("prior proposal + explicit authorization → USER_APPROVED")
|
||||
return ConstructionIntent(
|
||||
intent_id=f"intent-{uuid.uuid4().hex[:8]}",
|
||||
objective=target_prop.summary,
|
||||
requested_effect=f"construct: {target_prop.summary}",
|
||||
resolved_target=context.active_project,
|
||||
resolved_referent=target_prop.proposal_id,
|
||||
origin_context_id=origin_context_id,
|
||||
action_intent="ACTION", approval_state="USER_APPROVED",
|
||||
confidence=0.9, ambiguity="NONE",
|
||||
consequence_level=_consequence(t, context),
|
||||
relevant_constraints=_constraints(context),
|
||||
classification_evidence=ev)
|
||||
|
||||
# 3. explicit imperative construction demand with resolved target
|
||||
if imperative:
|
||||
# non-constructible object check is Scaffolding's classifier's
|
||||
# job (§17O) — this layer records the demand structure only
|
||||
ev.append(f"imperative construction verb: {imperative} — strong/"
|
||||
f"direct demand signal (§17K)")
|
||||
target = context.active_project
|
||||
if target:
|
||||
ev.append(f"target resolved from active context: {target}")
|
||||
return _intent(t, "ACTION", "USER_APPROVED", 0.9, "NONE",
|
||||
ev, probes, context, origin_context_id,
|
||||
requested_effect=f"construct: {t}",
|
||||
consequence_level=_consequence(t, context))
|
||||
# no active project: a CREATION verb + indefinite article signals
|
||||
# a NEW standalone artifact — the demand is complete without an
|
||||
# existing target ("build me a program that...")
|
||||
if imperative in _CREATION_VERBS and _NEW_ARTICLE.search(low):
|
||||
ev.append("creation verb + indefinite article → new standalone "
|
||||
"artifact demand; no existing target required")
|
||||
return _intent(t, "ACTION", "USER_APPROVED", 0.85, "NONE",
|
||||
ev, probes, context, origin_context_id,
|
||||
requested_effect=f"construct new: {t}")
|
||||
ev.append("imperative demand but no resolvable target — "
|
||||
"clarification, never guessing")
|
||||
probes.append("build where — which project/system?")
|
||||
return _intent(t, "ACTION", "DISCUSSION", 0.5, "TARGET",
|
||||
ev, probes, context, origin_context_id)
|
||||
|
||||
# 4. default: discussion / context statement
|
||||
ev.append("no authorization structure, no imperative demand → "
|
||||
"discussion")
|
||||
return _intent(t, "DISCUSSION", "DISCUSSION", 0.7, "NONE",
|
||||
ev, probes, context, origin_context_id)
|
||||
|
||||
|
||||
def _resolve_referent(low: str, context: ConversationContext) -> dict | None:
|
||||
"""Recency-first referent resolution over LIVE proposals (§17M).
|
||||
Stale-context-resistant: proposals older than STALE_GAP_TURNS are
|
||||
ignored unless the user's text explicitly references their content.
|
||||
Returns {"proposal": Proposal} or {"ambiguous": True,
|
||||
"candidates": [...]} or None."""
|
||||
live = context.live_proposals()
|
||||
if not live:
|
||||
return None
|
||||
fresh = [p for p in live
|
||||
if context.turn - p.created_turn <= STALE_GAP_TURNS
|
||||
or _references(p.summary, low)]
|
||||
if not fresh:
|
||||
return None # all live proposals are stale for this turn
|
||||
live = fresh
|
||||
# option-selectors pick among live proposals by ordinal
|
||||
m = _OPTION_REFERENTS.search(low)
|
||||
if m:
|
||||
ordinals = {"first": 0, "second": 1, "third": 2, "other": 1,
|
||||
"latter": len(live) - 1, "former": 0}
|
||||
idx = ordinals.get(m.group(1).lower())
|
||||
if idx is not None and idx < len(live):
|
||||
return {"proposal": live[idx]}
|
||||
if len(live) > 1:
|
||||
return {"ambiguous": True, "candidates": live}
|
||||
# multiple live proposals + no ordinal → ambiguous unless recency
|
||||
# is decisive (only one proposal younger than the last topic shift)
|
||||
if len(live) > 1:
|
||||
# recency heuristic: if the newest proposal is at least 2 turns
|
||||
# newer than the runner-up, recency decides; otherwise ask
|
||||
newest, runner = live[-1], live[-2]
|
||||
if newest.created_turn - runner.created_turn >= 2:
|
||||
return {"proposal": newest}
|
||||
return {"ambiguous": True, "candidates": live}
|
||||
return {"proposal": live[0]}
|
||||
|
||||
|
||||
def _references(summary: str, low: str) -> bool:
|
||||
"""Does the user's text explicitly reference the proposal's content?
|
||||
Distinctive content words (len>4) appearing in the text revive a
|
||||
stale proposal (explicit > recency)."""
|
||||
words = {w for w in _tokens(summary) if len(w) > 4}
|
||||
return bool(words & set(_tokens(low)))
|
||||
|
||||
|
||||
def _consequence(text: str, context: ConversationContext) -> str:
|
||||
low = text.lower()
|
||||
if any(w in low for w in ("replace", "migrate", "rewrite", "delete",
|
||||
"remove all", "subsystem")):
|
||||
return "HIGH"
|
||||
if any(w in low for w in ("add", "extend", "port", "integrate")):
|
||||
return "MEDIUM"
|
||||
return "LOW"
|
||||
|
||||
|
||||
def _constraints(context: ConversationContext) -> list:
|
||||
"""User/project constraints recorded in recent turns (light capture;
|
||||
the constraint STORE is future Substrate's — §17O boundary)."""
|
||||
out = []
|
||||
for turn in context.recent_turns[-6:]:
|
||||
txt = turn.get("text", "").lower()
|
||||
if "must not" in txt or "don't" in txt or "never" in txt or \
|
||||
"no hardcoded" in txt:
|
||||
out.append(turn["text"][:120])
|
||||
return out
|
||||
|
||||
|
||||
def _intent(objective: str, action_intent: str, approval_state: str,
|
||||
confidence: float, ambiguity: str, ev: list, probes: list,
|
||||
context: ConversationContext, origin_context_id: str | None,
|
||||
requested_effect: str = "",
|
||||
consequence_level: str | None = None) -> ConstructionIntent:
|
||||
return ConstructionIntent(
|
||||
intent_id=f"intent-{uuid.uuid4().hex[:8]}",
|
||||
objective=objective,
|
||||
requested_effect=requested_effect or objective,
|
||||
resolved_target=context.active_project,
|
||||
resolved_referent=None,
|
||||
origin_context_id=origin_context_id,
|
||||
action_intent=action_intent,
|
||||
approval_state=approval_state,
|
||||
confidence=confidence, ambiguity=ambiguity,
|
||||
consequence_level=consequence_level
|
||||
or ("LOW" if action_intent == "DISCUSSION" else "MEDIUM"),
|
||||
relevant_constraints=_constraints(context),
|
||||
classification_evidence=ev, probe_questions=probes)
|
||||
@@ -293,9 +293,32 @@ def classify_mode(
|
||||
file_scoped = True
|
||||
break
|
||||
|
||||
# T17 §17Q: indefinite-article new-artifact evidence — "a small CLI
|
||||
# that renames photos", "build me a program that..." — is
|
||||
# new-lifecycle language even without explicit standalone keywords.
|
||||
# Guarded: requires a CREATION verb or artifact noun, and yields to
|
||||
# local/scope/file evidence (fix a bug / add a flag never counts).
|
||||
_CREATION_VERBS = {"build", "create", "make", "write", "develop"}
|
||||
_ARTIFACT_NOUNS = {"program", "cli", "tool", "app", "script", "service",
|
||||
"library", "package", "module", "dashboard",
|
||||
"website", "bot", "generator", "tracker", "game",
|
||||
"application", "command"}
|
||||
article_standalone = False
|
||||
if re.search(r"\b(a|an|another)\s+[a-z]", obj.lower()):
|
||||
creation_verb = bool(_tokens(obj.lower()) & _CREATION_VERBS)
|
||||
artifact_noun = bool(_tokens(obj.lower()) & _ARTIFACT_NOUNS)
|
||||
# structural evidence outranks lexical newness (§5): a known
|
||||
# existing target means article language describes a CHANGE
|
||||
# ("add a flag to the deploy script"), not a new project
|
||||
if (creation_verb or artifact_noun) and not local and not scoped \
|
||||
and not file_scoped and not target_project \
|
||||
and project_exists is not True:
|
||||
article_standalone = True
|
||||
ev.append("indefinite-article new-artifact language")
|
||||
|
||||
# 3. PROJECT_BUILD: standalone/new-lifecycle language AND no existing
|
||||
# project for the target (project existence materially matters, §5)
|
||||
if standalone:
|
||||
if standalone or article_standalone:
|
||||
ev.append(f"standalone/new-lifecycle language: {standalone[:3]}")
|
||||
if project_exists is True:
|
||||
# §5: existing project preferred unless evidence says otherwise —
|
||||
|
||||
@@ -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}
|
||||
+120
-26
@@ -147,24 +147,39 @@ def _load_waitstore() -> Any:
|
||||
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."""
|
||||
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)
|
||||
if fifo.exists():
|
||||
fifo.unlink()
|
||||
os.mkfifo(fifo)
|
||||
# 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="builder",
|
||||
event_type="builder_exit",
|
||||
provider=provider,
|
||||
event_type=event_type,
|
||||
sha=sha,
|
||||
repo=project or "construction",
|
||||
run_ref=operation_id,
|
||||
expected="success",
|
||||
@@ -183,6 +198,13 @@ def register_process_wait(operation_id: str, *, contract_id: str = "",
|
||||
"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
|
||||
|
||||
|
||||
@@ -190,6 +212,29 @@ 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
|
||||
@@ -261,10 +306,18 @@ def liveness(operation_id: str, pgid: int | None = None) -> bool:
|
||||
|
||||
def await_resume_event(operation_id: str, *, watchdog_secs: int,
|
||||
pgid: int | None = None,
|
||||
max_rearms: int = 20) -> dict:
|
||||
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
|
||||
@@ -273,17 +326,21 @@ def await_resume_event(operation_id: str, *, watchdog_secs: int,
|
||||
build outlived a single 900s deadline, and node regrouping hid the
|
||||
processes from the pgid scan — neither is a failure of continuation.
|
||||
"""
|
||||
fifo = fifo_path(operation_id)
|
||||
# idempotent re-arm: a prior await consumed+unlinked the fifo; the
|
||||
# rendezvous point is (re)created so watchdog re-arm and recovery
|
||||
# paths can wait again on the same operation
|
||||
fifo.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not fifo.exists():
|
||||
os.mkfifo(fifo)
|
||||
for arm in range(max_rearms + 1):
|
||||
deadline = time.monotonic() + watchdog_secs
|
||||
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)
|
||||
try:
|
||||
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:
|
||||
@@ -293,7 +350,12 @@ def await_resume_event(operation_id: str, *, watchdog_secs: int,
|
||||
continue
|
||||
data = os.read(fd, 4096)
|
||||
if not data:
|
||||
continue
|
||||
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:
|
||||
@@ -302,15 +364,16 @@ def await_resume_event(operation_id: str, *, watchdog_secs: int,
|
||||
rec = ws.load(token.get("wait_id", ""))
|
||||
return {"kind": "event", "wait": rec,
|
||||
"resumed_at": token.get("resumed_at")}
|
||||
finally:
|
||||
# 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)
|
||||
# deadline expired: liveness decides re-arm vs PROCESS_LOST
|
||||
alive = liveness(operation_id, pgid)
|
||||
if not alive:
|
||||
return {"kind": "watchdog", "alive": False,
|
||||
"rearms": arm}
|
||||
return {"kind": "watchdog", "alive": liveness(operation_id, pgid),
|
||||
"rearms": max_rearms}
|
||||
|
||||
|
||||
def operation_wait(operation_id: str) -> dict | None:
|
||||
@@ -347,6 +410,37 @@ def resume_or_recover(operation_id: str) -> dict:
|
||||
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -21,10 +21,11 @@ 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, UNKNOWN)
|
||||
APPROVAL_REQUIRED, PROVIDER_UNAVAILABLE, UNKNOWN)
|
||||
|
||||
# recovery policy per class: (max_retries, action)
|
||||
POLICY = {
|
||||
@@ -33,6 +34,9 @@ POLICY = {
|
||||
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"),
|
||||
}
|
||||
|
||||
@@ -74,6 +78,14 @@ def classify_failure(error_text: str, *, context: dict | None = None) -> dict:
|
||||
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",
|
||||
|
||||
@@ -22,6 +22,7 @@ import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
|
||||
# stage states
|
||||
@@ -101,6 +102,15 @@ class Programme:
|
||||
"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"}
|
||||
|
||||
@@ -163,3 +173,177 @@ def run_programme(prog: Programme, stage_runner, *,
|
||||
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
|
||||
@@ -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.60
|
||||
Version: v0.66.69
|
||||
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.60 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
f"[dim]Rune v0.66.69 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
)
|
||||
|
||||
# Auto-show night mode suggestion if evening/night hours
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,35 @@
|
||||
# T17 §17F/§17G/§17H audits (2026-09-15, from parallel read-only sweeps)
|
||||
|
||||
## 17F — task-state surfaces
|
||||
1. TaskStateRepository (orchestration/conversation_state.py:111) — CANONICAL
|
||||
(durable JSONL + hot checkpoint, legal transitions, wired through composition)
|
||||
2. RuneState.tasks in operator.json (rune_operator.py:377) — DUPLICATE
|
||||
(double-written on every send_task; the one true duplicate authority pair)
|
||||
3. task_persist.py persisted.json — LEGACY/QUEUE (TUI startup load is dead code:
|
||||
calls load() which does not exist, silently swallowed)
|
||||
4. memory_store working table — CACHE (single consumer: its own CLI)
|
||||
5. auto_tasks.db — QUEUE (self-contained, no bridge)
|
||||
6. work_manager batch meta — QUEUE (task-adjacent, TUI /work only)
|
||||
CONSOLIDATION: #1 authority; migrate #2 writers (send_task → repo only,
|
||||
derive get_status/list_tasks), fix #3 dead load, keep #4-6 as bounded caches/queues.
|
||||
|
||||
## 17G — build_mode
|
||||
Unique: VCS worktree isolation (create/merge/delete) — NOT in Scaffolding;
|
||||
phase ledger/resume. NOT unique: DAG batching (work_manager), correction
|
||||
loop (counter only), complexity assess (placeholder). Plugin BuildStateMachine
|
||||
= second parallel orchestrator (DUPLICATE, only assess.py referenced).
|
||||
T15 classification lives in operator hook path; TUI /build start bypasses
|
||||
it and mints unclassified state.
|
||||
SMALLEST MIGRATION: _cmd_build start → classify first (same classify_mode),
|
||||
refuse NOT_CONSTRUCTION/UNKNOWN with probes, activate contract, then delegate
|
||||
to BuildManager as bounded adapter (worktree + resume ledger). Delete plugin
|
||||
duplicate.
|
||||
|
||||
## 17H — W/R daemon ownership (verdict)
|
||||
INTENTIONAL SHARED INFRASTRUCTURE with packaging debt. The bridge serves
|
||||
multiple consumers (deploy waiter as runner user, daemon as nick, MiMo
|
||||
sessions). Smallest clean migration: keep out-of-repo deployment BUT (a)
|
||||
versioned snapshot (git tag per release), (b) RUNE_WR_CODE discovery
|
||||
already implemented in event_wait, (c) health endpoint exists. Full
|
||||
in-repo packaging DEFERRED — the shared-root contract (0644/0666) is the
|
||||
load-bearing fix and it is now guarded by tests.
|
||||
@@ -1,5 +1,71 @@
|
||||
{
|
||||
"entities": [
|
||||
{
|
||||
"checkpoints": [],
|
||||
"evidence_refs": [
|
||||
"contract-created:17e-stage-3-fails"
|
||||
],
|
||||
"id": "contract:17e-stage-3-fails",
|
||||
"identities": {
|
||||
"acceptance": [],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [],
|
||||
"gap_evidence": "",
|
||||
"migration_obligations": [],
|
||||
"need": "deliberately failing stage",
|
||||
"next_action": "review for approval",
|
||||
"non_goals": [],
|
||||
"origin": "programme",
|
||||
"regression_obligations": [],
|
||||
"requirement": "",
|
||||
"research_refs": [],
|
||||
"resolution": "",
|
||||
"rollback_ref": "",
|
||||
"scope": [],
|
||||
"toolchain": {},
|
||||
"verification_obligations": []
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T17:50:29Z",
|
||||
"phase": "draft",
|
||||
"status": "open",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"checkpoints": [],
|
||||
"evidence_refs": [
|
||||
"contract-created:17e-stage-4-final"
|
||||
],
|
||||
"id": "contract:17e-stage-4-final",
|
||||
"identities": {
|
||||
"acceptance": [],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [],
|
||||
"gap_evidence": "",
|
||||
"migration_obligations": [],
|
||||
"need": "final stage",
|
||||
"next_action": "review for approval",
|
||||
"non_goals": [],
|
||||
"origin": "programme",
|
||||
"regression_obligations": [],
|
||||
"requirement": "",
|
||||
"research_refs": [],
|
||||
"resolution": "",
|
||||
"rollback_ref": "",
|
||||
"scope": [],
|
||||
"toolchain": {},
|
||||
"verification_obligations": []
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T17:50:29Z",
|
||||
"phase": "draft",
|
||||
"status": "open",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
@@ -34781,6 +34847,674 @@
|
||||
"status": "closed",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-09-15T16:35:22Z",
|
||||
"evidence": "",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
},
|
||||
{
|
||||
"at": "2026-09-15T16:35:22Z",
|
||||
"evidence": "",
|
||||
"note": "",
|
||||
"phase": "building"
|
||||
},
|
||||
{
|
||||
"at": "2026-09-15T16:35:22Z",
|
||||
"evidence": "",
|
||||
"note": "",
|
||||
"phase": "closed"
|
||||
},
|
||||
{
|
||||
"at": "2026-09-15T16:35:22Z",
|
||||
"evidence": "",
|
||||
"note": "",
|
||||
"phase": "verifying"
|
||||
},
|
||||
{
|
||||
"evidence": "YAML parses; glob fixed; 24 canary files at target"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:16R-maintenance-canary-glob"
|
||||
],
|
||||
"id": "contract:16R-maintenance-canary-glob",
|
||||
"identities": {
|
||||
"acceptance": [
|
||||
"canary files exist at the glob target",
|
||||
"glob points at src/rune/canary_*.py",
|
||||
"workflow YAML parses"
|
||||
],
|
||||
"affected_entities": [
|
||||
"module:canary_native"
|
||||
],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [],
|
||||
"gap_evidence": "",
|
||||
"migration_obligations": [],
|
||||
"need": "maintenance.yml canary-suite job loops shared/canary_*.py \u2014 real canaries live in src/rune/; job silently no-ops (16M finding #6)",
|
||||
"next_action": "observe real outcome; capture lessons",
|
||||
"non_goals": [
|
||||
"broad maintenance-ledger consolidation (16Q DEFER)",
|
||||
"build_mode removal (DEFER \u2014 needs migration plan)"
|
||||
],
|
||||
"origin": "user",
|
||||
"regression_obligations": [],
|
||||
"requirement": "",
|
||||
"research_refs": [],
|
||||
"resolution": "BUILD_NEW",
|
||||
"rollback_ref": "",
|
||||
"scope": [
|
||||
".gitea/workflows/maintenance.yml"
|
||||
],
|
||||
"toolchain": {},
|
||||
"verification_obligations": []
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T16:35:22Z",
|
||||
"phase": "closed",
|
||||
"status": "closed",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-09-15T17:50:29Z",
|
||||
"evidence": "programme-stage-start",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:17e-stage-1-governance"
|
||||
],
|
||||
"id": "contract:17e-stage-1-governance",
|
||||
"identities": {
|
||||
"acceptance": [],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [],
|
||||
"gap_evidence": "",
|
||||
"migration_obligations": [],
|
||||
"need": "governance-only stage",
|
||||
"next_action": "begin building",
|
||||
"non_goals": [],
|
||||
"origin": "programme",
|
||||
"regression_obligations": [],
|
||||
"requirement": "",
|
||||
"research_refs": [],
|
||||
"resolution": "",
|
||||
"rollback_ref": "",
|
||||
"scope": [],
|
||||
"toolchain": {},
|
||||
"verification_obligations": []
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T17:50:29Z",
|
||||
"phase": "approved",
|
||||
"status": "open",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-09-15T17:50:29Z",
|
||||
"evidence": "programme-stage-start",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:17e-stage-2-async"
|
||||
],
|
||||
"id": "contract:17e-stage-2-async",
|
||||
"identities": {
|
||||
"acceptance": [],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [],
|
||||
"gap_evidence": "",
|
||||
"migration_obligations": [],
|
||||
"need": "async work under event bridge",
|
||||
"next_action": "begin building",
|
||||
"non_goals": [],
|
||||
"origin": "programme",
|
||||
"regression_obligations": [],
|
||||
"requirement": "",
|
||||
"research_refs": [],
|
||||
"resolution": "",
|
||||
"rollback_ref": "",
|
||||
"scope": [],
|
||||
"toolchain": {},
|
||||
"verification_obligations": []
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T17:50:29Z",
|
||||
"phase": "approved",
|
||||
"status": "open",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-09-15T17:51:19Z",
|
||||
"evidence": "programme-stage-start",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:17e2-stage-1-governance"
|
||||
],
|
||||
"id": "contract:17e2-stage-1-governance",
|
||||
"identities": {
|
||||
"acceptance": [],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [],
|
||||
"gap_evidence": "",
|
||||
"migration_obligations": [],
|
||||
"need": "governance-only stage",
|
||||
"next_action": "begin building",
|
||||
"non_goals": [],
|
||||
"origin": "programme",
|
||||
"regression_obligations": [],
|
||||
"requirement": "",
|
||||
"research_refs": [],
|
||||
"resolution": "",
|
||||
"rollback_ref": "",
|
||||
"scope": [],
|
||||
"toolchain": {},
|
||||
"verification_obligations": []
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T17:51:19Z",
|
||||
"phase": "approved",
|
||||
"status": "open",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-09-15T17:51:19Z",
|
||||
"evidence": "programme-stage-start",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:17e2-stage-2-async"
|
||||
],
|
||||
"id": "contract:17e2-stage-2-async",
|
||||
"identities": {
|
||||
"acceptance": [],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [],
|
||||
"gap_evidence": "",
|
||||
"migration_obligations": [],
|
||||
"need": "async work under event bridge",
|
||||
"next_action": "begin building",
|
||||
"non_goals": [],
|
||||
"origin": "programme",
|
||||
"regression_obligations": [],
|
||||
"requirement": "",
|
||||
"research_refs": [],
|
||||
"resolution": "",
|
||||
"rollback_ref": "",
|
||||
"scope": [],
|
||||
"toolchain": {},
|
||||
"verification_obligations": []
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T17:51:19Z",
|
||||
"phase": "approved",
|
||||
"status": "open",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-09-15T17:51:22Z",
|
||||
"evidence": "programme-stage-start",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:17e2-stage-3-fails"
|
||||
],
|
||||
"id": "contract:17e2-stage-3-fails",
|
||||
"identities": {
|
||||
"acceptance": [],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [],
|
||||
"gap_evidence": "",
|
||||
"migration_obligations": [],
|
||||
"need": "deliberately failing stage",
|
||||
"next_action": "begin building",
|
||||
"non_goals": [],
|
||||
"origin": "programme",
|
||||
"regression_obligations": [],
|
||||
"requirement": "",
|
||||
"research_refs": [],
|
||||
"resolution": "",
|
||||
"rollback_ref": "",
|
||||
"scope": [],
|
||||
"toolchain": {},
|
||||
"verification_obligations": []
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T17:51:22Z",
|
||||
"phase": "approved",
|
||||
"status": "open",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-09-15T17:51:22Z",
|
||||
"evidence": "programme-stage-start",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:17e2-stage-4-final"
|
||||
],
|
||||
"id": "contract:17e2-stage-4-final",
|
||||
"identities": {
|
||||
"acceptance": [],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [],
|
||||
"gap_evidence": "",
|
||||
"migration_obligations": [],
|
||||
"need": "final stage",
|
||||
"next_action": "begin building",
|
||||
"non_goals": [],
|
||||
"origin": "programme",
|
||||
"regression_obligations": [],
|
||||
"requirement": "",
|
||||
"research_refs": [],
|
||||
"resolution": "",
|
||||
"rollback_ref": "",
|
||||
"scope": [],
|
||||
"toolchain": {},
|
||||
"verification_obligations": []
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T17:51:22Z",
|
||||
"phase": "approved",
|
||||
"status": "open",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-09-15T17:52:26Z",
|
||||
"evidence": "programme-stage-start",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
},
|
||||
{
|
||||
"at": "2026-09-15T17:52:27Z",
|
||||
"evidence": "closure-record",
|
||||
"note": "predicted-vs-observed captured",
|
||||
"phase": "closed"
|
||||
},
|
||||
{
|
||||
"at": "2026-09-15T17:52:27Z",
|
||||
"evidence": "governance-only-stage",
|
||||
"note": "",
|
||||
"phase": "verifying"
|
||||
},
|
||||
{
|
||||
"at": "2026-09-15T17:52:27Z",
|
||||
"evidence": "programme-stage-start",
|
||||
"note": "",
|
||||
"phase": "building"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:17e3-stage-1-governance"
|
||||
],
|
||||
"ext": {
|
||||
"contract_quality": {
|
||||
"candidate_corrections": [],
|
||||
"captured_at": "2026-09-15T17:52:27Z",
|
||||
"contract": "17e3-stage-1-governance",
|
||||
"correctly_predicted": [],
|
||||
"evidence_sources": [
|
||||
"programme-driver"
|
||||
],
|
||||
"expected_new_entities": [],
|
||||
"expected_new_missing": [],
|
||||
"expected_new_observed": [],
|
||||
"expected_runtime_effects": [],
|
||||
"expected_runtime_missing": [],
|
||||
"expected_runtime_observed": [],
|
||||
"genuinely_unexpected_new": [],
|
||||
"missed": [],
|
||||
"needless": [],
|
||||
"observed": [],
|
||||
"observed_existing": [],
|
||||
"observed_new": [],
|
||||
"predicted_but_unaffected": [],
|
||||
"predicted_existing_affected": [],
|
||||
"scope_check": {
|
||||
"in_scope": [],
|
||||
"out_of_scope": [],
|
||||
"reason": "contract has no approved scope",
|
||||
"verdict": "unknown"
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "contract:17e3-stage-1-governance",
|
||||
"identities": {
|
||||
"acceptance": [],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [],
|
||||
"gap_evidence": "",
|
||||
"migration_obligations": [],
|
||||
"need": "governance-only stage",
|
||||
"next_action": "observe real outcome; capture lessons",
|
||||
"non_goals": [],
|
||||
"origin": "programme",
|
||||
"regression_obligations": [],
|
||||
"requirement": "",
|
||||
"research_refs": [],
|
||||
"resolution": "",
|
||||
"rollback_ref": "",
|
||||
"scope": [],
|
||||
"toolchain": {},
|
||||
"verification_obligations": []
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T17:52:27Z",
|
||||
"phase": "closed",
|
||||
"status": "closed",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-09-15T17:52:27Z",
|
||||
"evidence": "programme-stage-start",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
},
|
||||
{
|
||||
"at": "2026-09-15T17:52:27Z",
|
||||
"evidence": "programme-stage-start",
|
||||
"note": "",
|
||||
"phase": "building"
|
||||
},
|
||||
{
|
||||
"at": "2026-09-15T17:52:29Z",
|
||||
"evidence": "closure-record",
|
||||
"note": "predicted-vs-observed captured",
|
||||
"phase": "closed"
|
||||
},
|
||||
{
|
||||
"at": "2026-09-15T17:52:29Z",
|
||||
"evidence": "programme-stage-verify",
|
||||
"note": "",
|
||||
"phase": "verifying"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:17e3-stage-2-async"
|
||||
],
|
||||
"ext": {
|
||||
"contract_quality": {
|
||||
"candidate_corrections": [],
|
||||
"captured_at": "2026-09-15T17:52:29Z",
|
||||
"contract": "17e3-stage-2-async",
|
||||
"correctly_predicted": [],
|
||||
"evidence_sources": [
|
||||
"programme-driver"
|
||||
],
|
||||
"expected_new_entities": [],
|
||||
"expected_new_missing": [],
|
||||
"expected_new_observed": [],
|
||||
"expected_runtime_effects": [],
|
||||
"expected_runtime_missing": [],
|
||||
"expected_runtime_observed": [],
|
||||
"genuinely_unexpected_new": [],
|
||||
"missed": [],
|
||||
"needless": [],
|
||||
"observed": [],
|
||||
"observed_existing": [],
|
||||
"observed_new": [],
|
||||
"predicted_but_unaffected": [],
|
||||
"predicted_existing_affected": [],
|
||||
"scope_check": {
|
||||
"in_scope": [],
|
||||
"out_of_scope": [],
|
||||
"reason": "contract has no approved scope",
|
||||
"verdict": "unknown"
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "contract:17e3-stage-2-async",
|
||||
"identities": {
|
||||
"acceptance": [],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [],
|
||||
"gap_evidence": "",
|
||||
"migration_obligations": [],
|
||||
"need": "async work under event bridge",
|
||||
"next_action": "observe real outcome; capture lessons",
|
||||
"non_goals": [],
|
||||
"origin": "programme",
|
||||
"regression_obligations": [],
|
||||
"requirement": "",
|
||||
"research_refs": [],
|
||||
"resolution": "",
|
||||
"rollback_ref": "",
|
||||
"scope": [],
|
||||
"toolchain": {},
|
||||
"verification_obligations": []
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T17:52:29Z",
|
||||
"phase": "closed",
|
||||
"status": "closed",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-09-15T17:52:30Z",
|
||||
"evidence": "closure-record",
|
||||
"note": "predicted-vs-observed captured",
|
||||
"phase": "closed"
|
||||
},
|
||||
{
|
||||
"at": "2026-09-15T17:52:30Z",
|
||||
"evidence": "programme-stage-start",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
},
|
||||
{
|
||||
"at": "2026-09-15T17:52:30Z",
|
||||
"evidence": "programme-stage-start",
|
||||
"note": "",
|
||||
"phase": "building"
|
||||
},
|
||||
{
|
||||
"at": "2026-09-15T17:52:30Z",
|
||||
"evidence": "programme-stage-verify",
|
||||
"note": "",
|
||||
"phase": "verifying"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:17e3-stage-3-fails"
|
||||
],
|
||||
"ext": {
|
||||
"contract_quality": {
|
||||
"candidate_corrections": [],
|
||||
"captured_at": "2026-09-15T17:52:30Z",
|
||||
"contract": "17e3-stage-3-fails",
|
||||
"correctly_predicted": [],
|
||||
"evidence_sources": [
|
||||
"programme-driver"
|
||||
],
|
||||
"expected_new_entities": [],
|
||||
"expected_new_missing": [],
|
||||
"expected_new_observed": [],
|
||||
"expected_runtime_effects": [],
|
||||
"expected_runtime_missing": [],
|
||||
"expected_runtime_observed": [],
|
||||
"genuinely_unexpected_new": [],
|
||||
"missed": [],
|
||||
"needless": [],
|
||||
"observed": [],
|
||||
"observed_existing": [],
|
||||
"observed_new": [],
|
||||
"predicted_but_unaffected": [],
|
||||
"predicted_existing_affected": [],
|
||||
"scope_check": {
|
||||
"in_scope": [],
|
||||
"out_of_scope": [],
|
||||
"reason": "contract has no approved scope",
|
||||
"verdict": "unknown"
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "contract:17e3-stage-3-fails",
|
||||
"identities": {
|
||||
"acceptance": [],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [],
|
||||
"gap_evidence": "",
|
||||
"migration_obligations": [],
|
||||
"need": "deliberately failing stage",
|
||||
"next_action": "observe real outcome; capture lessons",
|
||||
"non_goals": [],
|
||||
"origin": "programme",
|
||||
"regression_obligations": [],
|
||||
"requirement": "",
|
||||
"research_refs": [],
|
||||
"resolution": "",
|
||||
"rollback_ref": "",
|
||||
"scope": [],
|
||||
"toolchain": {},
|
||||
"verification_obligations": []
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T17:52:30Z",
|
||||
"phase": "closed",
|
||||
"status": "closed",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-09-15T17:52:30Z",
|
||||
"evidence": "programme-stage-start",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
},
|
||||
{
|
||||
"at": "2026-09-15T17:52:30Z",
|
||||
"evidence": "programme-stage-start",
|
||||
"note": "",
|
||||
"phase": "building"
|
||||
},
|
||||
{
|
||||
"at": "2026-09-15T17:52:30Z",
|
||||
"evidence": "programme-stage-verify",
|
||||
"note": "",
|
||||
"phase": "verifying"
|
||||
},
|
||||
{
|
||||
"at": "2026-09-15T17:52:31Z",
|
||||
"evidence": "closure-record",
|
||||
"note": "predicted-vs-observed captured",
|
||||
"phase": "closed"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:17e3-stage-4-final"
|
||||
],
|
||||
"ext": {
|
||||
"contract_quality": {
|
||||
"candidate_corrections": [],
|
||||
"captured_at": "2026-09-15T17:52:31Z",
|
||||
"contract": "17e3-stage-4-final",
|
||||
"correctly_predicted": [],
|
||||
"evidence_sources": [
|
||||
"programme-driver"
|
||||
],
|
||||
"expected_new_entities": [],
|
||||
"expected_new_missing": [],
|
||||
"expected_new_observed": [],
|
||||
"expected_runtime_effects": [],
|
||||
"expected_runtime_missing": [],
|
||||
"expected_runtime_observed": [],
|
||||
"genuinely_unexpected_new": [],
|
||||
"missed": [],
|
||||
"needless": [],
|
||||
"observed": [],
|
||||
"observed_existing": [],
|
||||
"observed_new": [],
|
||||
"predicted_but_unaffected": [],
|
||||
"predicted_existing_affected": [],
|
||||
"scope_check": {
|
||||
"in_scope": [],
|
||||
"out_of_scope": [],
|
||||
"reason": "contract has no approved scope",
|
||||
"verdict": "unknown"
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "contract:17e3-stage-4-final",
|
||||
"identities": {
|
||||
"acceptance": [],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [],
|
||||
"gap_evidence": "",
|
||||
"migration_obligations": [],
|
||||
"need": "final stage",
|
||||
"next_action": "observe real outcome; capture lessons",
|
||||
"non_goals": [],
|
||||
"origin": "programme",
|
||||
"regression_obligations": [],
|
||||
"requirement": "",
|
||||
"research_refs": [],
|
||||
"resolution": "",
|
||||
"rollback_ref": "",
|
||||
"scope": [],
|
||||
"toolchain": {},
|
||||
"verification_obligations": []
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T17:52:31Z",
|
||||
"phase": "closed",
|
||||
"status": "closed",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"DEPLOY-MONOTONIC-001"
|
||||
@@ -35526,6 +36260,34 @@
|
||||
"observed_at": "2026-09-13T02:16:19Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"scaffold-init"
|
||||
],
|
||||
"id": "project:rune-self",
|
||||
"identities": {
|
||||
"purpose": "Rune retroactive self-scaffolding (16M inventory live)",
|
||||
"requirement": ""
|
||||
},
|
||||
"kind": "project",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T16:35:22Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"scaffold-init"
|
||||
],
|
||||
"id": "project:t17-programme-proof",
|
||||
"identities": {
|
||||
"purpose": "T17 17E live multi-stage programme continuation proof",
|
||||
"requirement": ""
|
||||
},
|
||||
"kind": "project",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-09-15T17:49:48Z",
|
||||
"tags": []
|
||||
},
|
||||
{
|
||||
"evidence_refs": [
|
||||
"store/evidence/t14/runA-end-to-end.log",
|
||||
@@ -47833,6 +48595,182 @@
|
||||
"mode": "rune_internal",
|
||||
"observed_at": "2026-09-14T01:54:18Z",
|
||||
"operation": "submit_need"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e-stage-1-governance \u2192 approved",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:50:29Z",
|
||||
"operation": "transition"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e-stage-2-async \u2192 approved",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:50:29Z",
|
||||
"operation": "transition"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e2-stage-1-governance \u2192 approved",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:51:19Z",
|
||||
"operation": "transition"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e2-stage-2-async \u2192 approved",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:51:19Z",
|
||||
"operation": "transition"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e2-stage-3-fails \u2192 approved",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:51:22Z",
|
||||
"operation": "transition"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e2-stage-4-final \u2192 approved",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:51:22Z",
|
||||
"operation": "transition"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e3-stage-1-governance \u2192 approved",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:52:26Z",
|
||||
"operation": "transition"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e3-stage-1-governance \u2192 building",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:52:27Z",
|
||||
"operation": "transition"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e3-stage-1-governance \u2192 verifying",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:52:27Z",
|
||||
"operation": "transition"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e3-stage-1-governance closed",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:52:27Z",
|
||||
"operation": "close"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e3-stage-2-async \u2192 approved",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:52:27Z",
|
||||
"operation": "transition"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e3-stage-2-async \u2192 building",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:52:27Z",
|
||||
"operation": "transition"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e3-stage-2-async \u2192 verifying",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:52:29Z",
|
||||
"operation": "transition"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e3-stage-2-async closed",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:52:29Z",
|
||||
"operation": "close"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e3-stage-3-fails \u2192 approved",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:52:30Z",
|
||||
"operation": "transition"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e3-stage-3-fails \u2192 building",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:52:30Z",
|
||||
"operation": "transition"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e3-stage-3-fails \u2192 verifying",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:52:30Z",
|
||||
"operation": "transition"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e3-stage-3-fails closed",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:52:30Z",
|
||||
"operation": "close"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e3-stage-4-final \u2192 approved",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:52:30Z",
|
||||
"operation": "transition"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e3-stage-4-final \u2192 building",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:52:30Z",
|
||||
"operation": "transition"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e3-stage-4-final \u2192 verifying",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:52:30Z",
|
||||
"operation": "transition"
|
||||
},
|
||||
{
|
||||
"builder": "t17-programme",
|
||||
"detail": "17e3-stage-4-final closed",
|
||||
"kind": "builder_session",
|
||||
"mode": "external_project",
|
||||
"observed_at": "2026-09-15T17:52:31Z",
|
||||
"operation": "close"
|
||||
}
|
||||
],
|
||||
"relationships": [
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
+685
-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)
|
||||
@@ -0,0 +1,270 @@
|
||||
"""test_contextual_construction.py — T17 §17J-§17Q: conversational
|
||||
interpretation held-outs.
|
||||
|
||||
The matrix cases are TESTS, not trigger vocabulary: every authorization
|
||||
and referent case below uses fresh paraphrases not present in the
|
||||
implementation. The intent layer resolves MEANING; Scaffolding's
|
||||
classify_mode (the ONE classifier) decides mode. §17O boundary holds
|
||||
throughout.
|
||||
"""
|
||||
|
||||
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_intent as cint # noqa: E402
|
||||
from rune import construction_mode as cm # noqa: E402
|
||||
|
||||
|
||||
def ctx(**kw) -> cint.ConversationContext:
|
||||
return cint.ConversationContext(**kw)
|
||||
|
||||
|
||||
def propose(c, summary) -> cint.Proposal:
|
||||
return c.add_proposal(summary)
|
||||
|
||||
|
||||
# ── 17Q case 1: explicit demand, new standalone program ────────────────
|
||||
def test_q1_explicit_demand_new_program():
|
||||
c = ctx()
|
||||
intent = cint.resolve_construction_intent(
|
||||
"Build me a program that tracks my climbing sessions", c)
|
||||
assert intent.action_intent == "ACTION"
|
||||
assert intent.approval_state == "USER_APPROVED"
|
||||
# Scaffolding (the ONE classifier) decides the mode
|
||||
mode = cm.classify_mode(intent.objective, project_exists=False)
|
||||
assert mode.mode == "PROJECT_BUILD" and mode.bootstrap_required
|
||||
|
||||
|
||||
# ── 17Q case 2: "how would you build X?" is discussion only ────────────
|
||||
def test_q2_how_question_never_authorizes():
|
||||
c = ctx()
|
||||
intent = cint.resolve_construction_intent(
|
||||
"How would you build a session tracker for climbing?", c)
|
||||
assert intent.action_intent == "DISCUSSION"
|
||||
assert intent.approval_state == "DISCUSSION"
|
||||
mode = cm.classify_mode(intent.objective)
|
||||
assert mode.mode != "PROJECT_BUILD" or intent.approval_state != \
|
||||
"USER_APPROVED" # no construction intent, no mutation
|
||||
|
||||
|
||||
# ── 17Q case 3: propose → later authorize (§17L lifecycle) ─────────────
|
||||
def test_q3_proposal_then_authorization():
|
||||
c = ctx()
|
||||
i1 = cint.resolve_construction_intent(
|
||||
"Do you think the wait/resume machinery could be simpler?", c)
|
||||
assert i1.approval_state == "DISCUSSION"
|
||||
# Rune proposes (recorded via the context, not mutation)
|
||||
propose(c, "Consolidate the four wait/verify paths into one bridge "
|
||||
"adapter with per-kind verdict functions")
|
||||
i2 = cint.resolve_construction_intent(
|
||||
"Alright, let's go ahead with that.", c)
|
||||
assert i2.approval_state == "USER_APPROVED"
|
||||
assert i2.resolved_referent == c.live_proposals()[0].proposal_id
|
||||
assert "Consolidate" in i2.objective
|
||||
|
||||
|
||||
# ── 17Q case 4: unseen indirect authorization of a prior proposal ──────
|
||||
def test_q4_unseen_indirect_authorization():
|
||||
c = ctx()
|
||||
propose(c, "Add a drift-aware resume gate to the programme runner")
|
||||
i = cint.resolve_construction_intent(
|
||||
"Yeah that's the one, make it happen.", c)
|
||||
assert i.approval_state == "USER_APPROVED"
|
||||
assert i.resolved_referent is not None
|
||||
|
||||
|
||||
# ── 17Q case 5: ordinal option selection among multiple proposals ──────
|
||||
def test_q5_ordinal_referent():
|
||||
c = ctx()
|
||||
p1 = propose(c, "Option A: poll-based retry sweep")
|
||||
p2 = propose(c, "Option B: event-driven resume with watchdog")
|
||||
i = cint.resolve_construction_intent(
|
||||
"Go with the second approach.", c)
|
||||
assert i.approval_state == "USER_APPROVED"
|
||||
assert i.resolved_referent == p2.proposal_id
|
||||
|
||||
|
||||
# ── 17Q case 6: ambiguous "do that" → clarification, never a guess ─────
|
||||
def test_q6_ambiguous_referent_asks():
|
||||
c = ctx()
|
||||
p1 = propose(c, "Rewrite the transport layer")
|
||||
p2 = propose(c, "Rewrite the router escalation")
|
||||
# both proposed same turn → recency NOT decisive
|
||||
i = cint.resolve_construction_intent("Do that.", c)
|
||||
assert i.ambiguity == "REFERENT"
|
||||
assert i.approval_state != "USER_APPROVED"
|
||||
assert i.probe_questions # asks, doesn't guess
|
||||
|
||||
|
||||
# ── 17Q case 7: explicit imperative "build that", resolved referent ────
|
||||
def test_q7_imperative_with_resolved_referent():
|
||||
c = ctx()
|
||||
propose(c, "Bounded CLI for the ledger")
|
||||
i = cint.resolve_construction_intent("Build that.", c)
|
||||
assert i.action_intent == "ACTION"
|
||||
assert i.approval_state == "USER_APPROVED"
|
||||
assert i.resolved_referent == c.live_proposals()[0].proposal_id
|
||||
|
||||
|
||||
# ── 17Q case 8: topic changed → stale proposal not selected ────────────
|
||||
def test_q8_stale_context_resistance():
|
||||
c = ctx()
|
||||
propose(c, "Refactor the old exporter")
|
||||
c.record_turn("user", "actually, never mind that — let's talk about "
|
||||
"the TUI fonts instead")
|
||||
c.record_turn("assistant", "sure, fonts it is")
|
||||
c.next_turn()
|
||||
i = cint.resolve_construction_intent("Build that.", c)
|
||||
# "that" now plausibly refers to the font topic, not the stale
|
||||
# proposal — the layer must NOT silently build the exporter
|
||||
assert i.approval_state != "USER_APPROVED" or \
|
||||
"exporter" not in i.objective
|
||||
|
||||
|
||||
# ── 17Q case 9: research discussion with construction language ─────────
|
||||
def test_q9_research_not_mutation():
|
||||
c = ctx()
|
||||
i = cint.resolve_construction_intent(
|
||||
"Research how other tools implement retry budgets — what would "
|
||||
"building something like that involve?", c)
|
||||
assert i.action_intent == "DISCUSSION"
|
||||
|
||||
|
||||
# ── 17Q case 10: non-construction meaning of "build" ───────────────────
|
||||
def test_q10_non_construction_build():
|
||||
c = ctx()
|
||||
i = cint.resolve_construction_intent(
|
||||
"Build a list of the pros and cons.", c)
|
||||
# imperative "build" + non-constructible object → Scaffolding's
|
||||
# verb-object check refuses construction semantics
|
||||
mode = cm.classify_mode(i.objective)
|
||||
assert mode.mode == "NOT_CONSTRUCTION"
|
||||
|
||||
|
||||
# ── 17Q cases 11-14: mode proportionality through the ONE classifier ───
|
||||
def test_q11_existing_project_change():
|
||||
mode = cm.classify_mode("fix the pagination bug in the rune TUI",
|
||||
target_project="rune", project_exists=True)
|
||||
assert mode.mode == "EXISTING_PROJECT_CHANGE"
|
||||
|
||||
|
||||
def test_q12_small_local_change():
|
||||
mode = cm.classify_mode("fix the typo in the README",
|
||||
target_project="rune", project_exists=True)
|
||||
assert mode.mode == "LOCAL_CHANGE"
|
||||
|
||||
|
||||
def test_q13_large_architectural_migration():
|
||||
mode = cm.classify_mode(
|
||||
"migrate the whole provider layer to a new adapter protocol",
|
||||
target_project="rune", project_exists=True)
|
||||
assert mode.mode == "EXISTING_PROJECT_CHANGE"
|
||||
# depth is Scaffolding's decision; the intent layer's consequence
|
||||
# signal is HIGH (checked at the intent layer level below)
|
||||
c = ctx(active_project="rune")
|
||||
i = cint.resolve_construction_intent(
|
||||
"migrate the whole provider layer to a new adapter protocol", c)
|
||||
assert i.consequence_level == "HIGH"
|
||||
|
||||
|
||||
def test_q14_new_standalone():
|
||||
mode = cm.classify_mode("a small CLI that renames photos by EXIF date",
|
||||
project_exists=False)
|
||||
assert mode.mode == "PROJECT_BUILD"
|
||||
|
||||
|
||||
# ── 17Q cases 15-16: command / natural-language / contextual
|
||||
# equivalence — all three produce the same intent semantics ──────────
|
||||
def test_q15_command_and_language_same_backend():
|
||||
# /build X (command form) and "build X" (natural language) must both
|
||||
# end at classify_mode with the same inputs
|
||||
cmd_objective = "a password generator with configurable entropy"
|
||||
nl_objective = "build me a password generator with configurable entropy"
|
||||
m_cmd = cm.classify_mode(cmd_objective, project_exists=False)
|
||||
i_nl = cint.resolve_construction_intent(nl_objective, ctx())
|
||||
m_nl = cm.classify_mode(i_nl.objective, project_exists=False)
|
||||
assert m_cmd.mode == m_nl.mode == "PROJECT_BUILD"
|
||||
assert m_cmd.bootstrap_required == m_nl.bootstrap_required
|
||||
|
||||
|
||||
def test_q16_contextual_and_explicit_equivalent_semantics():
|
||||
c = ctx(active_project="rune")
|
||||
propose(c, "Add a --dry-run flag to the deploy script")
|
||||
i_ctx = cint.resolve_construction_intent("yes, go ahead with that", c)
|
||||
i_exp = cint.resolve_construction_intent(
|
||||
"Add a --dry-run flag to the deploy script",
|
||||
ctx(active_project="rune"))
|
||||
# contextual route resolves through the referent; explicit route
|
||||
# through the imperative — SAME approval semantics
|
||||
assert i_ctx.approval_state == i_exp.approval_state == "USER_APPROVED"
|
||||
assert i_ctx.action_intent == i_exp.action_intent == "ACTION"
|
||||
|
||||
|
||||
# ── 17Q case 17-18: provider ownership + approval not bypassed ─────────
|
||||
def test_q17_provider_ownership_preserved():
|
||||
# the intent layer emits no provider field at all — provider
|
||||
# selection cannot pass through it (17O boundary, structural)
|
||||
c = ctx()
|
||||
i = cint.resolve_construction_intent("build a fixture runner", c)
|
||||
assert not hasattr(i, "provider") and "provider" not in i.to_dict()
|
||||
|
||||
|
||||
def test_q18_approval_not_bypassed_by_context():
|
||||
# a proposal is NOT authorization: approval_state stays PROPOSAL
|
||||
# until a user turn authorizes
|
||||
c = ctx()
|
||||
propose(c, "Rewrite the auth subsystem")
|
||||
i = cint.resolve_construction_intent(
|
||||
"sounds plausible, tell me more about the migration steps", c)
|
||||
assert i.approval_state != "USER_APPROVED"
|
||||
|
||||
|
||||
# ── 17Q case 19: unknown target → clarification ────────────────────────
|
||||
def test_q19_unknown_target_asks():
|
||||
c = ctx() # no active project
|
||||
i = cint.resolve_construction_intent("Implement the export feature.", c)
|
||||
assert i.ambiguity == "TARGET"
|
||||
assert i.approval_state != "USER_APPROVED"
|
||||
assert i.probe_questions
|
||||
|
||||
|
||||
# ── 17Q case 20: superseded proposal not accidentally built ────────────
|
||||
def test_q20_superseded_proposal_ignored():
|
||||
c = ctx()
|
||||
p_old = propose(c, "Use polling for CI waits")
|
||||
c.supersede_proposal(p_old.proposal_id)
|
||||
p_new = propose(c, "Use event-driven waits with watchdogs")
|
||||
i = cint.resolve_construction_intent("go ahead with that", c)
|
||||
assert i.resolved_referent == p_new.proposal_id
|
||||
|
||||
|
||||
# ── 17N: structured intent shape ────────────────────────────────────────
|
||||
def test_17n_intent_shape_complete():
|
||||
c = ctx(active_project="rune")
|
||||
i = cint.resolve_construction_intent("fix the flaky lease test", c)
|
||||
d = i.to_dict()
|
||||
for f in ("intent_id", "objective", "requested_effect",
|
||||
"resolved_target", "resolved_referent", "origin_context_id",
|
||||
"action_intent", "approval_state", "confidence", "ambiguity",
|
||||
"consequence_level", "relevant_constraints",
|
||||
"classification_evidence"):
|
||||
assert f in d, f"missing intent field {f}"
|
||||
assert d["resolved_target"] == "rune"
|
||||
|
||||
|
||||
# ── §17L: approval-state lifecycle legality ─────────────────────────────
|
||||
def test_17l_discussion_never_mutates():
|
||||
c = ctx(active_project="rune")
|
||||
for phrase in ("what do you think about the deploy script?",
|
||||
"should we improve the wait semantics?",
|
||||
"how would you build the exporter?"):
|
||||
i = cint.resolve_construction_intent(phrase, c)
|
||||
assert i.approval_state == "DISCUSSION"
|
||||
assert i.action_intent == "DISCUSSION"
|
||||
@@ -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"] == []
|
||||
@@ -49,7 +49,19 @@ 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):
|
||||
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),
|
||||
@@ -93,14 +105,11 @@ class Daemon:
|
||||
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(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RUNE_WR_ROOT", str(tmp_path)) # adapter binds here
|
||||
monkeypatch.setenv("RUNE_WR_CODE", str(WR))
|
||||
monkeypatch.setenv("RUNE_WR_ROOT", str(tmp_path))
|
||||
d = Daemon(tmp_path, port=8793)
|
||||
yield d
|
||||
d.stop()
|
||||
def daemon(wr_daemon):
|
||||
return wr_daemon
|
||||
|
||||
|
||||
# real fake-builder: sleeps then exits — cheap but genuinely a process
|
||||
|
||||
@@ -129,10 +129,28 @@ 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."""
|
||||
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"
|
||||
|
||||
@@ -105,6 +105,7 @@ def test_15m_every_stop_carries_an_honest_verdict():
|
||||
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, \
|
||||
|
||||
@@ -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,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) != []
|
||||
Reference in new issue
Block a user