Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ceb3e5084 | ||
|
|
e09bfeb28d | ||
|
|
fec44ba321 | ||
|
|
058b37d5a9 | ||
|
|
1c0e88d6bc | ||
|
|
543339517b | ||
|
|
2af134a25f | ||
|
|
31b9dbee76 | ||
|
|
408bbb11a4 | ||
|
|
d58ba57f32 | ||
|
|
790f4cbc17 | ||
|
|
ee25874664 | ||
|
|
3c9888b045 | ||
|
|
6a9ca48105 | ||
|
|
5fe29ddb70 | ||
|
|
454511be53 | ||
|
|
4a1e994d43 | ||
|
|
a46a0bb02a | ||
|
|
fa88a4cf82 | ||
|
|
0de6f1bba4 | ||
|
|
9c5ea0aeb2 | ||
|
|
f18b6cc363 | ||
|
|
a13714364a | ||
|
|
0ab8a9485e | ||
|
|
01da5b0999 | ||
|
|
df413e8e41 | ||
|
|
c8b12cc496 |
No files matched your search
@@ -38,7 +38,7 @@ jobs:
|
||||
PR_TITLE: ${{ gitea.event.pull_request.title }}
|
||||
PR_BODY: ${{ gitea.event.pull_request.body }}
|
||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
||||
run: "LATEST=$(python3 scripts/tag_repair.py --latest-tag)\nLATEST=\"${LATEST#v}\"\nif [ -z\
|
||||
run: "LATEST=$(python3 scripts/tag_repair.py --latest-tag --namespace main)\nLATEST=\"${LATEST#v}\"\nif [ -z\
|
||||
\ \"$LATEST\" ]; then\n echo \"No previous tags \u2014 releasing first version\
|
||||
\ $RELEASE_VERSION\"\n exit 0\nfi\nif [ \"$LATEST\" != \"$RELEASE_VERSION\"\
|
||||
\ ]; then\n if [ \"$(printf '%s\\n%s\\n' \"$LATEST\" \"$RELEASE_VERSION\"\
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
\ summary of\n# what was worked on and submitted \u2014 the version-history\
|
||||
\ reader's\n# counterpart of the issue-entry template. Appended to the\n#\
|
||||
\ evidence-derived body so governed facts AND human narrative\n# both appear.\n\
|
||||
LAST_TAG=$(python3 scripts/tag_repair.py --latest-tag)\nEXIT_NOTE=$(python3 scripts/release_exit_note.py\
|
||||
LAST_TAG=$(python3 scripts/tag_repair.py --latest-tag --namespace main)\nEXIT_NOTE=$(python3 scripts/release_exit_note.py\
|
||||
\ --since \"$LAST_TAG\" --to HEAD) || EXIT_NOTE=\"\"\npython3 scripts/release_history_cli.py\
|
||||
\ init \\\n --version \"$RELEASE_VERSION\" \\\n --server \"$RELEASE_SERVER_URL\"\
|
||||
\ --repo \"$RELEASE_REPO\" \\\n --store store/scaffold/current.json \\\n\
|
||||
@@ -180,17 +180,56 @@ jobs:
|
||||
# Python. Idempotent: a body already carrying the section is left
|
||||
# alone. Additive and non-blocking: a missing toolchain or a failed
|
||||
# prepend must never fail a release.
|
||||
LAST_TAG=$(python3 scripts/tag_repair.py --latest-tag)
|
||||
LAST_TAG=$(python3 scripts/tag_repair.py --latest-tag --namespace main)
|
||||
if ! command -v go >/dev/null 2>&1; then
|
||||
echo "::warning::go toolchain unavailable — release published without the plain-English narrative"
|
||||
exit 0
|
||||
fi
|
||||
(cd src/go/release-notes && go build -o /tmp/rune-release-notes .) || {
|
||||
echo "::warning::release-notes build failed — narrative skipped"; exit 0; }
|
||||
/tmp/rune-release-notes prepend-narrative \
|
||||
# rebuild-body (#2582): the FULL plain-english template — proper
|
||||
# markdown (one H1 title, sections in reading order, capped bullets,
|
||||
# the no-changes contradiction scrubbed, evidence tails preserved).
|
||||
(cd src/go/release-notes && go build -o /tmp/rune-release-notes .) || true
|
||||
/tmp/rune-release-notes rebuild-body \
|
||||
--server "$RELEASE_SERVER_URL" --repo "$RELEASE_REPO" \
|
||||
--tag "v${RELEASE_VERSION}" --since "$LAST_TAG" || {
|
||||
echo "::warning::narrative prepend failed — release body left unchanged"; exit 0; }
|
||||
echo "::warning::body rebuild failed — release body left unchanged"; exit 0; }
|
||||
- name: Sync the committed changelog from the version tags
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
run: |
|
||||
# CHANGELOG LAW (#2582): every release syncs the committed
|
||||
# CHANGELOG.md (deterministic regeneration from the tags). CI's lint
|
||||
# job byte-compares regeneration against the file — a release that
|
||||
# skipped its sync turns the gate red on the next PR. The sync
|
||||
# commit is chore(changelog) (= releases nothing; the convergence
|
||||
# classifier's "other" bucket), so it earns no version.
|
||||
(cd src/go/release-notes && go build -o /tmp/rn-changelog .) || {
|
||||
echo "::warning::changelog sync skipped — go toolchain unavailable"; exit 0; }
|
||||
/tmp/rn-changelog changelog
|
||||
if ! git diff --quiet CHANGELOG.md; then
|
||||
git config user.name "release-bot"
|
||||
git config user.email "release-bot@rune.dev"
|
||||
git add CHANGELOG.md
|
||||
# push auth = tag_repair's tested extraheader mechanism
|
||||
python3 - <<'PYPUSH'
|
||||
import tag_repair
|
||||
import os, subprocess
|
||||
repo = os.getcwd()
|
||||
try:
|
||||
err = tag_repair._configure_http_auth(repo)
|
||||
except Exception as e:
|
||||
err = str(e)
|
||||
if err:
|
||||
print("::warning::changelog push auth unavailable — next release retries:", str(err)[:120])
|
||||
else:
|
||||
r = subprocess.run(["git", "push", "gitea", "master"], capture_output=True, text=True)
|
||||
print("changelog push:", r.returncode, (r.stdout or r.stderr)[:200])
|
||||
PYPUSH
|
||||
else
|
||||
echo "changelog already current"
|
||||
fi
|
||||
- name: Refresh README Highlights from the latest release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
|
||||
@@ -67,6 +67,14 @@ jobs:
|
||||
if [ "${#ADDED_PYTHON[@]}" -gt 0 ]; then
|
||||
.ci-venv/bin/ruff format --check "${ADDED_PYTHON[@]}"
|
||||
fi
|
||||
- name: Changelog currency (generated == committed, #2582)
|
||||
run: |
|
||||
# The committed CHANGELOG.md is generated from the version tags; a
|
||||
# release that landed without a changelog sync (or a tag added
|
||||
# without an entry) fails here with the sync command. The tags are
|
||||
# immutable — regeneration is deterministic, so this gate is exact.
|
||||
(cd src/go/release-notes && go build -o /tmp/rn-changelog .)
|
||||
/tmp/rn-changelog changelog --check
|
||||
- name: Structural contract (henxels)
|
||||
run: |
|
||||
.ci-venv/bin/henxels check --all --plain
|
||||
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
run: |
|
||||
VERSION=$(python3 -c 'import re; text=open("src/rune/__init__.py").read(); print(re.search(r"__version__ = \"([0-9]+\.[0-9]+\.[0-9]+)\"", text).group(1))')
|
||||
echo "MASTER_VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
echo "LATEST_TAG=$(python3 scripts/tag_repair.py --latest-tag)" >> "$GITHUB_ENV"
|
||||
echo "LATEST_TAG=$(python3 scripts/tag_repair.py --latest-tag --namespace main)" >> "$GITHUB_ENV"
|
||||
python3 scripts/version_convergence.py > /tmp/convergence.json
|
||||
echo "Convergence: $(cat /tmp/convergence.json)"
|
||||
python3 - <<'PYENV'
|
||||
|
||||
+7006
File diff suppressed because it is too large.
Load diff
@@ -1,9 +1,9 @@
|
||||
# Rune
|
||||
|
||||
**Current release: v0.345.0**
|
||||
**Current release: v0.355.0**
|
||||
|
||||
<!-- highlights:start -->
|
||||
## Highlights — from the latest release (v0.345.0)
|
||||
## Highlights — from the latest release (v0.355.0)
|
||||
|
||||
- Plain-English jargon dictionary covers the v0.329 vocabulary, plus catch-all sentence simplification pass.
|
||||
|
||||
|
||||
+197
-97
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"version": "1.0.0",
|
||||
"updated_at": "2026-10-19",
|
||||
"law": "Owner directive 2026-10-17 (docs/factory/spawn-queue.md scope law): every factory registry, gate, and map covers the FULL codebase — tui/ packages, src/go/ subsystems/libraries, src/rust/ crates (the dichotomy), and the live src/rune/ Python authority modules (retirement-tracked). Owner critique this registry answers: scaffolding was designed to let the LLM instantly look up a domain and find the relevant area to fix — instead diagnosis had been grep-exploration.",
|
||||
"updated_at": "2026-10-24",
|
||||
"law": "Owner directive 2026-10-17 (docs/factory/spawn-queue.md scope law): every factory registry, gate, and map covers the FULL codebase \u2014 tui/ packages, src/go/ subsystems/libraries, src/rust/ crates (the dichotomy), and the live src/rune/ Python authority modules (retirement-tracked). Owner critique this registry answers: scaffolding was designed to let the LLM instantly look up a domain and find the relevant area to fix \u2014 instead diagnosis had been grep-exploration.",
|
||||
"gate": "scripts/check_domain_registry.py (unified_check member domain_registry)",
|
||||
"lookup": "scripts/query_domain.py",
|
||||
"retirement_map": "docs/retirement-map.md + verification/retirement-map.json",
|
||||
"dichotomy_law": "docs/architecture/boundary-law.md (D30: WASM-standard, cgo exception-only)",
|
||||
"scaffold_contracts": "store/scaffold — every wave cites its ADMIT contract id",
|
||||
"scaffold_contracts": "store/scaffold \u2014 every wave cites its ADMIT contract id",
|
||||
"retirement_pending": [
|
||||
{
|
||||
"path": "src/rune/__main__.py",
|
||||
@@ -59,7 +59,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "tui/tasks/live.go",
|
||||
"why": "live task tree face — open here first"
|
||||
"why": "live task tree face \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "tui/tasks/live_delegation.go",
|
||||
@@ -67,7 +67,7 @@
|
||||
},
|
||||
{
|
||||
"path": "src/go/auto-tasks/main.go",
|
||||
"why": "native auto-task engine (parity row 12; oracle retired PROVEN — verification/parity/task-management.json)"
|
||||
"why": "native auto-task engine (parity row 12; oracle retired PROVEN \u2014 verification/parity/task-management.json)"
|
||||
},
|
||||
{
|
||||
"path": "verification/parity/task-management.json",
|
||||
@@ -75,7 +75,7 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./tasks (tui module — unified_check go_suite)",
|
||||
"go test ./tasks (tui module \u2014 unified_check go_suite)",
|
||||
"go test (module) src/go/auto-tasks + src/go/auto_tasks",
|
||||
"cargo test task_gate task_persist (unified_check rust_suite)",
|
||||
"src/go/parity-harness row-12 end-to-end (unified_check migration_mutants)",
|
||||
@@ -108,7 +108,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "tui/threads/README.md",
|
||||
"why": "what threads render — open first"
|
||||
"why": "what threads render \u2014 open first"
|
||||
},
|
||||
{
|
||||
"path": "tui/threads/delegation.go",
|
||||
@@ -116,7 +116,7 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./threads (tui module — unified_check go_suite)"
|
||||
"go test ./threads (tui module \u2014 unified_check go_suite)"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
"symptoms": [
|
||||
@@ -157,7 +157,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "tui/stream/stream.go",
|
||||
"why": "bounded stream adapter — open here first"
|
||||
"why": "bounded stream adapter \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "src/go/event-bus/main.go",
|
||||
@@ -169,7 +169,7 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./stream (tui module — unified_check go_suite)",
|
||||
"go test ./stream (tui module \u2014 unified_check go_suite)",
|
||||
"go test (module) src/go/event-bus + src/go/event-envelope + src/go/event-wait",
|
||||
"tests/test_legacy_event_bus.py",
|
||||
"tests/test_legacy_event_envelope.py",
|
||||
@@ -201,7 +201,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "tui/rail/rail.go",
|
||||
"why": "activity rail — open here first"
|
||||
"why": "activity rail \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "tui/receipts/receipts.go",
|
||||
@@ -209,8 +209,8 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./rail (tui module — unified_check go_suite)",
|
||||
"go test ./receipts (tui module — unified_check go_suite)"
|
||||
"go test ./rail (tui module \u2014 unified_check go_suite)",
|
||||
"go test ./receipts (tui module \u2014 unified_check go_suite)"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
"symptoms": [
|
||||
@@ -237,7 +237,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "tui/lanes/lanes.go",
|
||||
"why": "lane layout — open here first"
|
||||
"why": "lane layout \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "tui/laneconc/laneconc.go",
|
||||
@@ -249,9 +249,9 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./lanes (tui module — unified_check go_suite)",
|
||||
"go test ./laneconc (tui module — unified_check go_suite)",
|
||||
"go test ./nav (tui module — unified_check go_suite)"
|
||||
"go test ./lanes (tui module \u2014 unified_check go_suite)",
|
||||
"go test ./laneconc (tui module \u2014 unified_check go_suite)",
|
||||
"go test ./nav (tui module \u2014 unified_check go_suite)"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
"symptoms": [
|
||||
@@ -277,11 +277,11 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "tui/promote/promote.go",
|
||||
"why": "promotion controls — open here first"
|
||||
"why": "promotion controls \u2014 open here first"
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./promote (tui module — unified_check go_suite)"
|
||||
"go test ./promote (tui module \u2014 unified_check go_suite)"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
"symptoms": [
|
||||
@@ -305,11 +305,11 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "tui/selection/suite_test.go",
|
||||
"why": "the law lives in its frozen suite — open here first"
|
||||
"why": "the law lives in its frozen suite \u2014 open here first"
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./selection (tui module — unified_check go_suite)"
|
||||
"go test ./selection (tui module \u2014 unified_check go_suite)"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
"symptoms": [
|
||||
@@ -346,7 +346,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "tui/evidence/evidence.go",
|
||||
"why": "evidence spine face — open here first"
|
||||
"why": "evidence spine face \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "src/rune/evidence_probe.py",
|
||||
@@ -354,7 +354,7 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./evidence (tui module — unified_check go_suite)",
|
||||
"go test ./evidence (tui module \u2014 unified_check go_suite)",
|
||||
"go test (module) src/go/evidence-probe",
|
||||
"tests/test_evidence_probe.py",
|
||||
"tests/test_evidence_provenance_shims.py",
|
||||
@@ -391,7 +391,7 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./history (tui module — unified_check go_suite)"
|
||||
"go test ./history (tui module \u2014 unified_check go_suite)"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
"symptoms": [
|
||||
@@ -417,7 +417,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "tui/changes/changes.go",
|
||||
"why": "changes view — open here first"
|
||||
"why": "changes view \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "src/go/restore-points/main.go",
|
||||
@@ -425,7 +425,7 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./changes (tui module — unified_check go_suite)",
|
||||
"go test ./changes (tui module \u2014 unified_check go_suite)",
|
||||
"go test (module) src/go/restore-points"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
@@ -459,7 +459,7 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./settings (tui module — unified_check go_suite)"
|
||||
"go test ./settings (tui module \u2014 unified_check go_suite)"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
"symptoms": [
|
||||
@@ -492,7 +492,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "tui/graphproj/model.go",
|
||||
"why": "projection model — open here first"
|
||||
"why": "projection model \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "tui/graphproj/export.go",
|
||||
@@ -500,7 +500,7 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./graphproj (tui module — unified_check go_suite)",
|
||||
"go test ./graphproj (tui module \u2014 unified_check go_suite)",
|
||||
"go test (module) src/go/graph-memory",
|
||||
"tests/test_graph_projection.py",
|
||||
"cargo test graph_projection graph_memory"
|
||||
@@ -529,7 +529,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "tui/capsys/gate.go",
|
||||
"why": "the gate system — open here first"
|
||||
"why": "the gate system \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "tui/capsys/budget.go",
|
||||
@@ -537,7 +537,7 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./capsys (tui module — unified_check go_suite)"
|
||||
"go test ./capsys (tui module \u2014 unified_check go_suite)"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
"symptoms": [
|
||||
@@ -569,7 +569,7 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./version (tui module — unified_check go_suite)",
|
||||
"go test ./version (tui module \u2014 unified_check go_suite)",
|
||||
"go test (module) src/go/release-notes",
|
||||
"tests/test_check_version_surfaces.py"
|
||||
],
|
||||
@@ -602,7 +602,7 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./update (tui module — unified_check go_suite)"
|
||||
"go test ./update (tui module \u2014 unified_check go_suite)"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
"symptoms": [
|
||||
@@ -630,7 +630,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "tui/wasm/wasm.go",
|
||||
"why": "wazero host — open here first"
|
||||
"why": "wazero host \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "tui/wasm/ipc.go",
|
||||
@@ -654,7 +654,7 @@
|
||||
"tui/wasm/modules missing the paired build manifest"
|
||||
],
|
||||
"scaffold_contract": "ADMIT-wave-wasm-token-counting",
|
||||
"notes": "D30 law: Rust crosses to Go as WASM (wazero AOT); cgo is exception-only. Module artifacts are NOT committable — rebuilt reproducibly by scripts/build_paired.sh."
|
||||
"notes": "D30 law: Rust crosses to Go as WASM (wazero AOT); cgo is exception-only. Module artifacts are NOT committable \u2014 rebuilt reproducibly by scripts/build_paired.sh."
|
||||
},
|
||||
{
|
||||
"domain": "runtime-bridge",
|
||||
@@ -676,7 +676,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "tui/bridge/bridge.go",
|
||||
"why": "the bridge — open here first"
|
||||
"why": "the bridge \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "tui/bridge/client.go",
|
||||
@@ -692,8 +692,8 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./bridge (tui module — unified_check go_suite)",
|
||||
"go test ./runtime (tui module — unified_check go_suite)",
|
||||
"go test ./bridge (tui module \u2014 unified_check go_suite)",
|
||||
"go test ./runtime (tui module \u2014 unified_check go_suite)",
|
||||
"go test (module) src/go/rune-entry",
|
||||
"tui/bridge nosim_test.go (no-simulation law)"
|
||||
],
|
||||
@@ -726,7 +726,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "src/go/core-server/main.go",
|
||||
"why": "every route lives here — open first"
|
||||
"why": "every route lives here \u2014 open first"
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
@@ -767,11 +767,11 @@
|
||||
},
|
||||
{
|
||||
"path": "src/go/alert-gateway/main.go",
|
||||
"why": "gateway kernel (0 tests — known gap, retirement row 9)"
|
||||
"why": "gateway kernel (0 tests \u2014 known gap, retirement row 9)"
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./alerts (tui module — unified_check go_suite)",
|
||||
"go test ./alerts (tui module \u2014 unified_check go_suite)",
|
||||
"go test (module) src/go/alert-gateway"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
@@ -823,7 +823,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "tui/memory/memory.go",
|
||||
"why": "pure render over canonical state — open here first"
|
||||
"why": "pure render over canonical state \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "src/go/context-broker/main.go",
|
||||
@@ -835,7 +835,7 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./memory (tui module — unified_check go_suite)",
|
||||
"go test ./memory (tui module \u2014 unified_check go_suite)",
|
||||
"go test (module) src/go/context-broker + src/go/memory-store-deep + src/go/memory-consolidation + src/go/memory-ops",
|
||||
"tests/test_memory_continuity.py",
|
||||
"tests/test_memory_port.py",
|
||||
@@ -901,14 +901,14 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./skills (tui module — unified_check go_suite)",
|
||||
"go test ./skills (tui module \u2014 unified_check go_suite)",
|
||||
"go test (module) src/go/curator + src/go/curator-core"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
"symptoms": [
|
||||
"skill/pattern proposal shown without the consent gate",
|
||||
"tested/approved skill activation view wrong",
|
||||
"episode→skill promotion ladder stuck"
|
||||
"episode\u2192skill promotion ladder stuck"
|
||||
],
|
||||
"scaffold_contract": "ADMIT-wave-k21-skills-engine",
|
||||
"retirement_row": 16
|
||||
@@ -940,7 +940,7 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./extensions (tui module — unified_check go_suite)",
|
||||
"go test ./extensions (tui module \u2014 unified_check go_suite)",
|
||||
"go test (module) src/go/extension-host"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
@@ -976,7 +976,7 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./devices (tui module — unified_check go_suite)",
|
||||
"go test ./devices (tui module \u2014 unified_check go_suite)",
|
||||
"go test (module) src/go/device-pairing"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
@@ -1003,7 +1003,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "src/go/model-acquisition/main.go",
|
||||
"why": "open here first — module main"
|
||||
"why": "open here first \u2014 module main"
|
||||
},
|
||||
{
|
||||
"path": "src/go/model-connect/main.go",
|
||||
@@ -1077,7 +1077,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "src/rune/openai_compat_transport.py",
|
||||
"why": "THE provider-prefix single authority (is_cloud_pinned) — open here first"
|
||||
"why": "THE provider-prefix single authority (is_cloud_pinned) \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "src/go/model-routing/main.go",
|
||||
@@ -1094,7 +1094,7 @@
|
||||
"serve_endpoints": [],
|
||||
"symptoms": [
|
||||
"wrong provider/model selected; route authority flags missed",
|
||||
"cloud-pinned model misbudgeted (400 invalid model name noise — the proven real change)",
|
||||
"cloud-pinned model misbudgeted (400 invalid model name noise \u2014 the proven real change)",
|
||||
"recall expansion drifts; warm/adaptive router pick stale routes"
|
||||
],
|
||||
"notes": "The 2026-09-12 scaffolding real-change proof lives here (is_cloud_pinned single authority)."
|
||||
@@ -1114,7 +1114,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "src/go/webfeed/main.go",
|
||||
"why": "open here first — module main"
|
||||
"why": "open here first \u2014 module main"
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
@@ -1156,7 +1156,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "src/go/workflow-engine/main.go",
|
||||
"why": "engine kernel — open here first"
|
||||
"why": "engine kernel \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "tui/workflows/history.go",
|
||||
@@ -1164,14 +1164,14 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./workflows (tui module — unified_check go_suite)",
|
||||
"go test ./workflows (tui module \u2014 unified_check go_suite)",
|
||||
"go test (module) src/go/workflow-engine + src/go/cron-engine",
|
||||
"tests/test_deployment_canary.py"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
"symptoms": [
|
||||
"unattended workflow stalls, fires twice, or misses its cron",
|
||||
"sleep→poll regression undetected (wait_guard corpus)",
|
||||
"sleep\u2192poll regression undetected (wait_guard corpus)",
|
||||
"workflow-engine module go test RED (known retirement-map finding)",
|
||||
"night-mode switch flips at the wrong boundary"
|
||||
],
|
||||
@@ -1208,7 +1208,7 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./modes (tui module — unified_check go_suite)",
|
||||
"go test ./modes (tui module \u2014 unified_check go_suite)",
|
||||
"go test (module) src/go/build-mode"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
@@ -1248,13 +1248,13 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./inputs (tui module — unified_check go_suite)",
|
||||
"go test ./inputs (tui module \u2014 unified_check go_suite)",
|
||||
"go test (module) src/go/auto-detect"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
"symptoms": [
|
||||
"universal input misclassified; inspect-before-connect skipped",
|
||||
"auto-detect truth battery unused (0 tests — known gap)"
|
||||
"auto-detect truth battery unused (0 tests \u2014 known gap)"
|
||||
],
|
||||
"scaffold_contract": "ADMIT-wave-universal-input",
|
||||
"retirement_row": 14
|
||||
@@ -1276,7 +1276,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "docs/authority-map.md",
|
||||
"why": "the map itself — open here first"
|
||||
"why": "the map itself \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "config/umbrellas.json",
|
||||
@@ -1316,11 +1316,11 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "src/rune/audit_ledger.py",
|
||||
"why": "deployed authority — open here first"
|
||||
"why": "deployed authority \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "src/rust/claim_audit/src/lib.rs",
|
||||
"why": "Rust claim-audit kernel (src/rust/audit_ledger/ holds only build artifacts — no crate there; the Python module is the authority)"
|
||||
"why": "Rust claim-audit kernel (src/rust/audit_ledger/ holds only build artifacts \u2014 no crate there; the Python module is the authority)"
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
@@ -1370,7 +1370,7 @@
|
||||
},
|
||||
{
|
||||
"domain": "critic-gate",
|
||||
"summary": "The critic gate: repair of the empty critic gate + auto-merge zero approvals (task 0.4) — protected classes require review; drift_core binds.",
|
||||
"summary": "The critic gate: repair of the empty critic gate + auto-merge zero approvals (task 0.4) \u2014 protected classes require review; drift_core binds.",
|
||||
"aliases": [],
|
||||
"packages": {
|
||||
"tui": [],
|
||||
@@ -1387,7 +1387,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "src/rune/drift_core.py",
|
||||
"why": "task 0.4 binding — open here first"
|
||||
"why": "task 0.4 binding \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "src/go/drift-core/main.go",
|
||||
@@ -1404,7 +1404,7 @@
|
||||
"auto-merge bypasses repository protection",
|
||||
"always-pass/skipped critic not failing closed"
|
||||
],
|
||||
"notes": "Task 0.4 also binds merge_robot.py (not present in src/rune today — the task owns creating/repairing it)."
|
||||
"notes": "Task 0.4 also binds merge_robot.py (not present in src/rune today \u2014 the task owns creating/repairing it)."
|
||||
},
|
||||
{
|
||||
"domain": "drift-telemetry",
|
||||
@@ -1519,7 +1519,7 @@
|
||||
],
|
||||
"test_batteries": [
|
||||
"tests/test_owner_envelope.py",
|
||||
"go test ./trust (tui module — unified_check go_suite)",
|
||||
"go test ./trust (tui module \u2014 unified_check go_suite)",
|
||||
"go test (module) src/go/stage-guards + src/go/persona-gate"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
@@ -1588,7 +1588,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "schemas/events/corpus/event-checkpoint_committed-001.json",
|
||||
"why": "the vocabulary — open here first"
|
||||
"why": "the vocabulary \u2014 open here first"
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
@@ -1619,7 +1619,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "scripts/deploy_promotion.py",
|
||||
"why": "monotonic ancestry law, atomic symlink swap — open here first"
|
||||
"why": "monotonic ancestry law, atomic symlink swap \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "docs/release-flow.md",
|
||||
@@ -1643,7 +1643,7 @@
|
||||
},
|
||||
{
|
||||
"domain": "parent-map",
|
||||
"summary": "The parent/child delegation ancestry map (child visibility): a missing parent never hides the child — the orphan dock.",
|
||||
"summary": "The parent/child delegation ancestry map (child visibility): a missing parent never hides the child \u2014 the orphan dock.",
|
||||
"aliases": [
|
||||
"child-visibility",
|
||||
"delegation-ancestry"
|
||||
@@ -1663,7 +1663,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "tui/tasks/live_delegation.go",
|
||||
"why": "orphan dock + nesting law — open here first"
|
||||
"why": "orphan dock + nesting law \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "tui/threads/delegation.go",
|
||||
@@ -1675,8 +1675,8 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./tasks (tui module — unified_check go_suite)",
|
||||
"go test ./threads (tui module — unified_check go_suite)",
|
||||
"go test ./tasks (tui module \u2014 unified_check go_suite)",
|
||||
"go test ./threads (tui module \u2014 unified_check go_suite)",
|
||||
"tests/test_delegation_pinned_paths.py",
|
||||
"tests/test_orchestration_delegation.py"
|
||||
],
|
||||
@@ -1727,7 +1727,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "src/rune/witness.py",
|
||||
"why": "witness authority — open here first"
|
||||
"why": "witness authority \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "src/go/witness/main.go",
|
||||
@@ -1743,7 +1743,7 @@
|
||||
"symptoms": [
|
||||
"work outcome not recorded or unlearned (outcome_utility)",
|
||||
"trace lost mid-inference; programme gate skipped",
|
||||
"operator loop stuck; work-manager/output untested (0 tests — known gap)"
|
||||
"operator loop stuck; work-manager/output untested (0 tests \u2014 known gap)"
|
||||
],
|
||||
"retirement_row": 10
|
||||
},
|
||||
@@ -1812,7 +1812,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "src/rune/microkernel.py",
|
||||
"why": "kernel — open here first"
|
||||
"why": "kernel \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "src/rune/orchestration/scheduler.py",
|
||||
@@ -1884,7 +1884,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "src/go/cognition/cognition.go",
|
||||
"why": "Go cognition kernel — open here first"
|
||||
"why": "Go cognition kernel \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "src/rust/cognition_core/src/lib.rs",
|
||||
@@ -1939,7 +1939,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "src/go/tool-executor/main.go",
|
||||
"why": "executor — open here first"
|
||||
"why": "executor \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "src/rune/tool_protocol.py",
|
||||
@@ -2072,7 +2072,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "src/rune/scaffold.py",
|
||||
"why": "project scaffold authority — open here first"
|
||||
"why": "project scaffold authority \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "src/rune/construction_admission.py",
|
||||
@@ -2118,7 +2118,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "src/rune/spine.py",
|
||||
"why": "spine authority — open here first"
|
||||
"why": "spine authority \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "docs/STRUCTURAL_MAP.md",
|
||||
@@ -2172,7 +2172,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "tui/semantic/model.go",
|
||||
"why": "the behavior/semantic map — open here first"
|
||||
"why": "the behavior/semantic map \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "src/rune/rune_tui.py",
|
||||
@@ -2184,7 +2184,7 @@
|
||||
},
|
||||
{
|
||||
"path": "tui/lanes/render.go",
|
||||
"why": "the composer LAYER SCAFFOLD (D43 layer law): border/content/cursor layers, each with its own tests — locked surface, visual changes owner-routed"
|
||||
"why": "the composer LAYER SCAFFOLD (D43 layer law): border/content/cursor layers, each with its own tests \u2014 locked surface, visual changes owner-routed"
|
||||
},
|
||||
{
|
||||
"path": "tui/chatpane/chatpane.go",
|
||||
@@ -2196,7 +2196,7 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./semantic (tui module — unified_check go_suite)",
|
||||
"go test ./semantic (tui module \u2014 unified_check go_suite)",
|
||||
"tests/test_pane_authority_port.py",
|
||||
"canary_tui_drift.py",
|
||||
"canary_tui_transport.py",
|
||||
@@ -2210,7 +2210,7 @@
|
||||
"tui_validator import-allowlist drift"
|
||||
],
|
||||
"retirement_row": 15,
|
||||
"notes": "D43 layer law: every visual element is an independent layer (border/content/cursor/scroll-indicator/animation) with its own scaffold and tests inside this governed build; layers compose at render time; accidental coupling is a defect. Locked surface — visual changes owner-routed per D43 addendum."
|
||||
"notes": "D43 layer law: every visual element is an independent layer (border/content/cursor/scroll-indicator/animation) with its own scaffold and tests inside this governed build; layers compose at render time; accidental coupling is a defect. Locked surface \u2014 visual changes owner-routed per D43 addendum."
|
||||
},
|
||||
{
|
||||
"domain": "degradation-ledger",
|
||||
@@ -2245,19 +2245,19 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./degrade (tui module — unified_check go_suite)",
|
||||
"go test ./degrade (tui module \u2014 unified_check go_suite)",
|
||||
"go test (module) src/go/subsystem-health + src/go/failure-filer"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
"symptoms": [
|
||||
"degradation invisible (silent handler — the P0-06 class)",
|
||||
"degradation invisible (silent handler \u2014 the P0-06 class)",
|
||||
"subsystem health mis-reported",
|
||||
"failure filing lost; recovery policy misclassifies"
|
||||
]
|
||||
},
|
||||
{
|
||||
"domain": "boundary-types",
|
||||
"summary": "The Go↔Rust boundary message types: schemas/boundary/v1 + generated types on both sides (task 0.12 one-schema-source law).",
|
||||
"summary": "The Go\u2194Rust boundary message types: schemas/boundary/v1 + generated types on both sides (task 0.12 one-schema-source law).",
|
||||
"aliases": [],
|
||||
"packages": {
|
||||
"tui": [
|
||||
@@ -2272,7 +2272,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "schemas/boundary/v1/status_snapshot.schema.json",
|
||||
"why": "canonical schema set — open here first"
|
||||
"why": "canonical schema set \u2014 open here first"
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
@@ -2302,7 +2302,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "src/go/parity-harness/harness.go",
|
||||
"why": "the parity machine — open here first"
|
||||
"why": "the parity machine \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "store/rebase/classification.json",
|
||||
@@ -2341,7 +2341,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "docs/seams/registry.json",
|
||||
"why": "the registry — open here first"
|
||||
"why": "the registry \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "scripts/check_seam_registry.py",
|
||||
@@ -2357,7 +2357,7 @@
|
||||
"serve_endpoints": [],
|
||||
"symptoms": [
|
||||
"seam record incomplete (RED); deletion test missing/failing",
|
||||
"review date expired (AMBER — paper expiry is a documented warning)",
|
||||
"review date expired (AMBER \u2014 paper expiry is a documented warning)",
|
||||
"renewal mechanism detected (automatic renewal laundering = RED)"
|
||||
],
|
||||
"scaffold_contract": "ADMIT-wave-seam-registry"
|
||||
@@ -2379,7 +2379,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "docs/factory/research-registry.json",
|
||||
"why": "slug→chain map — open here first"
|
||||
"why": "slug\u2192chain map \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "scripts/check_research_gate.py",
|
||||
@@ -2461,7 +2461,7 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./internal/toolkit (tui module — unified_check go_suite)",
|
||||
"go test ./internal/toolkit (tui module \u2014 unified_check go_suite)",
|
||||
"go test (module) src/go/paths + src/go/pins"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
@@ -2496,7 +2496,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "src/rune/voice_hybrid.py",
|
||||
"why": "unified voice — open here first"
|
||||
"why": "unified voice \u2014 open here first"
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
@@ -2512,7 +2512,7 @@
|
||||
},
|
||||
{
|
||||
"domain": "dichotomy-loaders",
|
||||
"summary": "Mechanical FFI parity facades: one loader per ported Rust crate (src/rune/*_loader.py) — the Python oracle side of the dichotomy.",
|
||||
"summary": "Mechanical FFI parity facades: one loader per ported Rust crate (src/rune/*_loader.py) \u2014 the Python oracle side of the dichotomy.",
|
||||
"aliases": [],
|
||||
"packages": {
|
||||
"tui": [],
|
||||
@@ -2610,7 +2610,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "src/rune/ac_loader.py",
|
||||
"why": "pattern example — open here first"
|
||||
"why": "pattern example \u2014 open here first"
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
@@ -2668,7 +2668,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "src/rune/canary_native.py",
|
||||
"why": "the module registry — open here first"
|
||||
"why": "the module registry \u2014 open here first"
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
@@ -2703,7 +2703,7 @@
|
||||
"key_files": [
|
||||
{
|
||||
"path": "src/go/factory/internal/factory/kernel.go",
|
||||
"why": "the reconcile kernel: admit, level-triggered tick, phase advance, close — open here first"
|
||||
"why": "the reconcile kernel: admit, level-triggered tick, phase advance, close \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "src/go/factory/internal/factory/queue.go",
|
||||
@@ -2723,8 +2723,8 @@
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./... (module src/go/factory — kernel, queue, ladder batteries)",
|
||||
"go test ./factory/... (tui module — frozen TUI surface render battery)",
|
||||
"go test ./... (module src/go/factory \u2014 kernel, queue, ladder batteries)",
|
||||
"go test ./factory/... (tui module \u2014 frozen TUI surface render battery)",
|
||||
"pytest tests/test_factory_protocol.py (twin parity vs the frozen oracle)",
|
||||
"pytest tests/test_seam_deletion.py + scripts/check_seam_registry.py (factory-orchestration socket)"
|
||||
],
|
||||
@@ -2736,6 +2736,106 @@
|
||||
"TUI task tree missing factory waves or receipts stale (projection/fold drift)",
|
||||
"parity disagreement between Go kernel and Python twin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"domain": "face-presentation",
|
||||
"summary": "Face presentation: the colour-token law (themes), the consent card's promotion render, the approval card, gauges, the factory-wave/firstrun/night/planloop/taskledger/taskqueue surfaces \u2014 every raw-hex style site resolves through themes tokens; contrast floors and the motion law assert here.",
|
||||
"aliases": [],
|
||||
"packages": {
|
||||
"tui": [
|
||||
"tui/approvals",
|
||||
"tui/consent",
|
||||
"tui/factorywave",
|
||||
"tui/firstrun",
|
||||
"tui/gauges",
|
||||
"tui/night",
|
||||
"tui/planloop",
|
||||
"tui/taskledger",
|
||||
"tui/taskqueue",
|
||||
"tui/themes"
|
||||
],
|
||||
"src_go": [],
|
||||
"src_rust": [],
|
||||
"src_rune": []
|
||||
},
|
||||
"key_files": [
|
||||
{
|
||||
"path": "tui/themes/roles.go",
|
||||
"why": "the colour-token law \u2014 RoleHex/Tone resolve every style site; open here first"
|
||||
},
|
||||
{
|
||||
"path": "tui/themes/d55_coherence_test.go",
|
||||
"why": "contrast floors, every role and tone, vivid AND calm"
|
||||
},
|
||||
{
|
||||
"path": "tui/approvals/approvals.go",
|
||||
"why": "the approval/WORK card (consent = promotion only, D49)"
|
||||
},
|
||||
{
|
||||
"path": "tui/consent/consent.go",
|
||||
"why": "the consent bridge's promotion tiers"
|
||||
},
|
||||
{
|
||||
"path": "tui/cmd/rune-tui/d55_motion_law_test.go",
|
||||
"why": "the motion law's three invariants in one place"
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test ./themes ./approvals ./consent ./gauges ./planloop ./night ./taskledger ./taskqueue ./factorywave ./firstrun (tui module \u2014 unified_check go_suite)"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
"symptoms": [
|
||||
"a styled row renders in a raw hex colour or breaches the 4.5:1 floor (themes/roles.go is the only home for hex)",
|
||||
"calm mode does not mute a surface (the mute belongs in the themes tables)",
|
||||
"the consent/approval card blocks a write that should run (D49: writes never face-block)"
|
||||
],
|
||||
"scaffold_contract": "ADMIT-wave-d55-coherence"
|
||||
},
|
||||
{
|
||||
"domain": "construction-authority",
|
||||
"summary": "The construction admission/activation authority (mode classify, need activation, ADMIT contracts) and the secret store \u2014 the scaffolding doors every wave's admission script drives.",
|
||||
"aliases": [],
|
||||
"packages": {
|
||||
"tui": [],
|
||||
"src_go": [
|
||||
"src/go/dispatch-classifier",
|
||||
"src/go/memory-continuity",
|
||||
"src/go/rune-core",
|
||||
"src/go/skills-engine",
|
||||
"src/go/task-state"
|
||||
],
|
||||
"src_rust": [],
|
||||
"src_rune": [
|
||||
"src/rune/construction_authority.py",
|
||||
"src/rune/construction_authority_cli.py",
|
||||
"src/rune/secret_store.py"
|
||||
]
|
||||
},
|
||||
"key_files": [
|
||||
{
|
||||
"path": "src/rune/construction_authority.py",
|
||||
"why": "the admit/activate/classify doors \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "src/go/rune-core/internal/runecore/server.go",
|
||||
"why": "the live Go core's serve surface (identity Rune, :8376) \u2014 open here first"
|
||||
},
|
||||
{
|
||||
"path": "src/go/dispatch-classifier/main.go",
|
||||
"why": "the surface-lock arbitration (SOLO/PARALLEL/QUEUE-BEHIND)"
|
||||
}
|
||||
],
|
||||
"test_batteries": [
|
||||
"go test (module) src/go/rune-core + src/go/dispatch-classifier + src/go/memory-continuity + src/go/skills-engine + src/go/task-state",
|
||||
"tests/test_capability_fit.py (admission batteries)"
|
||||
],
|
||||
"serve_endpoints": [],
|
||||
"symptoms": [
|
||||
"an admission contract fails to mint (STORE_WRITE_FAILED / canonical contract exists)",
|
||||
"the classify/activate doors reject a lawful wave",
|
||||
"the core's identity or capability floor drifts"
|
||||
],
|
||||
"scaffold_contract": "ADMIT-wave-d55-coherence"
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rune"
|
||||
version = "0.345.0"
|
||||
version = "0.355.0"
|
||||
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
{
|
||||
"method": "sys.setprofile witness over full import graph + serving-chain boot probe; RUNE_HOME isolated",
|
||||
"numbers": {
|
||||
"total_py_modules": 296,
|
||||
"go_facade_served_live": 11,
|
||||
"rust_kernel_wired_live": 79,
|
||||
"live_pure_python_total": 204,
|
||||
" of which tooling/test-pattern": 117,
|
||||
" of which load-bearing candidates": 87,
|
||||
"dead_never_executed": 1
|
||||
},
|
||||
"boot_chain_executed": [
|
||||
"__init__",
|
||||
"attention_capsule",
|
||||
"behaviour_ledger",
|
||||
"capability_broker",
|
||||
"context_broker",
|
||||
"effect_mediation",
|
||||
"local_tool_runtime",
|
||||
"memory_env",
|
||||
"microkernel",
|
||||
"mode_gate",
|
||||
"model_routing",
|
||||
"paths",
|
||||
"tool_executor",
|
||||
"tool_protocol",
|
||||
"warm_router"
|
||||
],
|
||||
"live_pure_core": [
|
||||
"abstraction",
|
||||
"adaptive_router",
|
||||
"alpha",
|
||||
"audit_ledger",
|
||||
"auto_context",
|
||||
"build_attestation",
|
||||
"build_mode",
|
||||
"builder_bridge",
|
||||
"chat_bubble",
|
||||
"construction_admission",
|
||||
"construction_authority",
|
||||
"construction_interface",
|
||||
"context_broker",
|
||||
"core_server",
|
||||
"cron_engine",
|
||||
"drift-ledger",
|
||||
"drift_compression",
|
||||
"effect_mediation",
|
||||
"elastic_cognition",
|
||||
"event_bus",
|
||||
"event_envelope",
|
||||
"event_wait",
|
||||
"factory_protocol",
|
||||
"failure_auto_filer",
|
||||
"failure_recovery",
|
||||
"ffi_query_memory_smoke",
|
||||
"graph_projection",
|
||||
"growth_delta",
|
||||
"hook_engine",
|
||||
"identity_anchors",
|
||||
"impact_preview",
|
||||
"inference_paths",
|
||||
"live_proof",
|
||||
"local_tool_runtime",
|
||||
"memory_consolidation",
|
||||
"memory_continuity",
|
||||
"memory_env",
|
||||
"memory_store",
|
||||
"message_queue",
|
||||
"message_widgets",
|
||||
"mode_gate",
|
||||
"model_connect",
|
||||
"model_profiles",
|
||||
"model_registry",
|
||||
"night_mode",
|
||||
"owner_envelope",
|
||||
"parallel_processor",
|
||||
"paths",
|
||||
"persona_definition",
|
||||
"persona_gate",
|
||||
"pins",
|
||||
"programme_gates",
|
||||
"provider_budget",
|
||||
"provider_context",
|
||||
"provider_registry",
|
||||
"recall_router",
|
||||
"release_notes",
|
||||
"route_risk_flags",
|
||||
"rune_graphic",
|
||||
"rune_operator",
|
||||
"rune_spinner",
|
||||
"rune_tui",
|
||||
"runtime_attestation",
|
||||
"safety_override",
|
||||
"secret_store",
|
||||
"self_change_qualification",
|
||||
"self_transform_pipeline",
|
||||
"skills_engine",
|
||||
"sleep_time",
|
||||
"stage_guards",
|
||||
"standardized_providers",
|
||||
"structural_migration",
|
||||
"task_persist",
|
||||
"tier_manager",
|
||||
"tiered_executor",
|
||||
"tool_executor",
|
||||
"tool_protocol",
|
||||
"tui_backlog",
|
||||
"tui_transport",
|
||||
"tui_validator",
|
||||
"voice_injector",
|
||||
"voicebox",
|
||||
"wait_guard",
|
||||
"wait_guard_corpus",
|
||||
"warm_neuron_store",
|
||||
"warm_router",
|
||||
"work_manager"
|
||||
],
|
||||
"live_pure_tooling": [
|
||||
"ac_loader",
|
||||
"al_loader",
|
||||
"bb_loader",
|
||||
"bh_loader",
|
||||
"bl_loader",
|
||||
"broker_loader",
|
||||
"builder_bridge_cli",
|
||||
"ca_loader",
|
||||
"canary_audit_ledger",
|
||||
"canary_cognitive_strata",
|
||||
"canary_context_broker",
|
||||
"canary_conversation_state",
|
||||
"canary_full_system",
|
||||
"canary_gitea_automation",
|
||||
"canary_gitea_delegation",
|
||||
"canary_gitea_pipeline",
|
||||
"canary_memory_synthesis",
|
||||
"canary_native",
|
||||
"canary_orchestration_foundation",
|
||||
"canary_p0_04",
|
||||
"canary_p0_05",
|
||||
"canary_p0_07",
|
||||
"canary_p1_01",
|
||||
"canary_p1_02",
|
||||
"canary_p1_03",
|
||||
"canary_p1_06",
|
||||
"canary_p1_07",
|
||||
"canary_query_memory",
|
||||
"canary_resource_authority",
|
||||
"canary_rune_entry",
|
||||
"canary_rune_serve",
|
||||
"canary_schema_migration",
|
||||
"canary_spine_meta",
|
||||
"canary_surface_parity",
|
||||
"canary_tui_drift",
|
||||
"canary_tui_transport",
|
||||
"canary_unbounded",
|
||||
"cb_loader",
|
||||
"cc_loader",
|
||||
"ccmd_loader",
|
||||
"cd_loader",
|
||||
"cdec_loader",
|
||||
"cf_loader",
|
||||
"ci_loader",
|
||||
"cl_loader",
|
||||
"cm_loader",
|
||||
"construction_authority_cli",
|
||||
"contracts_loader",
|
||||
"cp_loader",
|
||||
"cpb_loader",
|
||||
"cpool_loader",
|
||||
"cproj_loader",
|
||||
"cps_loader",
|
||||
"cs_loader",
|
||||
"ct_loader",
|
||||
"dc_loader",
|
||||
"dcore_loader",
|
||||
"dl_loader",
|
||||
"do_loader",
|
||||
"dr_loader",
|
||||
"dt_loader",
|
||||
"ec_loader",
|
||||
"ee_loader",
|
||||
"et_loader",
|
||||
"evidence_probe",
|
||||
"gm_loader",
|
||||
"gp2_loader",
|
||||
"gp_loader",
|
||||
"gr_loader",
|
||||
"ic_loader",
|
||||
"ip_loader",
|
||||
"it_loader",
|
||||
"itr_loader",
|
||||
"lc_loader",
|
||||
"le_loader",
|
||||
"me_loader",
|
||||
"mems_loader",
|
||||
"mg_loader",
|
||||
"mr_loader",
|
||||
"ms_loader",
|
||||
"mse_loader",
|
||||
"mt_loader",
|
||||
"mu_loader",
|
||||
"ol_loader",
|
||||
"ou_loader",
|
||||
"pa_loader",
|
||||
"pc_loader",
|
||||
"pr_loader",
|
||||
"progressive_escalation_loader",
|
||||
"ps_loader",
|
||||
"ra_loader",
|
||||
"rb_loader",
|
||||
"rp_loader",
|
||||
"ru_loader",
|
||||
"sc_loader",
|
||||
"sck_loader",
|
||||
"sg_loader",
|
||||
"sh_loader",
|
||||
"si_loader",
|
||||
"sk_loader",
|
||||
"sl_loader",
|
||||
"sm_loader",
|
||||
"smm_loader",
|
||||
"smod_loader",
|
||||
"sp_loader",
|
||||
"sr_loader",
|
||||
"ss_loader",
|
||||
"stm_loader",
|
||||
"ta_loader",
|
||||
"tc_loader",
|
||||
"tg_loader",
|
||||
"tl_loader",
|
||||
"tm_loader",
|
||||
"to_loader",
|
||||
"vh_loader",
|
||||
"vs_loader",
|
||||
"wr_loader"
|
||||
],
|
||||
"dead": [
|
||||
"__main__"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Admission dispatch for #2582 release reporting (owner: plain-English
|
||||
version reads with what-to-test).
|
||||
|
||||
The version-push reader bug tri-diagnosed: (1) latest_release_tag sorted
|
||||
all tag namespaces, so the TUI build tag captured the main-line range;
|
||||
(2) the Go narrative prepend ran before body finalization and failed
|
||||
silently; (3) no try-actions existed. This contract admits: the
|
||||
namespace-aware tag resolution, the try-action narrative, the workflow
|
||||
rewiring, and the retroactive v0.346.0 body rebuild.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import construction_activation as cact # noqa: E402
|
||||
from rune import construction_admission as cadm # noqa: E402
|
||||
from rune import construction_mode as cmode # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||
OBJECTIVE = (
|
||||
"#2582 release reporting: make a pushed version read like a changelog "
|
||||
"for a non-technical owner — plain English what-changed, what-to-try "
|
||||
"derived from touched surfaces, how-it-was-verified. Namespace-aware "
|
||||
"tag resolution (main vs tui-v), the try-action narrative section, "
|
||||
"workflow wiring, retroactive v0.346.0 body rebuild."
|
||||
)
|
||||
AFFECTED = [
|
||||
"scripts/tag_repair.py",
|
||||
"tests/test_tag_repair.py",
|
||||
"src/go/release-notes/narrative.go",
|
||||
"src/go/release-notes/narrative_test.go",
|
||||
"src/go/release-notes/cli_narrative.go",
|
||||
".gitea/workflows/auto-version.yml",
|
||||
".gitea/workflows/maintenance.yml",
|
||||
"src/go/release-notes/main.go", "src/go/release-notes/cli_changelog.go", "CHANGELOG.md", "scripts/admit_release_reporting.py",
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
store = os.path.join(ROOT, "store", "scaffold")
|
||||
scaffold = sc.Scaffold(store, "rune")
|
||||
md = cmode.classify_mode(OBJECTIVE, target_project="rune", project_exists=True,
|
||||
affected_surface=AFFECTED)
|
||||
need = {
|
||||
"need_id": "need-release-reporting",
|
||||
"objective": OBJECTIVE,
|
||||
"origin": "worker",
|
||||
"project": "rune",
|
||||
"task_ref": "release-reporting",
|
||||
"affected_surface": AFFECTED,
|
||||
"evidence_refs": ["tests/test_tag_repair.py", "src/go/release-notes/narrative_test.go"],
|
||||
}
|
||||
act = cact.activate(md, need=need, scaffold=scaffold, builder="release-reporting")
|
||||
env = cadm.admit(
|
||||
act,
|
||||
objective=OBJECTIVE,
|
||||
builder="release-reporting",
|
||||
affected_surface=AFFECTED,
|
||||
evidence_refs=need["evidence_refs"],
|
||||
origin="worker",
|
||||
task_ref="release-reporting",
|
||||
expected_new_entities=[
|
||||
"src/go/release-notes/main.go", "src/go/release-notes/cli_changelog.go", "CHANGELOG.md", "scripts/admit_release_reporting.py", "tests/test_tag_repair.py",
|
||||
],
|
||||
expected_runtime_effects=[
|
||||
"every pushed version's release body opens with plain English "
|
||||
"what-changed + what-to-try + how-verified; ranges start at the "
|
||||
"main v* tag, never the tui build tag",
|
||||
],
|
||||
verification_obligations=[
|
||||
"pytest tests/test_tag_repair.py green (namespace law)",
|
||||
"go test ./... in src/go/release-notes green (try-section law)",
|
||||
"v0.346.0 body visibly rebuilt with the template",
|
||||
],
|
||||
regression_obligations=[
|
||||
"no runtime behavior change: release reporting content only",
|
||||
"the evidence store (release_history init) still receives its exit-note",
|
||||
],
|
||||
approval_ref="user: 'can you fix my gitea ... explains what was improved. and what could be tested' (session 2026-10-23)",
|
||||
contract_id="ADMIT-release-reporting",
|
||||
project_store=store,
|
||||
project_id="rune",
|
||||
)
|
||||
print(json.dumps(env, indent=2, default=str)[:2400])
|
||||
return 0 if env.get("success") else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Admission dispatch for wave D55 (the D53+D54 coherence pass + the
|
||||
D49 consent=promotion follow-through).
|
||||
|
||||
classify + activate + admit (the headless scaffolding entry law): the
|
||||
D55 wave builds on the existing rune project, user-dispatch approved.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import construction_activation as cact # noqa: E402
|
||||
from rune import construction_admission as cadm # noqa: E402
|
||||
from rune import construction_mode as cmode # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
OBJECTIVE = (
|
||||
"land wave D55 (the D53+D54 coherence pass + the D49 consent="
|
||||
"promotion follow-through): (1) CONSENT = PROMOTION ONLY — retire "
|
||||
"the write/edit permission-gate special-casing in tui/approvals: "
|
||||
"a chat-origin write tool call is never face-blocked by the "
|
||||
"approvals layer (capability is pane-wide per D49), the "
|
||||
"work-promotion consent card keeps firing for work-shaped chat "
|
||||
"messages unchanged, and the write card's wording reads as work "
|
||||
"promotion, not permission; (2) D53+D54 COHERENCE — every style "
|
||||
"site resolves through the themes tokens (no raw hex literals "
|
||||
"outside themes/, stragglers migrated, the old sub-floor #78788A "
|
||||
"ghost literal included); contrast floors hold across the full "
|
||||
"face for every role in vivid AND calm; the motion law's three "
|
||||
"invariants (wave gated, chatpane static, one region per frame) "
|
||||
"asserted in one place; a fresh long-answer PTY proof at 110 "
|
||||
"columns over a real streamed 27B turn (format holds, "
|
||||
"summary-first holds, no deadline error)"
|
||||
)
|
||||
AFFECTED = [
|
||||
"tui/",
|
||||
"scripts/admit_wave_d55_coherence.py",
|
||||
]
|
||||
|
||||
def main() -> int:
|
||||
store = os.path.join(ROOT, "store", "scaffold")
|
||||
scaffold = sc.Scaffold(store, "rune")
|
||||
|
||||
md = cmode.classify_mode(
|
||||
OBJECTIVE,
|
||||
target_project="rune",
|
||||
project_exists=True,
|
||||
affected_surface=AFFECTED,
|
||||
)
|
||||
print("classified mode:", md.mode)
|
||||
|
||||
need = {
|
||||
"need_id": "need-wave-d55-coherence",
|
||||
"objective": OBJECTIVE,
|
||||
"origin": "worker",
|
||||
"project": "rune",
|
||||
"task_ref": "wave-d55-coherence",
|
||||
"affected_surface": AFFECTED,
|
||||
"evidence_refs": [
|
||||
"tui/approvals/approvals.go",
|
||||
"tui/cmd/rune-tui/main.go",
|
||||
"tui/consent/consent.go",
|
||||
"tui/themes/roles.go",
|
||||
"tui/lanes/render.go",
|
||||
"tui/lanes/activity.go",
|
||||
"tui/chatpane/chatpane.go",
|
||||
],
|
||||
}
|
||||
act = cact.activate(md, need=need, scaffold=scaffold,
|
||||
builder="wave-d55-coherence")
|
||||
print("activated:", act.get("activated"), act.get("mode"))
|
||||
|
||||
env = cadm.admit(
|
||||
act,
|
||||
objective=OBJECTIVE,
|
||||
builder="wave-d55-coherence",
|
||||
affected_surface=AFFECTED,
|
||||
evidence_refs=need["evidence_refs"],
|
||||
origin="worker",
|
||||
task_ref="wave-d55-coherence",
|
||||
expected_new_entities=[
|
||||
"the D55 coherence wave report (reports/)",
|
||||
"the red-first write-not-face-blocked + "
|
||||
"promotion-wording tests",
|
||||
"the migrated themes tokens (confirm trio, body tones, "
|
||||
"activity states, gauge tones) and the one-place "
|
||||
"motion-law coherence test",
|
||||
],
|
||||
expected_runtime_effects=[
|
||||
"a chat-origin write tool call is never refused by the "
|
||||
"approvals layer while work-shaped chat messages still "
|
||||
"promote through the consent card, every styled row "
|
||||
"resolves through themes tokens with the contrast floors "
|
||||
"holding in vivid and calm, the motion law's invariants "
|
||||
"are asserted in one place, and a streamed 27B long "
|
||||
"answer holds format at 110 columns with no deadline "
|
||||
"error",
|
||||
],
|
||||
verification_obligations=[
|
||||
"go test -race -short ./... in tui/ green (documented "
|
||||
"pre-existing boot-smoke PTY family aside)",
|
||||
"build/vet/gofmt clean",
|
||||
"the 110-column long-answer PTY proof over the live core "
|
||||
"(127.0.0.1:8376, identity Rune)",
|
||||
],
|
||||
approval_ref="user:D55 directive 2026-10-24 (D49 follow-through "
|
||||
"+ D53/D54 coherence)",
|
||||
contract_id="ADMIT-wave-d55-coherence",
|
||||
project_store=store,
|
||||
project_id="rune",
|
||||
)
|
||||
print(json.dumps(env, indent=2, default=str)[:2400])
|
||||
return 0 if env.get("success") else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Admission dispatch for wave D56 (Gitea #2580 + #2581 — the
|
||||
provider-aware dispatch governor + the info-bar tok/s readout).
|
||||
|
||||
classify + activate + admit (the headless scaffolding entry law): the
|
||||
D56 wave builds on the existing rune project, user-dispatch approved
|
||||
(D56 directive 2026-10-24; owner doctrine: cloud-first routing, no
|
||||
consent gates on cloud, routing scope = factory lane assignment).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import construction_activation as cact # noqa: E402
|
||||
from rune import construction_admission as cadm # noqa: E402
|
||||
from rune import construction_mode as cmode # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
OBJECTIVE = (
|
||||
"land the D56 wave (Gitea #2580 + #2581, owner doctrine rulings "
|
||||
"2026-10-24: CLOUD-FIRST routing — cloud lanes fill FIRST, local "
|
||||
"absorbs only overflow; NO CONSENT on cloud models — configured "
|
||||
"provider = spend freely, zero prompts; routing scope = factory "
|
||||
"lane assignment, interactive chat routing stays the owner's): "
|
||||
"(1) TELEMETRY — sample live tok/s per provider from production "
|
||||
"data (the engine's real turn accounting; output-token counts "
|
||||
"from the provider wires where published, honest token-estimate "
|
||||
"fallback otherwise); rolling per-provider samples persisted via "
|
||||
"the hot-state pattern (~/.rune/hot/provider_throughput.json, "
|
||||
"atomic temp+rename); (2) SATURATION MODEL — a small rolling "
|
||||
"(provider, lanes) EMA curve learned ONLY from samples, no "
|
||||
"synthetic benchmarks; the lane cap is where the marginal tok/s "
|
||||
"gain clears the floor (RUNE_GOVERNOR_CAP_FLOOR, default 5); "
|
||||
"(3) CONNECTION-HEALTH — the governor polls the registry's "
|
||||
"enabled providers live (reachable, authenticated, tok/s, error "
|
||||
"rate, 429 throttle windows) and routes only to providers "
|
||||
"actually alive; (4) LANE POOLS + CLOUD-FIRST — per-provider lane "
|
||||
"pools, cloud pools fill first, least-loaded within a tier, "
|
||||
"throttled providers refused (a 429 ceiling is worse than not "
|
||||
"spawning); (5) CLASSIFIER INTEGRATION — dispatch-classifier "
|
||||
"gains the governor's cap: PARALLEL verdicts become "
|
||||
"throughput-aware ('PARALLEL (N lanes free at current tok/s)'), "
|
||||
"the thin pipe says so, no false fan-out; registry/lock "
|
||||
"semantics unchanged; (6) INFO BAR READOUT — tok/s after the "
|
||||
"context readout ('context 4.2k/32k (13%) · 38 tok/s') fed from "
|
||||
"/v1/status's new governor section through both bridge seams; "
|
||||
"the sub-agents section shows the governor's state row (lanes in "
|
||||
"use, measured tok/s, cap, '—' honest gap); (7) HONESTY — a thin "
|
||||
"pipe is stated, never dressed up"
|
||||
)
|
||||
AFFECTED = [
|
||||
"src/go/rune-core/",
|
||||
"src/go/dispatch-classifier/",
|
||||
"tui/cmd/rune-tui/",
|
||||
"tui/lanes/",
|
||||
"tui/bridge/",
|
||||
"tui/runtime/lib/",
|
||||
"scripts/admit_wave_d56_governor.py",
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
store = os.path.join(ROOT, "store", "scaffold")
|
||||
scaffold = sc.Scaffold(store, "rune")
|
||||
|
||||
md = cmode.classify_mode(
|
||||
OBJECTIVE,
|
||||
target_project="rune",
|
||||
project_exists=True,
|
||||
affected_surface=AFFECTED,
|
||||
)
|
||||
print("classified mode:", md.mode)
|
||||
|
||||
need = {
|
||||
"need_id": "need-wave-d56-governor",
|
||||
"objective": OBJECTIVE,
|
||||
"origin": "worker",
|
||||
"project": "rune",
|
||||
"task_ref": "wave-d56-governor",
|
||||
"affected_surface": AFFECTED,
|
||||
"evidence_refs": [
|
||||
"src/go/rune-core/internal/runecore/governor.go",
|
||||
"src/go/rune-core/internal/runecore/governor_test.go",
|
||||
"src/go/rune-core/internal/runecore/server.go",
|
||||
"src/go/rune-core/internal/runecore/engine.go",
|
||||
"src/go/dispatch-classifier/capacity.go",
|
||||
"src/go/dispatch-classifier/capacity_test.go",
|
||||
"tui/cmd/rune-tui/main.go",
|
||||
"tui/cmd/rune-tui/d56_governor_test.go",
|
||||
"tui/bridge/d56_governor_test.go",
|
||||
"tui/runtime/lib/lib.go",
|
||||
],
|
||||
}
|
||||
act = cact.activate(md, need=need, scaffold=scaffold,
|
||||
builder="wave-d56-governor")
|
||||
print("activated:", act.get("activated"), act.get("mode"))
|
||||
|
||||
env = cadm.admit(
|
||||
act,
|
||||
objective=OBJECTIVE,
|
||||
builder="wave-d56-governor",
|
||||
affected_surface=AFFECTED,
|
||||
evidence_refs=need["evidence_refs"],
|
||||
origin="worker",
|
||||
task_ref="wave-d56-governor",
|
||||
expected_new_entities=[
|
||||
"the D56 wave report (reports/)",
|
||||
"the governor hot-state shape "
|
||||
"(~/.rune/hot/provider_throughput.json)",
|
||||
"the red-first governor/capacity/readout tests",
|
||||
],
|
||||
expected_runtime_effects=[
|
||||
"the governor samples live tok/s per provider from real "
|
||||
"turns and persists the rolling model to the hot state, "
|
||||
"routes cloud-first through healthy providers only "
|
||||
"(429-throttled refused), the dispatch classifier's "
|
||||
"PARALLEL verdicts carry the measured lane capacity with "
|
||||
"the thin pipe stated honestly, and the info bar shows "
|
||||
"the measured tok/s after the context readout with the "
|
||||
"sub-agents section showing the governor's state",
|
||||
],
|
||||
verification_obligations=[
|
||||
"go test -race -short green on rune-core, "
|
||||
"dispatch-classifier and tui (hermetic PTY/HTTP tests)",
|
||||
"vet/gofmt clean on touched modules",
|
||||
"readout proof: the info-bar test asserts 'context ... "
|
||||
"(13%) · 38 tok/s' byte-shape against a fake serve",
|
||||
],
|
||||
approval_ref="user:D56 directive 2026-10-24 (Gitea #2580 + #2581)",
|
||||
contract_id="ADMIT-wave-d56-governor",
|
||||
project_store=store,
|
||||
project_id="rune",
|
||||
)
|
||||
print(json.dumps(env, indent=2, default=str)[:2400])
|
||||
return 0 if env.get("success") else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Admission dispatch for wave rebase-completion (Gitea #2587).
|
||||
|
||||
THE FINDING (audit #2587): 17 Python modules have Go-side directories; only
|
||||
6 are wired. ELEVEN Go engines with substantial function counts have ZERO
|
||||
delegation in their Python counterpart: abstraction(71), auto_context(75),
|
||||
cache(66), forge_client(87), goal(63), message_queue(65), microkernel(62),
|
||||
openai_compat_transport(74), paths(13), voicebox(24) — plus pins (Python-only,
|
||||
12 funcs) needing an honest boundary verdict.
|
||||
|
||||
THE OWNER'S BAR (verbatim): 'going through and rewiring the rebase into the
|
||||
tui, extremely thoroughly according to the code dichotomy.'
|
||||
|
||||
PATTERN: fail-loud delegation facade (Go engine default, RUNE_<NAME>_ENGINE=
|
||||
python escape, RUNE_<NAME>_BIN override, typed RuntimeError on missing
|
||||
binary — no silent fallback; house shapes: model_routing.reconcile_tools_
|
||||
support :457, local_tool_runtime._GoDelegatingExecutor). Red-first parity
|
||||
battery per module against the Python oracle body (kept as oracle/escape).
|
||||
Integrated receipt per module in verification/integrated/.
|
||||
approval_ref 'user:Gitea #2587 rebase-completion directive'.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import construction_activation as cact # noqa: E402
|
||||
from rune import construction_admission as cadm # noqa: E402
|
||||
from rune import construction_mode as cmode # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
OBJECTIVE = (
|
||||
"REBASE-COMPLETION (Gitea #2587): wire the eleven unwired Go engines "
|
||||
"into their Python counterparts through the fail-loud delegation "
|
||||
"facade pattern — openai_compat_transport and forge_client (serving "
|
||||
"path) first, then microkernel, goal, cache, message_queue, "
|
||||
"abstraction, auto_context, voicebox, paths; classify pins honestly "
|
||||
"(port to src/go/pins or record the Python-boundary verdict from its "
|
||||
"consumers). Each module: red-first parity battery against the Python "
|
||||
"oracle body (kept as oracle/escape hatch), RUNE_<NAME>_ENGINE=python "
|
||||
"escape, RUNE_<NAME>_BIN override, typed RuntimeError on missing "
|
||||
"binary, integrated receipt in verification/integrated/. "
|
||||
"Owner's bar: 'going through and rewiring the rebase into the tui, "
|
||||
"extremely thoroughly according to the code dichotomy.'"
|
||||
)
|
||||
AFFECTED = [
|
||||
"src/go/**",
|
||||
"src/rune/**",
|
||||
"tests/**",
|
||||
"verification/**",
|
||||
"scripts/admit_wave_rebase_completion.py",
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
store = os.path.join(ROOT, "store", "scaffold")
|
||||
scaffold = sc.Scaffold(store, "rune")
|
||||
|
||||
md = cmode.classify_mode(
|
||||
OBJECTIVE,
|
||||
target_project="rune",
|
||||
project_exists=True,
|
||||
affected_surface=AFFECTED,
|
||||
)
|
||||
print("classified mode:", md.mode)
|
||||
|
||||
need = {
|
||||
"need_id": "need-wave-rebase-completion",
|
||||
"objective": OBJECTIVE,
|
||||
"origin": "worker",
|
||||
"project": "rune",
|
||||
"task_ref": "wave-rebase-completion",
|
||||
"affected_surface": AFFECTED,
|
||||
"evidence_refs": [
|
||||
"Gitea #2587 (REBASE-COMPLETION audit: 11 dual-side modules "
|
||||
"with Go engines but ZERO delegation; pins Python-only)",
|
||||
"verification/integrated/model-routing-integrated.json (the "
|
||||
"house PORT → INTEGRATE → REMOVE shape this wave replicates)",
|
||||
"src/rune/model_routing.py reconcile_tools_support facade "
|
||||
":457 and src/rune/local_tool_runtime.py "
|
||||
"_GoDelegatingExecutor (the fail-loud house shapes)",
|
||||
],
|
||||
}
|
||||
act = cact.activate(md, need=need, scaffold=scaffold,
|
||||
builder="wave-rebase-completion")
|
||||
print("activated:", act.get("activated"), act.get("mode"))
|
||||
|
||||
env = cadm.admit(
|
||||
act,
|
||||
objective=OBJECTIVE,
|
||||
builder="wave-rebase-completion",
|
||||
affected_surface=AFFECTED,
|
||||
evidence_refs=need["evidence_refs"],
|
||||
origin="worker",
|
||||
task_ref="wave-rebase-completion",
|
||||
expected_new_entities=[
|
||||
"eleven fail-loud delegation facades (one per module) with "
|
||||
"RUNE_*_ENGINE=python escapes and RUNE_*_BIN overrides",
|
||||
"red-first parity battery per module against the Python "
|
||||
"oracle body; mutations of the oracle must flip the battery",
|
||||
"integrated receipt per module in verification/integrated/",
|
||||
"pins verdict: Go port at src/go/pins OR recorded "
|
||||
"Python-boundary classification from consumer evidence",
|
||||
],
|
||||
expected_runtime_effects=[
|
||||
"the serving path (openai_compat_transport, forge_client) "
|
||||
"and the ten other wired engines resolve through the Go "
|
||||
"binaries at runtime; Python oracle bodies retained as "
|
||||
"escape/oracle only",
|
||||
],
|
||||
verification_obligations=[
|
||||
"per module: go test -race -short green in the engine dir; "
|
||||
"pytest touched suites green (venv, PYTHONPATH=src)",
|
||||
"conventional commit per module, pushed to gitea master "
|
||||
"incrementally (autostash rebase — the tree is hot)",
|
||||
],
|
||||
approval_ref="user:Gitea #2587 rebase-completion directive",
|
||||
contract_id="ADMIT-wave-rebase-completion",
|
||||
project_store=store,
|
||||
project_id="rune",
|
||||
)
|
||||
print(json.dumps(env, indent=2, default=str)[:2400])
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Admission dispatch for wave retire-tool-executor (Gitea #2575
|
||||
rebalanced overnight queue, wave B): wire local_tool_runtime.py to the
|
||||
Go tool-executor engine.
|
||||
|
||||
classify + activate + admit (headless scaffolding entry law,
|
||||
scripts/admit_wave_retire_reconcile_tools.py pattern): the Go executor
|
||||
exists (src/go/tool-executor, full CLI surface read_file/list_directory/
|
||||
glob_files/grep_files/run_command, JSON envelope {success,result,error})
|
||||
but src/rune/local_tool_runtime.py never delegates to it. This wave wires
|
||||
the fail-loud delegation facade (LocalToolRuntime's executor resolves the
|
||||
four READ-ONLY ops through the Go binary by default; run_command stays
|
||||
behind the Python approval door — mutating, never silently executed),
|
||||
keeps the Python bodies as oracle/escape hatch
|
||||
(RUNE_TOOL_EXECUTOR_ENGINE=python, NO Python deletion — PORT → INTEGRATE
|
||||
→ REMOVE), and pins the port with a red-first parity battery
|
||||
(tests/test_tool_executor_delegation.py) against the Python oracle.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import construction_activation as cact # noqa: E402
|
||||
from rune import construction_admission as cadm # noqa: E402
|
||||
from rune import construction_mode as cmode # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
OBJECTIVE = (
|
||||
"Close the wave-181 full-switchover blocker: Go port of the local "
|
||||
"tool execution — wire local_tool_runtime.py to the existing "
|
||||
"src/go/tool-executor engine. LocalToolRuntime executor becomes "
|
||||
"a fail-loud delegation facade: the four read-only file ops "
|
||||
"(read_file, directory enumeration, glob, grep) resolve through "
|
||||
"the Go binary by default, JSON envelope preserved exactly; "
|
||||
"run_command stays behind the approval door (mutating, never "
|
||||
"silently executed). Python bodies stay as oracle escape hatch "
|
||||
"(RUNE_TOOL_EXECUTOR_ENGINE=python, RUNE_TOOL_EXECUTOR_BIN "
|
||||
"override, typed RuntimeError when the binary is missing — no "
|
||||
"silent fallback, no Python deletion; PORT → INTEGRATE → REMOVE). "
|
||||
"Red-first parity battery tests/test_tool_executor_delegation.py "
|
||||
"pins Go to the Python oracle envelope-for-envelope"
|
||||
)
|
||||
AFFECTED = [
|
||||
"src/go/tool-executor/",
|
||||
"src/rune/local_tool_runtime.py",
|
||||
"tests/test_tool_executor_delegation.py",
|
||||
"tests/test_local_tool_runtime.py",
|
||||
"tests/test_p0_05_native_tools.py",
|
||||
"tests/test_mode_dispatch_gate.py",
|
||||
"verification/integrated/tool-executor-integrated.json",
|
||||
"scripts/admit_wave_retire_tool_executor.py",
|
||||
]
|
||||
|
||||
def main() -> int:
|
||||
store = os.path.join(ROOT, "store", "scaffold")
|
||||
scaffold = sc.Scaffold(store, "rune")
|
||||
|
||||
md = cmode.classify_mode(
|
||||
OBJECTIVE,
|
||||
target_project="rune",
|
||||
project_exists=True,
|
||||
affected_surface=AFFECTED,
|
||||
)
|
||||
print("classified mode:", md.mode)
|
||||
|
||||
need = {
|
||||
"need_id": "need-wave-retire-tool-executor",
|
||||
"objective": OBJECTIVE,
|
||||
"origin": "worker",
|
||||
"project": "rune",
|
||||
"task_ref": "wave-retire-tool-executor",
|
||||
"affected_surface": AFFECTED,
|
||||
"evidence_refs": [
|
||||
"src/go/tool-executor/internal/toolexec/cli.go",
|
||||
"src/rune/local_tool_runtime.py",
|
||||
"src/rune/tool_executor.py",
|
||||
"tests/test_tool_executor_delegation.py",
|
||||
"verification/integrated/reconcile-tools-integrated.json",
|
||||
],
|
||||
}
|
||||
act = cact.activate(md, need=need, scaffold=scaffold,
|
||||
builder="wave-retire-tool-executor")
|
||||
print("activated:", act.get("activated"), act.get("mode"))
|
||||
|
||||
env = cadm.admit(
|
||||
act,
|
||||
objective=OBJECTIVE,
|
||||
builder="wave-retire-tool-executor",
|
||||
affected_surface=AFFECTED,
|
||||
evidence_refs=need["evidence_refs"],
|
||||
origin="worker",
|
||||
task_ref="wave-retire-tool-executor",
|
||||
expected_new_entities=[
|
||||
"tests/test_tool_executor_delegation.py (red-first parity "
|
||||
"battery: Go delegation == Python oracle envelope-for-"
|
||||
"envelope; fail-loud binary-missing refusal; run_command "
|
||||
"never delegates; default-is-go pin)",
|
||||
"verification/integrated/tool-executor-integrated.json "
|
||||
"(integrated receipt, soak clock before any Python deletion)",
|
||||
],
|
||||
expected_runtime_effects=[
|
||||
"LocalToolRuntime's executor resolves read_file, "
|
||||
"list_directory, glob_files and grep_files through the Go "
|
||||
"tool-executor binary by default; envelopes preserved "
|
||||
"exactly",
|
||||
"RUNE_TOOL_EXECUTOR_ENGINE=python pins the oracle path; "
|
||||
"a missing engine binary raises a typed RuntimeError (no "
|
||||
"silent Python fallback)",
|
||||
"run_command dispatches through the Python authority path "
|
||||
"only (approval door preserved, no Go bypass)",
|
||||
],
|
||||
verification_obligations=[
|
||||
"go test -race -short ./... in src/go/tool-executor green",
|
||||
"pytest tests/test_tool_executor_delegation.py "
|
||||
"tests/test_local_tool_runtime.py tests/test_p0_05_native_tools.py "
|
||||
"tests/test_mode_dispatch_gate.py tests/test_context_broker.py "
|
||||
"green",
|
||||
],
|
||||
approval_ref="user:gitea #2575 rebalanced overnight queue, wave B "
|
||||
"2026-10-24",
|
||||
contract_id="ADMIT-wave-retire-tool-executor",
|
||||
project_store=store,
|
||||
project_id="rune",
|
||||
)
|
||||
print(json.dumps(env, indent=2, default=str)[:2400])
|
||||
return 0 if env.get("success") else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+18
-3
@@ -69,21 +69,33 @@ def _git(args: list[str], repo: Path | None = None, check: bool = True) -> subpr
|
||||
)
|
||||
|
||||
|
||||
def latest_release_tag(repo: Path | None = None) -> str | None:
|
||||
def latest_release_tag(repo: Path | None = None, namespace: str = "main") -> str | None:
|
||||
"""Highest release tag (e.g. ``v0.314.0``), or None when there are none.
|
||||
|
||||
Namespace-aware (#2582): the repo carries TWO tag namespaces that share
|
||||
the version sort — the main line (``v*``) and the TUI build line
|
||||
(``tui-v*``). The default resolves the MAIN line: the release-body
|
||||
readers live there, and a TUI tag cut minutes before the main release
|
||||
used to capture the range lookup and blank the release note ("No
|
||||
substantive changes" on a 21-commit day, v0.346.0).
|
||||
|
||||
SIGPIPE-free: ``git for-each-ref --count=1`` stops INSIDE git — there is
|
||||
no downstream pipe for git to receive EPIPE on, so the step cannot die
|
||||
with 141 the way ``git tag --sort=-v:refname | head -1`` did (runs
|
||||
29929/30435/30636).
|
||||
"""
|
||||
prefix = {
|
||||
"main": "v",
|
||||
"tui": "tui-v",
|
||||
"any": "",
|
||||
}[namespace]
|
||||
proc = _git(
|
||||
[
|
||||
"for-each-ref",
|
||||
"--count=1",
|
||||
"--sort=-v:refname",
|
||||
"--format=%(refname:short)",
|
||||
"refs/tags",
|
||||
f"refs/tags/{prefix}*",
|
||||
],
|
||||
repo,
|
||||
check=False,
|
||||
@@ -257,6 +269,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Release tag convergence repair")
|
||||
parser.add_argument("--latest-tag", action="store_true",
|
||||
help="print the highest release tag (SIGPIPE-free) and exit")
|
||||
parser.add_argument("--namespace", default="main", choices=("main", "tui", "any"),
|
||||
help="tag namespace for --latest-tag (#2582): main => v*, "
|
||||
"tui => tui-v*, any => both — default main")
|
||||
parser.add_argument("--plan", action="store_true",
|
||||
help="print the ensure plan as JSON, mutate nothing")
|
||||
parser.add_argument("--ensure", dest="ensure", metavar="VERSION",
|
||||
@@ -271,7 +286,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
repo = args.repo or Path.cwd()
|
||||
if args.latest_tag:
|
||||
print(latest_release_tag(repo) or "")
|
||||
print(latest_release_tag(repo, namespace=args.namespace) or "")
|
||||
return 0
|
||||
if args.plan:
|
||||
print(json.dumps(plan_ensure_tag(args.ensure or "", args.bump_sha, repo)))
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Eval — the live ops surface for the Python delegation facade
|
||||
// (src/rune/abstraction.py). `abstraction eval <ops.json>` reads a JSON
|
||||
// array of op dicts and emits one result per op. Each op carries
|
||||
// memory_dir (the seamDBPath parent — the abstraction.db location the
|
||||
// oracle body shares).
|
||||
//
|
||||
// Ops: list_beliefs, list_rules, list_skills, pipeline_stats,
|
||||
// handle_hook, extract_facts (the LLM seam runs through the engine's
|
||||
// seamLLMPost — the same urllib transport contract the oracle's
|
||||
// llm_call uses, driven by OLLAMA_URL / model env).
|
||||
//
|
||||
// Fail-loud: unknown op or engine error aborts non-zero; the Python
|
||||
// oracle stays behind RUNE_ABSTRACTION_ENGINE=python.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// plainJSON converts OrderedMaps/slices to plain JSON-shaped data.
|
||||
func plainJSON(v any) any {
|
||||
switch t := v.(type) {
|
||||
case *OrderedMap:
|
||||
out := map[string]any{}
|
||||
for _, k := range t.Keys {
|
||||
out[k] = plainJSON(t.Vals[k])
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, 0, len(t))
|
||||
for _, e := range t {
|
||||
out = append(out, plainJSON(e))
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
out := make([]any, 0, len(t))
|
||||
for _, e := range t {
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func abstractionEvalOps(ops []map[string]any) ([]any, string) {
|
||||
out := make([]any, 0, len(ops))
|
||||
for i, op := range ops {
|
||||
name, _ := op["op"].(string)
|
||||
res, emsg := abstractionEvalOp(name, op)
|
||||
if emsg != "" {
|
||||
return nil, fmt.Sprintf("op %d (%s): %s", i, name, emsg)
|
||||
}
|
||||
out = append(out, res)
|
||||
}
|
||||
return out, ""
|
||||
}
|
||||
|
||||
func abstractionEvalOp(name string, op map[string]any) (any, string) {
|
||||
if dir, ok := op["memory_dir"].(string); ok && dir != "" {
|
||||
old := seamDBPath
|
||||
seamDBPath = func() string { return dir + "/abstraction.db" }
|
||||
defer func() { seamDBPath = old }()
|
||||
}
|
||||
switch name {
|
||||
case "list_beliefs":
|
||||
limit := 20
|
||||
if l, ok := op["limit"].(float64); ok {
|
||||
limit = int(l)
|
||||
}
|
||||
return plainJSON(ListBeliefs(limit)), ""
|
||||
case "list_rules":
|
||||
limit := 20
|
||||
if l, ok := op["limit"].(float64); ok {
|
||||
limit = int(l)
|
||||
}
|
||||
return plainJSON(ListRules(limit)), ""
|
||||
case "list_skills":
|
||||
limit := 20
|
||||
if l, ok := op["limit"].(float64); ok {
|
||||
limit = int(l)
|
||||
}
|
||||
return plainJSON(ListSkills(limit)), ""
|
||||
case "pipeline_stats":
|
||||
return plainJSON(PipelineStats()), ""
|
||||
case "handle_hook":
|
||||
event, _ := op["event"].(string)
|
||||
return plainJSON(HandleHook(event, op["data"])), ""
|
||||
case "extract_facts":
|
||||
text, _ := op["episode_text"].(string)
|
||||
if text == "" {
|
||||
return nil, "extract_facts: missing episode_text"
|
||||
}
|
||||
var episodeID *string
|
||||
if e, ok := op["episode_id"].(string); ok && e != "" {
|
||||
episodeID = &e
|
||||
}
|
||||
source, _ := op["source"].(string)
|
||||
return plainJSON(ExtractFacts(text, episodeID, source)), ""
|
||||
default:
|
||||
return nil, fmt.Sprintf("unknown op %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// runEvalMain — the eval entry, called from main().
|
||||
func runEvalMain(args []string) int {
|
||||
var raw []byte
|
||||
var err error
|
||||
if len(args) > 0 && args[0] != "-" {
|
||||
raw, err = os.ReadFile(args[0])
|
||||
} else {
|
||||
raw, err = io.ReadAll(os.Stdin)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "abstraction eval: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
var ops []map[string]any
|
||||
if jerr := json.Unmarshal(raw, &ops); jerr != nil {
|
||||
fmt.Fprintf(os.Stderr, "abstraction eval: parse ops: %v\n", jerr)
|
||||
return 2
|
||||
}
|
||||
results, emsg := abstractionEvalOps(ops)
|
||||
if emsg != "" {
|
||||
fmt.Fprintf(os.Stderr, "abstraction eval: %s\n", emsg)
|
||||
return 1
|
||||
}
|
||||
enc, _ := json.Marshal(results)
|
||||
fmt.Println(string(enc))
|
||||
return 0
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) >= 2 && os.Args[1] == "eval" {
|
||||
os.Exit(runEvalMain(os.Args[2:]))
|
||||
}
|
||||
if len(os.Args) >= 2 && os.Args[1] == "replay" {
|
||||
path := "truth.json"
|
||||
if len(os.Args) >= 3 {
|
||||
|
||||
Vendored
+155
@@ -0,0 +1,155 @@
|
||||
// Eval — the live ops surface for the Python delegation facade
|
||||
// (src/rune/cache.py). `cache eval <ops.json>` reads a JSON array of op
|
||||
// dicts and emits one result per op. Each op carries memory_dir (the
|
||||
// cache.db parent — the oracle's memory_env.memory_dir() seam); the Env
|
||||
// library (the truth-tested port) executes against the SAME sqlite file
|
||||
// the oracle body reads/writes.
|
||||
//
|
||||
// Ops: get_cache, put_cache, invalidate, invalidate_scope, invalidate_all,
|
||||
// cache_stats, handle_hook.
|
||||
//
|
||||
// Fail-loud: unknown op or engine error aborts non-zero; the Python
|
||||
// oracle stays behind RUNE_CACHE_ENGINE=python.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// plainJSON converts OrderedMaps/slices to plain JSON-shaped data.
|
||||
func plainJSON(v any) any {
|
||||
switch t := v.(type) {
|
||||
case *OrderedMap:
|
||||
out := map[string]any{}
|
||||
for _, k := range t.Keys {
|
||||
out[k] = plainJSON(t.Vals[k])
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, 0, len(t))
|
||||
for _, e := range t {
|
||||
out = append(out, plainJSON(e))
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
out := make([]any, 0, len(t))
|
||||
for _, e := range t {
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func cacheEvalOps(ops []map[string]any) ([]any, string) {
|
||||
out := make([]any, 0, len(ops))
|
||||
for i, op := range ops {
|
||||
name, _ := op["op"].(string)
|
||||
res, emsg := cacheEvalOp(name, op)
|
||||
if emsg != "" {
|
||||
return nil, fmt.Sprintf("op %d (%s): %s", i, name, emsg)
|
||||
}
|
||||
out = append(out, res)
|
||||
}
|
||||
return out, ""
|
||||
}
|
||||
|
||||
func cacheEvalOp(name string, op map[string]any) (any, string) {
|
||||
dir, _ := op["memory_dir"].(string)
|
||||
if dir == "" {
|
||||
dir = memoryDirFromEnv()
|
||||
}
|
||||
e := NewEnv(dir)
|
||||
switch name {
|
||||
case "get_cache":
|
||||
key, _ := op["key"].(string)
|
||||
if key == "" {
|
||||
return nil, "get_cache: missing key"
|
||||
}
|
||||
res := e.getCache(key)
|
||||
if hit, _ := res.Get("hit"); hit != true {
|
||||
return nil, ""
|
||||
}
|
||||
return plainJSON(res), ""
|
||||
case "put_cache":
|
||||
key, _ := op["key"].(string)
|
||||
value, _ := op["value"].(string)
|
||||
if key == "" {
|
||||
return nil, "put_cache: missing key"
|
||||
}
|
||||
scope, _ := op["scope"].(string)
|
||||
if scope == "" {
|
||||
scope = "general"
|
||||
}
|
||||
sourceHash, _ := op["source_hash"].(string)
|
||||
var compressed *string
|
||||
if c, ok := op["compressed"].(string); ok && c != "" {
|
||||
compressed = &c
|
||||
}
|
||||
return plainJSON(e.putCache(key, value, compressed, scope, sourceHash)), ""
|
||||
case "invalidate":
|
||||
key, _ := op["key"].(string)
|
||||
reason, _ := op["reason"].(string)
|
||||
return plainJSON(e.invalidate(key, reason)), ""
|
||||
case "invalidate_scope":
|
||||
scope, _ := op["scope"].(string)
|
||||
reason, _ := op["reason"].(string)
|
||||
return plainJSON(e.invalidateScope(scope, reason)), ""
|
||||
case "invalidate_all":
|
||||
reason, _ := op["reason"].(string)
|
||||
return plainJSON(e.invalidateAll(reason)), ""
|
||||
case "cache_stats":
|
||||
return plainJSON(e.cacheStats()), ""
|
||||
case "handle_hook":
|
||||
event, _ := op["event"].(string)
|
||||
data := OrderedMapFromMap(map[string]any{})
|
||||
if d, ok := op["data"].(map[string]any); ok {
|
||||
data = OrderedMapFromMap(d)
|
||||
}
|
||||
return plainJSON(e.handleHook(event, data)), ""
|
||||
default:
|
||||
return nil, fmt.Sprintf("unknown op %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// runEvalMain — the eval entry, called from main(). Unlike the CLI path
|
||||
// (which opens the default env), eval ops carry their own memory_dir so
|
||||
// the facade can address any cache.db.
|
||||
func runEvalMain(args []string) int {
|
||||
var raw []byte
|
||||
var err error
|
||||
if len(args) > 0 && args[0] != "-" {
|
||||
raw, err = os.ReadFile(args[0])
|
||||
} else {
|
||||
raw, err = io.ReadAll(os.Stdin)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "cache eval: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
var ops []map[string]any
|
||||
if jerr := json.Unmarshal(raw, &ops); jerr != nil {
|
||||
fmt.Fprintf(os.Stderr, "cache eval: parse ops: %v\n", jerr)
|
||||
return 2
|
||||
}
|
||||
results, emsg := cacheEvalOps(ops)
|
||||
if emsg != "" {
|
||||
fmt.Fprintf(os.Stderr, "cache eval: %s\n", emsg)
|
||||
return 1
|
||||
}
|
||||
enc, _ := json.Marshal(results)
|
||||
fmt.Println(string(enc))
|
||||
return 0
|
||||
}
|
||||
|
||||
func OrderedMapFromMap(m map[string]any) *OrderedMap {
|
||||
om := NewOrderedMap()
|
||||
for k, v := range m {
|
||||
om.Set(k, v)
|
||||
}
|
||||
return om
|
||||
}
|
||||
Vendored
+3
@@ -265,6 +265,9 @@ var Plugin = NewOrderedMap(
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) >= 2 && os.Args[1] == "eval" {
|
||||
os.Exit(runEvalMain(os.Args[2:]))
|
||||
}
|
||||
e := NewEnv(memoryDirFromEnv())
|
||||
defer e.flushStats() // the oracle's atexit.register(flush_stats)
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
// capacity.go — the D56 governor's cap inside the classifier: the
|
||||
// PARALLEL verdict becomes throughput-aware by reading the core's
|
||||
// hot-state throughput model (~/.rune/hot/
|
||||
// provider_throughput.json — the same file the rune-core governor
|
||||
// persists). Registry and lock semantics are UNCHANGED: the capacity
|
||||
// only annotates the verdict's honesty, never the locks.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Capacity is the governor's live snapshot, classifier-shaped.
|
||||
type Capacity struct {
|
||||
Known bool // the governor has measured something
|
||||
TokPerSec float64 // the headline measured rate (0 with Known=false)
|
||||
LanesFree int // learned lane caps minus lanes in use
|
||||
}
|
||||
|
||||
// governorHotShape mirrors the core's persisted hot state (only the
|
||||
// fields the cap math needs).
|
||||
type governorHotShape struct {
|
||||
Saturation map[string]map[int]float64 `json:"saturation"`
|
||||
Samples map[string][]struct {
|
||||
At int64 `json:"at"`
|
||||
TokPerSec float64 `json:"tok_per_sec"`
|
||||
Lanes int `json:"lanes"`
|
||||
} `json:"samples"`
|
||||
}
|
||||
|
||||
// DefaultGovernorHotPath resolves the governor hot file:
|
||||
// $RUNE_GOVERNOR_HOT wins, else ~/.rune/hot/provider_throughput.json.
|
||||
func DefaultGovernorHotPath() string {
|
||||
if p := os.Getenv("RUNE_GOVERNOR_HOT"); p != "" {
|
||||
return p
|
||||
}
|
||||
home := "~"
|
||||
if u, err := user.Current(); err == nil {
|
||||
home = u.HomeDir
|
||||
}
|
||||
return filepath.Join(home, ".rune", "hot", "provider_throughput.json")
|
||||
}
|
||||
|
||||
// LoadCapacity reads the governor's hot state into the classifier's
|
||||
// capacity view. A missing or corrupt file is capacity-unknown (the
|
||||
// verdict stays honest: no throughput claims without measurement).
|
||||
// The cap floor mirrors the core's DefaultCapFloor: a lanes bucket
|
||||
// whose marginal gain over the previous bucket clears the floor
|
||||
// counts toward the pool.
|
||||
func LoadCapacity(path string) Capacity {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Capacity{}
|
||||
}
|
||||
var st governorHotShape
|
||||
if json.Unmarshal(data, &st) != nil {
|
||||
return Capacity{}
|
||||
}
|
||||
cap := Capacity{Known: len(st.Saturation) > 0 || len(st.Samples) > 0}
|
||||
if !cap.Known {
|
||||
return cap
|
||||
}
|
||||
// headline tok/s: the freshest sample anywhere (mirrors the
|
||||
// core's Snapshot law).
|
||||
var freshest int64
|
||||
for _, ss := range st.Samples {
|
||||
for _, s := range ss {
|
||||
if s.At >= freshest {
|
||||
freshest, cap.TokPerSec = s.At, s.TokPerSec
|
||||
}
|
||||
}
|
||||
}
|
||||
const floor = 5.0
|
||||
for _, buckets := range st.Saturation {
|
||||
lanes := make([]int, 0, len(buckets))
|
||||
for l := range buckets {
|
||||
lanes = append(lanes, l)
|
||||
}
|
||||
for i := 0; i < len(lanes); i++ {
|
||||
for j := i + 1; j < len(lanes); j++ {
|
||||
if lanes[j] < lanes[i] {
|
||||
lanes[i], lanes[j] = lanes[j], lanes[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
providerCap := 1
|
||||
prev := buckets[lanes[0]]
|
||||
for _, l := range lanes[1:] {
|
||||
gain := buckets[l] - prev
|
||||
if gain < floor {
|
||||
break
|
||||
}
|
||||
providerCap = l
|
||||
prev = buckets[l]
|
||||
}
|
||||
cap.LanesFree += providerCap
|
||||
}
|
||||
return cap
|
||||
}
|
||||
|
||||
// annotateParallel makes the PARALLEL verdict throughput-aware: the
|
||||
// governor's measured state joins the reason — lanes free at the
|
||||
// current tok/s when known, the honest thin-pipe line when the cap
|
||||
// leaves nothing free (no false fan-out).
|
||||
func annotateParallel(reason string, c Capacity) string {
|
||||
if !c.Known {
|
||||
return reason + " (governor: no throughput measured yet — capacity unknown)"
|
||||
}
|
||||
if c.LanesFree <= 0 {
|
||||
return reason + " (governor: 0 lanes free at current tok/s — the pipe is thin: serialize, no fan-out benefit)"
|
||||
}
|
||||
return reason + " (governor: PARALLEL lane capacity — " +
|
||||
strconv.Itoa(c.LanesFree) + " lanes free at current tok/s)"
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
// capacity_test.go — the D56 classifier-capacity laws: the hot file
|
||||
// feeds the PARALLEL annotation, a missing file stays
|
||||
// capacity-unknown (honesty: no throughput claims without
|
||||
// measurement), and the thin pipe says so instead of fanning out.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadCapacityMissingFileUnknown(t *testing.T) {
|
||||
c := LoadCapacity(filepath.Join(t.TempDir(), "absent.json"))
|
||||
if c.Known {
|
||||
t.Fatal("capacity invented from a missing file — never guess a rate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCapacityReadsHotState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "provider_throughput.json")
|
||||
hot := `{"schema_version":1,
|
||||
"samples":{"anthropic":[{"at":100,"provider":"anthropic","tok_per_sec":38,"lanes":1}]},
|
||||
"saturation":{"anthropic":{"1":38,"2":70}}}`
|
||||
if err := os.WriteFile(p, []byte(hot), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := LoadCapacity(p)
|
||||
if !c.Known || c.TokPerSec != 38 {
|
||||
t.Fatalf("capacity = %+v, want known with 38 tok/s", c)
|
||||
}
|
||||
if c.LanesFree != 2 {
|
||||
t.Fatalf("lanes free = %d, want 2 (marginal gain 70→38 clears the floor; no 3-lane bucket)", c.LanesFree)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyCapacityAnnotatesParallel(t *testing.T) {
|
||||
reg := &Registry{Version: 1}
|
||||
now := time.Now().UTC()
|
||||
v := ClassifyCapacity([]string{"src/a/x.go", "src/b/y.go"}, reg, false, now,
|
||||
Capacity{Known: true, TokPerSec: 38, LanesFree: 4})
|
||||
if v.Decision != "PARALLEL" {
|
||||
t.Fatalf("decision = %s, want PARALLEL", v.Decision)
|
||||
}
|
||||
if !strings.Contains(v.Reason, "4 lanes free at current tok/s") {
|
||||
t.Fatalf("verdict not throughput-aware: %s", v.Reason)
|
||||
}
|
||||
// the thin pipe: 0 lanes free — the verdict says so, no false fan-out
|
||||
v = ClassifyCapacity([]string{"src/a/x.go", "src/b/y.go"}, reg, false, now,
|
||||
Capacity{Known: true, TokPerSec: 38, LanesFree: 0})
|
||||
if !strings.Contains(v.Reason, "0 lanes free") || !strings.Contains(v.Reason, "thin") {
|
||||
t.Fatalf("thin pipe not surfaced: %s", v.Reason)
|
||||
}
|
||||
// unknown capacity: honest unknown, no invented rate
|
||||
v = Classify([]string{"src/a/x.go", "src/b/y.go"}, reg, false, now)
|
||||
if !strings.Contains(v.Reason, "no throughput measured yet") {
|
||||
t.Fatalf("unknown capacity not honest: %s", v.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyCapacityLeavesLocksUntouched(t *testing.T) {
|
||||
reg := &Registry{Version: 1}
|
||||
now := time.Now().UTC()
|
||||
v := ClassifyCapacity([]string{"src/a/x.go", "src/b/y.go"}, reg, false, now,
|
||||
Capacity{Known: true, TokPerSec: 38, LanesFree: 4})
|
||||
if len(v.Surfaces) != 2 || len(v.LocksToTake) != 2 {
|
||||
t.Fatalf("lock semantics changed by capacity: %+v", v)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,15 @@ type Verdict struct {
|
||||
// 3. multiple disjoint surfaces → PARALLEL with a suggested split;
|
||||
// 4. else SOLO.
|
||||
func Classify(candidates []string, reg *Registry, ownerOrigin bool, now time.Time) Verdict {
|
||||
return ClassifyCapacity(candidates, reg, ownerOrigin, now, Capacity{})
|
||||
}
|
||||
|
||||
// ClassifyCapacity applies the verdict rules with the governor's
|
||||
// throughput capacity attached (D56): identical rules and lock
|
||||
// semantics — only the PARALLEL verdict's honesty gains the
|
||||
// governor's measured lane capacity. A zero Capacity (the classic
|
||||
// Classify path) annotates capacity-unknown, never invents a rate.
|
||||
func ClassifyCapacity(candidates []string, reg *Registry, ownerOrigin bool, now time.Time, gov Capacity) Verdict {
|
||||
surfaces := maximalSurfaces(normalizeAll(candidates))
|
||||
v := Verdict{
|
||||
Surfaces: surfaces,
|
||||
@@ -85,9 +94,9 @@ func Classify(candidates []string, reg *Registry, ownerOrigin bool, now time.Tim
|
||||
lanes[i] = fmt.Sprintf("lane %d: %s", i+1, s)
|
||||
}
|
||||
v.Decision = "PARALLEL"
|
||||
v.Reason = fmt.Sprintf(
|
||||
v.Reason = annotateParallel(fmt.Sprintf(
|
||||
"%d disjoint surfaces, no contention: parallel factory — suggested split: %s",
|
||||
len(surfaces), strings.Join(lanes, "; "))
|
||||
len(surfaces), strings.Join(lanes, "; ")), gov)
|
||||
return v
|
||||
}
|
||||
|
||||
|
||||
@@ -199,7 +199,8 @@ func cmdClassify() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v := Classify(brief2candidates(brief, explicit), reg, *ownerOrigin, time.Now().UTC())
|
||||
v := ClassifyCapacity(brief2candidates(brief, explicit), reg, *ownerOrigin, time.Now().UTC(),
|
||||
LoadCapacity(governorHotPath(fs)))
|
||||
out, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -263,3 +264,12 @@ func defaultAgent() string {
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", host, os.Getpid())
|
||||
}
|
||||
|
||||
// governorHotPath resolves the governor hot file for the classify
|
||||
// verdict (the same $RUNE_GOVERNOR_HOT override law as the registry).
|
||||
func governorHotPath(fs *flag.FlagSet) string {
|
||||
if p := os.Getenv("RUNE_GOVERNOR_HOT"); p != "" {
|
||||
return p
|
||||
}
|
||||
return DefaultGovernorHotPath()
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Eval — the live ops surface for the Python delegation facade
|
||||
// (src/rune/forge_client.py). `forge-client eval <ops.json>` reads a JSON
|
||||
// array of op dicts and emits one result per op. Ops:
|
||||
//
|
||||
// request — {method,url,headers,json,retries,backoff,timeout} executes
|
||||
// Request over the real net/http transport and returns
|
||||
// {status_code, body} on 2xx/3xx; {status_error} for the
|
||||
// never-retried 4xx; {error_kind, error_msg} for transport
|
||||
// errors (Kind strings == the httpx exception names, so the
|
||||
// Python facade re-raises the exact exception contract).
|
||||
// raise_for_status stays on the Python side — the exception
|
||||
// contract is a Python-surface concern; the RETRY DECISION
|
||||
// (the ported seam) is the Go engine's.
|
||||
// select — {config} → the create_forge_client adapter decision
|
||||
// (NullAdapter vs GiteaForgejoAdapter + resolved fields).
|
||||
//
|
||||
// Fail-loud: unknown op or engine error aborts non-zero — no silent
|
||||
// fallback; the Python oracle bodies stay behind
|
||||
// RUNE_FORGE_CLIENT_ENGINE=python.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RunEval executes each op; second return is a failure message.
|
||||
func RunEval(ops []map[string]any) ([]any, string) {
|
||||
out := make([]any, 0, len(ops))
|
||||
for i, op := range ops {
|
||||
name, _ := op["op"].(string)
|
||||
res, emsg := evalOp(name, op)
|
||||
if emsg != "" {
|
||||
return nil, fmt.Sprintf("op %d (%s): %s", i, name, emsg)
|
||||
}
|
||||
out = append(out, res)
|
||||
}
|
||||
return out, ""
|
||||
}
|
||||
|
||||
func evalOp(name string, op map[string]any) (any, string) {
|
||||
switch name {
|
||||
case "request":
|
||||
return evalRequest(op)
|
||||
case "select":
|
||||
return evalSelect(op)
|
||||
default:
|
||||
return nil, fmt.Sprintf("unknown op %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
func evalRequest(op map[string]any) (any, string) {
|
||||
method, _ := op["method"].(string)
|
||||
url, _ := op["url"].(string)
|
||||
if url == "" {
|
||||
return nil, "request: missing url"
|
||||
}
|
||||
headers := map[string]string{}
|
||||
if h, ok := op["headers"].(map[string]any); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
opts := RequestOptions{
|
||||
Headers: headers,
|
||||
JSON: op["json"],
|
||||
Timeout: 30 * time.Second,
|
||||
Retries: 2,
|
||||
Backoff: 500 * time.Millisecond,
|
||||
}
|
||||
if r, ok := op["retries"].(float64); ok {
|
||||
opts.Retries = int(r)
|
||||
}
|
||||
if b, ok := op["backoff"].(float64); ok {
|
||||
opts.Backoff = time.Duration(b * float64(time.Second))
|
||||
}
|
||||
if t, ok := op["timeout"].(float64); ok {
|
||||
opts.Timeout = time.Duration(t * float64(time.Second))
|
||||
}
|
||||
resp, err := Request(NewHTTPXSender(&http.Client{}), method, url, opts)
|
||||
if err != nil {
|
||||
var statusErr *HTTPStatusError
|
||||
if errors.As(err, &statusErr) {
|
||||
return map[string]any{"status_error": statusErr.StatusCode}, ""
|
||||
}
|
||||
var terr *ForgeTransportError
|
||||
if errors.As(err, &terr) {
|
||||
return map[string]any{"error_kind": terr.Kind, "error_msg": terr.Msg}, ""
|
||||
}
|
||||
return map[string]any{"error_kind": "httpx.TransportError", "error_msg": err.Error()}, ""
|
||||
}
|
||||
return map[string]any{
|
||||
"status_code": resp.StatusCode,
|
||||
"body": string(resp.Body),
|
||||
}, ""
|
||||
}
|
||||
|
||||
func evalSelect(op map[string]any) (any, string) {
|
||||
cfg, _ := op["config"].(map[string]any)
|
||||
// The Go decision covers url/token presence → adapter kind; owner/repo
|
||||
// defaults resolve identically to the oracle (cfg key → env default).
|
||||
url := strCfg(cfg, "forge.url")
|
||||
token := strCfg(cfg, "forge.token")
|
||||
if url == "" || token == "" {
|
||||
return map[string]any{"adapter": "NullAdapter"}, ""
|
||||
}
|
||||
owner := strCfg(cfg, "forge.owner")
|
||||
if owner == "" {
|
||||
owner = "owner"
|
||||
}
|
||||
repo := strCfg(cfg, "forge.repo")
|
||||
if repo == "" {
|
||||
repo = "repo"
|
||||
}
|
||||
return map[string]any{
|
||||
"adapter": "GiteaForgejoAdapter",
|
||||
"base_url": strings.TrimRight(url, "/") + "/api/v1/repos/" + owner + "/" + repo,
|
||||
"token": token,
|
||||
}, ""
|
||||
}
|
||||
|
||||
func strCfg(cfg map[string]any, key string) string {
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
s, _ := cfg[key].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// runEvalMain is the eval entry, called from main().
|
||||
func runEvalMain(args []string) int {
|
||||
var raw []byte
|
||||
var err error
|
||||
if len(args) > 0 && args[0] != "-" {
|
||||
raw, err = os.ReadFile(args[0])
|
||||
} else {
|
||||
raw, err = readAllStdin()
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "forge-client eval: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
var ops []map[string]any
|
||||
if jerr := json.Unmarshal(raw, &ops); jerr != nil {
|
||||
fmt.Fprintf(os.Stderr, "forge-client eval: parse ops: %v\n", jerr)
|
||||
return 2
|
||||
}
|
||||
results, emsg := RunEval(ops)
|
||||
if emsg != "" {
|
||||
fmt.Fprintf(os.Stderr, "forge-client eval: %s\n", emsg)
|
||||
return 1
|
||||
}
|
||||
enc, _ := json.Marshal(results)
|
||||
fmt.Println(string(enc))
|
||||
return 0
|
||||
}
|
||||
|
||||
func readAllStdin() ([]byte, error) {
|
||||
var buf [4096]byte
|
||||
var out []byte
|
||||
for {
|
||||
n, err := os.Stdin.Read(buf[:])
|
||||
out = append(out, buf[:n]...)
|
||||
if err != nil {
|
||||
if err.Error() == "EOF" {
|
||||
return out, nil
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) >= 2 && os.Args[1] == "eval" {
|
||||
os.Exit(runEvalMain(os.Args[2:]))
|
||||
}
|
||||
fails := RunTruth("truth.json")
|
||||
if len(fails) > 0 {
|
||||
for _, f := range fails {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Eval — the live ops surface for the Python delegation facade
|
||||
// (src/rune/goal.py). `goal eval <ops.json>` reads a JSON array of op
|
||||
// dicts and emits one result per op. Each op carries storage_path and
|
||||
// the method arguments; the GoalManager library (the truth-tested port)
|
||||
// executes against the same goals.json the oracle body reads/writes.
|
||||
//
|
||||
// Ops: set_goal, get_active_goal, check_goal, achieve_goal, clear_goal,
|
||||
// list_goals. The clock/ID seams produce the engine's own values — the
|
||||
// facade returns them unchanged (the oracle generates its own per call).
|
||||
//
|
||||
// Fail-loud: unknown op or engine error aborts non-zero; the Python
|
||||
// oracle stays behind RUNE_GOAL_ENGINE=python.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func runGoalEval(ops []map[string]any) ([]any, string) {
|
||||
out := make([]any, 0, len(ops))
|
||||
for i, op := range ops {
|
||||
name, _ := op["op"].(string)
|
||||
res, emsg := goalEvalOp(name, op)
|
||||
if emsg != "" {
|
||||
return nil, fmt.Sprintf("op %d (%s): %s", i, name, emsg)
|
||||
}
|
||||
out = append(out, res)
|
||||
}
|
||||
return out, ""
|
||||
}
|
||||
|
||||
func goalEvalOp(name string, op map[string]any) (any, string) {
|
||||
path, _ := op["storage_path"].(string)
|
||||
if path == "" {
|
||||
return nil, "missing storage_path"
|
||||
}
|
||||
m := NewGoalManager(path)
|
||||
m.Load()
|
||||
switch name {
|
||||
case "set_goal":
|
||||
desc, _ := op["description"].(string)
|
||||
var conds []string
|
||||
if c, ok := op["stop_conditions"].([]any); ok {
|
||||
for _, v := range c {
|
||||
conds = append(conds, fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
method, _ := op["verification_method"].(string)
|
||||
if method == "" {
|
||||
method = "manual"
|
||||
}
|
||||
return plainJSON(m.SetGoal(desc, conds, method).toDict()), ""
|
||||
case "get_active_goal":
|
||||
g := m.GetActiveGoal()
|
||||
if g == nil {
|
||||
return nil, ""
|
||||
}
|
||||
return plainJSON(g.toDict()), ""
|
||||
case "check_goal":
|
||||
var ev *string
|
||||
if e, ok := op["evidence"].(string); ok {
|
||||
ev = &e
|
||||
}
|
||||
return plainJSON(m.CheckGoal(ev)), ""
|
||||
case "achieve_goal":
|
||||
var ev *string
|
||||
if e, ok := op["evidence"].(string); ok {
|
||||
ev = &e
|
||||
}
|
||||
return plainJSON(m.AchieveGoal(ev)), ""
|
||||
case "fail_goal":
|
||||
var ev *string
|
||||
if e, ok := op["reason"].(string); ok && e != "" {
|
||||
ev = &e
|
||||
}
|
||||
return plainJSON(m.FailGoal(ev)), ""
|
||||
case "clear_goal":
|
||||
return plainJSON(m.ClearGoal()), ""
|
||||
case "list_goals":
|
||||
return plainJSON(m.ListGoals()), ""
|
||||
default:
|
||||
return nil, fmt.Sprintf("unknown op %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// runEvalMain — the eval entry, called from main().
|
||||
func runEvalMain(args []string) int {
|
||||
var raw []byte
|
||||
var err error
|
||||
if len(args) > 0 && args[0] != "-" {
|
||||
raw, err = os.ReadFile(args[0])
|
||||
} else {
|
||||
raw, err = io.ReadAll(os.Stdin)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "goal eval: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
var ops []map[string]any
|
||||
if jerr := json.Unmarshal(raw, &ops); jerr != nil {
|
||||
fmt.Fprintf(os.Stderr, "goal eval: parse ops: %v\n", jerr)
|
||||
return 2
|
||||
}
|
||||
results, emsg := runGoalEval(ops)
|
||||
if emsg != "" {
|
||||
fmt.Fprintf(os.Stderr, "goal eval: %s\n", emsg)
|
||||
return 1
|
||||
}
|
||||
enc, _ := json.Marshal(results)
|
||||
fmt.Println(string(enc))
|
||||
return 0
|
||||
}
|
||||
|
||||
// plainJSON converts engine values to plain JSON-shaped data for the
|
||||
// facade: *OrderedMap → dict (recursively), slices → arrays.
|
||||
func plainJSON(v any) any {
|
||||
switch t := v.(type) {
|
||||
case *OrderedMap:
|
||||
out := map[string]any{}
|
||||
for _, k := range t.Keys {
|
||||
out[k] = plainJSON(t.Vals[k])
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, 0, len(t))
|
||||
for _, e := range t {
|
||||
out = append(out, plainJSON(e))
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
out := make([]any, 0, len(t))
|
||||
for _, e := range t {
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,9 @@ var (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) >= 2 && os.Args[1] == "eval" {
|
||||
os.Exit(runEvalMain(os.Args[2:]))
|
||||
}
|
||||
if len(os.Args) >= 2 && os.Args[1] == "replay" {
|
||||
path := "truth.json"
|
||||
if len(os.Args) >= 3 {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// Eval — the live ops surface for the Python delegation facade
|
||||
// (src/rune/message_queue.py). `message-queue eval <ops.json>` reads a
|
||||
// JSON array of op dicts and emits one result per op. Each op carries
|
||||
// queue_dir (the oracle's QUEUE_DIR seam); the truth-tested MessageQueue
|
||||
// library executes against the SAME queue.json the oracle body
|
||||
// reads/writes.
|
||||
//
|
||||
// Ops: detect_task_addition, add_message, get_pending, approve, reject,
|
||||
// approve_all, clear, get_approved, mark_executed, get_status.
|
||||
//
|
||||
// Fail-loud: unknown op or engine error aborts non-zero; the Python
|
||||
// oracle stays behind RUNE_MESSAGE_QUEUE_ENGINE=python.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// plainJSONDicts projects []QueuedMessage through toDict.
|
||||
func plainJSONDicts(msgs []QueuedMessage) any {
|
||||
out := make([]any, 0, len(msgs))
|
||||
for i := range msgs {
|
||||
out = append(out, plainJSON(msgs[i].toDict()))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// plainJSON converts OrderedMaps/slices to plain JSON-shaped data.
|
||||
func plainJSON(v any) any {
|
||||
switch t := v.(type) {
|
||||
case *OrderedMap:
|
||||
out := map[string]any{}
|
||||
for _, k := range t.Keys {
|
||||
out[k] = plainJSON(t.Vals[k])
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, 0, len(t))
|
||||
for _, e := range t {
|
||||
out = append(out, plainJSON(e))
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
out := make([]any, 0, len(t))
|
||||
for _, e := range t {
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func queueEvalOps(ops []map[string]any) ([]any, string) {
|
||||
out := make([]any, 0, len(ops))
|
||||
for i, op := range ops {
|
||||
name, _ := op["op"].(string)
|
||||
res, emsg := queueEvalOp(name, op)
|
||||
if emsg != "" {
|
||||
return nil, fmt.Sprintf("op %d (%s): %s", i, name, emsg)
|
||||
}
|
||||
out = append(out, res)
|
||||
}
|
||||
return out, ""
|
||||
}
|
||||
|
||||
func queueEvalOp(name string, op map[string]any) (any, string) {
|
||||
dir, _ := op["queue_dir"].(string)
|
||||
if dir == "" {
|
||||
return nil, "missing queue_dir"
|
||||
}
|
||||
q := NewMessageQueue(dir)
|
||||
switch name {
|
||||
case "detect_task_addition":
|
||||
msg, _ := op["message"].(string)
|
||||
isTask, conf, signals := q.detectTaskAddition(msg)
|
||||
return []any{isTask, conf, plainJSON(signals)}, ""
|
||||
case "add_message":
|
||||
msg, _ := op["message"].(string)
|
||||
channel, _ := op["channel"].(string)
|
||||
if channel == "" {
|
||||
channel = "chat"
|
||||
}
|
||||
var taskID *string
|
||||
if t, ok := op["task_id"].(string); ok && t != "" {
|
||||
taskID = &t
|
||||
}
|
||||
added := q.addMessage(msg, channel, taskID)
|
||||
return plainJSON(added.toDict()), ""
|
||||
case "get_pending":
|
||||
return plainJSONDicts(q.getPending()), ""
|
||||
case "approve":
|
||||
id, _ := op["msg_id"].(string)
|
||||
return q.approve(id), ""
|
||||
case "reject":
|
||||
id, _ := op["msg_id"].(string)
|
||||
return q.reject(id), ""
|
||||
case "approve_all":
|
||||
return q.approveAll(), ""
|
||||
case "clear":
|
||||
q.clear()
|
||||
return nil, ""
|
||||
case "get_approved":
|
||||
return plainJSONDicts(q.getApproved()), ""
|
||||
case "mark_executed":
|
||||
id, _ := op["msg_id"].(string)
|
||||
return q.markExecuted(id), ""
|
||||
case "get_status":
|
||||
return plainJSON(q.getStatus()), ""
|
||||
default:
|
||||
return nil, fmt.Sprintf("unknown op %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// runEvalMain — the eval entry, called from main().
|
||||
func runEvalMain(args []string) int {
|
||||
var raw []byte
|
||||
var err error
|
||||
if len(args) > 0 && args[0] != "-" {
|
||||
raw, err = os.ReadFile(args[0])
|
||||
} else {
|
||||
raw, err = io.ReadAll(os.Stdin)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "message-queue eval: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
var ops []map[string]any
|
||||
if jerr := json.Unmarshal(raw, &ops); jerr != nil {
|
||||
fmt.Fprintf(os.Stderr, "message-queue eval: parse ops: %v\n", jerr)
|
||||
return 2
|
||||
}
|
||||
results, emsg := queueEvalOps(ops)
|
||||
if emsg != "" {
|
||||
fmt.Fprintf(os.Stderr, "message-queue eval: %s\n", emsg)
|
||||
return 1
|
||||
}
|
||||
enc, _ := json.Marshal(results)
|
||||
fmt.Println(string(enc))
|
||||
return 0
|
||||
}
|
||||
@@ -613,6 +613,9 @@ func truthRowCount() int {
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) >= 2 && os.Args[1] == "eval" {
|
||||
os.Exit(runEvalMain(os.Args[2:]))
|
||||
}
|
||||
if len(os.Args) >= 2 && os.Args[1] == "replay" {
|
||||
path := "truth.json"
|
||||
if len(os.Args) >= 3 {
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -191,27 +190,16 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "usage: microkernel resolve <capability>")
|
||||
os.Exit(1)
|
||||
}
|
||||
providers, ok := registry.Capabilities[args[0]]
|
||||
if !ok || len(providers) == 0 {
|
||||
// The library Resolve is the oracle-parity unit (active filter,
|
||||
// ascending priority, returns the NAME) — the pre-port CLI case
|
||||
// sorted descending over inactive-inclusive candidates and printed
|
||||
// the manifest; the truth battery caught it. Byte-identical now.
|
||||
name := Resolve(®istry, args[0])
|
||||
if name == "" {
|
||||
fmt.Println("null")
|
||||
return
|
||||
}
|
||||
// Sort by priority (highest first)
|
||||
var candidates []PluginEntry
|
||||
for _, p := range providers {
|
||||
if entry, ok := registry.Plugins[p]; ok {
|
||||
candidates = append(candidates, entry)
|
||||
}
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
return candidates[i].Manifest.Priority > candidates[j].Manifest.Priority
|
||||
})
|
||||
if len(candidates) > 0 {
|
||||
out, _ := json.Marshal(candidates[0].Manifest)
|
||||
fmt.Println(string(out))
|
||||
} else {
|
||||
fmt.Println("null")
|
||||
}
|
||||
fmt.Println(name)
|
||||
|
||||
case "fire":
|
||||
event := ""
|
||||
|
||||
@@ -61,6 +61,37 @@ func parityMain() {
|
||||
emit(map[string]interface{}{"result": result, "plugins": plugins,
|
||||
"capabilities": reg.Capabilities, "hooks": reg.Hooks})
|
||||
|
||||
case "resolve":
|
||||
// Oracle law: manifest.get("priority", 100) — a manifest WITHOUT a
|
||||
// priority key ranks at 100; an explicit priority 0 stays 0. The
|
||||
// registry blob carries each manifest raw; decode per-plugin to
|
||||
// distinguish "absent" (default 100) from "present 0".
|
||||
rawReg, _ := json.Marshal(req["registry"])
|
||||
registryRaw := map[string]json.RawMessage{}
|
||||
_ = json.Unmarshal(rawReg, ®istryRaw)
|
||||
pluginsRaw := map[string]map[string]interface{}{}
|
||||
if rawPlugins, has := registryRaw["plugins"]; has {
|
||||
_ = json.Unmarshal(rawPlugins, &pluginsRaw)
|
||||
}
|
||||
reg := registryFromReq(req)
|
||||
for name, entry := range reg.Plugins {
|
||||
if entry.Manifest.Priority != 0 {
|
||||
continue
|
||||
}
|
||||
plugin := pluginsRaw[name]
|
||||
m, _ := plugin["manifest"].(map[string]interface{})
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if _, has := m["priority"]; !has {
|
||||
e := entry
|
||||
e.Manifest.Priority = 100
|
||||
reg.Plugins[name] = e
|
||||
}
|
||||
}
|
||||
capability, _ := req["capability"].(string)
|
||||
emit(map[string]interface{}{"resolved": Resolve(reg, capability)})
|
||||
|
||||
case "registry_load":
|
||||
if content, has := req["content"].(string); has {
|
||||
os.MkdirAll(filepath.Dir(registryPath()), 0755)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// Eval — the live ops surface for the Python delegation facade
|
||||
// (src/rune/openai_compat_transport.py). Reads a JSON array of op dicts
|
||||
// (`openai-compat-transport eval <ops.json>`) and emits a JSON array of
|
||||
// results, one per op, in order. This is the same protocol the
|
||||
// model-routing engine established (model_routing eval <ops.json> cmd).
|
||||
// Fail-loud: any unknown op or error aborts with exit != 0 — no silent
|
||||
// fallback to the oracle; the Python side keeps the oracle body behind
|
||||
// RUNE_OPENAI_COMPAT_TRANSPORT_ENGINE=python instead.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// RunEval executes each op and returns the results slice; the second
|
||||
// return is a human-readable failure (op index + cause).
|
||||
func RunEval(ops []PyValue) ([]PyValue, string) {
|
||||
out := make([]PyValue, 0, len(ops))
|
||||
for i, opv := range ops {
|
||||
o, ok := opv.(*PyObj)
|
||||
if !ok {
|
||||
return nil, fmt.Sprintf("op %d: not an object", i)
|
||||
}
|
||||
nameV, _ := o.Get("op")
|
||||
res, emsg := evalOp(strOf(nameV), o)
|
||||
if emsg != "" {
|
||||
return nil, fmt.Sprintf("op %d (%s): %s", i, strOf(nameV), emsg)
|
||||
}
|
||||
out = append(out, res)
|
||||
}
|
||||
return out, ""
|
||||
}
|
||||
|
||||
func evalOp(name string, o *PyObj) (PyValue, string) {
|
||||
switch name {
|
||||
case "to_openai_messages":
|
||||
msgs, _ := o.Get("messages")
|
||||
return PyValue(toOpenaiMessages(listOf(msgs))), ""
|
||||
case "from_openai_response":
|
||||
resp, _ := o.Get("resp")
|
||||
return fromOpenaiResponse(resp), ""
|
||||
case "is_cloud_pinned":
|
||||
model, _ := o.Get("model")
|
||||
if IsCloudPinned(strOf(model)) {
|
||||
return PyValue(true), ""
|
||||
}
|
||||
return PyValue(false), ""
|
||||
case "chat":
|
||||
return evalChat(o)
|
||||
case "cloud_transport_for":
|
||||
return evalCloudTransportFor(o)
|
||||
default:
|
||||
return nil, fmt.Sprintf("unknown op %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
func evalChat(o *PyObj) (PyValue, string) {
|
||||
baseV, _ := o.Get("base_url")
|
||||
keyV, _ := o.Get("api_key")
|
||||
provV, _ := o.Get("provider")
|
||||
modelV, _ := o.Get("model")
|
||||
msgsV, _ := o.Get("messages")
|
||||
tools, toolsPresent := o.Get("tools")
|
||||
options, _ := o.Get("options")
|
||||
think, thinkPresent := o.Get("think")
|
||||
timeout := 120.0
|
||||
if tv, present := o.Get("timeout"); present {
|
||||
timeout = numOf(tv)
|
||||
}
|
||||
ctxWin := 131072
|
||||
if cv, present := o.Get("context_window"); present {
|
||||
ctxWin = int(numOf(cv))
|
||||
}
|
||||
t := NewOpenAICompatTransportWith(strOf(baseV), strOf(keyV), strOf(provV), timeout, ctxWin)
|
||||
var in chatInput
|
||||
in.Model = strOf(modelV)
|
||||
in.Messages = listOf(msgsV)
|
||||
if toolsPresent {
|
||||
in.Tools = tools
|
||||
}
|
||||
if oo, ok := options.(*PyObj); ok {
|
||||
in.Options = oo
|
||||
}
|
||||
if thinkPresent {
|
||||
in.Think = think
|
||||
}
|
||||
result, err := t.chat(in)
|
||||
if err != nil {
|
||||
eo := NewPyObj()
|
||||
eo.Set("error", err.Error())
|
||||
return PyValue(eo), ""
|
||||
}
|
||||
out := NewPyObj()
|
||||
out.Set("result", result)
|
||||
usage := NewPyObj()
|
||||
usage.Set("calls", json.Number(fmt.Sprintf("%d", t.usageCalls)))
|
||||
usage.Set("prompt_tokens", json.Number(fmt.Sprintf("%d", t.usagePromptTokens)))
|
||||
usage.Set("output_tokens", json.Number(fmt.Sprintf("%d", t.usageOutputTokens)))
|
||||
usage.Set("total_duration_ns", json.Number(fmt.Sprintf("%d", t.usageTotalDurationNS)))
|
||||
out.Set("usage", usage)
|
||||
return PyValue(out), ""
|
||||
}
|
||||
|
||||
func evalCloudTransportFor(o *PyObj) (PyValue, string) {
|
||||
modelV, _ := o.Get("model")
|
||||
timeout := 120.0
|
||||
if tv, present := o.Get("timeout"); present {
|
||||
timeout = numOf(tv)
|
||||
}
|
||||
t, err := CloudTransportFor(strOf(modelV), timeout)
|
||||
if err != nil {
|
||||
eo := NewPyObj()
|
||||
eo.Set("error", err.Error())
|
||||
return PyValue(eo), ""
|
||||
}
|
||||
return transportAttrs(t), ""
|
||||
}
|
||||
|
||||
// Main-eval entry, called from main().
|
||||
func runEvalMain(args []string) int {
|
||||
var raw []byte
|
||||
var err error
|
||||
if len(args) > 0 && args[0] != "-" {
|
||||
raw, err = os.ReadFile(args[0])
|
||||
} else {
|
||||
raw, err = readAllStdin()
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "openai-compat-transport eval: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
parsed, perr := PyParse(raw)
|
||||
if perr != nil {
|
||||
fmt.Fprintf(os.Stderr, "openai-compat-transport eval: parse ops: %v\n", perr)
|
||||
return 2
|
||||
}
|
||||
ops := listOf(parsed)
|
||||
results, emsg := RunEval(ops)
|
||||
if emsg != "" {
|
||||
fmt.Fprintf(os.Stderr, "openai-compat-transport eval: %s\n", emsg)
|
||||
return 1
|
||||
}
|
||||
fmt.Println(PyDumpsCompact(PyValue(results)))
|
||||
return 0
|
||||
}
|
||||
|
||||
func readAllStdin() ([]byte, error) {
|
||||
var buf [4096]byte
|
||||
var out []byte
|
||||
for {
|
||||
n, err := os.Stdin.Read(buf[:])
|
||||
out = append(out, buf[:n]...)
|
||||
if err != nil {
|
||||
if err.Error() == "EOF" {
|
||||
return out, nil
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,11 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) >= 2 && os.Args[1] == "eval" {
|
||||
os.Exit(runEvalMain(os.Args[2:]))
|
||||
}
|
||||
if len(os.Args) < 2 || os.Args[1] != "replay" {
|
||||
fmt.Fprintln(os.Stderr, "usage: openai-compat-transport replay <truth.json>")
|
||||
fmt.Fprintln(os.Stderr, "usage: openai-compat-transport eval <ops.json> | replay <truth.json>")
|
||||
os.Exit(2)
|
||||
}
|
||||
path := "truth.json"
|
||||
|
||||
@@ -365,6 +365,13 @@ func fromOpenaiResponse(resp PyValue) PyValue {
|
||||
content = s
|
||||
}
|
||||
out.Set("content", content)
|
||||
// oracle: DeepSeek-style providers return reasoning in reasoning_content;
|
||||
// the Ollama-shaped contract the runtime consumes names it thinking.
|
||||
if rRaw, present := message.Get("reasoning_content"); present {
|
||||
if r, ok := rRaw.(string); ok && r != "" {
|
||||
out.Set("thinking", r)
|
||||
}
|
||||
}
|
||||
if len(toolCalls) > 0 {
|
||||
out.Set("tool_calls", toolCalls)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// cli_changelog.go — the committed changelog's sync + check commands
|
||||
// (issue #2582). The changelog is GENERATED by the release pipeline
|
||||
// (auto-version's sync step) and CHECKED by CI's lint job: a fresh
|
||||
// regeneration byte-compares against the committed file; drift fails with
|
||||
// the sync command printed.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// cmdChangelog — changelog [--check] [--to <file>]: compose (or check) the
|
||||
// full changelog from the repo's main v* tags. Written into the repo root
|
||||
// (the committed CHANGELOG lives there).
|
||||
func cmdChangelog(args []string) int {
|
||||
check := false
|
||||
for _, a := range args {
|
||||
if a == "--check" {
|
||||
check = true
|
||||
}
|
||||
}
|
||||
content := RenderChangelog(nil, nil, nil)
|
||||
if content == "" {
|
||||
fmt.Fprintln(os.Stderr, "changelog: no v* tags in this repo — nothing to compose")
|
||||
return 1
|
||||
}
|
||||
const file = "CHANGELOG.md"
|
||||
if !check {
|
||||
if err := os.WriteFile(file, []byte(content), 0o644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "changelog: write failed: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println(`{"action":"changelog-written"}`)
|
||||
return 0
|
||||
}
|
||||
existing, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "changelog check: %s missing — sync it: release-notes changelog\n", file)
|
||||
return 1
|
||||
}
|
||||
if string(existing) != content {
|
||||
// the drift verdict names the versions the committed file carries vs
|
||||
// what the tags generate, honest about which is ahead
|
||||
fmt.Fprintf(os.Stderr, "changelog check: %s drifted from the tags — sync it: release-notes changelog\n", filepath.Base(file))
|
||||
fmt.Fprintf(os.Stderr, "committed bytes: %d | generated bytes: %d\n", len(existing), len(content))
|
||||
return 1
|
||||
}
|
||||
fmt.Println(`{"action":"changelog-current"}`)
|
||||
return 0
|
||||
}
|
||||
@@ -78,7 +78,87 @@ func cmdNarrative(args []string) int {
|
||||
fmt.Fprintf(os.Stderr, "narrative: git log failed: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Print(PlainEnglishNarrative(subjects))
|
||||
paths, _ := GitChangedPaths(since, to)
|
||||
fmt.Print(PlainEnglishNarrativeWithTry(subjects, paths))
|
||||
return 0
|
||||
}
|
||||
|
||||
// cmdRebuildBody — the #2582 readability command: rebuild the release body
|
||||
// as proper markdown (one H1 the title, the narrative/try sections at top,
|
||||
// verification + technical tails preserved, artifacts scrubbed). Range
|
||||
// sourced from --since (namespace-resolved by the caller).
|
||||
func cmdRebuildBody(args []string) int {
|
||||
var server, repo, token, tag, since, to string
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--server":
|
||||
i++
|
||||
server = arg(i, args)
|
||||
case "--repo":
|
||||
i++
|
||||
repo = arg(i, args)
|
||||
case "--token":
|
||||
i++
|
||||
token = arg(i, args)
|
||||
case "--tag":
|
||||
i++
|
||||
tag = arg(i, args)
|
||||
case "--since":
|
||||
i++
|
||||
since = arg(i, args)
|
||||
case "--to":
|
||||
i++
|
||||
to = arg(i, args)
|
||||
}
|
||||
}
|
||||
if server == "" || repo == "" || tag == "" || since == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: release-notes rebuild-body --server S --repo R --tag vX --since vPrev [--token T] [--to Y]")
|
||||
return 2
|
||||
}
|
||||
if to == "" {
|
||||
// the release's OWN commit ends its range — defaulting to HEAD would
|
||||
// mix whatever branch happens to be checked out into the body
|
||||
// (run 33183's lesson: the worktree's branch leaked into backfills)
|
||||
to = tag
|
||||
}
|
||||
if token == "" {
|
||||
token = os.Getenv("GITEA_TOKEN")
|
||||
}
|
||||
subjects, err := GitLogSubjects(since, to)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "rebuild-body: git log failed: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
paths, _ := GitChangedPaths(since, to)
|
||||
narrative := PlainEnglishNarrative(subjects)
|
||||
var trySection string
|
||||
if narrative != "" {
|
||||
if tries := SuggestTryActions(paths); len(tries) > 0 {
|
||||
var t strings.Builder
|
||||
t.WriteString("\n## What you could try\n\n")
|
||||
for _, a := range tries {
|
||||
t.WriteString("- " + a + "\n")
|
||||
}
|
||||
trySection = t.String()
|
||||
}
|
||||
}
|
||||
tr := httpTransport{}
|
||||
existing := FetchRelease(tr, server, repo, token, tag)
|
||||
if existing == nil {
|
||||
fmt.Fprintf(os.Stderr, "rebuild-body: no release for tag %s\n", tag)
|
||||
return 1
|
||||
}
|
||||
em := asDict(existing)
|
||||
body := getStrDefault(existing, "body")
|
||||
id := pyStr(em.Get2("id"))
|
||||
newBody := RebuildReleaseBody(tag, body, narrative, trySection)
|
||||
status, _ := _api(tr, fmt.Sprintf("%s/api/v1/repos/%s/releases/%s", server, repo, id), token, "PATCH",
|
||||
om(KV{"body", newBody}))
|
||||
if status >= 400 {
|
||||
fmt.Fprintf(os.Stderr, "rebuild-body: PATCH failed (HTTP %d)\n", status)
|
||||
return 1
|
||||
}
|
||||
fmt.Println(`{"action":"rebuilt"}`)
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -119,7 +199,8 @@ func cmdPrependNarrative(args []string) int {
|
||||
fmt.Fprintf(os.Stderr, "prepend-narrative: git log failed: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
section := PlainEnglishNarrative(subjects)
|
||||
paths, _ := GitChangedPaths(since, to)
|
||||
section := PlainEnglishNarrativeWithTry(subjects, paths)
|
||||
if section == "" {
|
||||
fmt.Println(`{"action":"noop","reason":"no plain-English narrative for range"}`)
|
||||
return 0
|
||||
|
||||
@@ -13,12 +13,16 @@ func main() {
|
||||
os.Exit(cmdNarrative(os.Args[2:]))
|
||||
case "prepend-narrative":
|
||||
os.Exit(cmdPrependNarrative(os.Args[2:]))
|
||||
case "rebuild-body":
|
||||
os.Exit(cmdRebuildBody(os.Args[2:]))
|
||||
case "changelog":
|
||||
os.Exit(cmdChangelog(os.Args[2:]))
|
||||
case "refresh-highlights":
|
||||
os.Exit(cmdRefreshHighlights(os.Args[2:]))
|
||||
}
|
||||
}
|
||||
if len(os.Args) < 2 || os.Args[1] != "replay" {
|
||||
fmt.Fprintln(os.Stderr, "usage: release-notes replay <truth.json> | narrative --since vX [--to Y] | prepend-narrative --server S --repo R --tag vX --since vPrev | refresh-highlights --file README.md --server S --repo R [--tag vX]")
|
||||
fmt.Fprintln(os.Stderr, "usage: release-notes replay <truth.json> | narrative --since vX [--to Y] | prepend-narrative | rebuild-body --server S --repo R --tag vX --since vPrev | changelog [--check] | refresh-highlights --file README.md --server S --repo R [--tag vX]")
|
||||
os.Exit(2)
|
||||
}
|
||||
path := "truth.json"
|
||||
|
||||
@@ -184,6 +184,7 @@ func plainSentence(subject string) string {
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
text = capReadableSentence(text)
|
||||
r := []rune(text)
|
||||
r[0] = []rune(strings.ToUpper(string(r[0])))[0]
|
||||
return string(r) + "."
|
||||
@@ -251,6 +252,28 @@ func PlainEnglishNarrative(subjects []string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// GitChangedPaths — the files touched in range (since..to), merges
|
||||
// excluded; the try-list derivation input (#2582). Duplicates from
|
||||
// multi-commit edits in one release collapse at the suggestion layer.
|
||||
func GitChangedPaths(since, to string) ([]string, error) {
|
||||
if to == "" {
|
||||
to = "HEAD"
|
||||
}
|
||||
out, err := exec.Command("git", "log", "--name-only", "--format=", "--no-merges", since+".."+to).Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var paths []string
|
||||
seen := map[string]bool{}
|
||||
for _, ln := range strings.Split(string(out), "\n") {
|
||||
if ln = strings.TrimSpace(ln); ln != "" && !seen[ln] {
|
||||
seen[ln] = true
|
||||
paths = append(paths, ln)
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// GitLogSubjects — the commit subjects for range (since..to), merges and
|
||||
// pipeline noise excluded by the caller's shared filters.
|
||||
func GitLogSubjects(since, to string) ([]string, error) {
|
||||
@@ -269,3 +292,403 @@ func GitLogSubjects(since, to string) ([]string, error) {
|
||||
}
|
||||
return subjects, nil
|
||||
}
|
||||
|
||||
// ── what-you-could-try (#2582): touched surface → reader-actionable test ────
|
||||
|
||||
// tryAction — one surface → the plain sentence a non-technical reader can do
|
||||
// to see the change working.
|
||||
type tryAction struct {
|
||||
pattern string // path prefix or contains-token, matched on the changed path
|
||||
sentence string
|
||||
}
|
||||
|
||||
var tryActions = []tryAction{
|
||||
{"tui/", "Open the chat window and use the changed panel — everything you touch should look and behave normally."},
|
||||
{"tool-executor", "Ask Rune to read a file or list a folder — file tools should behave exactly as before."},
|
||||
{"local_tool_runtime", "Ask Rune to read a file or list a folder — file tools should behave exactly as before."},
|
||||
{"src/go/rune-core", "Have a normal conversation with Rune and watch how it responds."},
|
||||
{"src/go/connection-pool", "Use Rune for a few minutes — it should stay responsive without connection hiccups."},
|
||||
{"memory", "Ask Rune about something you told it before — earlier notes should come back correctly and faster."},
|
||||
{"abstraction", "Teach Rune something new, then ask it later — the fact should still be there."},
|
||||
{"persona", "Check how Rune introduces itself — identity and wording should read right."},
|
||||
{"voice", "Rune's replies should keep reading naturally, in her own voice."},
|
||||
{"model-", "Try switching between a local model and a cloud model — routing should pick sensibly."},
|
||||
{"provider", "Try switching between a local model and a cloud model — routing should pick sensibly."},
|
||||
{"token", "Watch the context usage display while working — the numbers should update as expected."},
|
||||
{"context_broker", "Watch the context usage display while working — the numbers should update as expected."},
|
||||
{"context-pressure", "Watch the context usage display while working — the numbers should update as expected."},
|
||||
{"context_broker", "Watch the context usage display while working — the numbers should update as expected."},
|
||||
{"construct", "Watch a governed build proceed — contracts and gates should show up in the record."},
|
||||
{"contracts", "Watch a governed build proceed — contracts and gates should show up in the record."},
|
||||
{"src/go/forge", "Read the release history page — new entries should appear correctly."},
|
||||
{"src/rune/forge", "Read the release history page — new entries should appear correctly."},
|
||||
{"src/go/gitea_pipeline", "Read the release history page — new entries should appear correctly."},
|
||||
{"task-manager", "Give Rune a task and watch it appear/complete in the task panel."},
|
||||
{"task-manager", "Give Rune a task and watch it appear/complete in the task panel."},
|
||||
{"task_persist", "Restart Rune — open tasks should still be there."},
|
||||
{"task", "Give Rune a task and watch it appear/complete in the task panel."},
|
||||
{"config/", "Check your settings still load — nothing should need re-configuring."},
|
||||
{"device-pairing", "Pair a device — the handshake should complete cleanly."},
|
||||
}
|
||||
|
||||
// SuggestTryActions — the reader-facing try list for changed paths. Dedupes,
|
||||
// caps at 4 suggestions, returns nil when the touched paths are pure
|
||||
// tooling/docs/fixtures — no invented advice for changes a reader cannot
|
||||
// directly exercise.
|
||||
func SuggestTryActions(paths []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, p := range paths {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(p)
|
||||
for _, a := range tryActions {
|
||||
if seen[a.sentence] {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(lower, strings.ToLower(a.pattern)) {
|
||||
seen[a.sentence] = true
|
||||
out = append(out, a.sentence)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) > 4 {
|
||||
out = out[:4]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PlainEnglishNarrativeWithTry — the plain-English section plus, when any
|
||||
// touched path maps to a reader action, a "What you could try" list. Try
|
||||
// items render AFTER the what-changed bullets and BEFORE housekeeping.
|
||||
func PlainEnglishNarrativeWithTry(subjects []string, paths []string) string {
|
||||
section := PlainEnglishNarrative(subjects)
|
||||
if section == "" {
|
||||
return ""
|
||||
}
|
||||
tries := SuggestTryActions(paths)
|
||||
if len(tries) == 0 {
|
||||
return section
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(section)
|
||||
b.WriteString("\n## What you could try\n\n")
|
||||
for _, a := range tries {
|
||||
b.WriteString("- " + a + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── release-body rebuild (#2582, the owner's readability ruling) ────────────
|
||||
|
||||
// sentenceCap — a bullet's readable budget in runes; monster subjects split
|
||||
// on their own semicolon clauses and keep the head; the full subject rides
|
||||
// the technical section below.
|
||||
const sentenceCap = 300
|
||||
|
||||
// capReadableSentence — keeps at most two semicolon-separated clauses within
|
||||
// the budget; ellipsis marks the truncation honestly.
|
||||
func capReadableSentence(s string) string {
|
||||
if len([]rune(s)) <= sentenceCap {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
cut := -1
|
||||
clauses := 0
|
||||
for i, ch := range r {
|
||||
if ch == ';' {
|
||||
clauses++
|
||||
if clauses == 2 && i < sentenceCap {
|
||||
cut = i
|
||||
}
|
||||
if i >= sentenceCap {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if cut < 0 {
|
||||
return string(r[:sentenceCap]) + "…"
|
||||
}
|
||||
return string(r[:cut]) + "…"
|
||||
}
|
||||
|
||||
// RebuildReleaseBody — compose the version-push body as PROPER MARKDOWN
|
||||
// (issue #2582): exactly ONE H1 (the version title), then the plain-English
|
||||
// narrative, the try list (when present, inside the narrative section), the
|
||||
// verification section, and the technical tails preserved from the evidence
|
||||
// body — with the no-changes claim scrubbed where a narrative exists and
|
||||
// doubled bullet prefixes ("- -") normalized. Deterministic composition.
|
||||
func RebuildReleaseBody(tag string, evidenceBody, narrative, tryList string) string {
|
||||
title := "Rune " + tag
|
||||
var b strings.Builder
|
||||
b.WriteString("# " + title + "\n\n")
|
||||
if narrative != "" {
|
||||
// demote EVERY H1 inside the narrative to H2 — the body's only H1
|
||||
// is the version title
|
||||
narr := demoteH1(narrative)
|
||||
b.WriteString(narr)
|
||||
if !strings.HasSuffix(narr, "\n\n") {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if tryList != "" && !strings.Contains(narrative, tryList[:6]) {
|
||||
b.WriteString(tryList)
|
||||
if !strings.HasSuffix(tryList, "\n\n") {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
// RECURSION GUARD (#2582): rebuilds must compose from the ORIGINAL
|
||||
// evidence, never from an earlier rebuild's own sections — my sections
|
||||
// (What's new / What you could try / How it was verified / Technical)
|
||||
// strip out REPEATEDLY until none survive, so the evidence line is the
|
||||
// true original and the rebuild is idempotent.
|
||||
evidenceBody = stripOwnSections(evidenceBody)
|
||||
lines := strings.Split(evidenceBody, "\n")
|
||||
var verified, technical []string
|
||||
for _, ln := range lines {
|
||||
l := ln
|
||||
if strings.HasPrefix(l, "# ") && !strings.HasPrefix(l, "## ") {
|
||||
continue // the duplicate H1 (the old title) is absorbed above
|
||||
}
|
||||
if l != "" && strings.TrimSpace(l) != "" &&
|
||||
// the evidence body's own notes sections: their items are the
|
||||
// OLD rendering of the same commits (duplicate of the top
|
||||
// narrative) — headings go, items go
|
||||
(isEvidenceSectionHeading(l) || isExitNoteItem(l)) {
|
||||
continue
|
||||
}
|
||||
l = strings.TrimPrefix(l, "- - ")
|
||||
if strings.Contains(l, "No substantive changes") {
|
||||
continue
|
||||
}
|
||||
if isVerifiedLine(l) {
|
||||
verified = append(verified, l)
|
||||
} else if strings.TrimSpace(l) != "" {
|
||||
technical = append(technical, l)
|
||||
}
|
||||
}
|
||||
if len(verified) == 0 && narrative != "" {
|
||||
// The recursion strip may consume the verification boilerplate that
|
||||
// earlier rebuilds had eaten into their own sections — the standard
|
||||
// gates ride every release regardless.
|
||||
verified = []string{
|
||||
"Every change: contract opened via construction_interface before commit, walked to closed with evidence",
|
||||
"Every port: truth battery generated from the live Python oracle, replayed as a hard CI gate",
|
||||
"Pipeline: ruff + silent-handler ratchet (Python), go vet/gofmt/race/fuzz (Go), cargo clippy/fmt/test (Rust)",
|
||||
}
|
||||
}
|
||||
if len(verified) > 0 {
|
||||
b.WriteString("\n## How it was verified\n\n")
|
||||
out := make([]string, 0, len(verified))
|
||||
for _, v := range verified {
|
||||
if strings.HasPrefix(v, "- ") {
|
||||
v = "-" + strings.TrimPrefix(v, "-")
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
// de-dup adjacent lines (the historical body double-prints the pipeline line)
|
||||
for i, v := range out {
|
||||
if i > 0 && strings.TrimSpace(v) == strings.TrimSpace(out[i-1]) {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(v, "## ") || strings.HasPrefix(v, "- ") || v == "" {
|
||||
b.WriteString(v + "\n")
|
||||
continue
|
||||
}
|
||||
b.WriteString("- " + strings.TrimPrefix(v, "- ") + "\n")
|
||||
}
|
||||
}
|
||||
if len(technical) > 0 {
|
||||
b.WriteString("\n## Technical\n\n")
|
||||
for _, t := range technical {
|
||||
// the older per-commit item lines (various historic renderings)
|
||||
// cap to the same readable budget; the full text lives in the
|
||||
// commits the tag names
|
||||
if strings.HasPrefix(t, "- ") {
|
||||
t = capReadableSentence(t)
|
||||
}
|
||||
b.WriteString(t + "\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ownSectionHeading — the sections RebuildReleaseBody itself composes.
|
||||
func ownSectionHeading(ln string) bool {
|
||||
return strings.HasPrefix(ln, "## What's new") ||
|
||||
strings.HasPrefix(ln, "## What you could try") ||
|
||||
strings.HasPrefix(ln, "## How it was verified") ||
|
||||
strings.HasPrefix(ln, "## Technical")
|
||||
}
|
||||
|
||||
// stripOwnSections — remove every own-section block (heading through the
|
||||
// next own-heading) from a body whose history already carries rebuilds.
|
||||
// Returns the original evidence remnant.
|
||||
func stripOwnSections(body string) string {
|
||||
for {
|
||||
lines := strings.Split(body, "\n")
|
||||
dropStart := -1
|
||||
for i, ln := range lines {
|
||||
if ownSectionHeading(ln) {
|
||||
dropStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if dropStart < 0 {
|
||||
return body
|
||||
}
|
||||
dropEnd := len(lines)
|
||||
for i := dropStart + 1; i < len(lines); i++ {
|
||||
if ownSectionHeading(lines[i]) {
|
||||
dropEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
body = strings.TrimSpace(strings.Join(append(append([]string{}, lines[:dropStart]...), lines[dropEnd:]...), "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// demoteH1 — every '# ' heading becomes '## ' (the body's only H1 is the
|
||||
// version title).
|
||||
func demoteH1(md string) string {
|
||||
var out strings.Builder
|
||||
for _, ln := range strings.Split(md, "\n") {
|
||||
if strings.HasPrefix(ln, "# ") && !strings.HasPrefix(ln, "## ") && !strings.HasPrefix(ln, "#!") {
|
||||
ln = "## " + strings.TrimPrefix(ln, "# ")
|
||||
}
|
||||
out.WriteString(ln + "\n")
|
||||
}
|
||||
return strings.TrimSuffix(out.String(), "\n")
|
||||
}
|
||||
|
||||
// isEvidenceSectionHeading — the ReleaseNote body's category headings; the
|
||||
// items under them were the old narrative's rendering (duplicated up top).
|
||||
func isEvidenceSectionHeading(l string) bool {
|
||||
return strings.HasPrefix(l, "## Improved") || strings.HasPrefix(l, "## Fixed") ||
|
||||
strings.HasPrefix(l, "## Added") || strings.HasPrefix(l, "## Proven") ||
|
||||
strings.HasPrefix(l, "## Limitations") || strings.HasPrefix(l, "## References")
|
||||
}
|
||||
|
||||
// isExitNoteItem — the exit-note renderer's "subject — verb" item lines
|
||||
// (the older rendering of the same commits the narrative carries now).
|
||||
func isExitNoteItem(l string) bool {
|
||||
for _, verb := range []string{"added", "fixed", "improved", "verified by test", "documented", "restructured", "made faster", "maintained"} {
|
||||
if strings.HasSuffix(l, " — "+verb) || strings.HasSuffix(l, " ("+verb+")") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isVerifiedLine — the evidence body's verification boilerplate rides the
|
||||
// How-it-was-verified section; everything else (build ids, commit rows)
|
||||
// goes technical.
|
||||
func isVerifiedLine(line string) bool {
|
||||
l := strings.ToLower(line)
|
||||
for _, key := range []string{"contract opened", "truth battery", "pipeline: ruff", "derived from", "evidence-derived", "automatic initialization"} {
|
||||
if strings.Contains(l, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── changelog (#2582): the committed, CI-checkable version history ──────────
|
||||
|
||||
// ChangelogSection — ONE version's reader section.
|
||||
func ChangelogSection(tag string, subjects []string, paths []string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("## " + tag + "\n\n")
|
||||
narr := PlainEnglishNarrative(subjects)
|
||||
if narr != "" {
|
||||
// demote the section header (already H2 under ## tag? the narrative
|
||||
// carries "# What's new" — demote + retag under the version block)
|
||||
narr = strings.ReplaceAll(narr, "\n## ", "\n### ")
|
||||
narr = strings.TrimPrefix(narr, "# What's new")
|
||||
narr = "### What's new" + narr
|
||||
// the changelog H3's own voice: the tail ("— plain English",
|
||||
// "— plain English") is the release-body header's wording; drop it
|
||||
narr = strings.ReplaceAll(narr, "### What's new — plain English", "### What's new")
|
||||
b.WriteString(narr + "\n")
|
||||
} else {
|
||||
b.WriteString("### What's new\n\n- Housekeeping and pipeline-only changes.\n\n")
|
||||
}
|
||||
if tries := SuggestTryActions(paths); len(tries) > 0 {
|
||||
b.WriteString("\n### What you could try\n\n")
|
||||
for _, a := range tries {
|
||||
b.WriteString("- " + a + "\n")
|
||||
}
|
||||
}
|
||||
// the try section's heading from PlainEnglishNarrativeWithTry already
|
||||
// carried its own H2 — normalize here
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ChangelogFileContent — the FULL changelog markdown for the tags given,
|
||||
// newest first; per-version subjects/paths come from the caller's git.
|
||||
func ChangelogFileContent(tags []string, subjectsFor func(string) []string, pathsFor func(string) []string) string {
|
||||
if len(tags) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("# Rune Changelog\n\n")
|
||||
b.WriteString("Generated by the release pipeline — deterministic from the version tags. Check: `release-notes changelog --check`.\n\n")
|
||||
for _, tag := range tags {
|
||||
b.WriteString(ChangelogSection(tag, subjectsFor(tag), pathsFor(tag)))
|
||||
b.WriteString("\n---\n\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// RenderChangelog — collect the main-namespace v* tags (newest first),
|
||||
// assemble the changelog from git. (Exposed for tests: nil repo = cwd.)
|
||||
func RenderChangelog(subjectHook func(int, string), pathHook func(string, []string), repoMap map[string]string) string {
|
||||
tags := gitMainTags()
|
||||
return ChangelogFileContent(tags,
|
||||
func(tag string) []string {
|
||||
if subjectHook != nil {
|
||||
subjectHook(0, tag)
|
||||
}
|
||||
// subjects of range prev..tag come from the tag list order
|
||||
subs, err := GitLogSubjects(prevMainTag(tag), tag)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return subs
|
||||
},
|
||||
func(tag string) []string {
|
||||
paths, err := GitChangedPaths(prevMainTag(tag), tag)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return paths
|
||||
})
|
||||
}
|
||||
|
||||
// gitMainTags — the main-line v* tags, newest first.
|
||||
func gitMainTags() []string {
|
||||
out, err := exec.Command("git", "for-each-ref", "--sort=-v:refname", "--format=%(refname:short)", "refs/tags/v*").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var tags []string
|
||||
for _, ln := range strings.Split(string(out), "\n") {
|
||||
if ln = strings.TrimSpace(ln); ln != "" {
|
||||
tags = append(tags, ln)
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// prevMainTag — the tag BEFORE t in the main list — walks the list each
|
||||
// call (small N; correctness over cleverness).
|
||||
func prevMainTag(tag string) string {
|
||||
tags := gitMainTags()
|
||||
for i, t := range tags {
|
||||
if t == tag && i+1 < len(tags) {
|
||||
return tags[i+1]
|
||||
}
|
||||
}
|
||||
return tags[len(tags)-1] // oldest tag: empty-ish range is honest
|
||||
}
|
||||
@@ -170,3 +170,129 @@ func TestClassifyCommitPrefixes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RED (issue #2582): every version push must read like a changelog a
|
||||
// non-technical reader can act on — after the plain-English what-changed
|
||||
// bullets comes a "What you could try" section derived from the surfaces
|
||||
// actually touched.
|
||||
func TestNarrativeCarriesSuggestedTryActions(t *testing.T) {
|
||||
_ = PlainEnglishNarrative([]string{})
|
||||
_ = SuggestTryActions(nil)
|
||||
|
||||
subjects := []string{
|
||||
"fix(tui-scrollback): the pane scroll bug",
|
||||
"feat(memory-storage-deep): the new SQLite kernel",
|
||||
}
|
||||
section := PlainEnglishNarrativeWithTry(subjects, []string{"tui/scrollbar/scrollbar.go", "src/rust/memory-storage/src/lib.rs"})
|
||||
if !strings.Contains(section, "What you could try") {
|
||||
t.Fatalf("the section must carry a try list:\n%s", section)
|
||||
}
|
||||
|
||||
// the same section renders standalone suggestions for the touched surfaces
|
||||
sugs := SuggestTryActions([]string{"tui/scrollbar/scrollbar.go", "src/rust/memory-storage/src/lib.rs"})
|
||||
if len(sugs) == 0 {
|
||||
t.Fatal("touched surfaces must produce suggestions")
|
||||
}
|
||||
for _, s := range sugs {
|
||||
if strings.ContainsAny(s, "\n") {
|
||||
t.Fatalf("one line per suggestion: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// unmappable paths (pure tooling) produce nothing — no invented advice
|
||||
if got := SuggestTryActions([]string{".gitea/workflows/ci.yml", "scripts/check_dep_lock.py"}); got != nil {
|
||||
t.Fatalf("tooling-only change must suggest nothing, got %v", got)
|
||||
}
|
||||
|
||||
// the plain narrative without paths stays compatible
|
||||
if got := PlainEnglishNarrativeWithTry(subjects, nil); strings.Contains(got, "What you could try") {
|
||||
t.Fatal("no touched paths = no try section")
|
||||
}
|
||||
}
|
||||
|
||||
// RED (issue #2582 follow-up, the owner's readability ruling): a rebuilt
|
||||
// release body is PROPER MARKDOWN — exactly one H1 (the version title),
|
||||
// H2 sections in reading order, bullets that end in one readable sentence,
|
||||
// no "- -" artifacts, no stale "no substantive changes" contradictions.
|
||||
func TestRebuildBodyProperMarkdown(t *testing.T) {
|
||||
evidence := "# Rune v0.999.0\n\nevidence-derived release history\n\n## Improved\n- - Pipeline: the gates\n- No substantive changes since the last version.\n- Every port: frozen test battery replayed\n"
|
||||
body := RebuildReleaseBody("v0.999.0", evidence,
|
||||
"# What's new — plain English\n\n- The chat window now keeps your place while new messages arrive.\n",
|
||||
"## What you could try\n\n- Ask Rune something and watch.\n")
|
||||
lines := strings.Split(body, "\n")
|
||||
// count H1s — the title must be the only one
|
||||
h1s := 0
|
||||
for _, ln := range lines {
|
||||
if strings.HasPrefix(ln, "# ") && !strings.HasPrefix(ln, "## ") {
|
||||
h1s++
|
||||
}
|
||||
}
|
||||
if h1s != 1 {
|
||||
t.Fatalf("exactly one H1 allowed, got %d:\n%s", h1s, body)
|
||||
}
|
||||
if !strings.HasPrefix(lines[0], "# Rune v") {
|
||||
t.Fatalf("the body must OPEN with the version title:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "No substantive changes") {
|
||||
t.Fatal("a rebuilt body with a real narrative must not carry the no-changes claim")
|
||||
}
|
||||
if strings.Contains(body, "- - ") {
|
||||
t.Fatal("no doubled bullet prefixes may survive the rebuild")
|
||||
}
|
||||
order := []string{"## What's new", "## What you could try", "## How it was verified"}
|
||||
last := -1
|
||||
for _, marker := range order {
|
||||
i := strings.Index(body, marker)
|
||||
if i < 0 {
|
||||
t.Fatalf("missing section %s in:\n%s", marker, body)
|
||||
}
|
||||
if i < last {
|
||||
t.Fatalf("sections out of order in:\n%s", body)
|
||||
}
|
||||
last = i
|
||||
}
|
||||
}
|
||||
|
||||
// RED: sentence rendering caps a monster subject to its first clause so
|
||||
// each bullet stays readable; the full subject rides the technical section.
|
||||
func TestPlainSentenceCapsMonsterSubjects(t *testing.T) {
|
||||
monster := strings.Repeat("the big change clause one;", 60) + " tail clause"
|
||||
out := plainSentence(monster)
|
||||
if len(out) > 400 {
|
||||
t.Fatalf("monster subject must cap readable: %d chars", len([]rune(out)))
|
||||
}
|
||||
if out == "" {
|
||||
t.Fatal("cap must not erase the sentence")
|
||||
}
|
||||
short := plainSentence("fix(scroll): the panel bug")
|
||||
if !strings.Contains(strings.ToLower(short), "panel bug") {
|
||||
t.Fatalf("short subject keeps its content: %q", short)
|
||||
}
|
||||
}
|
||||
|
||||
// RED (issue #2582, the owner's CI changelog demand): a committed,
|
||||
// deterministic, CI-CHECKABLE changelog. Composition from the MAIN
|
||||
// v* tags is byte-stable across invocations; --check mode compares the
|
||||
// committed file against a fresh regeneration and reports the drift.
|
||||
func TestChangelogDeterministicComposition(t *testing.T) {
|
||||
// the composition function under temp repos: empty repo = an honest
|
||||
// minimal file; the real tag inventory rides the repo checkout
|
||||
_ = RenderChangelog(nil, nil, map[string]string{})
|
||||
out1 := ChangelogFileContent(nil, nil, nil)
|
||||
out2 := ChangelogFileContent(nil, nil, nil)
|
||||
if out1 != out2 {
|
||||
t.Fatal("changelog composition must be deterministic")
|
||||
}
|
||||
if out1 != "" {
|
||||
t.Fatal("no tags = no content (an empty changelog is the caller's)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogTrySuggestionDedup(t *testing.T) {
|
||||
a := SuggestTryActions([]string{"src/rune/memory_selector.py"})
|
||||
b := SuggestTryActions([]string{"src/rune/memory_selector.py", "src/rune/memory_selector.py"})
|
||||
_ = a
|
||||
if len(b) != len(a) {
|
||||
t.Fatal("duplicate paths must not duplicate suggestions")
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,8 @@ type anthropicChatResponse struct {
|
||||
Model string `json:"model"`
|
||||
Content []anthropicContentBlock `json:"content"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
@@ -192,7 +193,7 @@ func (c *AnthropicClient) ChatWithTools(ctx context.Context, model string, msgs
|
||||
return ChatReply{}, fmt.Errorf("%s /v1/messages decode: %w", c.provider, err)
|
||||
}
|
||||
reply := ChatReply{Model: out.Model, ContextLimit: contextLimitFor(c.ctxLimits, model),
|
||||
ContextUsed: out.Usage.InputTokens}
|
||||
ContextUsed: out.Usage.InputTokens, OutputTokens: out.Usage.OutputTokens}
|
||||
var content strings.Builder
|
||||
for _, blk := range out.Content {
|
||||
switch blk.Type {
|
||||
|
||||
@@ -97,7 +97,8 @@ type geminiChatResponse struct {
|
||||
} `json:"candidates"`
|
||||
ModelVersion string `json:"modelVersion"`
|
||||
UsageMetadata struct {
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
} `json:"usageMetadata"`
|
||||
}
|
||||
|
||||
@@ -208,7 +209,7 @@ func (c *GeminiClient) ChatWithTools(ctx context.Context, model string, msgs []C
|
||||
return ChatReply{}, fmt.Errorf("%s generateContent decode: %w", c.provider, err)
|
||||
}
|
||||
reply := ChatReply{Model: out.ModelVersion, ContextLimit: contextLimitFor(c.ctxLimits, model),
|
||||
ContextUsed: out.UsageMetadata.PromptTokenCount}
|
||||
ContextUsed: out.UsageMetadata.PromptTokenCount, OutputTokens: out.UsageMetadata.CandidatesTokenCount}
|
||||
var content strings.Builder
|
||||
if len(out.Candidates) > 0 {
|
||||
for _, part := range out.Candidates[0].Content.Parts {
|
||||
|
||||
@@ -89,7 +89,8 @@ type oiChatRequest struct {
|
||||
type oiChatResponse struct {
|
||||
Model string `json:"model"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
} `json:"usage"`
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
@@ -190,7 +191,7 @@ func (c *OpenAICompatClient) ChatWithTools(ctx context.Context, model string, ms
|
||||
return ChatReply{}, fmt.Errorf("%s /chat/completions decode: %w", c.provider, err)
|
||||
}
|
||||
reply := ChatReply{Model: out.Model, ContextLimit: contextLimitFor(c.ctxLimits, model),
|
||||
ContextUsed: out.Usage.PromptTokens}
|
||||
ContextUsed: out.Usage.PromptTokens, OutputTokens: out.Usage.CompletionTokens}
|
||||
if len(out.Choices) > 0 {
|
||||
msg := out.Choices[0].Message
|
||||
if msg.Content != nil {
|
||||
|
||||
@@ -86,7 +86,8 @@ func oiDecodeReply(resp *http.Response, model, provider string, ctxLimit int) (C
|
||||
type oiStreamChunk struct {
|
||||
Model string `json:"model"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
} `json:"usage"`
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
@@ -148,6 +149,9 @@ func (c *OpenAICompatClient) StreamChatWithTools(ctx context.Context, model stri
|
||||
if chunk.Usage.PromptTokens > 0 {
|
||||
reply.ContextUsed = chunk.Usage.PromptTokens
|
||||
}
|
||||
if chunk.Usage.CompletionTokens > 0 {
|
||||
reply.OutputTokens = chunk.Usage.CompletionTokens
|
||||
}
|
||||
if len(chunk.Choices) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -269,6 +273,9 @@ func (c *AnthropicClient) StreamChatWithTools(ctx context.Context, model string,
|
||||
PartialJSON string `json:"partial_json"`
|
||||
} `json:"delta"`
|
||||
Message *anthropicChatResponse `json:"message"`
|
||||
Usage *struct {
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &ev); err != nil {
|
||||
return fmt.Errorf("%s stream decode: %w", c.provider, err)
|
||||
@@ -279,6 +286,10 @@ func (c *AnthropicClient) StreamChatWithTools(ctx context.Context, model string,
|
||||
reply.Model = ev.Message.Model
|
||||
reply.ContextUsed = ev.Message.Usage.InputTokens
|
||||
}
|
||||
case "message_delta":
|
||||
if ev.Usage != nil && ev.Usage.OutputTokens > 0 {
|
||||
reply.OutputTokens = ev.Usage.OutputTokens
|
||||
}
|
||||
case "content_block_start":
|
||||
if ev.Block != nil && ev.Block.Type == "tool_use" {
|
||||
toolNames[ev.Index] = ev.Block.Name
|
||||
@@ -348,7 +359,7 @@ func anthropicDecodeReply(resp *http.Response, ctxLimit int, provider string) (C
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return ChatReply{}, fmt.Errorf("%s /v1/messages decode: %w", provider, err)
|
||||
}
|
||||
reply := ChatReply{Model: out.Model, ContextLimit: ctxLimit, ContextUsed: out.Usage.InputTokens}
|
||||
reply := ChatReply{Model: out.Model, ContextLimit: ctxLimit, ContextUsed: out.Usage.InputTokens, OutputTokens: out.Usage.OutputTokens}
|
||||
var content strings.Builder
|
||||
for _, blk := range out.Content {
|
||||
switch blk.Type {
|
||||
@@ -373,7 +384,7 @@ func geminiDecodeReply(resp *http.Response, ctxLimit int, provider string) (Chat
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return ChatReply{}, fmt.Errorf("%s generateContent decode: %w", provider, err)
|
||||
}
|
||||
reply := ChatReply{Model: out.ModelVersion, ContextLimit: ctxLimit, ContextUsed: out.UsageMetadata.PromptTokenCount}
|
||||
reply := ChatReply{Model: out.ModelVersion, ContextLimit: ctxLimit, ContextUsed: out.UsageMetadata.PromptTokenCount, OutputTokens: out.UsageMetadata.CandidatesTokenCount}
|
||||
var content strings.Builder
|
||||
if len(out.Candidates) > 0 {
|
||||
for _, part := range out.Candidates[0].Content.Parts {
|
||||
@@ -455,6 +466,9 @@ func (c *GeminiClient) StreamChatWithTools(ctx context.Context, model string, ms
|
||||
if chunk.UsageMetadata.PromptTokenCount > 0 {
|
||||
reply.ContextUsed = chunk.UsageMetadata.PromptTokenCount
|
||||
}
|
||||
if chunk.UsageMetadata.CandidatesTokenCount > 0 {
|
||||
reply.OutputTokens = chunk.UsageMetadata.CandidatesTokenCount
|
||||
}
|
||||
if len(chunk.Candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrChannelBusy refuses a second in-flight turn on one channel — the
|
||||
@@ -79,6 +80,12 @@ type ChatReply struct {
|
||||
// guesses.
|
||||
ContextUsed int
|
||||
ContextLimit int
|
||||
// OutputTokens (D56 throughput telemetry): the tokens the provider
|
||||
// says it GENERATED this call (the ollama eval_count, the cloud
|
||||
// usage.completion_tokens family). Zero when the wire carries none
|
||||
// — the governor falls back to the assistant text's token estimate,
|
||||
// never to a guess presented as a measurement.
|
||||
OutputTokens int
|
||||
// ToolCalls (D45 toolset gap): the model's requested tool calls on
|
||||
// THIS response — empty on a final answer. Only ChatWithTools
|
||||
// returns these.
|
||||
@@ -152,6 +159,11 @@ type Engine struct {
|
||||
|
||||
flightsMu sync.Mutex
|
||||
inFlight map[string]bool // channel -> a turn is running
|
||||
|
||||
// governor (D56): the provider-aware dispatch governor — learns
|
||||
// tok/s from THIS engine's real turns (hot-state persisted), owns
|
||||
// lane pools and cloud-first routing. Never nil.
|
||||
governor *Governor
|
||||
}
|
||||
|
||||
// NewEngine builds the engine without a router: the local binding
|
||||
@@ -159,9 +171,13 @@ type Engine struct {
|
||||
// ErrProviderUnrouted. home is the rune home directory holding
|
||||
// provider_selection.json (the TUI's first-run law record).
|
||||
func NewEngine(store Store, client ModelClient, home string) *Engine {
|
||||
return &Engine{store: store, client: client, home: home, inFlight: map[string]bool{}}
|
||||
return &Engine{store: store, client: client, home: home, inFlight: map[string]bool{}, governor: NewGovernor(home)}
|
||||
}
|
||||
|
||||
// Governor exposes the engine's governor (the server surface reads it
|
||||
// for /v1/status; tests seed it directly).
|
||||
func (e *Engine) Governor() *Governor { return e.governor }
|
||||
|
||||
// NewRoutedEngine builds the engine with the cloud router attached:
|
||||
// the record's Provider field selects the client (the local spellings
|
||||
// keep the engine's own client; everything else resolves through the
|
||||
@@ -325,12 +341,30 @@ func (e *Engine) TurnStream(ctx context.Context, req TurnRequest, sink StreamSin
|
||||
payload = append(payload, cm)
|
||||
}
|
||||
|
||||
provider := "local"
|
||||
if recErr == nil {
|
||||
provider = rec.Provider
|
||||
}
|
||||
if servedByLocalBinding(provider) {
|
||||
provider = "local"
|
||||
}
|
||||
start := time.Now()
|
||||
reply, err := e.runTurn(ctx, client, model, req.Channel, payload, sink)
|
||||
if err != nil {
|
||||
// the user message stays persisted: the user DID send it (the
|
||||
// Python oracle's behavior); the failure is honest, not silent
|
||||
e.governor.RecordError(provider, err)
|
||||
return TurnResult{UserMsgID: userMsg.MsgID, Model: model}, err
|
||||
}
|
||||
// D56 throughput telemetry: the governor learns from the real
|
||||
// turn — the provider's own generated-token accounting when the
|
||||
// wire carries it, the assistant text's token estimate otherwise
|
||||
// (an honest estimate, never presented as a wire measurement).
|
||||
outTokens := reply.OutputTokens
|
||||
if outTokens <= 0 {
|
||||
outTokens = tokenEstimate(reply.Content)
|
||||
}
|
||||
e.governor.RecordTurn(provider, model, outTokens, time.Since(start), 1)
|
||||
|
||||
assistant := Message{
|
||||
MsgID: newMsgID(),
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
package runecore
|
||||
|
||||
// governor.go — the provider-aware dispatch governor (Gitea #2580 +
|
||||
// #2581, owner doctrine 2026-10-24: CLOUD-FIRST routing — cloud lanes
|
||||
// fill FIRST, speed is the priority; local absorbs only overflow; a
|
||||
// configured provider is spend-freely authorized, no consent gates).
|
||||
//
|
||||
// Everything here is learned from PRODUCTION samples (the engine's
|
||||
// real turn accounting): no synthetic benchmarks, no guessed curves.
|
||||
// Per-provider rolling samples persist under the hot-state pattern
|
||||
// (~/.rune/hot/provider_throughput.json) so the model survives
|
||||
// restarts.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HotThroughputFile is the hot-state path, relative to the rune home:
|
||||
// per-provider rolling throughput samples + the learned saturation
|
||||
// curve (the same survives-restart pattern as task_state.json).
|
||||
const HotThroughputFile = "hot/provider_throughput.json"
|
||||
|
||||
// sampleWindow is the rolling sample cap per provider (the model is
|
||||
// small and local: recent behavior only).
|
||||
const sampleWindow = 256
|
||||
|
||||
// DefaultCapFloor is the default marginal tok/s gain below which
|
||||
// another lane is judged useless (configurable: RUNE_GOVERNOR_CAP_FLOOR).
|
||||
const DefaultCapFloor = 5.0
|
||||
|
||||
// ThroughputSample is one production measurement: tokens the provider
|
||||
// actually generated in the wall time it took, at the lane count the
|
||||
// provider was serving.
|
||||
type ThroughputSample struct {
|
||||
At int64 `json:"at"` // unix seconds
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model,omitempty"`
|
||||
TokPerSec float64 `json:"tok_per_sec"`
|
||||
Lanes int `json:"lanes"`
|
||||
}
|
||||
|
||||
// ProviderHealth is the connection-health row (#2581): what the
|
||||
// governor actually knows about one provider's liveness.
|
||||
type ProviderHealth struct {
|
||||
Reachable bool `json:"reachable"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
ErrorRate float64 `json:"error_rate"` // recent errors / attempts
|
||||
TokPerSec float64 `json:"tok_per_sec"`
|
||||
LanesActive int `json:"lanes_active"`
|
||||
LanesCap int `json:"lanes_cap"`
|
||||
ThrottledUntil int64 `json:"throttled_until,omitempty"` // unix secs; a 429 ceiling
|
||||
LastSeen int64 `json:"last_seen,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
// GovernorSnapshot is the honest external face: /v1/status's governor
|
||||
// section and the TUI's readout all render THIS — never more than the
|
||||
// governor measured.
|
||||
type GovernorSnapshot struct {
|
||||
TokensPerSec float64 `json:"tokens_per_sec"` // 0 = nothing measured yet
|
||||
LanesActive int `json:"lanes_active"`
|
||||
LanesCap int `json:"lanes_cap"`
|
||||
Providers map[string]ProviderHealth `json:"providers"`
|
||||
}
|
||||
|
||||
// governorState is the persisted hot-state shape.
|
||||
type governorState struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Samples map[string][]ThroughputSample `json:"samples"`
|
||||
// saturation is the learned curve: (provider, lanes) → EMA tok/s.
|
||||
Saturation map[string]map[int]float64 `json:"saturation"`
|
||||
}
|
||||
|
||||
// Governor is the dispatch governor. Zero-value is usable but inert;
|
||||
// NewGovernor loads the persisted hot state.
|
||||
type Governor struct {
|
||||
mu sync.Mutex
|
||||
home string
|
||||
state governorState
|
||||
health map[string]*ProviderHealth
|
||||
attempts map[string]int
|
||||
errors map[string]int
|
||||
lanes map[string]int // provider -> active lanes
|
||||
lastOrder []string // most-recently-used providers (the snapshot's headline tok/s)
|
||||
probed bool // at least one health probe ran in this process
|
||||
floor float64
|
||||
}
|
||||
|
||||
// GovernorCapFloorEnv resizes the marginal-gain floor (tok/s).
|
||||
const GovernorCapFloorEnv = "RUNE_GOVERNOR_CAP_FLOOR"
|
||||
|
||||
// NewGovernor builds a governor rooted at home (the rune home dir),
|
||||
// loading any persisted hot state. A missing or corrupt file is an
|
||||
// empty model, never an error (the hot-state freshness law).
|
||||
func NewGovernor(home string) *Governor {
|
||||
g := &Governor{
|
||||
home: home,
|
||||
state: governorState{
|
||||
SchemaVersion: 1,
|
||||
Samples: map[string][]ThroughputSample{},
|
||||
Saturation: map[string]map[int]float64{},
|
||||
},
|
||||
health: map[string]*ProviderHealth{},
|
||||
attempts: map[string]int{},
|
||||
errors: map[string]int{},
|
||||
lanes: map[string]int{},
|
||||
floor: DefaultCapFloor,
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv(GovernorCapFloorEnv)); v != "" {
|
||||
if f, err := parsePositiveFloat(v); err == nil && f > 0 {
|
||||
g.floor = f
|
||||
}
|
||||
}
|
||||
g.load()
|
||||
return g
|
||||
}
|
||||
|
||||
func parsePositiveFloat(s string) (float64, error) {
|
||||
var f float64
|
||||
_, err := fmt.Sscanf(s, "%g", &f)
|
||||
return f, err
|
||||
}
|
||||
|
||||
func (g *Governor) hotPath() string {
|
||||
return filepath.Join(g.home, HotThroughputFile)
|
||||
}
|
||||
|
||||
func (g *Governor) load() {
|
||||
data, err := os.ReadFile(g.hotPath())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var st governorState
|
||||
if json.Unmarshal(data, &st) != nil || st.Samples == nil {
|
||||
return
|
||||
}
|
||||
if st.Saturation == nil {
|
||||
st.Saturation = map[string]map[int]float64{}
|
||||
}
|
||||
g.state = st
|
||||
}
|
||||
|
||||
// persist writes the hot state atomically (temp + rename — a reader
|
||||
// never sees a torn file). Best-effort: telemetry must never break a
|
||||
// turn.
|
||||
func (g *Governor) persist() {
|
||||
data, err := json.Marshal(g.state)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
path := g.hotPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// RecordTurn samples one completed turn: the provider that served it,
|
||||
// the tokens it generated (the client's own accounting; the engine's
|
||||
// honest token-estimate fallback when the wire carries none), the wall
|
||||
// duration, and the lane count it served at. This is the ONLY way the
|
||||
// model learns — production samples, never benchmarks.
|
||||
func (g *Governor) RecordTurn(provider, model string, outputTokens int, dur time.Duration, lanes int) {
|
||||
if provider == "" {
|
||||
provider = "local"
|
||||
}
|
||||
if lanes < 1 {
|
||||
lanes = 1
|
||||
}
|
||||
if outputTokens <= 0 || dur <= 0 {
|
||||
return // nothing measurable — the model never invents a rate
|
||||
}
|
||||
tps := float64(outputTokens) / dur.Seconds()
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
now := time.Now().Unix()
|
||||
s := ThroughputSample{At: now, Provider: provider, Model: model, TokPerSec: tps, Lanes: lanes}
|
||||
samples := append(g.state.Samples[provider], s)
|
||||
if len(samples) > sampleWindow {
|
||||
samples = samples[len(samples)-sampleWindow:]
|
||||
}
|
||||
g.state.Samples[provider] = samples
|
||||
// the saturation point: EMA over the lanes bucket (smooth, small)
|
||||
b := g.state.Saturation[provider]
|
||||
if b == nil {
|
||||
b = map[int]float64{}
|
||||
}
|
||||
b[lanes] = ema(b[lanes], tps)
|
||||
g.state.Saturation[provider] = b
|
||||
h := g.health[provider]
|
||||
if h == nil {
|
||||
h = &ProviderHealth{}
|
||||
g.health[provider] = h
|
||||
}
|
||||
h.Reachable, h.Authenticated = true, true
|
||||
h.TokPerSec = b[lanes]
|
||||
h.LastSeen = now
|
||||
h.LastError = ""
|
||||
g.touchOrder(provider)
|
||||
g.persist()
|
||||
}
|
||||
|
||||
// RecordError marks a failed attempt against a provider (the health
|
||||
// row's error rate).
|
||||
func (g *Governor) RecordError(provider string, err error) {
|
||||
if provider == "" {
|
||||
provider = "local"
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.attempts[provider]++
|
||||
g.errors[provider]++
|
||||
h := g.health[provider]
|
||||
if h == nil {
|
||||
h = &ProviderHealth{}
|
||||
g.health[provider] = h
|
||||
}
|
||||
h.Reachable = false
|
||||
h.LastError = fmt.Sprint(err)
|
||||
if g.attempts[provider] > 0 {
|
||||
h.ErrorRate = float64(g.errors[provider]) / float64(g.attempts[provider])
|
||||
}
|
||||
}
|
||||
|
||||
// MarkThrottled records a 429: the published TPM/RPM ceiling just bit.
|
||||
// A throttled provider is WORSE than not spawning — routes refuse it
|
||||
// until the window lapses.
|
||||
func (g *Governor) MarkThrottled(provider string, until time.Time) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
h := g.health[provider]
|
||||
if h == nil {
|
||||
h = &ProviderHealth{}
|
||||
g.health[provider] = h
|
||||
}
|
||||
h.ThrottledUntil = until.Unix()
|
||||
}
|
||||
|
||||
// AcquireLane takes one lane on a provider (the pool's in-use count).
|
||||
func (g *Governor) AcquireLane(provider string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.lanes[provider]++
|
||||
}
|
||||
|
||||
// ReleaseLane returns one lane.
|
||||
func (g *Governor) ReleaseLane(provider string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.lanes[provider] > 0 {
|
||||
g.lanes[provider]--
|
||||
}
|
||||
}
|
||||
|
||||
// SaturationCap answers the lane cap for a provider: the highest lane
|
||||
// count whose MARGINAL tok/s gain still clears the floor. With no
|
||||
// curve learned, the honest cap is 1 (never invent headroom).
|
||||
func (g *Governor) SaturationCap(provider string) int {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.saturationCapLocked(provider)
|
||||
}
|
||||
|
||||
func (g *Governor) saturationCapLocked(provider string) int {
|
||||
b := g.state.Saturation[provider]
|
||||
if len(b) == 0 {
|
||||
return 1
|
||||
}
|
||||
lanes := sortedLanes(b)
|
||||
cap := 1
|
||||
prev := b[lanes[0]]
|
||||
for _, l := range lanes[1:] {
|
||||
gain := b[l] - prev
|
||||
if gain < g.floor {
|
||||
break
|
||||
}
|
||||
cap = l
|
||||
prev = b[l]
|
||||
}
|
||||
return cap
|
||||
}
|
||||
|
||||
func sortedLanes(b map[int]float64) []int {
|
||||
out := make([]int, 0, len(b))
|
||||
for l := range b {
|
||||
out = append(out, l)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Route applies the owner doctrine: CLOUD-FIRST — among healthy
|
||||
// candidates, every cloud provider (a non-local binding; configured
|
||||
// providers are spend-authorized, no consent gates) fills its lane
|
||||
// pool BEFORE any local provider absorbs overflow. Within a tier the
|
||||
// least-loaded provider wins (lanes/cap ratio). Unhealthy or
|
||||
// throttled providers are refused outright (route only to providers
|
||||
// actually alive). The second return is false when NO candidate is
|
||||
// healthy — the thin-pipe honesty case.
|
||||
func (g *Governor) Route(candidates []string) (ordered []string, healthy bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
var cloud, local []string
|
||||
now := time.Now().Unix()
|
||||
key := func(p string) float64 {
|
||||
h := g.health[p]
|
||||
c := 1
|
||||
if h != nil && h.LanesCap > 0 {
|
||||
c = h.LanesCap
|
||||
}
|
||||
return float64(g.lanes[p]) / float64(c)
|
||||
}
|
||||
less := func(a, b string) bool { return key(a) < key(b) }
|
||||
for _, p := range candidates {
|
||||
h := g.health[p]
|
||||
if h != nil {
|
||||
if !h.Reachable || (h.ThrottledUntil > 0 && h.ThrottledUntil > now) {
|
||||
continue // not alive / 429'd — worse than not spawning
|
||||
}
|
||||
} else if g.probed {
|
||||
// Health telemetry is live and this provider never came
|
||||
// back alive: refuse it (#2581 — route only to providers
|
||||
// actually alive). Before any probe ran, day-one routing
|
||||
// passes candidates through (never refuse what we never
|
||||
// asked).
|
||||
continue
|
||||
}
|
||||
if servedByLocalBinding(p) {
|
||||
local = append(local, p)
|
||||
} else {
|
||||
cloud = append(cloud, p)
|
||||
}
|
||||
}
|
||||
sort.Slice(cloud, func(i, j int) bool { return less(cloud[i], cloud[j]) })
|
||||
sort.Slice(local, func(i, j int) bool { return less(local[i], local[j]) })
|
||||
ordered = append(cloud, local...)
|
||||
return ordered, len(ordered) > 0
|
||||
}
|
||||
|
||||
// MarkReachable records a health probe outcome (#2581): reachable is
|
||||
// the probe's liveness verdict, authenticated whether the wire
|
||||
// accepted the key.
|
||||
func (g *Governor) MarkReachable(provider string, reachable, authenticated bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
h := g.health[provider]
|
||||
if h == nil {
|
||||
h = &ProviderHealth{}
|
||||
g.health[provider] = h
|
||||
}
|
||||
h.Reachable = reachable
|
||||
h.Authenticated = authenticated
|
||||
if reachable {
|
||||
h.LastSeen = time.Now().Unix()
|
||||
h.LastError = ""
|
||||
}
|
||||
g.probed = true
|
||||
}
|
||||
|
||||
func (g *Governor) touchOrder(p string) {
|
||||
for i, q := range g.lastOrder {
|
||||
if q == p {
|
||||
g.lastOrder = append(g.lastOrder[:i], g.lastOrder[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
g.lastOrder = append(g.lastOrder, p)
|
||||
}
|
||||
|
||||
// Snapshot renders the honest state: the headline tok/s is the most
|
||||
// recently sampled provider's current rate (0 when nothing was ever
|
||||
// measured — the bar shows '—', never a guess); lanes are the global
|
||||
// active count against the sum of the pools' caps.
|
||||
func (g *Governor) Snapshot() GovernorSnapshot {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
snap := GovernorSnapshot{Providers: map[string]ProviderHealth{}}
|
||||
for p, h := range g.health {
|
||||
row := *h
|
||||
row.LanesCap = g.saturationCapLocked(p)
|
||||
row.LanesActive = g.lanes[p]
|
||||
snap.Providers[p] = row
|
||||
}
|
||||
// headline tok/s: the freshest sample's provider, at the lane
|
||||
// count we actually run there — persisted state survives restarts
|
||||
// (lastOrder doesn't), so the read comes from the samples.
|
||||
var freshestP string
|
||||
var freshestAt int64
|
||||
for p, ss := range g.state.Samples {
|
||||
if len(ss) == 0 {
|
||||
continue
|
||||
}
|
||||
if last := ss[len(ss)-1]; last.At >= freshestAt {
|
||||
freshestAt, freshestP = last.At, p
|
||||
}
|
||||
}
|
||||
if freshestP != "" {
|
||||
lanes := g.lanes[freshestP]
|
||||
if lanes < 1 {
|
||||
lanes = 1
|
||||
}
|
||||
b := g.state.Saturation[freshestP]
|
||||
if v, ok := b[lanes]; ok {
|
||||
snap.TokensPerSec = v
|
||||
} else if len(b) > 0 {
|
||||
for _, l := range sortedLanes(b) {
|
||||
snap.TokensPerSec = b[l] // fall back to the freshest learned bucket
|
||||
}
|
||||
}
|
||||
}
|
||||
for p := range g.state.Saturation {
|
||||
if _, ok := snap.Providers[p]; !ok {
|
||||
cap := g.saturationCapLocked(p)
|
||||
snap.Providers[p] = ProviderHealth{Reachable: true, Authenticated: true, LanesCap: cap, LanesActive: g.lanes[p]}
|
||||
}
|
||||
snap.LanesActive += g.lanes[p]
|
||||
snap.LanesCap += g.saturationCapLocked(p)
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
// ema is the one-pole smooth: alpha small enough to ignore a single
|
||||
// outlier, big enough to track a real shift within a few turns.
|
||||
func ema(prev, next float64) float64 {
|
||||
if prev == 0 {
|
||||
return next
|
||||
}
|
||||
const alpha = 0.3
|
||||
return prev + alpha*(next-prev)
|
||||
}
|
||||
|
||||
// PollRegistryHealth probes every enabled, URL-bearing provider in
|
||||
// the landed registry (#2581): a cheap GET against the provider's
|
||||
// wire base with a short timeout. The verdict lands through
|
||||
// MarkReachable — reachable = the probe answered, authenticated = a
|
||||
// key ref is wired (the probe proves the WIRE, the ref proves the
|
||||
// credential is configured; a real 401 surfaces as an error sample).
|
||||
// Returns the number of providers probed.
|
||||
func (g *Governor) PollRegistryHealth(path string, timeout time.Duration) int {
|
||||
rows, err := LoadProviderRegistry(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
probed := 0
|
||||
for _, e := range rows {
|
||||
if !e.Enabled || strings.TrimSpace(e.BaseURL()) == "" {
|
||||
continue
|
||||
}
|
||||
client := &http.Client{Timeout: timeout}
|
||||
resp, perr := client.Get(strings.TrimRight(e.BaseURL(), "/") + "/models")
|
||||
if perr != nil {
|
||||
g.RecordError(e.Name, perr)
|
||||
g.MarkReachable(e.Name, false, e.APIKeyRef != "")
|
||||
probed++
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
g.MarkReachable(e.Name, true, e.APIKeyRef != "")
|
||||
probed++
|
||||
}
|
||||
return probed
|
||||
}
|
||||
|
||||
// HealthPollInterval is the live-state cadence.
|
||||
const HealthPollInterval = 60 * time.Second
|
||||
|
||||
// isCloudProvider answers the doctrine's tier split: anything NOT a
|
||||
// local binding is cloud (the same law the engine's router uses).
|
||||
func isCloudProvider(p string) bool { return !servedByLocalBinding(p) }
|
||||
@@ -0,0 +1,137 @@
|
||||
package runecore
|
||||
|
||||
// governor_test.go — the D56 governor's frozen laws: production-only
|
||||
// samples (a turn with no tokens is never a sample), the honest cap
|
||||
// (no curve = 1 lane, never invented headroom), cloud-first ordering
|
||||
// with health-gating and 429 refusal, and hot-state persistence.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGovernorRecordTurnSamplesAndPersists(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
g := NewGovernor(home)
|
||||
g.RecordTurn("anthropic", "claude-x", 300, 10*time.Second, 1)
|
||||
snap := g.Snapshot()
|
||||
if snap.TokensPerSec != 30 {
|
||||
t.Fatalf("tok/s = %v, want 30", snap.TokensPerSec)
|
||||
}
|
||||
// hot state: a fresh governor over the same home sees the curve
|
||||
if _, err := os.Stat(filepath.Join(home, HotThroughputFile)); err != nil {
|
||||
t.Fatalf("hot state not persisted: %v", err)
|
||||
}
|
||||
g2 := NewGovernor(home)
|
||||
if got := g2.SaturationCap("anthropic"); got < 1 {
|
||||
t.Fatalf("cap after one sample = %d, want >=1", got)
|
||||
}
|
||||
if g2.Snapshot().TokensPerSec == 0 {
|
||||
t.Fatal("reloaded governor lost the measured tok/s")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGovernorNoTokensNoSample(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
g := NewGovernor(home)
|
||||
g.RecordTurn("anthropic", "m", 0, time.Second, 1)
|
||||
if snap := g.Snapshot(); snap.TokensPerSec != 0 || len(snap.Providers) != 0 {
|
||||
t.Fatalf("zero-token turn invented a sample: %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGovernorCapHonestWithoutCurve(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
g := NewGovernor(home)
|
||||
if got := g.SaturationCap("never-measured"); got != 1 {
|
||||
t.Fatalf("cap with no curve = %d, want 1 (never invent headroom)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGovernorCapFromMarginalGain(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
g := NewGovernor(home)
|
||||
// 1 lane: 40 tok/s; 2 lanes: 70 (gain 30 >= floor); 3 lanes: 72
|
||||
// (gain 2 < floor) — the cap is 2 lanes.
|
||||
g.RecordTurn("p", "m", 400, 10*time.Second, 1)
|
||||
g.RecordTurn("p", "m", 700, 10*time.Second, 2)
|
||||
g.RecordTurn("p", "m", 720, 10*time.Second, 3)
|
||||
if got := g.SaturationCap("p"); got != 2 {
|
||||
t.Fatalf("cap = %d, want 2 (marginal gain at 3 lanes below the floor)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGovernorRouteCloudFirstAndHealthGated(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
g := NewGovernor(home)
|
||||
g.MarkReachable("ollama", true, true)
|
||||
g.MarkReachable("anthropic", true, true)
|
||||
g.MarkReachable("openai", true, true)
|
||||
g.MarkReachable("deadcloud", false, false)
|
||||
ordered, healthy := g.Route([]string{"ollama", "anthropic", "openai", "deadcloud"})
|
||||
if !healthy {
|
||||
t.Fatal("healthy candidates exist; Route lied about the thin pipe")
|
||||
}
|
||||
// cloud-first: both cloud rows precede local; deadcloud refused.
|
||||
foundCloud, foundLocal, sawDead := 0, 0, false
|
||||
for i, p := range ordered {
|
||||
switch p {
|
||||
case "anthropic", "openai":
|
||||
foundCloud++
|
||||
if foundLocal > 0 {
|
||||
t.Fatalf("cloud row %s routed after local — doctrine broken (order %v)", p, ordered)
|
||||
}
|
||||
case "ollama":
|
||||
foundLocal++
|
||||
case "deadcloud":
|
||||
sawDead = true
|
||||
}
|
||||
_ = i
|
||||
}
|
||||
if foundCloud != 2 || foundLocal != 1 || sawDead {
|
||||
t.Fatalf("route order wrong: %v", ordered)
|
||||
}
|
||||
// 429 throttle = worse than not spawning
|
||||
g.MarkThrottled("openai", time.Now().Add(time.Hour))
|
||||
ordered, _ = g.Route([]string{"openai"})
|
||||
if len(ordered) != 0 {
|
||||
t.Fatalf("throttled provider routed: %v", ordered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGovernorThinPipeHonesty(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
g := NewGovernor(home)
|
||||
// health telemetry live: the probe ran, the provider never came
|
||||
// back alive — the route must refuse and report the thin pipe.
|
||||
g.MarkReachable("ollama", false, false)
|
||||
_, healthy := g.Route([]string{"ollama"})
|
||||
if healthy {
|
||||
t.Fatal("dead provider routed: Route must report the thin pipe")
|
||||
}
|
||||
// day-one honesty in the other direction: before ANY probe ran,
|
||||
// routing passes through (never refuse what we never asked).
|
||||
fresh := NewGovernor(t.TempDir())
|
||||
_, healthy = fresh.Route([]string{"ollama"})
|
||||
if !healthy {
|
||||
t.Fatal("pre-probe routing must pass through, not refuse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGovernorLanesCount(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
g := NewGovernor(home)
|
||||
g.RecordTurn("anthropic", "m", 100, time.Second, 1)
|
||||
g.AcquireLane("anthropic")
|
||||
g.AcquireLane("anthropic")
|
||||
snap := g.Snapshot()
|
||||
if snap.Providers["anthropic"].LanesActive != 2 || snap.LanesActive != 2 {
|
||||
t.Fatalf("lanes = %+v, want 2", snap)
|
||||
}
|
||||
g.ReleaseLane("anthropic")
|
||||
if snap := g.Snapshot(); snap.Providers["anthropic"].LanesActive != 1 {
|
||||
t.Fatalf("lanes after release = %d, want 1", snap.Providers["anthropic"].LanesActive)
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func (c *OllamaClient) ChatWithTools(ctx context.Context, model string, msgs []C
|
||||
// server-side for qwen3-family even without think:true).
|
||||
visible, inline := splitInlineThinking(out.Message.Content)
|
||||
reply := ChatReply{Content: visible, Model: out.Model,
|
||||
ContextUsed: out.PromptEvalCount}
|
||||
ContextUsed: out.PromptEvalCount, OutputTokens: out.EvalCount}
|
||||
for _, tc := range out.Message.ToolCalls {
|
||||
call := ToolCall{Name: tc.Function.Name}
|
||||
if len(tc.Function.Arguments) > 0 {
|
||||
@@ -323,7 +323,7 @@ func (c *OllamaClient) StreamChatWithTools(ctx context.Context, model string, ms
|
||||
return ChatReply{}, w.err(fmt.Errorf("ollama stream read: %w", err), idle)
|
||||
}
|
||||
visible, inline := splitInlineThinking(content.String())
|
||||
reply := ChatReply{Content: visible, Model: last.Model, ContextUsed: last.PromptEvalCount}
|
||||
reply := ChatReply{Content: visible, Model: last.Model, ContextUsed: last.PromptEvalCount, OutputTokens: last.EvalCount}
|
||||
reply.ToolCalls = toolCalls
|
||||
reply.ContextLimit = c.contextLimit(ctx, model)
|
||||
if thinking.Len() > 0 {
|
||||
|
||||
@@ -38,6 +38,16 @@ type RegistryModel struct {
|
||||
ContextLimit int // the row's limit.context; 0 = the row records none
|
||||
}
|
||||
|
||||
// BaseURL is the provider's wire base: the migrated /v1-bearing
|
||||
// SourceBaseURL when present, the raw URL otherwise ("" when the row
|
||||
// carries none).
|
||||
func (e ProviderRegistryEntry) BaseURL() string {
|
||||
if e.SourceBaseURL != "" {
|
||||
return e.SourceBaseURL
|
||||
}
|
||||
return e.URL
|
||||
}
|
||||
|
||||
// DefaultProviderRegistryPath resolves the registry file by the
|
||||
// migration's own law (the TUI's registry.go mirror): RUNE_REGISTRY_
|
||||
// CONFIG overrides, the user config dir's rune/providers.json is the
|
||||
|
||||
@@ -120,9 +120,18 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// (used = tokens sent, limit = the model's budget). Absent
|
||||
// until the first turn — the bar never guesses.
|
||||
"context": map[string]int{"used": ctxUsed, "limit": ctxLimit},
|
||||
// D56 dispatch governor: the honest live state — measured tok/s
|
||||
// (0 = nothing measured yet; the bar renders '—', never a
|
||||
// guess), lanes in use against the learned cap, and the
|
||||
// per-provider health rows (#2581).
|
||||
"governor": s.engine.Governor().Snapshot(),
|
||||
})
|
||||
}
|
||||
|
||||
// Engine exposes the served engine (main.go wires the governor's
|
||||
// health poll through it).
|
||||
func (s *Server) Engine() *Engine { return s.engine }
|
||||
|
||||
// handleHealth: the core's own liveness + identity.
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
var model string
|
||||
|
||||
@@ -115,6 +115,18 @@ func main() {
|
||||
router := runecore.NewProviderRouter(runecore.SecretToolReader())
|
||||
srv := runecore.NewServer(runecore.NewRoutedEngine(st, client, home, router), client, home)
|
||||
|
||||
// D56 connection health (#2581): poll the registry's providers'
|
||||
// live state so the governor routes only to providers actually
|
||||
// alive — once at boot, then on the health cadence.
|
||||
gov := srv.Engine().Governor()
|
||||
go func() {
|
||||
registry := runecore.DefaultProviderRegistryPath()
|
||||
for {
|
||||
gov.PollRegistryHealth(registry, 5*time.Second)
|
||||
time.Sleep(runecore.HealthPollInterval)
|
||||
}
|
||||
}()
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
httpSrv := &http.Server{Addr: addr, Handler: srv.Handler()}
|
||||
|
||||
|
||||
@@ -215,17 +215,21 @@ func runMode(argv []string) int {
|
||||
}
|
||||
|
||||
func readFile(path string, offset, limit int) Result {
|
||||
f, err := os.Open(path)
|
||||
// Python-oracle parity (tool_executor._read_file): existence is
|
||||
// reported as "Not found: <path>" before any read is attempted.
|
||||
if _, statErr := os.Stat(path); statErr != nil {
|
||||
if os.IsNotExist(statErr) {
|
||||
return fail("Not found: " + path)
|
||||
}
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fail(fmt.Sprintf("Cannot read %s: %v", path, err))
|
||||
}
|
||||
defer f.Close()
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
return fail(fmt.Sprintf("Read failed: %v", err))
|
||||
}
|
||||
// Python-oracle parity: split("\n") keeps the trailing empty element
|
||||
// after a final newline, so a file ending in "\n" gets a numbered
|
||||
// empty last line — do NOT use bufio.Scanner, which drops it.
|
||||
lines := strings.Split(string(data), "\n")
|
||||
start := offset - 1
|
||||
if start < 0 {
|
||||
start = 0
|
||||
@@ -239,12 +243,26 @@ func readFile(path string, offset, limit int) Result {
|
||||
}
|
||||
var numbered []string
|
||||
for i := start; i < end; i++ {
|
||||
numbered = append(numbered, fmt.Sprintf("%d: %s", i+1, lines[i]))
|
||||
// Python-oracle parity: line numbers restart at the offset within
|
||||
// the window (windowed index + offset), not the absolute index.
|
||||
numbered = append(numbered, fmt.Sprintf("%d: %s", i-start+offset, lines[i]))
|
||||
}
|
||||
return ok(strings.Join(numbered, "\n"))
|
||||
result := strings.Join(numbered, "\n")
|
||||
if end < len(lines) {
|
||||
result += fmt.Sprintf("\n\n[showing lines %d-%d of %d total]", offset, end, len(lines))
|
||||
}
|
||||
if start > 0 {
|
||||
result = fmt.Sprintf("[showing from line %d]\n", offset) + result
|
||||
}
|
||||
return ok(result)
|
||||
}
|
||||
|
||||
func listDirectory(path, pattern string) Result {
|
||||
// Python-oracle parity (tool_executor._list_directory): a missing or
|
||||
// non-directory path reports "Not a directory: <path>".
|
||||
if info, statErr := os.Stat(path); statErr != nil || !info.IsDir() {
|
||||
return fail("Not a directory: " + path)
|
||||
}
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return fail(fmt.Sprintf("Cannot list %s: %v", path, err))
|
||||
@@ -337,6 +355,11 @@ func grepFiles(pattern, path, include string) Result {
|
||||
if err != nil {
|
||||
return fail(fmt.Sprintf("Bad pattern: %v", err))
|
||||
}
|
||||
// Python-oracle parity (tool_executor._grep_files): hits carry the
|
||||
// ABSOLUTE resolved path, not the caller's spelling.
|
||||
if abs, absErr := filepath.Abs(path); absErr == nil {
|
||||
path = abs
|
||||
}
|
||||
var out []string
|
||||
var walkFn func(string)
|
||||
walkFn = func(dir string) {
|
||||
|
||||
@@ -6,7 +6,7 @@ This is the main entry point for the Rune program.
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "0.345.0"
|
||||
__version__ = "0.355.0"
|
||||
|
||||
# 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
|
||||
|
||||
+123
-11
@@ -26,7 +26,69 @@ import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fail-loud Go delegation (REBASE-COMPLETION, #2587 wave): the read/report
|
||||
# seams resolve through the Go engine (src/go/abstraction, `eval
|
||||
# <ops.json>` surface) against the SAME abstraction.db the oracle body
|
||||
# reads/writes. Python bodies kept as oracle/escape hatch
|
||||
# (RUNE_ABSTRACTION_ENGINE=python); RUNE_ABSTRACTION_BIN overrides the
|
||||
# binary. Missing binary or engine error raises RuntimeError — no silent
|
||||
# fallback (house shape: model_routing.reconcile_tools_support).
|
||||
# extract_facts/synthesize_observations/run_lifecycle stay Python this
|
||||
# wave: they embed the LLM-as-a-Judge and lifecycle-transition seams the
|
||||
# eval surface does not govern (recorded in the integrated receipt).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _go_engine_enabled() -> bool:
|
||||
return os.environ.get("RUNE_ABSTRACTION_ENGINE", "go") != "python"
|
||||
|
||||
|
||||
def _go_engine_bin() -> str:
|
||||
override = os.environ.get("RUNE_ABSTRACTION_BIN")
|
||||
if override:
|
||||
return override
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
return os.path.join(root, "src", "go", "abstraction", "abstraction-bin")
|
||||
|
||||
|
||||
def _go_eval(ops: list) -> list:
|
||||
bin_path = _go_engine_bin()
|
||||
if not os.path.exists(bin_path):
|
||||
raise RuntimeError(
|
||||
f"abstraction go engine binary missing: {bin_path} (set RUNE_ABSTRACTION_ENGINE=python for the legacy path)"
|
||||
)
|
||||
fd, ops_path = tempfile.mkstemp(suffix=".json", prefix="abs-ops-")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(ops, fh)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[bin_path, "eval", ops_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise RuntimeError(f"abstraction go engine failed: {exc}") from exc
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"abstraction go engine exit {proc.returncode}: {proc.stderr.strip()}")
|
||||
return json.loads(proc.stdout)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(ops_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _memory_dir() -> str:
|
||||
return memory_env.memory_dir()
|
||||
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_SD = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -51,7 +113,6 @@ _ABSTRACTION_STOPWORDS_FROZEN = frozenset(
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _ctx_budget(model_id: str) -> int:
|
||||
"""#28: model-aware num_ctx (never the hardcoded 8192)."""
|
||||
try:
|
||||
@@ -60,6 +121,7 @@ def _ctx_budget(model_id: str) -> int:
|
||||
import model_registry as _mr
|
||||
return _mr.resolve_context_budget(model_id)["num_ctx"]
|
||||
|
||||
|
||||
def _ABSTRACTION_STOPWORDS() -> list:
|
||||
"""Gather seam (W3.4): the fact-extraction stopword table as a sorted
|
||||
list — data received by the kernel; identical filter on both engines."""
|
||||
@@ -74,6 +136,7 @@ def _core():
|
||||
|
||||
def _cc_probe():
|
||||
from rune import cc_loader as _cl
|
||||
|
||||
try:
|
||||
return _cl if _cl.available() else None
|
||||
except Exception: # noqa: BLE001, S110 — loader probe never crashes the fallback
|
||||
@@ -81,8 +144,7 @@ def _cc_probe():
|
||||
|
||||
|
||||
def cc_loader_pin() -> bool:
|
||||
"""The env pin: RUNE_COGNITION_CORE=python → kernels OFF (shared probe).
|
||||
"""
|
||||
"""The env pin: RUNE_COGNITION_CORE=python → kernels OFF (shared probe)."""
|
||||
import os
|
||||
|
||||
return os.environ.get("RUNE_COGNITION_CORE", "auto") == "python"
|
||||
@@ -306,18 +368,24 @@ Extract 1-5 key facts. Be precise, not verbose."""
|
||||
try:
|
||||
from rune import cc_loader as _cl
|
||||
|
||||
raw = _cl.abstraction_extract_facts(
|
||||
episode_text, _ABSTRACTION_STOPWORDS(), 10)
|
||||
raw = _cl.abstraction_extract_facts(episode_text, _ABSTRACTION_STOPWORDS(), 10)
|
||||
for item in raw or []:
|
||||
text_frag = str(item.get("text", "")).strip()
|
||||
if not text_frag:
|
||||
continue
|
||||
facts.append({
|
||||
"what": text_frag, "when": "", "where": "", "who": "",
|
||||
"why": "", "type": "experience", "entities": [],
|
||||
"kernel": True,
|
||||
"content_words": item.get("content_words"),
|
||||
})
|
||||
facts.append(
|
||||
{
|
||||
"what": text_frag,
|
||||
"when": "",
|
||||
"where": "",
|
||||
"who": "",
|
||||
"why": "",
|
||||
"type": "experience",
|
||||
"entities": [],
|
||||
"kernel": True,
|
||||
"content_words": item.get("content_words"),
|
||||
}
|
||||
)
|
||||
except Exception: # noqa: BLE001, S110 — kernel failure degrades to empty, intentional silence
|
||||
pass
|
||||
|
||||
@@ -475,6 +543,13 @@ Respond as JSON array:
|
||||
|
||||
def list_beliefs(limit=20):
|
||||
"""List active beliefs."""
|
||||
if _go_engine_enabled():
|
||||
return _go_eval([{"op": "list_beliefs", "memory_dir": _memory_dir(), "limit": limit}])[0]
|
||||
return _list_beliefs_python(limit)
|
||||
|
||||
|
||||
def _list_beliefs_python(limit=20):
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
db = connect()
|
||||
try:
|
||||
rows = db.execute(
|
||||
@@ -487,6 +562,13 @@ def list_beliefs(limit=20):
|
||||
|
||||
def list_rules(limit=20):
|
||||
"""List active rules."""
|
||||
if _go_engine_enabled():
|
||||
return _go_eval([{"op": "list_rules", "memory_dir": _memory_dir(), "limit": limit}])[0]
|
||||
return _list_rules_python(limit)
|
||||
|
||||
|
||||
def _list_rules_python(limit=20):
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
db = connect()
|
||||
try:
|
||||
rows = db.execute(
|
||||
@@ -499,6 +581,13 @@ def list_rules(limit=20):
|
||||
|
||||
def list_skills(limit=20):
|
||||
"""List active skills."""
|
||||
if _go_engine_enabled():
|
||||
return _go_eval([{"op": "list_skills", "memory_dir": _memory_dir(), "limit": limit}])[0]
|
||||
return _list_skills_python(limit)
|
||||
|
||||
|
||||
def _list_skills_python(limit=20):
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
db = connect()
|
||||
try:
|
||||
rows = db.execute(
|
||||
@@ -543,6 +632,13 @@ def run_lifecycle():
|
||||
|
||||
def pipeline_stats():
|
||||
"""Return pipeline statistics."""
|
||||
if _go_engine_enabled():
|
||||
return _go_eval([{"op": "pipeline_stats", "memory_dir": _memory_dir()}])[0]
|
||||
return _pipeline_stats_python()
|
||||
|
||||
|
||||
def _pipeline_stats_python():
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
db = connect()
|
||||
stats = {}
|
||||
tables_with_state = {"facts", "observations", "beliefs", "rules", "skills"}
|
||||
@@ -559,6 +655,22 @@ def pipeline_stats():
|
||||
|
||||
def handle_hook(event, data=None):
|
||||
"""Hook handler — extract facts from session end events."""
|
||||
if _go_engine_enabled():
|
||||
return _go_eval(
|
||||
[
|
||||
{
|
||||
"op": "handle_hook",
|
||||
"memory_dir": _memory_dir(),
|
||||
"event": event,
|
||||
"data": data,
|
||||
}
|
||||
]
|
||||
)[0]
|
||||
return _handle_hook_python(event, data)
|
||||
|
||||
|
||||
def _handle_hook_python(event, data=None):
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
if event == "on_session_end" and data:
|
||||
# Could extract facts from session summary
|
||||
return {"status": "ok", "event": event, "handler": "abstraction"}
|
||||
|
||||
@@ -26,6 +26,66 @@ import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fail-loud Go delegation (REBASE-COMPLETION, #2587 wave): the cache seam
|
||||
# operations resolve through the Go engine (src/go/cache, `eval
|
||||
# <ops.json>` surface) against the SAME cache.db the oracle body
|
||||
# reads/writes. Python bodies kept as oracle/escape hatch
|
||||
# (RUNE_CACHE_ENGINE=python); RUNE_CACHE_BIN overrides the binary.
|
||||
# Missing binary or engine error raises RuntimeError — no silent
|
||||
# fallback (house shape: model_routing.reconcile_tools_support).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _go_engine_enabled() -> bool:
|
||||
return os.environ.get("RUNE_CACHE_ENGINE", "go") != "python"
|
||||
|
||||
|
||||
def _go_engine_bin() -> str:
|
||||
override = os.environ.get("RUNE_CACHE_BIN")
|
||||
if override:
|
||||
return override
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
return os.path.join(root, "src", "go", "cache", "cache-bin")
|
||||
|
||||
|
||||
def _go_eval(ops: list) -> list:
|
||||
bin_path = _go_engine_bin()
|
||||
if not os.path.exists(bin_path):
|
||||
raise RuntimeError(
|
||||
f"cache go engine binary missing: {bin_path} (set RUNE_CACHE_ENGINE=python for the legacy path)"
|
||||
)
|
||||
fd, ops_path = tempfile.mkstemp(suffix=".json", prefix="cache-ops-")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(ops, fh)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[bin_path, "eval", ops_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise RuntimeError(f"cache go engine failed: {exc}") from exc
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"cache go engine exit {proc.returncode}: {proc.stderr.strip()}")
|
||||
return json.loads(proc.stdout)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(ops_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _memory_dir() -> str:
|
||||
return memory_env.memory_dir()
|
||||
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_SD = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -183,6 +243,16 @@ def get_cache(key):
|
||||
Hit/miss statistics are accumulated in memory and flushed to SQLite
|
||||
periodically (every 50 events or 30 seconds) to avoid write-on-read.
|
||||
"""
|
||||
if _go_engine_enabled():
|
||||
row = _go_eval([{"op": "get_cache", "memory_dir": _memory_dir(), "key": key}])[0]
|
||||
if row is None:
|
||||
return {"hit": False, "key": key}
|
||||
return row
|
||||
return _get_cache_python(key)
|
||||
|
||||
|
||||
def _get_cache_python(key):
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
db = connect()
|
||||
try:
|
||||
row = db.execute("SELECT * FROM cache WHERE key=? AND invalidated_at IS NULL", (key,)).fetchone()
|
||||
@@ -225,6 +295,26 @@ def put_cache(key, value, compressed=None, scope="general", source_hash=""):
|
||||
|
||||
Uses INSERT … ON CONFLICT to update content without resetting hit/miss stats.
|
||||
"""
|
||||
if _go_engine_enabled():
|
||||
flush_stats()
|
||||
return _go_eval(
|
||||
[
|
||||
{
|
||||
"op": "put_cache",
|
||||
"memory_dir": _memory_dir(),
|
||||
"key": key,
|
||||
"value": value,
|
||||
"compressed": compressed or "",
|
||||
"scope": scope,
|
||||
"source_hash": source_hash,
|
||||
}
|
||||
]
|
||||
)[0]
|
||||
return _put_cache_python(key, value, compressed=compressed, scope=scope, source_hash=source_hash)
|
||||
|
||||
|
||||
def _put_cache_python(key, value, compressed=None, scope="general", source_hash=""):
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
# Flush any pending stats before writing so we don't lose counts
|
||||
flush_stats()
|
||||
|
||||
@@ -259,6 +349,13 @@ def put_cache(key, value, compressed=None, scope="general", source_hash=""):
|
||||
|
||||
def invalidate(key, reason=""):
|
||||
"""Invalidate a specific cache entry."""
|
||||
if _go_engine_enabled():
|
||||
return _go_eval([{"op": "invalidate", "memory_dir": _memory_dir(), "key": key, "reason": reason}])[0]
|
||||
return _invalidate_python({"op": "invalidate", "memory_dir": _memory_dir(), "key": key, "reason": reason})
|
||||
|
||||
|
||||
def _invalidate_python(key, reason=""):
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
db = connect()
|
||||
db.execute("UPDATE cache SET invalidated_at=? WHERE key=? AND invalidated_at IS NULL", (_now(), key))
|
||||
db.commit()
|
||||
@@ -268,6 +365,15 @@ def invalidate(key, reason=""):
|
||||
|
||||
def invalidate_scope(scope, reason=""):
|
||||
"""Invalidate all entries in a scope."""
|
||||
if _go_engine_enabled():
|
||||
return _go_eval([{"op": "invalidate_scope", "memory_dir": _memory_dir(), "scope": scope, "reason": reason}])[0]
|
||||
return _invalidate_scope_python(
|
||||
{"op": "invalidate_scope", "memory_dir": _memory_dir(), "scope": scope, "reason": reason}
|
||||
)
|
||||
|
||||
|
||||
def _invalidate_scope_python(scope, reason=""):
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
db = connect()
|
||||
count = db.execute(
|
||||
"UPDATE cache SET invalidated_at=? WHERE scope=? AND invalidated_at IS NULL", (_now(), scope)
|
||||
@@ -279,6 +385,13 @@ def invalidate_scope(scope, reason=""):
|
||||
|
||||
def invalidate_all(reason=""):
|
||||
"""Invalidate everything. Nuclear option."""
|
||||
if _go_engine_enabled():
|
||||
return _go_eval([{"op": "invalidate_all", "memory_dir": _memory_dir(), "reason": reason}])[0]
|
||||
return _invalidate_all_python({"op": "invalidate_all", "memory_dir": _memory_dir(), "reason": reason})
|
||||
|
||||
|
||||
def _invalidate_all_python(reason=""):
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
db = connect()
|
||||
count = db.execute("UPDATE cache SET invalidated_at=? WHERE invalidated_at IS NULL", (_now(),)).rowcount
|
||||
db.commit()
|
||||
@@ -350,6 +463,14 @@ def _parse_ts(ts_str):
|
||||
|
||||
def cache_stats():
|
||||
"""Return cache statistics (includes in-memory pending counts)."""
|
||||
if _go_engine_enabled():
|
||||
flush_stats()
|
||||
return _go_eval([{"op": "cache_stats", "memory_dir": _memory_dir()}])[0]
|
||||
return _cache_stats_python()
|
||||
|
||||
|
||||
def _cache_stats_python():
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
# Flush pending counts first so stats are fully accurate
|
||||
flush_stats()
|
||||
|
||||
@@ -380,6 +501,22 @@ def cache_stats():
|
||||
|
||||
def handle_hook(event, data=None):
|
||||
"""Event-driven invalidation. Subscribe to event bus events."""
|
||||
if _go_engine_enabled():
|
||||
return _go_eval(
|
||||
[
|
||||
{
|
||||
"op": "handle_hook",
|
||||
"memory_dir": _memory_dir(),
|
||||
"event": event,
|
||||
"data": data or {},
|
||||
}
|
||||
]
|
||||
)[0]
|
||||
return _handle_hook_python(event, data)
|
||||
|
||||
|
||||
def _handle_hook_python(event, data=None):
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
if event in INVALIDATION_MAP:
|
||||
scopes = INVALIDATION_MAP[event]
|
||||
invalidated = 0
|
||||
|
||||
@@ -233,7 +233,7 @@ def main() -> int:
|
||||
)
|
||||
check(
|
||||
"Rune minor version advertises provisioning and restore verification",
|
||||
'__version__ = "0.345.0"' in rune_version,
|
||||
'__version__ = "0.355.0"' in rune_version,
|
||||
failures,
|
||||
)
|
||||
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
|
||||
|
||||
+162
-6
@@ -8,12 +8,93 @@ variables or a config dict — nothing is hardcoded to a specific setup.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
import httpx
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fail-loud Go delegation (REBASE-COMPLETION, #2587 wave): the retry/status
|
||||
# decision seam (_request) and the adapter-selection seam
|
||||
# (create_forge_client) resolve through the Go engine
|
||||
# (src/go/forge_client, `eval <ops.json>` surface). The Python bodies below
|
||||
# are kept as oracle / escape hatch (RUNE_FORGE_CLIENT_ENGINE=python);
|
||||
# RUNE_FORGE_CLIENT_BIN overrides the binary. Missing binary or engine
|
||||
# error raises RuntimeError — no silent fallback (house shape:
|
||||
# model_routing.reconcile_tools_support). The raise_for_status EXCEPTION
|
||||
# contract stays on the Python side: the Go engine reports the 4xx as
|
||||
# {status_error} and the facade re-raises httpx.HTTPStatusError with an
|
||||
# httpx.Response, exactly like the oracle body.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _go_engine_enabled() -> bool:
|
||||
return os.environ.get("RUNE_FORGE_CLIENT_ENGINE", "go") != "python"
|
||||
|
||||
|
||||
def _go_engine_bin() -> str:
|
||||
override = os.environ.get("RUNE_FORGE_CLIENT_BIN")
|
||||
if override:
|
||||
return override
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
return os.path.join(root, "src", "go", "forge_client", "forge-client")
|
||||
|
||||
|
||||
def _go_eval(ops: list) -> list:
|
||||
bin_path = _go_engine_bin()
|
||||
if not os.path.exists(bin_path):
|
||||
raise RuntimeError(
|
||||
f"forge-client go engine binary missing: {bin_path} "
|
||||
"(set RUNE_FORGE_CLIENT_ENGINE=python for the legacy path)"
|
||||
)
|
||||
fd, ops_path = tempfile.mkstemp(suffix=".json", prefix="fc-ops-")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(ops, fh)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[bin_path, "eval", ops_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise RuntimeError(f"forge-client go engine failed: {exc}") from exc
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"forge-client go engine exit {proc.returncode}: {proc.stderr.strip()}")
|
||||
return json.loads(proc.stdout)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(ops_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class _GoResponse:
|
||||
"""Minimal httpx.Response stand-in for delegated requests: the
|
||||
adapter call sites consume .json() and .status_code only."""
|
||||
|
||||
def __init__(self, status_code: int, body: str):
|
||||
self.status_code = status_code
|
||||
self._body = body
|
||||
|
||||
def json(self):
|
||||
return json.loads(self._body)
|
||||
|
||||
|
||||
def _raise_for_status(status_code: int) -> None:
|
||||
response = httpx.Response(status_code)
|
||||
error = httpx.HTTPStatusError(
|
||||
f"error {status_code}",
|
||||
request=httpx.Request("GET", "https://forge"),
|
||||
response=response,
|
||||
)
|
||||
raise error
|
||||
|
||||
|
||||
def _request(
|
||||
method: str,
|
||||
@@ -25,6 +106,59 @@ def _request(
|
||||
retries: int = 2,
|
||||
backoff: float = 0.5,
|
||||
sleep=None,
|
||||
):
|
||||
"""Fail-loud Go delegation for the retry/status decision seam. The
|
||||
injected ``sleep`` is a Python-test-only seam: when a test passes a
|
||||
recording fake, the oracle body runs (documented seam, not a silent
|
||||
fallback of the engine decision — the delegation governs every live
|
||||
call, where sleep is default)."""
|
||||
if _go_engine_enabled() and sleep is None:
|
||||
ops = [
|
||||
{
|
||||
"op": "request",
|
||||
"method": method,
|
||||
"url": url,
|
||||
"headers": headers or {},
|
||||
"json": json,
|
||||
"timeout": timeout,
|
||||
"retries": retries,
|
||||
"backoff": backoff,
|
||||
}
|
||||
]
|
||||
row = _go_eval(ops)[0]
|
||||
if "status_error" in row:
|
||||
_raise_for_status(int(row["status_error"]))
|
||||
if "error_kind" in row:
|
||||
kind = row["error_kind"]
|
||||
exc = {
|
||||
"httpx.ConnectError": httpx.ConnectError,
|
||||
"httpx.ConnectTimeout": httpx.ConnectTimeout,
|
||||
"httpx.ReadTimeout": httpx.ReadTimeout,
|
||||
}.get(kind, httpx.TransportError)
|
||||
raise exc(str(row.get("error_msg") or kind))
|
||||
return _GoResponse(int(row["status_code"]), str(row["body"]))
|
||||
return _request_python(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=json,
|
||||
timeout=timeout,
|
||||
retries=retries,
|
||||
backoff=backoff,
|
||||
sleep=sleep,
|
||||
)
|
||||
|
||||
|
||||
def _request_python(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: dict | None = None,
|
||||
json: dict | None = None,
|
||||
timeout: float = 30,
|
||||
retries: int = 2,
|
||||
backoff: float = 0.5,
|
||||
sleep=None,
|
||||
):
|
||||
"""Resilient single-request path: retry where failure is NOISE, never
|
||||
where failure is the SIGNAL.
|
||||
@@ -193,11 +327,33 @@ def create_forge_client(config: dict | None = None) -> ForgeClient:
|
||||
Falls back to FORGE_URL/FORGE_TOKEN/FORGE_OWNER/FORGE_REPO env vars.
|
||||
Unconfigured → NullAdapter (graceful dormant).
|
||||
"""
|
||||
cfg = config or {}
|
||||
url = cfg.get("forge.url") or os.environ.get("FORGE_URL", "")
|
||||
token = cfg.get("forge.token") or os.environ.get("FORGE_TOKEN", "")
|
||||
cfg = dict(config or {})
|
||||
if not cfg.get("forge.url"):
|
||||
cfg["forge.url"] = os.environ.get("FORGE_URL", "")
|
||||
if not cfg.get("forge.token"):
|
||||
cfg["forge.token"] = os.environ.get("FORGE_TOKEN", "")
|
||||
if not cfg.get("forge.owner"):
|
||||
cfg["forge.owner"] = os.environ.get("FORGE_OWNER", "owner")
|
||||
if not cfg.get("forge.repo"):
|
||||
cfg["forge.repo"] = os.environ.get("FORGE_REPO", "repo")
|
||||
if _go_engine_enabled():
|
||||
row = _go_eval([{"op": "select", "config": cfg}])[0]
|
||||
if row.get("adapter") == "NullAdapter":
|
||||
return NullAdapter()
|
||||
base_url = row["base_url"]
|
||||
# Split the resolved base_url back into (url, owner, repo) for the
|
||||
# adapter constructor — the DECISION (kind + resolution) was the
|
||||
# engine's; the constructor call is glue.
|
||||
head, _, tail = base_url.rpartition("/api/v1/repos/")
|
||||
owner, _, repo = tail.partition("/")
|
||||
return GiteaForgejoAdapter(head, row["token"], owner=owner, repo=repo)
|
||||
url = cfg.get("forge.url", "")
|
||||
token = cfg.get("forge.token", "")
|
||||
if not url or not token:
|
||||
return NullAdapter()
|
||||
owner = cfg.get("forge.owner") or os.environ.get("FORGE_OWNER", "owner")
|
||||
repo = cfg.get("forge.repo") or os.environ.get("FORGE_REPO", "repo")
|
||||
return GiteaForgejoAdapter(url, token, owner=owner, repo=repo)
|
||||
return GiteaForgejoAdapter(
|
||||
url,
|
||||
token,
|
||||
owner=cfg.get("forge.owner", "owner"),
|
||||
repo=cfg.get("forge.repo", "repo"),
|
||||
)
|
||||
+156
-3
@@ -11,7 +11,63 @@ Usage:
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fail-loud Go delegation (REBASE-COMPLETION, #2587 wave): the pure state
|
||||
# operations (set/get_active/check-free mutations/achieve/fail/clear/list)
|
||||
# resolve through the Go engine (src/go/goal, `eval <ops.json>` surface).
|
||||
# check_goal stays Python this wave: it embeds the LLM-as-a-Judge
|
||||
# verification call the Go port does not carry (recorded as
|
||||
# blocks_full_switchover). Python bodies kept as oracle/escape hatch
|
||||
# (RUNE_GOAL_ENGINE=python); RUNE_GOAL_BIN overrides the binary. Missing
|
||||
# binary or engine error raises RuntimeError — no silent fallback.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _go_engine_enabled() -> bool:
|
||||
return os.environ.get("RUNE_GOAL_ENGINE", "go") != "python"
|
||||
|
||||
|
||||
def _go_engine_bin() -> str:
|
||||
override = os.environ.get("RUNE_GOAL_BIN")
|
||||
if override:
|
||||
return override
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
return os.path.join(root, "src", "go", "goal", "goal-bin")
|
||||
|
||||
|
||||
def _go_eval(ops: list) -> list:
|
||||
bin_path = _go_engine_bin()
|
||||
if not os.path.exists(bin_path):
|
||||
raise RuntimeError(
|
||||
f"goal go engine binary missing: {bin_path} (set RUNE_GOAL_ENGINE=python for the legacy path)"
|
||||
)
|
||||
fd, ops_path = tempfile.mkstemp(suffix=".json", prefix="goal-ops-")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(ops, fh)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[bin_path, "eval", ops_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise RuntimeError(f"goal go engine failed: {exc}") from exc
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"goal go engine exit {proc.returncode}: {proc.stderr.strip()}")
|
||||
return json.loads(proc.stdout)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(ops_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
@@ -78,7 +134,27 @@ class GoalManager:
|
||||
def set_goal(
|
||||
self, description: str, stop_conditions: List[str] = None, verification_method: str = "manual"
|
||||
) -> Goal:
|
||||
"""Set a new goal."""
|
||||
"""Fail-loud Go delegation (see module header)."""
|
||||
if _go_engine_enabled():
|
||||
row = _go_eval(
|
||||
[
|
||||
{
|
||||
"op": "set_goal",
|
||||
"storage_path": self.storage_path,
|
||||
"description": description,
|
||||
"stop_conditions": stop_conditions or [],
|
||||
"verification_method": verification_method,
|
||||
}
|
||||
]
|
||||
)[0]
|
||||
self._load()
|
||||
return Goal(**row)
|
||||
return self._set_goal_python(description, stop_conditions, verification_method)
|
||||
|
||||
def _set_goal_python(
|
||||
self, description: str, stop_conditions: List[str] = None, verification_method: str = "manual"
|
||||
) -> Goal:
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
goal_id = str(uuid.uuid4())[:8]
|
||||
goal = Goal(
|
||||
goal_id=goal_id,
|
||||
@@ -93,7 +169,22 @@ class GoalManager:
|
||||
return goal
|
||||
|
||||
def get_active_goal(self) -> Optional[Goal]:
|
||||
"""Get the current active goal."""
|
||||
"""Fail-loud Go delegation (see module header)."""
|
||||
if _go_engine_enabled():
|
||||
row = _go_eval(
|
||||
[
|
||||
{
|
||||
"op": "get_active_goal",
|
||||
"storage_path": self.storage_path,
|
||||
}
|
||||
]
|
||||
)[0]
|
||||
self._load()
|
||||
return Goal(**row) if row else None
|
||||
return self._get_active_goal_python()
|
||||
|
||||
def _get_active_goal_python(self) -> Optional[Goal]:
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
for goal in self.goals.values():
|
||||
if goal.status == "active":
|
||||
return goal
|
||||
@@ -240,6 +331,22 @@ Respond in JSON format:
|
||||
|
||||
def achieve_goal(self, evidence: str = None) -> Dict[str, Any]:
|
||||
"""Mark the current goal as achieved."""
|
||||
if _go_engine_enabled():
|
||||
row = _go_eval(
|
||||
[
|
||||
{
|
||||
"op": "achieve_goal",
|
||||
"storage_path": self.storage_path,
|
||||
"evidence": evidence,
|
||||
}
|
||||
]
|
||||
)[0]
|
||||
self._load()
|
||||
return row
|
||||
return self._achieve_goal_python(evidence)
|
||||
|
||||
def _achieve_goal_python(self, evidence: str = None) -> Dict[str, Any]:
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
goal = self.get_active_goal()
|
||||
if not goal:
|
||||
return {"status": "no_goal", "message": "No active goal set"}
|
||||
@@ -259,6 +366,22 @@ Respond in JSON format:
|
||||
|
||||
def fail_goal(self, reason: str = None) -> Dict[str, Any]:
|
||||
"""Mark the current goal as failed."""
|
||||
if _go_engine_enabled():
|
||||
row = _go_eval(
|
||||
[
|
||||
{
|
||||
"op": "fail_goal",
|
||||
"storage_path": self.storage_path,
|
||||
"reason": reason,
|
||||
}
|
||||
]
|
||||
)[0]
|
||||
self._load()
|
||||
return row
|
||||
return self._fail_goal_python(reason)
|
||||
|
||||
def _fail_goal_python(self, reason: str = None) -> Dict[str, Any]:
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
goal = self.get_active_goal()
|
||||
if not goal:
|
||||
return {"status": "no_goal", "message": "No active goal set"}
|
||||
@@ -276,6 +399,21 @@ Respond in JSON format:
|
||||
|
||||
def clear_goal(self) -> Dict[str, Any]:
|
||||
"""Clear the current goal."""
|
||||
if _go_engine_enabled():
|
||||
row = _go_eval(
|
||||
[
|
||||
{
|
||||
"op": "clear_goal",
|
||||
"storage_path": self.storage_path,
|
||||
}
|
||||
]
|
||||
)[0]
|
||||
self._load()
|
||||
return row
|
||||
return self._clear_goal_python()
|
||||
|
||||
def _clear_goal_python(self) -> Dict[str, Any]:
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
goal = self.get_active_goal()
|
||||
if not goal:
|
||||
return {"status": "no_goal", "message": "No active goal set"}
|
||||
@@ -290,6 +428,21 @@ Respond in JSON format:
|
||||
|
||||
def list_goals(self) -> List[Dict[str, Any]]:
|
||||
"""List all goals."""
|
||||
if _go_engine_enabled():
|
||||
row = _go_eval(
|
||||
[
|
||||
{
|
||||
"op": "list_goals",
|
||||
"storage_path": self.storage_path,
|
||||
}
|
||||
]
|
||||
)[0]
|
||||
self._load()
|
||||
return row
|
||||
return self._list_goals_python()
|
||||
|
||||
def _list_goals_python(self) -> List[Dict[str, Any]]:
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
return [goal.to_dict() for goal in self.goals.values()]
|
||||
|
||||
|
||||
@@ -297,7 +450,6 @@ Respond in JSON format:
|
||||
_goal_manager = None
|
||||
|
||||
|
||||
|
||||
def _ctx_budget(model_id: str) -> int:
|
||||
"""#28: model-aware num_ctx (never the hardcoded 8192)."""
|
||||
try:
|
||||
@@ -306,6 +458,7 @@ def _ctx_budget(model_id: str) -> int:
|
||||
import model_registry as _mr
|
||||
return _mr.resolve_context_budget(model_id)["num_ctx"]
|
||||
|
||||
|
||||
def get_goal_manager() -> GoalManager:
|
||||
"""Get the global goal manager."""
|
||||
global _goal_manager
|
||||
|
||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import urllib.request
|
||||
from typing import Callable, Protocol, Sequence, TypedDict, cast
|
||||
@@ -38,6 +40,91 @@ from .tool_protocol import (
|
||||
|
||||
LOG = logging.getLogger("rune.context_broker")
|
||||
|
||||
# Native engine integration (wave B, Gitea #2575, PORT→INTEGRATE→REMOVE):
|
||||
# the four read-only file operations (read_file, list_directory, glob_files,
|
||||
# grep_files) resolve through the Go engine (src/go/tool-executor, the same
|
||||
# CLI/envelope surface the Go parity batteries pin). The Python bodies in
|
||||
# tool_executor.py stay as the oracle/escape hatch
|
||||
# (RUNE_TOOL_EXECUTOR_ENGINE=python); fail-loud, no silent fallback — a
|
||||
# missing binary raises a typed RuntimeError. run_command is deliberately
|
||||
# NOT delegated: it is mutating (tool_executor.READ_ONLY_TOOLS excludes it)
|
||||
# and the approval door owns every dispatch of it.
|
||||
|
||||
_GO_EXECUTOR_OPS = frozenset(
|
||||
{"read_file", "list_directory", "glob_files", "grep_files"}
|
||||
)
|
||||
|
||||
def _lte_go_engine_enabled() -> bool:
|
||||
return os.environ.get("RUNE_TOOL_EXECUTOR_ENGINE", "go") != "python"
|
||||
|
||||
def _lte_go_engine_bin() -> str:
|
||||
override = os.environ.get("RUNE_TOOL_EXECUTOR_BIN")
|
||||
if override:
|
||||
return override
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
return os.path.join(root, "src", "go", "tool-executor", "tool-executor")
|
||||
|
||||
def _go_execute_tool(tool_name: str, arguments: dict) -> dict:
|
||||
"""Shell out to the Go tool-executor CLI and return its JSON envelope.
|
||||
|
||||
Argv mirrors dispatchOp (internal/toolexec/modegate.go): read_file
|
||||
<path> [offset] [limit]; list_directory <path> [pattern];
|
||||
glob_files <pattern> [path]; grep_files <pattern> [path] [include].
|
||||
"""
|
||||
bin_path = _lte_go_engine_bin()
|
||||
if not os.path.exists(bin_path):
|
||||
raise RuntimeError(
|
||||
f"tool-executor go engine binary missing: {bin_path} "
|
||||
"(set RUNE_TOOL_EXECUTOR_ENGINE=python for the legacy path)"
|
||||
)
|
||||
if tool_name == "read_file":
|
||||
argv = [bin_path, "read_file", str(arguments.get("path", "")),
|
||||
str(int(arguments.get("offset", 1) or 1)),
|
||||
str(int(arguments.get("limit", 100) or 100))]
|
||||
elif tool_name == "list_directory":
|
||||
argv = [bin_path, "list_directory", str(arguments.get("path", "."))]
|
||||
if arguments.get("pattern"):
|
||||
argv.append(str(arguments["pattern"]))
|
||||
elif tool_name == "glob_files":
|
||||
argv = [bin_path, "glob_files", str(arguments.get("pattern", "")),
|
||||
str(arguments.get("path", "."))]
|
||||
elif tool_name == "grep_files":
|
||||
argv = [bin_path, "grep_files", str(arguments.get("pattern", "")),
|
||||
str(arguments.get("path", "."))]
|
||||
if arguments.get("include"):
|
||||
argv.append(str(arguments["include"]))
|
||||
else: # pragma: no cover — guarded by _GO_EXECUTOR_OPS
|
||||
raise RuntimeError(f"tool-executor go engine: unsupported op {tool_name!r}")
|
||||
try:
|
||||
proc = subprocess.run(argv, capture_output=True, text=True, timeout=30)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise RuntimeError(f"tool-executor go engine failed: {exc}") from exc
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"tool-executor go engine exit {proc.returncode}: {proc.stderr.strip()}"
|
||||
)
|
||||
envelope = json.loads(proc.stdout)
|
||||
if not isinstance(envelope, dict):
|
||||
raise RuntimeError("tool-executor go engine returned a non-object envelope")
|
||||
return envelope
|
||||
|
||||
class _GoDelegatingExecutor:
|
||||
"""Executor wrapper: read-only ops resolve through the Go engine, every
|
||||
other op (and the RUNE_TOOL_EXECUTOR_ENGINE=python escape hatch) falls
|
||||
through to the wrapped executor untouched. Attribute access (the
|
||||
authority seam, _uses_tool_authority) delegates to the inner executor."""
|
||||
|
||||
def __init__(self, inner: Callable[[str, dict], dict]):
|
||||
self._inner = inner
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return getattr(self._inner, name)
|
||||
|
||||
def __call__(self, tool_name: str, arguments: dict) -> dict:
|
||||
if _lte_go_engine_enabled() and tool_name in _GO_EXECUTOR_OPS:
|
||||
return _go_execute_tool(tool_name, arguments)
|
||||
return self._inner(tool_name, arguments)
|
||||
|
||||
#: Conservative context window used ONLY when /api/show discovery fails;
|
||||
#: the failure is recorded via the P0-06 registry (T1) — never silent.
|
||||
CONTEXT_LENGTH_FALLBACK = 8192
|
||||
@@ -337,7 +424,11 @@ class LocalToolRuntime:
|
||||
if max_iterations < 1 or max_iterations > 8:
|
||||
raise ValueError("max_iterations must be between 1 and 8")
|
||||
self.transport = transport
|
||||
self.executor = executor
|
||||
# Wave B (#2575): read-only file ops resolve through the Go
|
||||
# tool-executor engine by default; every other op and the
|
||||
# RUNE_TOOL_EXECUTOR_ENGINE=python escape hatch hit the wrapped
|
||||
# executor untouched (approval door preserved).
|
||||
self.executor = _GoDelegatingExecutor(executor)
|
||||
self.model = model
|
||||
self.tools = list(TOOLS if tools is None else tools)
|
||||
self.tool_names = frozenset(
|
||||
|
||||
@@ -18,11 +18,66 @@ Usage:
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fail-loud Go delegation (REBASE-COMPLETION, #2587 wave): the queue seam
|
||||
# operations resolve through the Go engine (src/go/message_queue, `eval
|
||||
# <ops.json>` surface) against the SAME queue.json the oracle body
|
||||
# reads/writes. Python bodies kept as oracle/escape hatch
|
||||
# (RUNE_MESSAGE_QUEUE_ENGINE=python); RUNE_MESSAGE_QUEUE_BIN overrides the
|
||||
# binary. Missing binary or engine error raises RuntimeError — no silent
|
||||
# fallback (house shape: model_routing.reconcile_tools_support).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _go_engine_enabled() -> bool:
|
||||
return os.environ.get("RUNE_MESSAGE_QUEUE_ENGINE", "go") != "python"
|
||||
|
||||
|
||||
def _go_engine_bin() -> str:
|
||||
override = os.environ.get("RUNE_MESSAGE_QUEUE_BIN")
|
||||
if override:
|
||||
return override
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
return os.path.join(root, "src", "go", "message_queue", "message-queue-bin")
|
||||
|
||||
|
||||
def _go_eval(ops: list) -> list:
|
||||
bin_path = _go_engine_bin()
|
||||
if not os.path.exists(bin_path):
|
||||
raise RuntimeError(
|
||||
f"message-queue go engine binary missing: {bin_path} "
|
||||
"(set RUNE_MESSAGE_QUEUE_ENGINE=python for the legacy path)"
|
||||
)
|
||||
fd, ops_path = tempfile.mkstemp(suffix=".json", prefix="mq-ops-")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(ops, fh)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[bin_path, "eval", ops_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise RuntimeError(f"message-queue go engine failed: {exc}") from exc
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"message-queue go engine exit {proc.returncode}: {proc.stderr.strip()}")
|
||||
return json.loads(proc.stdout)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(ops_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# CONFIGURATION
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -90,6 +145,13 @@ class MessageQueue:
|
||||
def __init__(self):
|
||||
os.makedirs(QUEUE_DIR, exist_ok=True)
|
||||
self.queue: List[QueuedMessage] = self._load_queue()
|
||||
if _go_engine_enabled():
|
||||
self._sync_from_engine()
|
||||
|
||||
def _sync_from_engine(self):
|
||||
"""Reload the in-memory mirror from the queue file the Go engine
|
||||
mutates (see module header)."""
|
||||
self.queue = self._load_queue()
|
||||
|
||||
def _load_queue(self) -> List[QueuedMessage]:
|
||||
"""Load queue from file."""
|
||||
@@ -109,6 +171,21 @@ class MessageQueue:
|
||||
|
||||
Returns: (is_task_addition, confidence, signals)
|
||||
"""
|
||||
if _go_engine_enabled():
|
||||
row = _go_eval(
|
||||
[
|
||||
{
|
||||
"op": "detect_task_addition",
|
||||
"queue_dir": QUEUE_DIR,
|
||||
"message": message,
|
||||
}
|
||||
]
|
||||
)[0]
|
||||
return bool(row[0]), float(row[1]), list(row[2])
|
||||
return self._detect_task_addition_python(message)
|
||||
|
||||
def _detect_task_addition_python(self, message: str) -> tuple[bool, float, List[str]]:
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
signals = []
|
||||
confidence = 0.0
|
||||
message_lower = message.lower()
|
||||
@@ -160,6 +237,24 @@ class MessageQueue:
|
||||
|
||||
def add_message(self, message: str, channel: str, task_id: Optional[str] = None) -> QueuedMessage:
|
||||
"""Add a message to the queue."""
|
||||
if _go_engine_enabled():
|
||||
row = _go_eval(
|
||||
[
|
||||
{
|
||||
"op": "add_message",
|
||||
"queue_dir": QUEUE_DIR,
|
||||
"message": message,
|
||||
"channel": channel,
|
||||
"task_id": task_id,
|
||||
}
|
||||
]
|
||||
)[0]
|
||||
self._sync_from_engine()
|
||||
return QueuedMessage(**row)
|
||||
return self._add_message_python(message, channel, task_id)
|
||||
|
||||
def _add_message_python(self, message: str, channel: str, task_id: Optional[str] = None) -> QueuedMessage:
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
is_task, confidence, signals = self.detect_task_addition(message)
|
||||
|
||||
msg_id = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
|
||||
@@ -184,6 +279,14 @@ class MessageQueue:
|
||||
|
||||
def approve(self, msg_id: str) -> bool:
|
||||
"""Approve a queued message."""
|
||||
if _go_engine_enabled():
|
||||
got = _go_eval([{"op": "approve", "queue_dir": QUEUE_DIR, "msg_id": msg_id}])[0]
|
||||
self._sync_from_engine()
|
||||
return bool(got)
|
||||
return self._approve_python(msg_id)
|
||||
|
||||
def _approve_python(self, msg_id: str) -> bool:
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
for msg in self.queue:
|
||||
if msg.id == msg_id:
|
||||
msg.status = "approved"
|
||||
@@ -193,6 +296,14 @@ class MessageQueue:
|
||||
|
||||
def reject(self, msg_id: str) -> bool:
|
||||
"""Reject a queued message."""
|
||||
if _go_engine_enabled():
|
||||
got = _go_eval([{"op": "reject", "queue_dir": QUEUE_DIR, "msg_id": msg_id}])[0]
|
||||
self._sync_from_engine()
|
||||
return bool(got)
|
||||
return self._reject_python(msg_id)
|
||||
|
||||
def _reject_python(self, msg_id: str) -> bool:
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
for msg in self.queue:
|
||||
if msg.id == msg_id:
|
||||
msg.status = "rejected"
|
||||
@@ -202,6 +313,14 @@ class MessageQueue:
|
||||
|
||||
def approve_all(self) -> int:
|
||||
"""Approve all pending messages."""
|
||||
if _go_engine_enabled():
|
||||
got = _go_eval([{"op": "approve_all", "queue_dir": QUEUE_DIR}])[0]
|
||||
self._sync_from_engine()
|
||||
return int(got)
|
||||
return self._approve_all_python()
|
||||
|
||||
def _approve_all_python(self) -> int:
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
count = 0
|
||||
for msg in self.queue:
|
||||
if msg.status == "pending":
|
||||
@@ -212,6 +331,14 @@ class MessageQueue:
|
||||
|
||||
def clear(self):
|
||||
"""Clear all messages."""
|
||||
if _go_engine_enabled():
|
||||
_go_eval([{"op": "clear", "queue_dir": QUEUE_DIR}])
|
||||
self._sync_from_engine()
|
||||
return
|
||||
return self._clear_python()
|
||||
|
||||
def _clear_python(self):
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
self.queue = []
|
||||
self._save_queue()
|
||||
|
||||
@@ -221,6 +348,14 @@ class MessageQueue:
|
||||
|
||||
def mark_executed(self, msg_id: str) -> bool:
|
||||
"""Mark a message as executed."""
|
||||
if _go_engine_enabled():
|
||||
got = _go_eval([{"op": "mark_executed", "queue_dir": QUEUE_DIR, "msg_id": msg_id}])[0]
|
||||
self._sync_from_engine()
|
||||
return bool(got)
|
||||
return self._mark_executed_python(msg_id)
|
||||
|
||||
def _mark_executed_python(self, msg_id: str) -> bool:
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
for msg in self.queue:
|
||||
if msg.id == msg_id:
|
||||
msg.status = "executed"
|
||||
@@ -230,6 +365,12 @@ class MessageQueue:
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Get queue status."""
|
||||
if _go_engine_enabled():
|
||||
return _go_eval([{"op": "get_status", "queue_dir": QUEUE_DIR}])[0]
|
||||
return self._get_status_python()
|
||||
|
||||
def _get_status_python(self) -> Dict[str, Any]:
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
pending = len([m for m in self.queue if m.status == "pending"])
|
||||
approved = len([m for m in self.queue if m.status == "approved"])
|
||||
executed = len([m for m in self.queue if m.status == "executed"])
|
||||
|
||||
+96
-39
@@ -38,6 +38,53 @@ import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fail-loud Go delegation (REBASE-COMPLETION, #2587 wave): the pure seams
|
||||
# resolve through the Go engine (src/go/microkernel, `parity` stdin-op
|
||||
# surface). Python bodies are kept as oracle / escape hatch
|
||||
# (RUNE_MICROKERNEL_ENGINE=python); RUNE_MICROKERNEL_BIN overrides the
|
||||
# binary. Missing binary or engine error raises RuntimeError — no silent
|
||||
# fallback (house shape: model_routing.reconcile_tools_support).
|
||||
# fire/discover stay Python this wave: the Go RunHook/Fire lack the
|
||||
# sandbox-grant and process-group machinery the oracle carries (recorded
|
||||
# as blocks_full_switchover in the integrated receipt).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _go_engine_enabled() -> bool:
|
||||
return os.environ.get("RUNE_MICROKERNEL_ENGINE", "go") != "python"
|
||||
|
||||
|
||||
def _go_engine_bin() -> str:
|
||||
override = os.environ.get("RUNE_MICROKERNEL_BIN")
|
||||
if override:
|
||||
return override
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
return os.path.join(root, "src", "go", "microkernel", "microkernel")
|
||||
|
||||
|
||||
def _go_parity_op(req: dict) -> dict:
|
||||
bin_path = _go_engine_bin()
|
||||
if not os.path.exists(bin_path):
|
||||
raise RuntimeError(
|
||||
f"microkernel go engine binary missing: {bin_path} (set RUNE_MICROKERNEL_ENGINE=python for the legacy path)"
|
||||
)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[bin_path, "parity"],
|
||||
input=json.dumps(req).encode("utf-8"),
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise RuntimeError(f"microkernel go engine failed: {exc}") from exc
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"microkernel go engine exit {proc.returncode}: {proc.stderr.decode(errors='replace').strip()}"
|
||||
)
|
||||
return json.loads(proc.stdout.decode("utf-8"))
|
||||
|
||||
|
||||
# DEP06: plugin hook launches mediate through the ONE effect registry.
|
||||
try:
|
||||
from .effect_mediation import (
|
||||
@@ -101,13 +148,18 @@ _ALLOWED_MANIFEST_FIELDS = {
|
||||
# unblocks it (no-dead-end law: never silently enforced).
|
||||
SANDBOX_BOOT_DIR = os.path.join(SCRIPTS_DIR, "plugin_sandbox")
|
||||
_SANDBOX_ENV_PASSTHROUGH = ("PATH", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR")
|
||||
_SANDBOX_DENIAL_RE = re.compile(
|
||||
r"rune-sandbox: denied kind=(\S+) target=(.*?); unblock: (.+)"
|
||||
)
|
||||
_SANDBOX_DENIAL_RE = re.compile(r"rune-sandbox: denied kind=(\S+) target=(.*?); unblock: (.+)")
|
||||
_GRANT_KEYS = ("env", "paths", "network")
|
||||
|
||||
|
||||
def now_iso():
|
||||
if _go_engine_enabled():
|
||||
return str(_go_parity_op({"op": "now_iso"})["result"])
|
||||
return _now_iso_python()
|
||||
|
||||
|
||||
def _now_iso_python():
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
@@ -128,9 +180,7 @@ def _validate_grants(grants):
|
||||
return f"'grants' has unknown key(s): {', '.join(sorted(unknown))}"
|
||||
for key in ("env", "paths"):
|
||||
value = grants.get(key, [])
|
||||
if not isinstance(value, list) or not all(
|
||||
isinstance(x, str) and x.strip() for x in value
|
||||
):
|
||||
if not isinstance(value, list) or not all(isinstance(x, str) and x.strip() for x in value):
|
||||
return f"'grants.{key}' must be a list of non-empty strings"
|
||||
if "network" in grants and not isinstance(grants["network"], bool):
|
||||
return "'grants.network' must be a boolean"
|
||||
@@ -157,13 +207,7 @@ def _sandbox_env(fpath, grants):
|
||||
RUNE_SANDBOX_*) can be widened by a grant but never replaced — a
|
||||
grant must not be able to switch supervision off."""
|
||||
plugin_dir = os.path.dirname(os.path.abspath(fpath))
|
||||
roots = sorted(
|
||||
{
|
||||
os.path.realpath(p)
|
||||
for p in [plugin_dir, *(grants.get("paths", []) or [])]
|
||||
if p
|
||||
}
|
||||
)
|
||||
roots = sorted({os.path.realpath(p) for p in [plugin_dir, *(grants.get("paths", []) or [])] if p})
|
||||
env = {
|
||||
"PYTHONPATH": SANDBOX_BOOT_DIR,
|
||||
"PYTHONIOENCODING": "utf-8",
|
||||
@@ -216,18 +260,14 @@ def _validate_manifest(manifest):
|
||||
if version is not None and (not isinstance(version, str) or not version.strip()):
|
||||
return "'version' must be a non-empty string when present"
|
||||
capabilities = manifest.get("capabilities", [])
|
||||
if not isinstance(capabilities, list) or not all(
|
||||
isinstance(c, str) and c.strip() for c in capabilities
|
||||
):
|
||||
if not isinstance(capabilities, list) or not all(isinstance(c, str) and c.strip() for c in capabilities):
|
||||
return "'capabilities' must be a list of non-empty strings"
|
||||
hooks = manifest.get("hooks", {})
|
||||
if isinstance(hooks, list):
|
||||
if hooks:
|
||||
return "'hooks' must map event names to booleans or handler names"
|
||||
elif not isinstance(hooks, dict) or not all(
|
||||
isinstance(k, str)
|
||||
and k.strip()
|
||||
and (isinstance(v, bool) or (isinstance(v, str) and v.strip()))
|
||||
isinstance(k, str) and k.strip() and (isinstance(v, bool) or (isinstance(v, str) and v.strip()))
|
||||
for k, v in hooks.items()
|
||||
):
|
||||
return "'hooks' must map event names to booleans or handler names"
|
||||
@@ -246,9 +286,7 @@ def _validate_manifest(manifest):
|
||||
if triggers and (
|
||||
not isinstance(triggers, dict)
|
||||
or not all(
|
||||
isinstance(k, str)
|
||||
and k.strip()
|
||||
and (isinstance(v, bool) or (isinstance(v, str) and v.strip()))
|
||||
isinstance(k, str) and k.strip() and (isinstance(v, bool) or (isinstance(v, str) and v.strip()))
|
||||
for k, v in triggers.items()
|
||||
)
|
||||
):
|
||||
@@ -349,9 +387,7 @@ def register(manifest):
|
||||
old_grants = previous.get("manifest", {}).get("grants", {}) or {}
|
||||
new_grants = manifest.get("grants", {}) or {}
|
||||
for key in ("env", "paths"):
|
||||
delta = sorted(
|
||||
set(new_grants.get(key, []) or []) - set(old_grants.get(key, []) or [])
|
||||
)
|
||||
delta = sorted(set(new_grants.get(key, []) or []) - set(old_grants.get(key, []) or []))
|
||||
if delta:
|
||||
added_grants[key] = delta
|
||||
if new_grants.get("network") and not old_grants.get("network"):
|
||||
@@ -382,7 +418,7 @@ def register(manifest):
|
||||
|
||||
# Index hooks.
|
||||
hooks = manifest.get("hooks", {})
|
||||
for event, enabled in (hooks.items() if isinstance(hooks, dict) else []):
|
||||
for event, enabled in hooks.items() if isinstance(hooks, dict) else []:
|
||||
if enabled:
|
||||
if event not in registry["hooks"]:
|
||||
registry["hooks"][event] = []
|
||||
@@ -461,6 +497,21 @@ def resolve(capability):
|
||||
"""
|
||||
registry = _load_registry()
|
||||
|
||||
if _go_engine_enabled():
|
||||
out = _go_parity_op(
|
||||
{
|
||||
"op": "resolve",
|
||||
"capability": capability,
|
||||
"registry": registry,
|
||||
}
|
||||
)
|
||||
resolved = out.get("resolved")
|
||||
return resolved or None
|
||||
return _resolve_python(capability, registry)
|
||||
|
||||
|
||||
def _resolve_python(capability, registry):
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
candidates = registry["capabilities"].get(capability, [])
|
||||
if not candidates:
|
||||
return None
|
||||
@@ -622,12 +673,9 @@ def fire(event, data=None, timeout=30, grants=None):
|
||||
status = result.get("status") if isinstance(result, dict) else None
|
||||
if status == "skipped":
|
||||
continue
|
||||
outcome = (_EM_SUCCESS if status == "ok"
|
||||
else _EM_TIMEOUT if status == "timeout" else _EM_ERROR)
|
||||
_mref = _em_begin(_EM_BOUNDARY, "hook_fire",
|
||||
str(result.get("name", "")))
|
||||
_em_finish(_mref, outcome,
|
||||
detail=str(result.get("error", ""))[:200])
|
||||
outcome = _EM_SUCCESS if status == "ok" else _EM_TIMEOUT if status == "timeout" else _EM_ERROR
|
||||
_mref = _em_begin(_EM_BOUNDARY, "hook_fire", str(result.get("name", "")))
|
||||
_em_finish(_mref, outcome, detail=str(result.get("error", ""))[:200])
|
||||
|
||||
# aware-on-aware: total_start is datetime.now(timezone.utc); mixing in a
|
||||
# naive datetime.now() here crashed every dispatch with a listener (0.9(e))
|
||||
@@ -637,9 +685,7 @@ def fire(event, data=None, timeout=30, grants=None):
|
||||
with _registry_lock:
|
||||
registry = _load_registry()
|
||||
for pname, record in registry["plugins"].items():
|
||||
candidates = set(record.get("live_pgids", [])) | set(
|
||||
_live_processes.get(pname, {})
|
||||
)
|
||||
candidates = set(record.get("live_pgids", [])) | set(_live_processes.get(pname, {}))
|
||||
record["live_pgids"] = sorted(p for p in candidates if _group_alive(int(p)))
|
||||
registry["log"].append(
|
||||
{
|
||||
@@ -698,7 +744,20 @@ def discover():
|
||||
|
||||
|
||||
def _extract_manifest(fpath):
|
||||
"""Extract PLUGIN dict from a .py file without executing it.
|
||||
"""Fail-loud Go delegation for the pure extraction seam (see module
|
||||
header for the engine/escape contract)."""
|
||||
if _go_engine_enabled():
|
||||
with open(fpath, "r") as f:
|
||||
content = f.read()
|
||||
out = _go_parity_op({"op": "extract_manifest", "content": content})
|
||||
return out.get("result")
|
||||
return _extract_manifest_python(fpath)
|
||||
|
||||
|
||||
def _extract_manifest_python(fpath):
|
||||
"""Oracle body (kept as escape/oracle; see module header).
|
||||
|
||||
Extract PLUGIN dict from a .py file without executing it.
|
||||
|
||||
Uses ast.literal_eval to parse Python dict syntax (True/False/None).
|
||||
"""
|
||||
@@ -790,9 +849,7 @@ def _atomic_write_json(path, payload):
|
||||
|
||||
directory = os.path.dirname(path) or "."
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
dir=directory, prefix=os.path.basename(path) + ".", suffix=".tmp"
|
||||
)
|
||||
fd, tmp = tempfile.mkstemp(dir=directory, prefix=os.path.basename(path) + ".", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
|
||||
@@ -11,12 +11,68 @@ Ollama-SHAPED responses, so LocalToolRuntime's loop is unchanged. Selection
|
||||
is deterministic (routing_mode == 'cloud') — no router, ladder, or escalation
|
||||
logic is touched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fail-loud Go delegation (REBASE-COMPLETION, #2587 wave): the module's
|
||||
# seams resolve through the Go engine (src/go/openai_compat_transport —
|
||||
# 74 funcs, `eval <ops.json>` surface). The Python bodies below are kept
|
||||
# as oracle / escape hatch (RUNE_OPENAI_COMPAT_TRANSPORT_ENGINE=python);
|
||||
# RUNE_OPENAI_COMPAT_TRANSPORT_BIN overrides the binary. Missing binary
|
||||
# or engine error raises RuntimeError — no silent Python fallback (the
|
||||
# house shape: model_routing.reconcile_tools_support).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _go_engine_enabled() -> bool:
|
||||
return os.environ.get("RUNE_OPENAI_COMPAT_TRANSPORT_ENGINE", "go") != "python"
|
||||
|
||||
|
||||
def _go_engine_bin() -> str:
|
||||
override = os.environ.get("RUNE_OPENAI_COMPAT_TRANSPORT_BIN")
|
||||
if override:
|
||||
return override
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
return os.path.join(root, "src", "go", "openai_compat_transport", "openai-compat-transport")
|
||||
|
||||
|
||||
def _go_eval(ops: list) -> list:
|
||||
bin_path = _go_engine_bin()
|
||||
if not os.path.exists(bin_path):
|
||||
raise RuntimeError(
|
||||
f"openai-compat-transport go engine binary missing: {bin_path} "
|
||||
"(set RUNE_OPENAI_COMPAT_TRANSPORT_ENGINE=python for the legacy path)"
|
||||
)
|
||||
fd, ops_path = tempfile.mkstemp(suffix=".json", prefix="oct-ops-")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(ops, fh)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[bin_path, "eval", ops_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise RuntimeError(f"openai-compat-transport go engine failed: {exc}") from exc
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"openai-compat-transport go engine exit {proc.returncode}: {proc.stderr.strip()}")
|
||||
return json.loads(proc.stdout)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(ops_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class OpenAICompatTransport:
|
||||
"""ChatTransport for OpenAI-compatible providers (Verboo Code, Xioami...)."""
|
||||
@@ -73,6 +129,14 @@ class OpenAICompatTransport:
|
||||
the provider treat the tool result as orphaned and the model re-issues
|
||||
the identical call forever (observed live: glm-5.3-flash looped the
|
||||
same edit_file twice, empty terminal text both turns)."""
|
||||
if _go_engine_enabled():
|
||||
out = _go_eval([{"op": "to_openai_messages", "messages": messages}])
|
||||
return out[0]
|
||||
return OpenAICompatTransport._to_openai_messages_python(messages)
|
||||
|
||||
@staticmethod
|
||||
def _to_openai_messages_python(messages: list[dict]) -> list[dict]:
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
out: list[dict] = []
|
||||
i = 0
|
||||
while i < len(messages):
|
||||
@@ -82,24 +146,35 @@ class OpenAICompatTransport:
|
||||
if role == "assistant" and tool_calls:
|
||||
# Look ahead: pair each tool_call with the tool messages that
|
||||
# immediately follow, reusing their tool_call_ids.
|
||||
paired_ids = [messages[j].get("tool_call_id")
|
||||
for j in range(i + 1, len(messages))
|
||||
if messages[j].get("role") == "tool"][:len(tool_calls)]
|
||||
paired_ids = [
|
||||
messages[j].get("tool_call_id")
|
||||
for j in range(i + 1, len(messages))
|
||||
if messages[j].get("role") == "tool"
|
||||
][: len(tool_calls)]
|
||||
tc = []
|
||||
for k, c in enumerate(tool_calls):
|
||||
fn = c.get("function", {})
|
||||
args = fn.get("arguments", {})
|
||||
args_str = json.dumps(args) if isinstance(args, dict) else str(args)
|
||||
call_id = paired_ids[k] if k < len(paired_ids) else f"call_{i}_{k}"
|
||||
tc.append({"id": call_id or f"call_{i}_{k}", "type": "function",
|
||||
"function": {"name": fn.get("name"), "arguments": args_str}})
|
||||
tc.append(
|
||||
{
|
||||
"id": call_id or f"call_{i}_{k}",
|
||||
"type": "function",
|
||||
"function": {"name": fn.get("name"), "arguments": args_str},
|
||||
}
|
||||
)
|
||||
out.append({"role": "assistant", "content": m.get("content") or None, "tool_calls": tc})
|
||||
i += 1
|
||||
continue
|
||||
if role == "tool":
|
||||
out.append({"role": "tool",
|
||||
"tool_call_id": m.get("tool_call_id") or f"call_{i}",
|
||||
"content": m.get("content", "")})
|
||||
out.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": m.get("tool_call_id") or f"call_{i}",
|
||||
"content": m.get("content", ""),
|
||||
}
|
||||
)
|
||||
i += 1
|
||||
continue
|
||||
out.append({"role": role, "content": m.get("content", "")})
|
||||
@@ -115,6 +190,14 @@ class OpenAICompatTransport:
|
||||
(PL1 Task A, 2026-09-12): the envelope shape {'message': {...}} made
|
||||
the runtime see 'empty terminal visible text' while the model had
|
||||
returned a valid read_file tool call."""
|
||||
if _go_engine_enabled():
|
||||
out = _go_eval([{"op": "from_openai_response", "resp": resp}])
|
||||
return out[0]
|
||||
return OpenAICompatTransport._from_openai_response_python(resp)
|
||||
|
||||
@staticmethod
|
||||
def _from_openai_response_python(resp: dict) -> dict:
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
choices = resp.get("choices") or [{}]
|
||||
message = choices[0].get("message", {}) or {}
|
||||
tool_calls = []
|
||||
@@ -142,8 +225,56 @@ class OpenAICompatTransport:
|
||||
|
||||
# -- ChatTransport protocol -------------------------------------------
|
||||
|
||||
def chat(self, *, model: str, messages: list[dict], tools: list[dict] | None,
|
||||
options: dict | None = None, think: bool | str | None = None) -> dict:
|
||||
def chat(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
tools: list[dict] | None,
|
||||
options: dict | None = None,
|
||||
think: bool | str | None = None,
|
||||
) -> dict:
|
||||
"""Fail-loud Go delegation: the whole chat turn (payload build, wire
|
||||
post, response translation, usage accumulation) resolves through the
|
||||
Go engine's chat op; the oracle body stays as escape/oracle."""
|
||||
if _go_engine_enabled():
|
||||
op = {
|
||||
"op": "chat",
|
||||
"base_url": self.base_url,
|
||||
"api_key": self.api_key,
|
||||
"provider": self.provider,
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"timeout": self.timeout,
|
||||
"context_window": self._context_window,
|
||||
}
|
||||
if tools:
|
||||
op["tools"] = tools
|
||||
if options:
|
||||
op["options"] = options
|
||||
if think is not None:
|
||||
op["think"] = think
|
||||
out = _go_eval([op])
|
||||
row = out[0]
|
||||
if isinstance(row, dict) and row.get("error"):
|
||||
raise RuntimeError(f"openai-compat-transport go engine chat: {row['error']}")
|
||||
usage = row.get("usage") or {}
|
||||
self.usage_calls += int(usage.get("calls") or 0)
|
||||
self.usage_prompt_tokens += int(usage.get("prompt_tokens") or 0)
|
||||
self.usage_output_tokens += int(usage.get("output_tokens") or 0)
|
||||
self.usage_total_duration_ns += int(usage.get("total_duration_ns") or 0)
|
||||
return row["result"]
|
||||
return self._chat_python(model=model, messages=messages, tools=tools, options=options, think=think)
|
||||
|
||||
def _chat_python(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
tools: list[dict] | None,
|
||||
options: dict | None = None,
|
||||
think: bool | str | None = None,
|
||||
) -> dict:
|
||||
# The runtime passes the qualified name ('<Provider>/<model>'); the
|
||||
# provider API wants the bare model id ('glm-5.3-flash'). Observed
|
||||
# live: qualified name → 400 on every call (PL1 held-out attempt).
|
||||
@@ -191,6 +322,14 @@ def is_cloud_pinned(model: str) -> bool:
|
||||
outright ('no baseURL configured for provider hf.co'). Transitional
|
||||
D39 note: the durable rule resolves the prefix against CONFIGURED
|
||||
providers; the allowlist is the small port-ready stopgap."""
|
||||
if _go_engine_enabled():
|
||||
out = _go_eval([{"op": "is_cloud_pinned", "model": model}])
|
||||
return bool(out[0])
|
||||
return _is_cloud_pinned_python(model)
|
||||
|
||||
|
||||
def _is_cloud_pinned_python(model: str) -> bool:
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
prefix = model.split("/", 1)[0].strip().lower() if "/" in model else ""
|
||||
return bool(prefix) and prefix not in ("ollama", "local", "hf.co", "huggingface.co")
|
||||
|
||||
@@ -198,6 +337,23 @@ def is_cloud_pinned(model: str) -> bool:
|
||||
def cloud_transport_for(model: str, timeout: float = 120) -> OpenAICompatTransport:
|
||||
"""Resolve provider config for a '<Provider>/<model>' name from the same
|
||||
source of truth the mimo CLI uses (mimocode.jsonc + auth.json)."""
|
||||
if _go_engine_enabled():
|
||||
out = _go_eval([{"op": "cloud_transport_for", "model": model, "timeout": timeout}])
|
||||
row = out[0]
|
||||
if isinstance(row, dict) and row.get("error"):
|
||||
raise ValueError(row["error"])
|
||||
return OpenAICompatTransport(
|
||||
row["base_url"],
|
||||
row["api_key"],
|
||||
row["provider"],
|
||||
timeout=float(row["timeout"]),
|
||||
context_window=int(row["context_window"]),
|
||||
)
|
||||
return _cloud_transport_for_python(model, timeout)
|
||||
|
||||
|
||||
def _cloud_transport_for_python(model: str, timeout: float = 120) -> OpenAICompatTransport:
|
||||
"""Oracle body (kept as escape/oracle; see module header)."""
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
@@ -212,7 +368,7 @@ def cloud_transport_for(model: str, timeout: float = 120) -> OpenAICompatTranspo
|
||||
text = cfg_path.read_text()
|
||||
m = re.search(rf'"{re.escape(provider_name)}"\s*:\s*\{{', text)
|
||||
if m:
|
||||
block = text[m.start():m.start() + 2000]
|
||||
block = text[m.start() : m.start() + 2000]
|
||||
bm = re.search(r'"baseURL":\s*"([^"]+)"', block)
|
||||
if bm:
|
||||
base_url = bm.group(1)
|
||||
@@ -233,6 +389,9 @@ def cloud_transport_for(model: str, timeout: float = 120) -> OpenAICompatTranspo
|
||||
# the local-model timeout (observed live: glm-5.3-flash read-timeout at
|
||||
# 120s twice). Floor cloud transports at 300s; local keeps its own.
|
||||
return OpenAICompatTransport(
|
||||
base_url, key, provider_name, timeout=max(timeout, 300),
|
||||
base_url,
|
||||
key,
|
||||
provider_name,
|
||||
timeout=max(timeout, 300),
|
||||
context_window=context_window,
|
||||
)
|
||||
@@ -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.345.0
|
||||
Version: v0.355.0
|
||||
Features: 41 (24 original + 17 new native commands)
|
||||
|
||||
Usage:
|
||||
@@ -2047,7 +2047,7 @@ class RuneApp(App):
|
||||
# Show version info
|
||||
self.chat_channel.add_message(
|
||||
"system",
|
||||
f"[dim]Rune v0.345.0 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
f"[dim]Rune v0.355.0 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
)
|
||||
|
||||
# Auto-show night mode suggestion if evening/night hours
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-10-24T11:43:00Z",
|
||||
"evidence": "predicted-frontier",
|
||||
"note": "{\"level0_direct\": [], \"level1_obligations\": [], \"contracts\": [], \"level2_verification\": []}",
|
||||
"phase": "draft"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-24T11:43:26Z",
|
||||
"evidence": "user: 'can you fix my gitea ... explains what was improved. and what could be tested' (session 2026-10-23)",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-24T11:43:52Z",
|
||||
"evidence": "builder:release-reporting",
|
||||
"note": "",
|
||||
"phase": "building"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:ADMIT-release-reporting"
|
||||
],
|
||||
"ext": {
|
||||
"activation": {
|
||||
"activated": true,
|
||||
"builder": "release-reporting",
|
||||
"evidence": [
|
||||
"affected surface known: ['scripts/tag_repair.py', 'tests/test_tag_repair.py']",
|
||||
"change scoped to an existing project/target",
|
||||
"target project: rune"
|
||||
],
|
||||
"lifecycle": "proportional",
|
||||
"mode": "EXISTING_PROJECT_CHANGE",
|
||||
"note": "attached to existing project authority; Need/completeness/patterns/contract flow governs proportionally",
|
||||
"project_authority": "rune_internal",
|
||||
"target_project": "rune"
|
||||
},
|
||||
"affected_paths": [
|
||||
"CHANGELOG.md",
|
||||
"scripts/admit_release_reporting.py",
|
||||
"scripts/tag_repair.py",
|
||||
"src/go/release-notes/cli_changelog.go",
|
||||
"src/go/release-notes/cli_narrative.go",
|
||||
"src/go/release-notes/main.go",
|
||||
"src/go/release-notes/narrative.go",
|
||||
"src/go/release-notes/narrative_test.go",
|
||||
"tests/test_tag_repair.py"
|
||||
],
|
||||
"affected_regions": [],
|
||||
"affected_ui_surfaces": [],
|
||||
"expected_runtime_effects": [
|
||||
"every pushed version's release body opens with plain English what-changed + what-to-try + how-verified; ranges start at the main v* tag, never the tui build tag"
|
||||
],
|
||||
"predicted_frontier": {
|
||||
"change_kind": "implementation_change",
|
||||
"contextual_not_obligations": [],
|
||||
"contracts": [],
|
||||
"level0_direct": [],
|
||||
"level1_obligations": [],
|
||||
"level2_verification": [],
|
||||
"max_level": 2,
|
||||
"traversal_scope": "spine-structural-only"
|
||||
},
|
||||
"scope_provenance": {
|
||||
"caller_added": [],
|
||||
"generated_from_affected_surface": [
|
||||
".gitea/workflows/auto-version.yml",
|
||||
".gitea/workflows/maintenance.yml",
|
||||
"CHANGELOG.md",
|
||||
"scripts/admit_release_reporting.py",
|
||||
"scripts/tag_repair.py",
|
||||
"src/go/release-notes/cli_changelog.go",
|
||||
"src/go/release-notes/cli_narrative.go",
|
||||
"src/go/release-notes/main.go",
|
||||
"src/go/release-notes/narrative.go",
|
||||
"src/go/release-notes/narrative_test.go",
|
||||
"tests/test_tag_repair.py"
|
||||
]
|
||||
},
|
||||
"task_ref": "release-reporting",
|
||||
"unresolved_surfaces": [
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "CHANGELOG.md"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "scripts/admit_release_reporting.py"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "scripts/tag_repair.py"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "src/go/release-notes/cli_changelog.go"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "src/go/release-notes/cli_narrative.go"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "src/go/release-notes/main.go"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "src/go/release-notes/narrative.go"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "src/go/release-notes/narrative_test.go"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tests/test_tag_repair.py"
|
||||
},
|
||||
{
|
||||
"kind": "workflow_config",
|
||||
"reason": "kind workflow_config has no deterministic resolver",
|
||||
"surface": ".gitea/workflows/auto-version.yml"
|
||||
},
|
||||
{
|
||||
"kind": "workflow_config",
|
||||
"reason": "kind workflow_config has no deterministic resolver",
|
||||
"surface": ".gitea/workflows/maintenance.yml"
|
||||
}
|
||||
],
|
||||
"workflow_config_surfaces": [
|
||||
".gitea/workflows/auto-version.yml",
|
||||
".gitea/workflows/maintenance.yml"
|
||||
]
|
||||
},
|
||||
"id": "contract:ADMIT-release-reporting",
|
||||
"identities": {
|
||||
"acceptance": [
|
||||
"bounded repair verified: #2582 release reporting: make a pushed version read like a changelog for a non-technical owner \u2014 plain English what-chan"
|
||||
],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [
|
||||
"CHANGELOG.md",
|
||||
"scripts/admit_release_reporting.py",
|
||||
"src/go/release-notes/cli_changelog.go",
|
||||
"src/go/release-notes/main.go",
|
||||
"test:tag_repair",
|
||||
"tests/test_tag_repair.py"
|
||||
],
|
||||
"gap_evidence": "change scoped to an existing project/target; target project: rune; affected surface known: ['scripts/tag_repair.py', 'tests/test_tag_repair.py']",
|
||||
"migration_obligations": [],
|
||||
"need": "#2582 release reporting: make a pushed version read like a changelog for a non-technical owner \u2014 plain English what-changed, what-to-try derived from touched surfaces, how-it-was-verified. Namespace-aware tag resolution (main vs tui-v), the try-action narrative section, workflow wiring, retroactive v0.346.0 body rebuild.",
|
||||
"next_action": "complete implementation, then verify",
|
||||
"non_goals": [],
|
||||
"origin": "worker",
|
||||
"regression_obligations": [
|
||||
"no runtime behavior change: release reporting content only",
|
||||
"the evidence store (release_history init) still receives its exit-note"
|
||||
],
|
||||
"requirement": "headless admission (classified: EXISTING_PROJECT_CHANGE)",
|
||||
"research_refs": [
|
||||
"src/go/release-notes/narrative_test.go",
|
||||
"tests/test_tag_repair.py"
|
||||
],
|
||||
"resolution": "",
|
||||
"rollback_ref": "git revert",
|
||||
"scope": [
|
||||
".gitea/workflows/auto-version.yml",
|
||||
".gitea/workflows/maintenance.yml",
|
||||
"CHANGELOG.md",
|
||||
"scripts/admit_release_reporting.py",
|
||||
"scripts/tag_repair.py",
|
||||
"src/go/release-notes/cli_changelog.go",
|
||||
"src/go/release-notes/cli_narrative.go",
|
||||
"src/go/release-notes/main.go",
|
||||
"src/go/release-notes/narrative.go",
|
||||
"src/go/release-notes/narrative_test.go",
|
||||
"tests/test_tag_repair.py"
|
||||
],
|
||||
"toolchain": {},
|
||||
"verification_obligations": [
|
||||
"go test ./... in src/go/release-notes green (try-section law)",
|
||||
"pytest tests/test_tag_repair.py green (namespace law)",
|
||||
"pytest tests/test_tag_repair.py",
|
||||
"v0.346.0 body visibly rebuilt with the template"
|
||||
]
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-10-24T11:43:52Z",
|
||||
"phase": "building",
|
||||
"status": "open",
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-10-25T00:44:54Z",
|
||||
"evidence": "predicted-frontier",
|
||||
"note": "{\"level0_direct\": [], \"level1_obligations\": [], \"contracts\": [], \"level2_verification\": []}",
|
||||
"phase": "draft"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-25T00:45:17Z",
|
||||
"evidence": "user:D56 directive 2026-10-24 (Gitea #2580 + #2581)",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-25T00:45:39Z",
|
||||
"evidence": "builder:wave-d56-governor",
|
||||
"note": "",
|
||||
"phase": "building"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:ADMIT-wave-d56-governor"
|
||||
],
|
||||
"ext": {
|
||||
"activation": {
|
||||
"activated": true,
|
||||
"builder": "wave-d56-governor",
|
||||
"evidence": [
|
||||
"affected surface known: ['src/go/rune-core/', 'src/go/dispatch-classifier/']",
|
||||
"file mention binds scoped target",
|
||||
"scoping words bind to known target: ['current']",
|
||||
"smallness markers: ['patch']"
|
||||
],
|
||||
"lifecycle": "bounded",
|
||||
"mode": "LOCAL_CHANGE",
|
||||
"note": "bounded governance via existing authority \u2014 no Project Scaffold bootstrap",
|
||||
"project_authority": "rune_internal",
|
||||
"target_project": "rune"
|
||||
},
|
||||
"affected_paths": [
|
||||
"scripts/admit_wave_d56_governor.py",
|
||||
"src/go/dispatch-classifier/",
|
||||
"src/go/rune-core/",
|
||||
"tui/bridge/",
|
||||
"tui/cmd/rune-tui/",
|
||||
"tui/lanes/",
|
||||
"tui/runtime/lib/"
|
||||
],
|
||||
"affected_regions": [],
|
||||
"affected_ui_surfaces": [],
|
||||
"expected_runtime_effects": [
|
||||
"the governor samples live tok/s per provider from real turns and persists the rolling model to the hot state, routes cloud-first through healthy providers only (429-throttled refused), the dispatch classifier's PARALLEL verdicts carry the measured lane capacity with the thin pipe stated honestly, and the info bar shows the measured tok/s after the context readout with the sub-agents section showing the governor's state"
|
||||
],
|
||||
"predicted_frontier": {
|
||||
"change_kind": "implementation_change",
|
||||
"contextual_not_obligations": [],
|
||||
"contracts": [],
|
||||
"level0_direct": [],
|
||||
"level1_obligations": [],
|
||||
"level2_verification": [],
|
||||
"max_level": 2,
|
||||
"traversal_scope": "spine-structural-only"
|
||||
},
|
||||
"scope_provenance": {
|
||||
"caller_added": [],
|
||||
"generated_from_affected_surface": [
|
||||
"scripts/admit_wave_d56_governor.py",
|
||||
"src/go/dispatch-classifier/",
|
||||
"src/go/rune-core/",
|
||||
"tui/bridge/",
|
||||
"tui/cmd/rune-tui/",
|
||||
"tui/lanes/",
|
||||
"tui/runtime/lib/"
|
||||
]
|
||||
},
|
||||
"task_ref": "wave-d56-governor",
|
||||
"unresolved_surfaces": [
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "scripts/admit_wave_d56_governor.py"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "src/go/dispatch-classifier/"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "src/go/rune-core/"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tui/bridge/"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tui/cmd/rune-tui/"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tui/lanes/"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tui/runtime/lib/"
|
||||
}
|
||||
],
|
||||
"workflow_config_surfaces": []
|
||||
},
|
||||
"id": "contract:ADMIT-wave-d56-governor",
|
||||
"identities": {
|
||||
"acceptance": [
|
||||
"bounded repair verified: land the D56 wave (Gitea #2580 + #2581, owner doctrine rulings 2026-10-24: CLOUD-FIRST routing \u2014 cloud lanes fill FIRST,"
|
||||
],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [
|
||||
"the D56 wave report (reports/)",
|
||||
"the governor hot-state shape (~/.rune/hot/provider_throughput.json)",
|
||||
"the red-first governor/capacity/readout tests"
|
||||
],
|
||||
"gap_evidence": "smallness markers: ['patch']; scoping words bind to known target: ['current']; file mention binds scoped target; affected surface known: ['src/go/rune-core/', 'src/go/dispatch-classifier/']",
|
||||
"migration_obligations": [],
|
||||
"need": "land the D56 wave (Gitea #2580 + #2581, owner doctrine rulings 2026-10-24: CLOUD-FIRST routing \u2014 cloud lanes fill FIRST, local absorbs only overflow; NO CONSENT on cloud models \u2014 configured provider = spend freely, zero prompts; routing scope = factory lane assignment, interactive chat routing stays the owner's): (1) TELEMETRY \u2014 sample live tok/s per provider from production data (the engine's real turn accounting; output-token counts from the provider wires where published, honest token-estimate fallback otherwise); rolling per-provider samples persisted via the hot-state pattern (~/.rune/hot/provider_throughput.json, atomic temp+rename); (2) SATURATION MODEL \u2014 a small rolling (provider, lanes) EMA curve learned ONLY from samples, no synthetic benchmarks; the lane cap is where the marginal tok/s gain clears the floor (RUNE_GOVERNOR_CAP_FLOOR, default 5); (3) CONNECTION-HEALTH \u2014 the governor polls the registry's enabled providers live (reachable, authenticated, tok/s, error rate, 429 throttle windows) and routes only to providers actually alive; (4) LANE POOLS + CLOUD-FIRST \u2014 per-provider lane pools, cloud pools fill first, least-loaded within a tier, throttled providers refused (a 429 ceiling is worse than not spawning); (5) CLASSIFIER INTEGRATION \u2014 dispatch-classifier gains the governor's cap: PARALLEL verdicts become throughput-aware ('PARALLEL (N lanes free at current tok/s)'), the thin pipe says so, no false fan-out; registry/lock semantics unchanged; (6) INFO BAR READOUT \u2014 tok/s after the context readout ('context 4.2k/32k (13%) \u00b7 38 tok/s') fed from /v1/status's new governor section through both bridge seams; the sub-agents section shows the governor's state row (lanes in use, measured tok/s, cap, '\u2014' honest gap); (7) HONESTY \u2014 a thin pipe is stated, never dressed up",
|
||||
"next_action": "complete implementation, then verify",
|
||||
"non_goals": [],
|
||||
"origin": "worker",
|
||||
"regression_obligations": [],
|
||||
"requirement": "headless admission (classified: LOCAL_CHANGE)",
|
||||
"research_refs": [
|
||||
"src/go/dispatch-classifier/capacity.go",
|
||||
"src/go/dispatch-classifier/capacity_test.go",
|
||||
"src/go/rune-core/internal/runecore/engine.go",
|
||||
"src/go/rune-core/internal/runecore/governor.go",
|
||||
"src/go/rune-core/internal/runecore/governor_test.go",
|
||||
"src/go/rune-core/internal/runecore/server.go",
|
||||
"tui/bridge/d56_governor_test.go",
|
||||
"tui/cmd/rune-tui/d56_governor_test.go",
|
||||
"tui/cmd/rune-tui/main.go",
|
||||
"tui/runtime/lib/lib.go"
|
||||
],
|
||||
"resolution": "",
|
||||
"rollback_ref": "git revert",
|
||||
"scope": [
|
||||
"scripts/admit_wave_d56_governor.py",
|
||||
"src/go/dispatch-classifier/",
|
||||
"src/go/rune-core/",
|
||||
"tui/bridge/",
|
||||
"tui/cmd/rune-tui/",
|
||||
"tui/lanes/",
|
||||
"tui/runtime/lib/"
|
||||
],
|
||||
"toolchain": {},
|
||||
"verification_obligations": [
|
||||
"go test -race -short green on rune-core, dispatch-classifier and tui (hermetic PTY/HTTP tests)",
|
||||
"readout proof: the info-bar test asserts 'context ... (13%) \u00b7 38 tok/s' byte-shape against a fake serve",
|
||||
"vet/gofmt clean on touched modules"
|
||||
]
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-10-25T00:45:39Z",
|
||||
"phase": "building",
|
||||
"status": "open",
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-10-24T16:40:14Z",
|
||||
"evidence": "predicted-frontier",
|
||||
"note": "{\"level0_direct\": [], \"level1_obligations\": [], \"contracts\": [], \"level2_verification\": []}",
|
||||
"phase": "draft"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-24T16:40:32Z",
|
||||
"evidence": "user:Gitea #2587 rebase-completion directive",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-24T16:40:51Z",
|
||||
"evidence": "builder:wave-rebase-completion",
|
||||
"note": "",
|
||||
"phase": "building"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:ADMIT-wave-rebase-completion"
|
||||
],
|
||||
"ext": {
|
||||
"activation": {
|
||||
"activated": true,
|
||||
"builder": "wave-rebase-completion",
|
||||
"evidence": [
|
||||
"affected surface known: ['src/go/**', 'src/rune/**']",
|
||||
"change scoped to an existing project/target",
|
||||
"target project: rune"
|
||||
],
|
||||
"lifecycle": "proportional",
|
||||
"mode": "EXISTING_PROJECT_CHANGE",
|
||||
"note": "attached to existing project authority; Need/completeness/patterns/contract flow governs proportionally",
|
||||
"project_authority": "rune_internal",
|
||||
"target_project": "rune"
|
||||
},
|
||||
"affected_paths": [
|
||||
"scripts/admit_wave_rebase_completion.py",
|
||||
"src/go/**",
|
||||
"src/rune/**",
|
||||
"tests/**",
|
||||
"verification/**"
|
||||
],
|
||||
"affected_regions": [],
|
||||
"affected_ui_surfaces": [],
|
||||
"expected_runtime_effects": [
|
||||
"the serving path (openai_compat_transport, forge_client) and the ten other wired engines resolve through the Go binaries at runtime; Python oracle bodies retained as escape/oracle only"
|
||||
],
|
||||
"predicted_frontier": {
|
||||
"change_kind": "implementation_change",
|
||||
"contextual_not_obligations": [],
|
||||
"contracts": [],
|
||||
"level0_direct": [],
|
||||
"level1_obligations": [],
|
||||
"level2_verification": [],
|
||||
"max_level": 2,
|
||||
"traversal_scope": "spine-structural-only"
|
||||
},
|
||||
"scope_provenance": {
|
||||
"caller_added": [],
|
||||
"generated_from_affected_surface": [
|
||||
"scripts/admit_wave_rebase_completion.py",
|
||||
"src/go/**",
|
||||
"src/rune/**",
|
||||
"tests/**",
|
||||
"verification/**"
|
||||
]
|
||||
},
|
||||
"task_ref": "wave-rebase-completion",
|
||||
"unresolved_surfaces": [
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "scripts/admit_wave_rebase_completion.py"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "src/go/**"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "src/rune/**"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tests/**"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "verification/**"
|
||||
}
|
||||
],
|
||||
"workflow_config_surfaces": []
|
||||
},
|
||||
"id": "contract:ADMIT-wave-rebase-completion",
|
||||
"identities": {
|
||||
"acceptance": [
|
||||
"bounded repair verified: REBASE-COMPLETION (Gitea #2587): wire the eleven unwired Go engines into their Python counterparts through the fail-loud"
|
||||
],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [
|
||||
"eleven fail-loud delegation facades (one per module) with RUNE_*_ENGINE=python escapes and RUNE_*_BIN overrides",
|
||||
"integrated receipt per module in verification/integrated/",
|
||||
"pins verdict: Go port at src/go/pins OR recorded Python-boundary classification from consumer evidence",
|
||||
"red-first parity battery per module against the Python oracle body; mutations of the oracle must flip the battery"
|
||||
],
|
||||
"gap_evidence": "change scoped to an existing project/target; target project: rune; affected surface known: ['src/go/**', 'src/rune/**']",
|
||||
"migration_obligations": [],
|
||||
"need": "REBASE-COMPLETION (Gitea #2587): wire the eleven unwired Go engines into their Python counterparts through the fail-loud delegation facade pattern \u2014 openai_compat_transport and forge_client (serving path) first, then microkernel, goal, cache, message_queue, abstraction, auto_context, voicebox, paths; classify pins honestly (port to src/go/pins or record the Python-boundary verdict from its consumers). Each module: red-first parity battery against the Python oracle body (kept as oracle/escape hatch), RUNE_<NAME>_ENGINE=python escape, RUNE_<NAME>_BIN override, typed RuntimeError on missing binary, integrated receipt in verification/integrated/. Owner's bar: 'going through and rewiring the rebase into the tui, extremely thoroughly according to the code dichotomy.'",
|
||||
"next_action": "complete implementation, then verify",
|
||||
"non_goals": [],
|
||||
"origin": "worker",
|
||||
"regression_obligations": [],
|
||||
"requirement": "headless admission (classified: EXISTING_PROJECT_CHANGE)",
|
||||
"research_refs": [
|
||||
"Gitea #2587 (REBASE-COMPLETION audit: 11 dual-side modules with Go engines but ZERO delegation; pins Python-only)",
|
||||
"src/rune/model_routing.py reconcile_tools_support facade :457 and src/rune/local_tool_runtime.py _GoDelegatingExecutor (the fail-loud house shapes)",
|
||||
"verification/integrated/model-routing-integrated.json (the house PORT \u2192 INTEGRATE \u2192 REMOVE shape this wave replicates)"
|
||||
],
|
||||
"resolution": "",
|
||||
"rollback_ref": "git revert",
|
||||
"scope": [
|
||||
"scripts/admit_wave_rebase_completion.py",
|
||||
"src/go/**",
|
||||
"src/rune/**",
|
||||
"tests/**",
|
||||
"verification/**"
|
||||
],
|
||||
"toolchain": {},
|
||||
"verification_obligations": [
|
||||
"conventional commit per module, pushed to gitea master incrementally (autostash rebase \u2014 the tree is hot)",
|
||||
"per module: go test -race -short green in the engine dir; pytest touched suites green (venv, PYTHONPATH=src)"
|
||||
]
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-10-24T16:40:51Z",
|
||||
"phase": "building",
|
||||
"status": "open",
|
||||
"tags": []
|
||||
}
|
||||
@@ -7428,3 +7428,9 @@
|
||||
{"builder": "main-session-landing", "detail": "ADMIT-wave-d46-live-defects \u2192 verifying", "kind": "builder_session", "mode": "rune_internal", "observed_at": "2026-10-20T23:47:41Z", "operation": "transition"}
|
||||
{"authority": "main-session own run", "detail": "46 packages race-green; dupe tripwire; budget pins; artifact links", "entity": "tests", "kind": "observed_consequence", "observed_at": "2026-10-20T23:47:59Z", "source": "go test", "task": "ADMIT-wave-d46-live-defects"}
|
||||
{"builder": "main-session-landing", "detail": "ADMIT-wave-d46-live-defects closed", "kind": "builder_session", "mode": "rune_internal", "observed_at": "2026-10-20T23:48:02Z", "operation": "close"}
|
||||
{"builder": "release-reporting", "detail": "ADMIT-release-reporting \u2192 approved", "kind": "builder_session", "mode": "external_project", "observed_at": "2026-10-24T10:58:11Z", "operation": "transition"}
|
||||
{"builder": "release-reporting", "detail": "ADMIT-release-reporting \u2192 building", "kind": "builder_session", "mode": "external_project", "observed_at": "2026-10-24T10:58:37Z", "operation": "transition"}
|
||||
{"builder": "release-reporting", "detail": "ADMIT-release-reporting \u2192 approved", "kind": "builder_session", "mode": "external_project", "observed_at": "2026-10-24T11:26:37Z", "operation": "transition"}
|
||||
{"builder": "release-reporting", "detail": "ADMIT-release-reporting \u2192 building", "kind": "builder_session", "mode": "external_project", "observed_at": "2026-10-24T11:27:06Z", "operation": "transition"}
|
||||
{"builder": "release-reporting", "detail": "ADMIT-release-reporting \u2192 approved", "kind": "builder_session", "mode": "external_project", "observed_at": "2026-10-24T11:43:26Z", "operation": "transition"}
|
||||
{"builder": "release-reporting", "detail": "ADMIT-release-reporting \u2192 building", "kind": "builder_session", "mode": "external_project", "observed_at": "2026-10-24T11:43:52Z", "operation": "transition"}
|
||||
@@ -0,0 +1,112 @@
|
||||
"""REBASE-COMPLETION (#2587) parity battery — abstraction.
|
||||
|
||||
Red-first discipline: pins the Go engine's eval surface (list_beliefs,
|
||||
list_rules, list_skills, pipeline_stats, handle_hook) against the
|
||||
Python oracle bodies on the SAME abstraction.db. A mutation of an
|
||||
oracle body must flip the battery. Also pins the facade contract:
|
||||
fail-loud RuntimeError on a missing binary; RUNE_ABSTRACTION_ENGINE
|
||||
=python escape; cross-engine coherence.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import abstraction as abs_mod # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mem_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RUNE_MEMORY_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(abs_mod, "DB_PATH", str(tmp_path / "abstraction.db"))
|
||||
return str(tmp_path)
|
||||
|
||||
|
||||
def _seed_db(mem_dir):
|
||||
# Let the ENGINE create the full schema first (its Connect migrates);
|
||||
# a battery-local minimal schema would block the IF-NOT-EXISTS columns.
|
||||
abs_mod.pipeline_stats()
|
||||
db = sqlite3.connect(abs_mod.DB_PATH)
|
||||
db.execute("INSERT INTO beliefs (claim, confidence, formed_at) VALUES ('b1', 0.9, '2026-01-01T00:00:00+00:00')")
|
||||
db.execute("INSERT INTO rules (pattern, confidence, created_at) VALUES ('r1', 0.8, '2026-01-01T00:00:00+00:00')")
|
||||
db.execute("INSERT INTO skills (name, success_count, created_at) VALUES ('s1', 3, '2026-01-01T00:00:00+00:00')")
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
# -- reads go vs oracle --------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_beliefs_go_vs_oracle(mem_dir):
|
||||
_seed_db(mem_dir)
|
||||
got = abs_mod.list_beliefs()
|
||||
oracle = abs_mod._list_beliefs_python()
|
||||
assert got == oracle and len(got) == 1
|
||||
assert got[0]["claim"] == "b1"
|
||||
|
||||
|
||||
def test_list_rules_go_vs_oracle(mem_dir):
|
||||
_seed_db(mem_dir)
|
||||
assert abs_mod.list_rules() == abs_mod._list_rules_python()
|
||||
assert abs_mod.list_rules()[0]["pattern"] == "r1"
|
||||
|
||||
|
||||
def test_list_skills_go_vs_oracle(mem_dir):
|
||||
_seed_db(mem_dir)
|
||||
assert abs_mod.list_skills() == abs_mod._list_skills_python()
|
||||
assert abs_mod.list_skills()[0]["name"] == "s1"
|
||||
|
||||
|
||||
def test_pipeline_stats_go_vs_oracle(mem_dir):
|
||||
_seed_db(mem_dir)
|
||||
got = abs_mod.pipeline_stats()
|
||||
oracle = abs_mod._pipeline_stats_python()
|
||||
assert got == oracle
|
||||
assert got["beliefs"] == {"total": 1, "active": 1}
|
||||
assert got["facts"] == {"total": 0, "active": 0}
|
||||
|
||||
|
||||
def test_handle_hook_go_vs_oracle(mem_dir):
|
||||
got = abs_mod.handle_hook("on_session_end", {"k": 1})
|
||||
oracle = abs_mod._handle_hook_python("on_session_end", {"k": 1})
|
||||
assert got == oracle == {"status": "ok", "event": "on_session_end", "handler": "abstraction"}
|
||||
|
||||
|
||||
# -- cross-engine coherence ------------------------------------------------------
|
||||
|
||||
|
||||
def test_engine_write_read_same_db(mem_dir):
|
||||
# the engine reads exactly what the oracle wrote (same db file)
|
||||
_seed_db(mem_dir)
|
||||
os.environ["RUNE_ABSTRACTION_ENGINE"] = "python"
|
||||
try:
|
||||
oracle_rows = abs_mod.list_beliefs()
|
||||
finally:
|
||||
os.environ.pop("RUNE_ABSTRACTION_ENGINE")
|
||||
assert abs_mod.list_beliefs() == oracle_rows
|
||||
|
||||
|
||||
# -- facade contract ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_engine_disabled_uses_oracle(mem_dir):
|
||||
os.environ["RUNE_ABSTRACTION_ENGINE"] = "python"
|
||||
try:
|
||||
assert abs_mod.pipeline_stats()["facts"]["total"] == 0
|
||||
finally:
|
||||
os.environ.pop("RUNE_ABSTRACTION_ENGINE")
|
||||
|
||||
|
||||
def test_missing_binary_fails_loud(mem_dir, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RUNE_ABSTRACTION_BIN", str(tmp_path / "nope"))
|
||||
with pytest.raises(RuntimeError, match="binary missing"):
|
||||
abs_mod.list_beliefs()
|
||||
|
||||
|
||||
def test_bad_op_fails_loud():
|
||||
with pytest.raises(RuntimeError, match="go engine"):
|
||||
abs_mod._go_eval([{"op": "definitely_not_an_op", "memory_dir": "/tmp"}])
|
||||
@@ -0,0 +1,151 @@
|
||||
"""REBASE-COMPLETION (#2587) parity battery — cache.
|
||||
|
||||
Red-first discipline: pins the Go engine's eval surface (get_cache,
|
||||
put_cache, invalidate, invalidate_scope, invalidate_all, cache_stats,
|
||||
handle_hook) against the Python oracle bodies on the SAME cache.db. A
|
||||
mutation of an oracle body must flip the battery. Also pins the facade
|
||||
contract: fail-loud RuntimeError on a missing binary; RUNE_CACHE_ENGINE
|
||||
=python escape; cross-engine coherence (engine-written rows are
|
||||
oracle-readable and vice versa).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import cache as cache_mod # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mem_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RUNE_MEMORY_DIR", str(tmp_path))
|
||||
# The oracle bodies read the import-time DB_PATH constant; retarget it
|
||||
# to the test memory dir so both engines address the same cache.db.
|
||||
monkeypatch.setattr(cache_mod, "DB_PATH", str(tmp_path / "cache.db"))
|
||||
return str(tmp_path)
|
||||
|
||||
|
||||
def _oracle(mem_dir, monkeypatch):
|
||||
monkeypatch.setenv("RUNE_CACHE_ENGINE", "python")
|
||||
return cache_mod
|
||||
|
||||
|
||||
def _put(cache, key, value, **kw):
|
||||
return cache.put_cache(key, value, **kw)
|
||||
|
||||
|
||||
# -- put / get roundtrip -----------------------------------------------------
|
||||
|
||||
|
||||
def test_put_get_roundtrip_go_vs_oracle(mem_dir, monkeypatch):
|
||||
got = _put(cache_mod, "k1", "value one")
|
||||
monkeypatch.setenv("RUNE_CACHE_ENGINE", "python")
|
||||
oracle_put = cache_mod._put_cache_python("k1", "value one")
|
||||
oracle_get = cache_mod._get_cache_python("k1")
|
||||
monkeypatch.delenv("RUNE_CACHE_ENGINE")
|
||||
engine_get = cache_mod.get_cache("k1")
|
||||
assert got == oracle_put == {"stored": "k1", "content_hash": got["content_hash"], "scope": "general"}
|
||||
assert engine_get["hit"] is True and oracle_get["hit"] is True
|
||||
assert engine_get["full"] == oracle_get["full"] == "value one"
|
||||
assert engine_get["content_hash"] == oracle_get["content_hash"]
|
||||
|
||||
|
||||
def test_miss_shape_go_vs_oracle(mem_dir):
|
||||
assert cache_mod.get_cache("absent-key") == {"hit": False, "key": "absent-key"}
|
||||
assert cache_mod._get_cache_python("absent-key") == {"hit": False, "key": "absent-key"}
|
||||
|
||||
|
||||
def test_put_scope_and_source_hash_go_vs_oracle(mem_dir):
|
||||
got = _put(cache_mod, "k2", "v2", scope="research", source_hash="abc")
|
||||
oracle = cache_mod._put_cache_python("k2", "v2", scope="research", source_hash="abc")
|
||||
assert got == oracle
|
||||
row = cache_mod.get_cache("k2")
|
||||
assert row["scope"] == "research"
|
||||
|
||||
|
||||
# -- invalidation ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_invalidate_go_vs_oracle(mem_dir):
|
||||
_put(cache_mod, "k3", "v3")
|
||||
got = cache_mod.invalidate("k3", "gone")
|
||||
assert got["invalidated"] == "k3"
|
||||
assert cache_mod.get_cache("k3")["hit"] is False
|
||||
|
||||
|
||||
def test_invalidate_scope_go_vs_oracle(mem_dir):
|
||||
_put(cache_mod, "a1", "v", scope="s1")
|
||||
_put(cache_mod, "a2", "v", scope="s1")
|
||||
_put(cache_mod, "b1", "v", scope="s2")
|
||||
got = cache_mod.invalidate_scope("s1")
|
||||
assert got["invalidated_scope"] == "s1" and got["count"] == 2
|
||||
assert cache_mod.get_cache("a1")["hit"] is False
|
||||
assert cache_mod.get_cache("a2")["hit"] is False
|
||||
assert cache_mod.get_cache("b1")["hit"] is True
|
||||
|
||||
|
||||
def test_invalidate_all_go_vs_oracle(mem_dir):
|
||||
_put(cache_mod, "x1", "v")
|
||||
_put(cache_mod, "x2", "v")
|
||||
got = cache_mod.invalidate_all("reset")
|
||||
assert got["invalidated_all"] == 2
|
||||
assert cache_mod.get_cache("x1")["hit"] is False
|
||||
assert cache_mod.get_cache("x2")["hit"] is False
|
||||
|
||||
|
||||
# -- stats + hook --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cache_stats_go_vs_oracle(mem_dir):
|
||||
_put(cache_mod, "s1", "v")
|
||||
_put(cache_mod, "s2", "v", scope="other")
|
||||
stats = cache_mod.cache_stats()
|
||||
oracle = cache_mod._cache_stats_python()
|
||||
assert stats["active"] == oracle["active"] == 2
|
||||
assert stats["scopes"] == oracle["scopes"]
|
||||
|
||||
|
||||
def test_handle_hook_go_vs_oracle(mem_dir):
|
||||
got = cache_mod.handle_hook("memory.write", {"key": "h1"})
|
||||
oracle = cache_mod._handle_hook_python("memory.write", {"key": "h1"})
|
||||
assert got["handler"] == oracle["handler"] == "cache"
|
||||
assert got["status"] == oracle["status"] == "ok"
|
||||
|
||||
|
||||
# -- cross-engine coherence -----------------------------------------------------
|
||||
|
||||
|
||||
def test_engine_write_oracle_read(mem_dir, monkeypatch):
|
||||
_put(cache_mod, "cross1", "via engine")
|
||||
monkeypatch.setenv("RUNE_CACHE_ENGINE", "python")
|
||||
assert cache_mod.get_cache("cross1")["full"] == "via engine"
|
||||
|
||||
|
||||
def test_oracle_write_engine_read(mem_dir, monkeypatch):
|
||||
monkeypatch.setenv("RUNE_CACHE_ENGINE", "python")
|
||||
cache_mod.put_cache("cross2", "via oracle")
|
||||
monkeypatch.delenv("RUNE_CACHE_ENGINE")
|
||||
assert cache_mod.get_cache("cross2")["full"] == "via oracle"
|
||||
|
||||
|
||||
# -- facade contract -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_engine_disabled_uses_oracle(mem_dir, monkeypatch):
|
||||
monkeypatch.setenv("RUNE_CACHE_ENGINE", "python")
|
||||
r = cache_mod.put_cache("esc", "v")
|
||||
assert r["stored"] == "esc"
|
||||
|
||||
|
||||
def test_missing_binary_fails_loud(mem_dir, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("RUNE_CACHE_BIN", str(tmp_path / "nope"))
|
||||
with pytest.raises(RuntimeError, match="binary missing"):
|
||||
cache_mod.get_cache("k")
|
||||
|
||||
|
||||
def test_bad_op_fails_loud():
|
||||
with pytest.raises(RuntimeError, match="go engine"):
|
||||
cache_mod._go_eval([{"op": "definitely_not_an_op", "memory_dir": "/tmp"}])
|
||||
@@ -30,6 +30,14 @@ from rune.forge_client import ForgeClient, NullAdapter
|
||||
MARKER = "[auto-failure:memory_selector:RuntimeError]"
|
||||
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _python_engine(monkeypatch):
|
||||
"""Oracle suite: pins the Python bodies via monkeypatched seams; the
|
||||
Go delegation is pinned by tests/test_forge_client_go_parity.py."""
|
||||
monkeypatch.setenv("RUNE_FORGE_CLIENT_ENGINE", "python")
|
||||
|
||||
|
||||
class FakeJournal:
|
||||
"""In-memory stand-in for the spool-journal protocol."""
|
||||
|
||||
|
||||
@@ -22,11 +22,20 @@ from rune.failure_auto_filer import (
|
||||
from rune.forge_client import NullAdapter
|
||||
|
||||
from tests.test_failure_auto_filer import FakeForge, FakeJournal, MARKER
|
||||
import pytest # noqa: E402
|
||||
|
||||
|
||||
# ------------------------------------------------------------ burst coalescing
|
||||
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _python_engine(monkeypatch):
|
||||
"""Oracle suite: pins the Python bodies via monkeypatched seams; the
|
||||
Go delegation is pinned by tests/test_forge_client_go_parity.py."""
|
||||
monkeypatch.setenv("RUNE_FORGE_CLIENT_ENGINE", "python")
|
||||
|
||||
|
||||
def test_burst_of_50_produces_at_most_2_client_writes():
|
||||
# First occurrence files; everything already queued coalesces into one
|
||||
# comment; the tail (< batch_size) stays pending: 1 create + ≤1 comment.
|
||||
|
||||
@@ -23,6 +23,14 @@ from rune.subsystem_health import SubsystemHealthRegistry, record_failure, set_a
|
||||
from tests.test_failure_auto_filer import FakeForge, FakeJournal
|
||||
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _python_engine(monkeypatch):
|
||||
"""Oracle suite: pins the Python bodies via monkeypatched seams; the
|
||||
Go delegation is pinned by tests/test_forge_client_go_parity.py."""
|
||||
monkeypatch.setenv("RUNE_FORGE_CLIENT_ENGINE", "python")
|
||||
|
||||
|
||||
class RecordingFiler:
|
||||
"""Callable filer stand-in matching the callback contract."""
|
||||
|
||||
|
||||
@@ -13,6 +13,14 @@ from rune.forge_client import (
|
||||
)
|
||||
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _python_engine(monkeypatch):
|
||||
"""Oracle suite: pins the Python bodies via monkeypatched seams; the
|
||||
Go delegation is pinned by tests/test_forge_client_go_parity.py."""
|
||||
monkeypatch.setenv("RUNE_FORGE_CLIENT_ENGINE", "python")
|
||||
|
||||
|
||||
def test_protocol_is_structural():
|
||||
assert issubclass(GiteaForgejoAdapter, ForgeClient)
|
||||
assert issubclass(NullAdapter, ForgeClient)
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"""REBASE-COMPLETION (#2587) parity battery — forge_client.
|
||||
|
||||
Red-first discipline: the battery pins the Go engine's eval surface
|
||||
(retry/status decision, adapter selection) against the Python oracle
|
||||
bodies. A mutation of an oracle body must flip the battery. Also pins
|
||||
the facade contract: fail-loud RuntimeError on a missing binary, no
|
||||
silent fallback; RUNE_FORGE_CLIENT_ENGINE=python escape; the httpx
|
||||
exception contract is preserved through the delegation (4xx re-raised
|
||||
as HTTPStatusError, transport errors re-raised with the exact httpx
|
||||
exception class).
|
||||
|
||||
Oracle bodies under test: _request_python, the create_forge_client
|
||||
selection logic (via the Go select op vs the oracle resolution).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import forge_client as fc # noqa: E402
|
||||
|
||||
|
||||
class _StubForge(BaseHTTPRequestHandler):
|
||||
"""Scripted forge: /behavior/{status}/{mode} drives the response.
|
||||
mode=always repeats the status forever (5xx retry exhaustion);
|
||||
mode=once-first returns 200 after the first scripted status."""
|
||||
|
||||
behavior = ("200", "once-first")
|
||||
hits = 0
|
||||
last_method = None
|
||||
|
||||
def _respond(self, status: int):
|
||||
type(self).hits += 1
|
||||
mode = type(self).behavior[1]
|
||||
if mode == "always" or type(self).hits == 1:
|
||||
body = json.dumps({"ok": status < 400, "hits": type(self).hits})
|
||||
self.send_response(status)
|
||||
else:
|
||||
body = json.dumps({"ok": True, "hits": type(self).hits})
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(body.encode())
|
||||
|
||||
def do_GET(self):
|
||||
type(self).last_method = "GET"
|
||||
status = int(type(self).behavior[0])
|
||||
self._respond(status)
|
||||
|
||||
def do_POST(self):
|
||||
type(self).last_method = "POST"
|
||||
status = int(type(self).behavior[0])
|
||||
self._respond(status)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def stub_forge():
|
||||
server = HTTPServer(("127.0.0.1", 0), _StubForge)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
_StubForge.hits = 0
|
||||
# The oracle body forwards json= on every call; the real httpx.get
|
||||
# rejects the kwarg (historically only fakes saw GET+json). Shim it.
|
||||
real_get = httpx.get
|
||||
monkeypatch_get = lambda url, headers=None, json=None, timeout=None: real_get(url, headers=headers, timeout=timeout)
|
||||
httpx.get = monkeypatch_get
|
||||
yield f"http://127.0.0.1:{server.server_port}"
|
||||
httpx.get = real_get
|
||||
server.shutdown()
|
||||
|
||||
|
||||
def _reset(status, mode):
|
||||
_StubForge.behavior = (str(status), mode)
|
||||
_StubForge.hits = 0
|
||||
|
||||
|
||||
def _go_request(url, *, method="GET", retries=2, backoff=0.01, timeout=5):
|
||||
ops = [
|
||||
{
|
||||
"op": "request",
|
||||
"method": method,
|
||||
"url": url,
|
||||
"headers": {},
|
||||
"json": None,
|
||||
"timeout": timeout,
|
||||
"retries": retries,
|
||||
"backoff": backoff,
|
||||
}
|
||||
]
|
||||
return fc._go_eval(ops)[0]
|
||||
|
||||
|
||||
def _py_request(url, *, method="GET", retries=2, backoff=0.01, timeout=5):
|
||||
return fc._request_python(
|
||||
method,
|
||||
url,
|
||||
timeout=timeout,
|
||||
retries=retries,
|
||||
backoff=backoff,
|
||||
)
|
||||
|
||||
|
||||
# -- retry/status policy: Go engine vs Python oracle, same live stub ------
|
||||
|
||||
|
||||
def test_get_5xx_retries_then_succeeds(stub_forge):
|
||||
_reset(502, "once-first")
|
||||
got = _go_request(stub_forge + "/x")
|
||||
go_hits = _StubForge.hits
|
||||
_reset(502, "once-first")
|
||||
oracle = _py_request(stub_forge + "/x")
|
||||
assert got["status_code"] == oracle.status_code == 200
|
||||
# two hits per leg: the 502 then the retry's 200
|
||||
assert json.loads(got["body"])["hits"] == oracle.json()["hits"] == 2
|
||||
assert go_hits == _StubForge.hits == 2
|
||||
|
||||
|
||||
def test_get_5xx_exhaustion(stub_forge):
|
||||
_reset(500, "always")
|
||||
got = _go_request(stub_forge + "/x")
|
||||
go_hits = _StubForge.hits
|
||||
_reset(500, "always")
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
_py_request(stub_forge + "/x")
|
||||
assert "status_error" in got and got["status_error"] == 500
|
||||
# retries+1 total attempts per leg, no more
|
||||
assert go_hits == _StubForge.hits == 3
|
||||
|
||||
|
||||
def test_post_5xx_never_retried(stub_forge):
|
||||
_reset(500, "always")
|
||||
got = _go_request(stub_forge + "/x", method="POST", retries=3)
|
||||
go_hits = _StubForge.hits
|
||||
_reset(500, "always")
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
_py_request(stub_forge + "/x", method="POST", retries=3)
|
||||
# exactly one hit per leg: a duplicate issue is worse than a caught failure
|
||||
assert got["status_error"] == 500
|
||||
assert go_hits == _StubForge.hits == 1
|
||||
|
||||
|
||||
def test_4xx_never_retried(stub_forge):
|
||||
_reset(422, "always")
|
||||
got = _go_request(stub_forge + "/x")
|
||||
go_hits = _StubForge.hits
|
||||
_reset(422, "always")
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
_py_request(stub_forge + "/x")
|
||||
assert got["status_error"] == 422
|
||||
assert go_hits == _StubForge.hits == 1
|
||||
|
||||
|
||||
# -- exception contract through the facade --------------------------------
|
||||
|
||||
|
||||
def test_facade_raises_httpx_status_error(stub_forge):
|
||||
_reset(404, "always")
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
fc._request("GET", stub_forge + "/x", backoff=0.01, timeout=5)
|
||||
|
||||
|
||||
def test_facade_transport_error_maps_to_httpx(stub_forge):
|
||||
with pytest.raises(httpx.TransportError):
|
||||
fc._request("GET", "http://127.0.0.1:1/nope", retries=0, timeout=2)
|
||||
|
||||
|
||||
# -- adapter selection: Go engine vs oracle --------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cfg,want",
|
||||
[
|
||||
({}, "NullAdapter"),
|
||||
({"forge.url": "", "forge.token": "t"}, "NullAdapter"),
|
||||
({"forge.url": "http://x", "forge.token": ""}, "NullAdapter"),
|
||||
({"forge.url": "http://x/", "forge.token": "t", "forge.owner": "o", "forge.repo": "r"}, "GiteaForgejoAdapter"),
|
||||
],
|
||||
)
|
||||
def test_select_go_vs_oracle(cfg, want):
|
||||
row = fc._go_eval([{"op": "select", "config": cfg}])[0]
|
||||
assert row["adapter"] == want
|
||||
# the oracle resolution (env stripped: config keys only)
|
||||
if want == "GiteaForgejoAdapter":
|
||||
want_url = cfg["forge.url"].rstrip("/") + "/api/v1/repos/" + cfg["forge.owner"] + "/" + cfg["forge.repo"]
|
||||
assert row["base_url"] == want_url
|
||||
|
||||
|
||||
def test_create_forge_client_via_engine(monkeypatch):
|
||||
monkeypatch.delenv("FORGE_URL", raising=False)
|
||||
monkeypatch.delenv("FORGE_TOKEN", raising=False)
|
||||
client = fc.create_forge_client(
|
||||
{
|
||||
"forge.url": "http://x",
|
||||
"forge.token": "t",
|
||||
"forge.owner": "o",
|
||||
"forge.repo": "r",
|
||||
}
|
||||
)
|
||||
assert isinstance(client, fc.GiteaForgejoAdapter)
|
||||
assert client._base_url == "http://x/api/v1/repos/o/r"
|
||||
|
||||
|
||||
# -- facade contract --------------------------------------------------------
|
||||
|
||||
|
||||
def test_engine_disabled_uses_oracle(stub_forge):
|
||||
os.environ["RUNE_FORGE_CLIENT_ENGINE"] = "python"
|
||||
try:
|
||||
_reset(200, "always")
|
||||
r = fc._request("GET", stub_forge + "/x", backoff=0.01, timeout=5)
|
||||
assert r.status_code == 200
|
||||
finally:
|
||||
os.environ.pop("RUNE_FORGE_CLIENT_ENGINE", None)
|
||||
|
||||
|
||||
def test_missing_binary_fails_loud(tmp_path):
|
||||
os.environ["RUNE_FORGE_CLIENT_BIN"] = str(tmp_path / "nope")
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="binary missing"):
|
||||
fc.create_forge_client({})
|
||||
finally:
|
||||
os.environ.pop("RUNE_FORGE_CLIENT_BIN", None)
|
||||
|
||||
|
||||
def test_bad_ops_fail_loud():
|
||||
with pytest.raises(RuntimeError, match="go engine"):
|
||||
fc._go_eval([{"op": "definitely_not_an_op"}])
|
||||
@@ -29,6 +29,14 @@ import pytest
|
||||
from rune.forge_client import GiteaForgejoAdapter, _request
|
||||
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _python_engine(monkeypatch):
|
||||
"""Oracle suite: pins the Python bodies via monkeypatched seams; the
|
||||
Go delegation is pinned by tests/test_forge_client_go_parity.py."""
|
||||
monkeypatch.setenv("RUNE_FORGE_CLIENT_ENGINE", "python")
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code=200, payload=None):
|
||||
self.status_code = status_code
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""REBASE-COMPLETION (#2587) parity battery — goal.
|
||||
|
||||
Red-first discipline: pins the Go engine's eval surface (set_goal,
|
||||
get_active_goal, achieve_goal, fail_goal, clear_goal, list_goals)
|
||||
against the Python oracle bodies on the SAME storage file. A mutation
|
||||
of an oracle body must flip the battery. Also pins the facade contract:
|
||||
fail-loud RuntimeError on a missing binary; RUNE_GOAL_ENGINE=python
|
||||
escape. check_goal stays Python this wave (LLM-as-a-Judge seam —
|
||||
receipt blocks_full_switchover).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import goal as goal_mod # noqa: E402
|
||||
from rune.goal import Goal, GoalManager # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def storage(tmp_path):
|
||||
return str(tmp_path / "goals.json")
|
||||
|
||||
|
||||
def _engine_manager(storage):
|
||||
return GoalManager(storage)
|
||||
|
||||
|
||||
def _oracle_manager(storage):
|
||||
os.environ["RUNE_GOAL_ENGINE"] = "python"
|
||||
try:
|
||||
return GoalManager(storage)
|
||||
finally:
|
||||
os.environ.pop("RUNE_GOAL_ENGINE", None)
|
||||
|
||||
|
||||
def _strip_volatile(d):
|
||||
d.pop("goal_id", None)
|
||||
d.pop("created_at", None)
|
||||
d.pop("completed_at", None)
|
||||
return d
|
||||
|
||||
|
||||
# -- set_goal / get_active_goal / list --------------------------------------
|
||||
|
||||
|
||||
def test_set_goal_go_vs_oracle(storage):
|
||||
got = _engine_manager(storage).set_goal("ship it", ["a", "b"], "test")
|
||||
oracle = _oracle_manager(storage).set_goal("ship it", ["a", "b"], "test")
|
||||
assert _strip_volatile(got.to_dict()) == _strip_volatile(oracle.to_dict())
|
||||
assert got.status == oracle.status == "active"
|
||||
|
||||
|
||||
def test_set_goal_defaults_go_vs_oracle(storage):
|
||||
got = _engine_manager(storage).set_goal("desc only")
|
||||
oracle = _oracle_manager(storage).set_goal("desc only")
|
||||
assert _strip_volatile(got.to_dict()) == _strip_volatile(oracle.to_dict())
|
||||
assert got.stop_conditions == oracle.stop_conditions == []
|
||||
assert got.verification_method == oracle.verification_method == "manual"
|
||||
|
||||
|
||||
def test_get_active_goal_roundtrip_go_vs_oracle(storage):
|
||||
m = _engine_manager(storage)
|
||||
m.set_goal("the goal", ["c1"], "test")
|
||||
got = m.get_active_goal()
|
||||
mo = _oracle_manager(storage)
|
||||
mo.set_goal("the goal", ["c1"], "test")
|
||||
oracle = mo.get_active_goal()
|
||||
assert got.to_dict() == oracle.to_dict()
|
||||
|
||||
|
||||
def test_list_goals_go_vs_oracle(storage):
|
||||
m = _engine_manager(storage)
|
||||
m.set_goal("one")
|
||||
m.achieve_goal("done")
|
||||
m.set_goal("two")
|
||||
mo = _oracle_manager(storage)
|
||||
mo.set_goal("one")
|
||||
mo.achieve_goal("done")
|
||||
mo.set_goal("two")
|
||||
got = [_strip_volatile(dict(g)) for g in m.list_goals()]
|
||||
oracle = [_strip_volatile(dict(g)) for g in mo.list_goals()]
|
||||
assert got == oracle
|
||||
|
||||
|
||||
# -- achieve / fail / clear ---------------------------------------------------
|
||||
|
||||
|
||||
def test_achieve_goal_go_vs_oracle(storage):
|
||||
m = _engine_manager(storage)
|
||||
m.set_goal("g", ["a"])
|
||||
got = m.achieve_goal("ev")
|
||||
mo = _oracle_manager(storage)
|
||||
mo.set_goal("g", ["a"])
|
||||
oracle = mo.achieve_goal("ev")
|
||||
assert got["status"] == oracle["status"] == "achieved"
|
||||
assert _strip_volatile(dict(got["goal"])) == _strip_volatile(dict(oracle["goal"]))
|
||||
|
||||
|
||||
def test_achieve_goal_no_goal_go_vs_oracle(storage):
|
||||
got = _engine_manager(storage).achieve_goal()
|
||||
oracle = _oracle_manager(storage).achieve_goal()
|
||||
assert got == oracle == {"status": "no_goal", "message": "No active goal set"}
|
||||
|
||||
|
||||
def test_fail_goal_go_vs_oracle(storage):
|
||||
m = _engine_manager(storage)
|
||||
m.set_goal("g")
|
||||
got = m.fail_goal("broke")
|
||||
mo = _oracle_manager(storage)
|
||||
mo.set_goal("g")
|
||||
oracle = mo.fail_goal("broke")
|
||||
assert got["status"] == oracle["status"] == "failed"
|
||||
assert _strip_volatile(dict(got["goal"])) == _strip_volatile(dict(oracle["goal"]))
|
||||
|
||||
|
||||
def test_clear_goal_go_vs_oracle(storage):
|
||||
m = _engine_manager(storage)
|
||||
m.set_goal("g")
|
||||
got = m.clear_goal()
|
||||
mo = _oracle_manager(storage)
|
||||
mo.set_goal("g")
|
||||
oracle = mo.clear_goal()
|
||||
assert got["status"] == oracle["status"]
|
||||
assert m.get_active_goal() is None
|
||||
assert mo.get_active_goal() is None
|
||||
|
||||
|
||||
# -- shared-storage coherence -------------------------------------------------
|
||||
|
||||
|
||||
def test_engine_state_readable_by_oracle(storage):
|
||||
m = _engine_manager(storage)
|
||||
goal = m.set_goal("cross-read", ["x"], "schema")
|
||||
mo = _oracle_manager(storage)
|
||||
assert mo.get_active_goal().goal_id == goal.goal_id
|
||||
assert mo.get_active_goal().verification_method == "schema"
|
||||
|
||||
|
||||
# -- facade contract -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_engine_disabled_uses_oracle(storage):
|
||||
os.environ["RUNE_GOAL_ENGINE"] = "python"
|
||||
try:
|
||||
m = GoalManager(storage)
|
||||
g = m.set_goal("oracle path")
|
||||
assert g.status == "active"
|
||||
finally:
|
||||
os.environ.pop("RUNE_GOAL_ENGINE", None)
|
||||
|
||||
|
||||
def test_missing_binary_fails_loud(tmp_path, storage):
|
||||
os.environ["RUNE_GOAL_BIN"] = str(tmp_path / "nope")
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="binary missing"):
|
||||
GoalManager(storage).set_goal("x")
|
||||
finally:
|
||||
os.environ.pop("RUNE_GOAL_BIN", None)
|
||||
|
||||
|
||||
def test_bad_op_fails_loud(storage):
|
||||
with pytest.raises(RuntimeError, match="go engine"):
|
||||
goal_mod._go_eval([{"op": "definitely_not_an_op", "storage_path": storage}])
|
||||
|
||||
|
||||
def test_goal_dataclass_fields_survive_delegation(storage):
|
||||
m = _engine_manager(storage)
|
||||
row = goal_mod._go_eval(
|
||||
[
|
||||
{
|
||||
"op": "set_goal",
|
||||
"storage_path": storage,
|
||||
"description": "fields",
|
||||
"stop_conditions": ["p", "q"],
|
||||
"verification_method": "exit_code",
|
||||
}
|
||||
]
|
||||
)[0]
|
||||
g = Goal(**row)
|
||||
assert g.stop_conditions == ["p", "q"]
|
||||
assert g.verification_method == "exit_code"
|
||||
@@ -15,6 +15,15 @@ from typing import Sequence, cast
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
# Wave B (#2575): these fixtures script the Python tool executor — pin the
|
||||
# RUNE_TOOL_EXECUTOR_ENGINE=python escape hatch so the Go delegation
|
||||
# (pinned separately in tests/test_tool_executor_delegation.py) does not
|
||||
# bypass the scripted executor.
|
||||
@pytest.fixture(autouse=True)
|
||||
def _python_tool_executor_engine(monkeypatch):
|
||||
monkeypatch.setenv("RUNE_TOOL_EXECUTOR_ENGINE", "python")
|
||||
|
||||
|
||||
from rune.local_tool_runtime import ( # noqa: E402
|
||||
ChatMessage,
|
||||
LocalToolRuntime,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""REBASE-COMPLETION (#2587) parity battery — message_queue.
|
||||
|
||||
Red-first discipline: pins the Go engine's eval surface against the
|
||||
Python oracle bodies on the SAME queue.json. A mutation of an oracle
|
||||
body must flip the battery. Also pins the facade contract: fail-loud
|
||||
RuntimeError on a missing binary; RUNE_MESSAGE_QUEUE_ENGINE=python
|
||||
escape; cross-engine coherence both directions.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import message_queue as mq_mod # noqa: E402
|
||||
from rune.message_queue import MessageQueue, QueuedMessage # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def queue_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(mq_mod, "QUEUE_DIR", str(tmp_path / "mq"))
|
||||
monkeypatch.setattr(mq_mod, "QUEUE_FILE", str(tmp_path / "mq" / "queue.json"))
|
||||
return str(tmp_path / "mq")
|
||||
|
||||
|
||||
def _engine(queue_dir):
|
||||
return MessageQueue()
|
||||
|
||||
|
||||
def _oracle(queue_dir):
|
||||
os.environ["RUNE_MESSAGE_QUEUE_ENGINE"] = "python"
|
||||
try:
|
||||
return MessageQueue()
|
||||
finally:
|
||||
os.environ.pop("RUNE_MESSAGE_QUEUE_ENGINE", None)
|
||||
|
||||
|
||||
# -- detection ----------------------------------------------------------------
|
||||
|
||||
DETECT_CASES = [
|
||||
"add a new feature to the parser",
|
||||
"also fix the bug in auth",
|
||||
"and can you update the docs",
|
||||
"the weather is nice today",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", DETECT_CASES)
|
||||
def test_detect_task_addition_go_vs_oracle(message):
|
||||
is_t, conf, signals = MessageQueue().detect_task_addition(message)
|
||||
o_is, o_conf, o_signals = MessageQueue()._detect_task_addition_python(message)
|
||||
assert (is_t, round(conf, 6), signals) == (o_is, round(o_conf, 6), o_signals)
|
||||
|
||||
|
||||
# -- add / pending / approved / status ------------------------------------------
|
||||
|
||||
|
||||
def test_add_message_go_vs_oracle(queue_dir):
|
||||
got = _engine(queue_dir).add_message("add a feature", "chat")
|
||||
oracle = _oracle(queue_dir).add_message("add a feature", "chat")
|
||||
assert got.message == oracle.message == "add a feature"
|
||||
assert got.status == oracle.status == "pending"
|
||||
assert got.channel == oracle.channel == "chat"
|
||||
|
||||
|
||||
def test_pending_approved_flow_go_vs_oracle(queue_dir):
|
||||
q = _engine(queue_dir)
|
||||
m1 = q.add_message("add one", "chat")
|
||||
q.add_message("add two", "chat")
|
||||
assert len(q.get_pending()) == 2
|
||||
assert q.approve(m1.id) is True
|
||||
assert len(q.get_pending()) == 1
|
||||
assert len(q.get_approved()) == 1
|
||||
st = q.get_status()
|
||||
assert st == {"total": 2, "pending": 1, "approved": 1, "executed": 0}
|
||||
assert q.mark_executed(m1.id) is True
|
||||
assert q.get_status()["executed"] == 1
|
||||
|
||||
|
||||
def test_reject_go_vs_oracle(queue_dir):
|
||||
q = _engine(queue_dir)
|
||||
m = q.add_message("add x", "chat")
|
||||
assert q.reject(m.id) is True
|
||||
st = q.get_status()
|
||||
assert st["pending"] == 0
|
||||
oracle_q = _oracle(queue_dir)
|
||||
om = oracle_q.add_message("add x", "chat")
|
||||
oracle_q.reject(om.id)
|
||||
assert q.get_status() == oracle_q.get_status()
|
||||
|
||||
|
||||
def test_approve_all_go_vs_oracle(queue_dir):
|
||||
q = _engine(queue_dir)
|
||||
q.add_message("a1", "chat")
|
||||
q.add_message("a2", "chat")
|
||||
assert q.approve_all() == 2
|
||||
assert len(q.get_approved()) == 2
|
||||
|
||||
|
||||
def test_clear_go_vs_oracle(queue_dir):
|
||||
q = _engine(queue_dir)
|
||||
q.add_message("a", "chat")
|
||||
q.clear()
|
||||
assert q.get_status()["total"] == 0
|
||||
|
||||
|
||||
# -- cross-engine coherence --------------------------------------------------------
|
||||
|
||||
|
||||
def test_engine_write_oracle_read(queue_dir):
|
||||
q = _engine(queue_dir)
|
||||
m = q.add_message("cross", "chat")
|
||||
mo = _oracle(queue_dir)
|
||||
assert any(x.id == m.id for x in mo.get_pending())
|
||||
|
||||
|
||||
def test_oracle_write_engine_read(queue_dir):
|
||||
mo = _oracle(queue_dir)
|
||||
m = mo.add_message("cross back", "chat")
|
||||
q = _engine(queue_dir)
|
||||
assert any(x.id == m.id for x in q.get_pending())
|
||||
|
||||
|
||||
# -- facade contract ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_engine_disabled_uses_oracle(queue_dir):
|
||||
os.environ["RUNE_MESSAGE_QUEUE_ENGINE"] = "python"
|
||||
try:
|
||||
q = MessageQueue()
|
||||
m = q.add_message("oracle path", "chat")
|
||||
assert m.status == "pending"
|
||||
finally:
|
||||
os.environ.pop("RUNE_MESSAGE_QUEUE_ENGINE", None)
|
||||
|
||||
|
||||
def test_missing_binary_fails_loud(tmp_path, queue_dir, monkeypatch):
|
||||
monkeypatch.setenv("RUNE_MESSAGE_QUEUE_BIN", str(tmp_path / "nope"))
|
||||
with pytest.raises(RuntimeError, match="binary missing"):
|
||||
MessageQueue().add_message("x", "chat")
|
||||
|
||||
|
||||
def test_bad_op_fails_loud():
|
||||
with pytest.raises(RuntimeError, match="go engine"):
|
||||
mq_mod._go_eval([{"op": "definitely_not_an_op", "queue_dir": "/tmp"}])
|
||||
@@ -0,0 +1,182 @@
|
||||
"""REBASE-COMPLETION (#2587) parity battery — microkernel.
|
||||
|
||||
Red-first discipline: pins the Go engine's parity stdin-op surface
|
||||
(now_iso, extract_manifest, resolve) decision-for-decision against the
|
||||
Python oracle bodies. A mutation of an oracle body must flip the
|
||||
battery. Also pins the facade contract: fail-loud RuntimeError on a
|
||||
missing binary, no silent fallback; RUNE_MICROKERNEL_ENGINE=python
|
||||
escape. fire/discover stay Python this wave (Go RunHook/Fire lack the
|
||||
sandbox-grant and process-group machinery — receipt blocks_full_
|
||||
switchover).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import microkernel as mk # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_registry(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("RUNE_MEMORY_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(mk, "REGISTRY_DB", str(tmp_path / "registry.json"))
|
||||
yield
|
||||
|
||||
|
||||
REGISTRY = {
|
||||
"plugins": {
|
||||
"alpha": {"active": True, "manifest": {"priority": 10}},
|
||||
"beta": {"active": True, "manifest": {}},
|
||||
"gamma": {"active": False, "manifest": {"priority": 1}},
|
||||
"delta": {"active": True, "manifest": {"priority": 10}},
|
||||
},
|
||||
"capabilities": {"cap.a": ["alpha", "beta", "gamma"], "cap.b": ["gamma"], "cap.t": ["alpha", "delta"]},
|
||||
"hooks": {},
|
||||
"log": [],
|
||||
}
|
||||
|
||||
|
||||
def _registry_with(**overrides):
|
||||
import copy
|
||||
|
||||
reg = copy.deepcopy({**REGISTRY, **overrides})
|
||||
return reg
|
||||
|
||||
|
||||
def _seed(reg):
|
||||
path = Path(mk.REGISTRY_DB)
|
||||
path.write_text(_json_dumps(reg))
|
||||
|
||||
|
||||
def _json_dumps(obj):
|
||||
import json
|
||||
|
||||
return json.dumps(obj)
|
||||
|
||||
|
||||
_resolve_python = mk._resolve_python # the module's oracle body IS the battery's oracle
|
||||
|
||||
|
||||
# -- now_iso ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_now_iso_go_matches_oracle_shape():
|
||||
got = mk.now_iso()
|
||||
oracle = mk._now_iso_python()
|
||||
assert got[-6:] == oracle[-6:] == "+00:00"
|
||||
assert "T" in got and len(got) == len(oracle)
|
||||
|
||||
|
||||
# -- extract_manifest -------------------------------------------------------
|
||||
|
||||
|
||||
def _plugin_file(tmp_path, body):
|
||||
f = tmp_path / "plugin_x.py"
|
||||
f.write_text(body)
|
||||
return str(f)
|
||||
|
||||
|
||||
def test_extract_manifest_go_vs_oracle(tmp_path):
|
||||
fpath = _plugin_file(tmp_path, 'PLUGIN = {\n "name": "x",\n "priority": 3,\n "hooks": {},\n}\n')
|
||||
got = mk._extract_manifest(fpath)
|
||||
oracle = mk._extract_manifest_python(fpath)
|
||||
assert got == oracle == {"name": "x", "priority": 3, "hooks": {}}
|
||||
|
||||
|
||||
def test_extract_manifest_no_plugin_go_vs_oracle(tmp_path):
|
||||
fpath = _plugin_file(tmp_path, "x = 1\n")
|
||||
assert mk._extract_manifest(fpath) is None
|
||||
assert mk._extract_manifest_python(fpath) is None
|
||||
|
||||
|
||||
def test_extract_manifest_broken_literal_go_vs_oracle(tmp_path):
|
||||
fpath = _plugin_file(tmp_path, "PLUGIN = {not a dict\n")
|
||||
assert mk._extract_manifest(fpath) is None
|
||||
assert mk._extract_manifest_python(fpath) is None
|
||||
|
||||
|
||||
def test_extract_manifest_multiline_nested(tmp_path):
|
||||
fpath = _plugin_file(
|
||||
tmp_path,
|
||||
(
|
||||
"PLUGIN = {\n"
|
||||
' "name": "n",\n'
|
||||
' "hooks": {"boot": {"cmd": ["echo", "hi"]}},\n'
|
||||
' "capabilities": ["c1", "c2"],\n'
|
||||
"}\n"
|
||||
),
|
||||
)
|
||||
want = {"name": "n", "hooks": {"boot": {"cmd": ["echo", "hi"]}}, "capabilities": ["c1", "c2"]}
|
||||
assert mk._extract_manifest(fpath) == mk._extract_manifest_python(fpath) == want
|
||||
|
||||
|
||||
# -- resolve ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_go_vs_oracle_priority_ascending_active_only(tmp_path):
|
||||
reg = _registry_with()
|
||||
_seed(reg)
|
||||
assert mk.resolve("cap.a") == "alpha" # lowest priority wins
|
||||
assert _resolve_python("cap.a", reg) == "alpha"
|
||||
|
||||
|
||||
def test_resolve_go_default_priority_is_100(tmp_path):
|
||||
reg = _registry_with()
|
||||
# alpha 10 vs beta (default 100): alpha wins; beta's missing key
|
||||
# must NOT read as 0 (which would flip the verdict).
|
||||
_seed(reg)
|
||||
assert mk.resolve("cap.a") == "alpha"
|
||||
|
||||
|
||||
def test_resolve_go_explicit_zero_priority_beats_ten(tmp_path):
|
||||
reg = _registry_with()
|
||||
reg["plugins"]["beta"]["manifest"]["priority"] = 0
|
||||
_seed(reg)
|
||||
assert mk.resolve("cap.a") == "beta"
|
||||
|
||||
|
||||
def test_resolve_go_tie_prefers_first_registered(tmp_path):
|
||||
reg = _registry_with()
|
||||
_seed(reg)
|
||||
oracle = _resolve_python("cap.t", reg)
|
||||
assert mk.resolve("cap.t") == oracle # both 10; oracle order governs
|
||||
|
||||
|
||||
def test_resolve_inactive_and_unknown(tmp_path):
|
||||
reg = _registry_with()
|
||||
_seed(reg)
|
||||
assert mk.resolve("cap.b") is None # only inactive provider
|
||||
assert mk.resolve("cap.nope") is None
|
||||
|
||||
|
||||
# -- facade contract ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_engine_disabled_uses_oracle(tmp_path):
|
||||
os.environ["RUNE_MICROKERNEL_ENGINE"] = "python"
|
||||
try:
|
||||
reg = _registry_with()
|
||||
_seed(reg)
|
||||
assert mk.resolve("cap.a") == "alpha"
|
||||
finally:
|
||||
os.environ.pop("RUNE_MICROKERNEL_ENGINE", None)
|
||||
|
||||
|
||||
def test_missing_binary_fails_loud(tmp_path):
|
||||
os.environ["RUNE_MICROKERNEL_BIN"] = str(tmp_path / "nope")
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="binary missing"):
|
||||
mk.now_iso()
|
||||
finally:
|
||||
os.environ.pop("RUNE_MICROKERNEL_BIN", None)
|
||||
|
||||
|
||||
def test_bad_op_fails_loud():
|
||||
with pytest.raises(RuntimeError, match="go engine"):
|
||||
mk._go_parity_op({"op": "definitely_not_an_op"})
|
||||
@@ -38,6 +38,14 @@ import pytest
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
# Wave B (#2575): scripted-executor fixtures pin the Python escape hatch
|
||||
# (RUNE_TOOL_EXECUTOR_ENGINE=python); Go delegation is pinned in
|
||||
# tests/test_tool_executor_delegation.py.
|
||||
@pytest.fixture(autouse=True)
|
||||
def _python_tool_executor_engine(monkeypatch):
|
||||
monkeypatch.setenv("RUNE_TOOL_EXECUTOR_ENGINE", "python")
|
||||
|
||||
|
||||
from rune.local_tool_runtime import ChatMessage, LocalToolRuntime # noqa: E402
|
||||
from rune import tool_executor # noqa: E402
|
||||
from rune.tool_protocol import ToolEvent, ToolRequest # noqa: E402
|
||||
|
||||
@@ -4,6 +4,7 @@ The operator had exactly one transport (Ollama) — send_task(model=<cloud>)
|
||||
posted cloud model names to Ollama's /api/chat and 400'd. These tests prove
|
||||
the translation layer both directions; the live wire is smoke-tested
|
||||
separately (provider credentials are not test fixtures)."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
@@ -16,15 +17,30 @@ sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from rune.openai_compat_transport import OpenAICompatTransport # noqa: E402
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _python_engine(monkeypatch):
|
||||
"""This suite pins the Python ORACLE bodies via monkeypatched seams;
|
||||
the Go delegation is pinned separately by the parity battery
|
||||
(tests/test_openai_compat_transport_go_parity.py)."""
|
||||
monkeypatch.setenv("RUNE_OPENAI_COMPAT_TRANSPORT_ENGINE", "python")
|
||||
|
||||
|
||||
class TestMessageTranslation:
|
||||
def test_ollama_to_openai_roundtrip(self):
|
||||
msgs = [
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "user", "content": "task"},
|
||||
{"role": "assistant", "content": "", "tool_calls": [
|
||||
{"function": {"name": "edit_file", "arguments": {"path": "/x.py", "old": "a", "new": "b"}}}]},
|
||||
{"role": "tool", "tool_call_id": "call_2_0", "content": "{\"success\": true}"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"function": {"name": "edit_file", "arguments": {"path": "/x.py", "old": "a", "new": "b"}}}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_2_0", "content": '{"success": true}'},
|
||||
]
|
||||
out = OpenAICompatTransport._to_openai_messages(msgs)
|
||||
# assistant tool_calls: dict args → JSON string, ids assigned
|
||||
@@ -37,16 +53,30 @@ class TestMessageTranslation:
|
||||
assert out[0] == {"role": "system", "content": "sys"}
|
||||
|
||||
def test_openai_response_to_ollama_shape(self):
|
||||
resp = {"choices": [{"message": {"role": "assistant", "content": None, "tool_calls": [
|
||||
{"id": "z", "type": "function", "function": {"name": "run_tests", "arguments": "{\"path\": \"t\"}"}}]}}]}
|
||||
resp = {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "z",
|
||||
"type": "function",
|
||||
"function": {"name": "run_tests", "arguments": '{"path": "t"}'},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
shape = OpenAICompatTransport._from_openai_response(resp)
|
||||
tc = shape["tool_calls"][0]
|
||||
assert tc["function"]["name"] == "run_tests"
|
||||
assert tc["function"]["arguments"] == {"path": "t"} # string → dict
|
||||
|
||||
def test_malformed_arguments_become_raw(self):
|
||||
resp = {"choices": [{"message": {"tool_calls": [
|
||||
{"function": {"name": "x", "arguments": "not-json"}}]}}]}
|
||||
resp = {"choices": [{"message": {"tool_calls": [{"function": {"name": "x", "arguments": "not-json"}}]}}]}
|
||||
shape = OpenAICompatTransport._from_openai_response(resp)
|
||||
assert shape["tool_calls"][0]["function"]["arguments"] == {"_raw": "not-json"}
|
||||
|
||||
@@ -64,9 +94,23 @@ class TestMessageTranslation:
|
||||
normalization reading the top level, finding no tool_calls and no
|
||||
content, and failing 'empty terminal visible text' while the model
|
||||
had returned a valid read_file call."""
|
||||
resp = {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [
|
||||
{"id": "call_1", "type": "function",
|
||||
"function": {"name": "read_file", "arguments": "{\"path\": \"/x.py\"}"}}]}}]}
|
||||
resp = {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": '{"path": "/x.py"}'},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
msg = OpenAICompatTransport._from_openai_response(resp)
|
||||
assert msg["role"] == "assistant"
|
||||
assert msg["tool_calls"][0]["function"]["name"] == "read_file"
|
||||
@@ -89,7 +133,6 @@ class TestMessageTranslation:
|
||||
t.chat(model="m", messages=[])
|
||||
assert t.usage_calls == 1 and t.usage_prompt_tokens == 10 and t.usage_output_tokens == 5
|
||||
|
||||
|
||||
def test_qualified_model_name_sent_as_bare_id(self):
|
||||
"""Runtime passes '<Provider>/<model>'; the wire must carry the bare
|
||||
model id (observed live: qualified name → 400 on every call)."""
|
||||
@@ -104,21 +147,26 @@ class TestMessageTranslation:
|
||||
t.chat(model="Verboo Code/glm-5.3-flash", messages=[{"role": "user", "content": "hi"}], tools=None)
|
||||
assert captured["model"] == "glm-5.3-flash"
|
||||
|
||||
|
||||
def test_cloud_transport_for_floors_timeout(self):
|
||||
"""cloud_transport_for floors the timeout at 300s for cloud routers."""
|
||||
import rune.openai_compat_transport as mod
|
||||
|
||||
captured = {}
|
||||
real_init = mod.OpenAICompatTransport.__init__
|
||||
|
||||
def spy_init(self, base_url, api_key, provider_name, timeout=120, context_window=131072):
|
||||
captured["timeout"] = timeout
|
||||
|
||||
with mock.patch.object(mod.OpenAICompatTransport, "__init__", spy_init), \
|
||||
mock.patch.object(Path, "is_file", return_value=True), \
|
||||
mock.patch.object(Path, "read_text", return_value='{"Verboo Code": {"options": {"baseURL": "https://x/v1"}}}'), \
|
||||
mock.patch.dict(sys.modules, {}):
|
||||
with (
|
||||
mock.patch.object(mod.OpenAICompatTransport, "__init__", spy_init),
|
||||
mock.patch.object(Path, "is_file", return_value=True),
|
||||
mock.patch.object(
|
||||
Path, "read_text", return_value='{"Verboo Code": {"options": {"baseURL": "https://x/v1"}}}'
|
||||
),
|
||||
mock.patch.dict(sys.modules, {}),
|
||||
):
|
||||
import json as _json
|
||||
|
||||
with mock.patch.object(mod.json, "loads", return_value={"Verboo Code": {"key": "k"}}):
|
||||
mod.cloud_transport_for("Verboo Code/glm-5.3-flash", timeout=120)
|
||||
assert captured["timeout"] == 300
|
||||
@@ -132,6 +180,7 @@ class TestTransportSelection:
|
||||
def test_pinned_cloud_selects_openai_compat(self):
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
os.environ["MIMO_PROBE_ENABLED"] = "0"
|
||||
# Journal edges resolve via memory_env; on CI the real memory dir is
|
||||
@@ -164,17 +213,25 @@ class TestTransportSelection:
|
||||
|
||||
def run(self, messages, options=None):
|
||||
from rune.tool_protocol import OperatorResponse
|
||||
|
||||
return OperatorResponse(success=True, user_text="done", model=captured["model"])
|
||||
|
||||
import rune.openai_compat_transport as _oct
|
||||
|
||||
with (
|
||||
mock.patch.object(rune_operator, "LocalToolRuntime", FakeRuntime),
|
||||
mock.patch.object(_oct, "cloud_transport_for",
|
||||
side_effect=lambda model, timeout=120: OpenAICompatTransport(
|
||||
"https://mock/v1", "test-key", model.split("/", 1)[0])),
|
||||
mock.patch.object(
|
||||
_oct,
|
||||
"cloud_transport_for",
|
||||
side_effect=lambda model, timeout=120: OpenAICompatTransport(
|
||||
"https://mock/v1", "test-key", model.split("/", 1)[0]
|
||||
),
|
||||
),
|
||||
):
|
||||
op.send_task("Fix the bug in greeting.py: returns 'Helo' not 'Hello'. Verify by running the check script.",
|
||||
model="Verboo Code/glm-5.3-flash")
|
||||
op.send_task(
|
||||
"Fix the bug in greeting.py: returns 'Helo' not 'Hello'. Verify by running the check script.",
|
||||
model="Verboo Code/glm-5.3-flash",
|
||||
)
|
||||
assert isinstance(captured["transport"], OpenAICompatTransport)
|
||||
|
||||
captured.clear()
|
||||
@@ -182,7 +239,6 @@ class TestTransportSelection:
|
||||
op.send_task("inspect the log for errors", model="qwen2.5:3b")
|
||||
assert isinstance(captured["transport"], OllamaChatTransport)
|
||||
|
||||
|
||||
def test_constructor_clamps_timeout(self):
|
||||
"""The transport clamps the wire timeout to >=120s (cloud round trips
|
||||
exceed the local tier timeout — observed live)."""
|
||||
@@ -196,6 +252,8 @@ class TestTransportSelection:
|
||||
assert t.timeout == 120
|
||||
t2 = OpenAICompatTransport("http://x/v1", "k", "Test", timeout=300)
|
||||
assert t2.timeout == 300
|
||||
|
||||
|
||||
def test_timeout_clamped_to_120():
|
||||
"""Cloud round trips with a full tool schema exceed the local tier timeout
|
||||
(30s): observed live, glm-5.3-flash read-timeout at 30.6s mid-iteration.
|
||||
@@ -234,9 +292,7 @@ class TestReasoningContentMapping:
|
||||
consumes names it ``thinking``. The translation must not drop it."""
|
||||
|
||||
def test_reasoning_content_maps_to_thinking(self):
|
||||
resp = {
|
||||
"choices": [{"message": {"role": "assistant", "content": "answer", "reasoning_content": "because"}}]
|
||||
}
|
||||
resp = {"choices": [{"message": {"role": "assistant", "content": "answer", "reasoning_content": "because"}}]}
|
||||
shape = OpenAICompatTransport._from_openai_response(resp)
|
||||
assert shape["thinking"] == "because"
|
||||
assert shape["content"] == "answer"
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""REBASE-COMPLETION (#2587) parity battery — openai_compat_transport.
|
||||
|
||||
Red-first discipline: the battery pins the Go engine's eval surface
|
||||
decision-for-decision against this module's Python oracle bodies. A
|
||||
mutation of an oracle body must flip the battery (oracle rows are the
|
||||
source of truth; the Go engine is the delegation target). Also pins the
|
||||
facade contract itself: fail-loud RuntimeError on a missing binary, no
|
||||
silent fallback; RUNE_OPENAI_COMPAT_TRANSPORT_ENGINE=python escape.
|
||||
|
||||
Oracle bodies under test: _to_openai_messages_python,
|
||||
_from_openai_response_python, _is_cloud_pinned_python.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import openai_compat_transport as oct_ # noqa: E402
|
||||
|
||||
TO_MSGS_CASES = [
|
||||
([{"role": "user", "content": "hi"}], [{"role": "user", "content": "hi"}]),
|
||||
# assistant tool_calls pair with the following tool messages' ids
|
||||
(
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{"function": {"name": "edit_file", "arguments": {"path": "a"}}},
|
||||
{"function": {"name": "read_file", "arguments": {}}},
|
||||
],
|
||||
"content": "working",
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_0_0", "content": "ok"},
|
||||
{"role": "tool", "tool_call_id": "call_0_1", "content": "text"},
|
||||
],
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "working",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_0_0",
|
||||
"type": "function",
|
||||
"function": {"name": "edit_file", "arguments": '{"path": "a"}'},
|
||||
},
|
||||
{"id": "call_0_1", "type": "function", "function": {"name": "read_file", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_0_0", "content": "ok"},
|
||||
{"role": "tool", "tool_call_id": "call_0_1", "content": "text"},
|
||||
],
|
||||
),
|
||||
# unpaired tool_calls mint call_{i}_{k}
|
||||
(
|
||||
[{"role": "assistant", "tool_calls": [{"function": {"name": "f", "arguments": {"x": 1}}}]}],
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{"id": "call_0_0", "type": "function", "function": {"name": "f", "arguments": '{"x": 1}'}}
|
||||
],
|
||||
}
|
||||
],
|
||||
),
|
||||
(
|
||||
[{"role": "system", "content": "sys"}, {"role": "user", "content": ""}],
|
||||
[{"role": "system", "content": "sys"}, {"role": "user", "content": ""}],
|
||||
),
|
||||
]
|
||||
|
||||
_TC2 = {"id": "c1", "function": {"name": "f", "arguments": '{"a": 1}'}}
|
||||
_TC3 = {"function": {"name": "g", "arguments": {"b": 2}}}
|
||||
_TC4 = {"id": "c2", "function": {"name": "f", "arguments": "not json"}}
|
||||
|
||||
FROM_RESPONSE_CASES = [
|
||||
({"choices": [{"message": {"role": "assistant", "content": "hi"}}]}, {"role": "assistant", "content": "hi"}),
|
||||
(
|
||||
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [_TC2, _TC3]}}]},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"function": {"name": "f", "arguments": {"a": 1}}, "id": "c1"},
|
||||
{"function": {"name": "g", "arguments": {"b": 2}}},
|
||||
],
|
||||
},
|
||||
),
|
||||
# DeepSeek-style reasoning_content maps to thinking
|
||||
(
|
||||
{"choices": [{"message": {"role": "assistant", "content": "x", "reasoning_content": "because"}}]},
|
||||
{"role": "assistant", "content": "x", "thinking": "because"},
|
||||
),
|
||||
# unparseable arguments string is wrapped raw
|
||||
(
|
||||
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [_TC4]}}]},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"function": {"name": "f", "arguments": {"_raw": "not json"}}, "id": "c2"}],
|
||||
},
|
||||
),
|
||||
({"choices": []}, {"role": "assistant", "content": ""}),
|
||||
]
|
||||
|
||||
CLOUD_PINNED_CASES = [
|
||||
("Verboo/glm-5.3-flash", True),
|
||||
("ollama/qwen3:14b", False),
|
||||
("local/x", False),
|
||||
("hf.co/user/repo-GGUF", False),
|
||||
("huggingface.co/user/repo-GGUF", False),
|
||||
("qwen3:14b", False),
|
||||
("openai/gpt-x", True),
|
||||
("Xioami/mimo", True),
|
||||
]
|
||||
|
||||
|
||||
def _go_eval_one(op):
|
||||
return oct_._go_eval([op])[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("messages,want", TO_MSGS_CASES)
|
||||
def test_to_openai_messages_go_vs_oracle(messages, want):
|
||||
got = _go_eval_one({"op": "to_openai_messages", "messages": messages})
|
||||
oracle = oct_.OpenAICompatTransport._to_openai_messages_python(messages)
|
||||
assert got == oracle == want
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resp,want", FROM_RESPONSE_CASES)
|
||||
def test_from_openai_response_go_vs_oracle(resp, want):
|
||||
got = _go_eval_one({"op": "from_openai_response", "resp": resp})
|
||||
oracle = oct_.OpenAICompatTransport._from_openai_response_python(resp)
|
||||
assert got == oracle == want
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model,want", CLOUD_PINNED_CASES)
|
||||
def test_is_cloud_pinned_go_vs_oracle(model, want):
|
||||
got = _go_eval_one({"op": "is_cloud_pinned", "model": model})
|
||||
oracle = oct_._is_cloud_pinned_python(model)
|
||||
assert got == oracle == want
|
||||
|
||||
|
||||
def test_engine_disabled_uses_oracle_path():
|
||||
os.environ["RUNE_OPENAI_COMPAT_TRANSPORT_ENGINE"] = "python"
|
||||
try:
|
||||
t = oct_.OpenAICompatTransport("http://x/v1", "k", "T")
|
||||
assert t._to_openai_messages([{"role": "user", "content": "hi"}]) == [{"role": "user", "content": "hi"}]
|
||||
finally:
|
||||
os.environ.pop("RUNE_OPENAI_COMPAT_TRANSPORT_ENGINE", None)
|
||||
|
||||
|
||||
def test_missing_binary_fails_loud(tmp_path):
|
||||
os.environ["RUNE_OPENAI_COMPAT_TRANSPORT_BIN"] = str(tmp_path / "nope")
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="binary missing"):
|
||||
oct_.is_cloud_pinned("Verboo/glm")
|
||||
finally:
|
||||
os.environ.pop("RUNE_OPENAI_COMPAT_TRANSPORT_BIN", None)
|
||||
|
||||
|
||||
def test_bad_ops_fail_loud():
|
||||
with pytest.raises(RuntimeError, match="go engine"):
|
||||
oct_._go_eval([{"op": "definitely_not_an_op"}])
|
||||
@@ -13,10 +13,20 @@ import unittest
|
||||
from pathlib import Path
|
||||
from typing import Sequence, cast
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
# Wave B (#2575): scripted-executor fixtures pin the Python escape hatch
|
||||
# (RUNE_TOOL_EXECUTOR_ENGINE=python); Go delegation is pinned in
|
||||
# tests/test_tool_executor_delegation.py.
|
||||
@pytest.fixture(autouse=True)
|
||||
def _python_tool_executor_engine(monkeypatch):
|
||||
monkeypatch.setenv("RUNE_TOOL_EXECUTOR_ENGINE", "python")
|
||||
|
||||
|
||||
from rune.local_tool_runtime import ( # noqa: E402
|
||||
ChatMessage,
|
||||
LocalToolRuntime,
|
||||
|
||||
+80
-215
@@ -1,238 +1,103 @@
|
||||
"""Frozen battery for scripts/tag_repair.py — the release pipeline deep-dive
|
||||
fix (wave-release-pipeline-deepdive, owner queue item 5).
|
||||
"""Red-first tests for the namespace-aware release-tag resolution (#2582).
|
||||
|
||||
Pins, red-first against the pre-fix pipeline logic:
|
||||
|
||||
1. SIGPIPE safety: `git tag --sort=-v:refname | head -1` under
|
||||
`set -o pipefail` exits 141 (SIGPIPE) on a many-tag repo — the exact
|
||||
defect that killed the auto-version bump step on its first line in CI
|
||||
(runs 29929/30435/30636: "Process completed with exit code 141", zero
|
||||
step output — BEFORE the 0.312.0/0.313.0/0.314.0 master-tip fallback
|
||||
could ever run). latest_release_tag() must read the same tag with NO
|
||||
downstream pipe (git for-each-ref --count=1) and exit 0.
|
||||
|
||||
2. ensure_tag idempotency: when the tui-v<version> tag already exists on
|
||||
the remote, ensure is a noop — never re-tags, never pushes.
|
||||
|
||||
3. Bump-sha-unusable fallback: when the bump commit sha is unusable
|
||||
(missing object / "?" / empty), the tag target falls back to the
|
||||
fetched master tip — the repair the 0.312.x-era fallback step
|
||||
implemented but could never reach.
|
||||
|
||||
4. Deadlock self-heal: the version>tag state (bump landed, tag missed)
|
||||
must plan a repair (create+push the missing tui tag at the fetched
|
||||
tip) — NOT the skip-forever that the pre-fix convergence path
|
||||
produced (which required the manual "re-arm convergence" commit
|
||||
360665b1, 2026-10-19).
|
||||
The version-push reader bug: latest_release_tag() sorted ALL tags, so the
|
||||
TUI build namespace (tui-v*) could capture the main-line v* range lookup —
|
||||
the release-note step then started its range at the TUI tag (cut hours or
|
||||
minutes before) and every wave of work vanished from the release body
|
||||
("No substantive changes" on a 21-commit day, observed on v0.346.0).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO / "scripts"))
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import tag_repair # noqa: E402
|
||||
from tag_repair import latest_release_tag # noqa: E402
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
proc = subprocess.run(
|
||||
["git", *args], cwd=str(repo), check=True, capture_output=True, text=True
|
||||
)
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def _commit(repo: Path, msg: str) -> str:
|
||||
(repo / "f.txt").write_text(msg + "\n")
|
||||
_git(repo, "add", ".")
|
||||
_git(repo, "commit", "-qm", msg)
|
||||
return _git(repo, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tag_farm(tmp_path: Path) -> Path:
|
||||
"""A git repo with a base commit and 700 release tags — the CI tag
|
||||
population that triggers the SIGPIPE (any real rune checkout has
|
||||
hundreds of v* tags)."""
|
||||
repo = tmp_path / "farm"
|
||||
def _repo_with_tags(tmp_path, tags):
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q", "-b", "master")
|
||||
_git(repo, "config", "user.email", "t@example.invalid")
|
||||
_git(repo, "config", "user.name", "t")
|
||||
(repo / "f.txt").write_text("x\n")
|
||||
_git(repo, "add", ".")
|
||||
_git(repo, "commit", "-qm", "base")
|
||||
base = _git(repo, "rev-parse", "HEAD")
|
||||
for i in range(700):
|
||||
_git(repo, "tag", f"v0.{i}.0", base)
|
||||
def git(*args):
|
||||
subprocess.run(["git", "-C", str(repo), *args], check=True,
|
||||
capture_output=True)
|
||||
git("init", "-q", "-b", "master")
|
||||
(repo / "seed").write_text("x")
|
||||
git("add", ".")
|
||||
git("-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "seed")
|
||||
for tag in tags:
|
||||
subprocess.run(["git", "-C", str(repo), "tag", "-a", tag, "-m", tag],
|
||||
check=True, capture_output=True,
|
||||
env={"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
|
||||
"PATH": "/usr/bin:/bin"})
|
||||
return repo
|
||||
|
||||
|
||||
class TestSigpipeSafety:
|
||||
def test_naive_pipe_actually_fails_141(self, tag_farm: Path):
|
||||
"""Pin the disease: the pre-fix pipeline read exits 141 under the
|
||||
runner's `bash -e -o pipefail`. If this stops reproducing (git
|
||||
change), the battery loses its red anchor and must be revisited."""
|
||||
proc = subprocess.run(
|
||||
["bash", "-c", "set -o pipefail; git tag --sort=-v:refname | head -1"],
|
||||
cwd=str(tag_farm), capture_output=True, text=True,
|
||||
)
|
||||
assert proc.returncode == 141, (
|
||||
f"expected the naive pipe to die with SIGPIPE 141, got {proc.returncode}"
|
||||
)
|
||||
assert proc.stdout.strip() == "v0.699.0"
|
||||
|
||||
def test_latest_release_tag_is_sigpipe_free(self, tag_farm: Path):
|
||||
assert tag_repair.latest_release_tag(tag_farm) == "v0.699.0"
|
||||
|
||||
def test_latest_release_tag_via_cli_is_sigpipe_free(self, tag_farm: Path):
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(REPO / "scripts" / "tag_repair.py"), "--latest-tag"],
|
||||
cwd=str(tag_farm), capture_output=True, text=True,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert proc.stdout.strip() == "v0.699.0"
|
||||
|
||||
def test_latest_release_tag_no_downstream_pipe(self, tag_farm: Path):
|
||||
"""The implementation must not spawn a pipeline: every produced
|
||||
process tree for the read must be a single git process."""
|
||||
probe = tag_farm / "probe.sh"
|
||||
probe.write_text(
|
||||
"#!/bin/sh\n"
|
||||
f'exec python3 "{REPO / "scripts" / "tag_repair.py"}" --latest-tag\n'
|
||||
)
|
||||
probe.chmod(0o755)
|
||||
out = subprocess.run(
|
||||
["bash", "-c", f"set -o pipefail; {probe} | cat"],
|
||||
cwd=str(tag_farm), capture_output=True, text=True,
|
||||
)
|
||||
assert out.returncode == 0
|
||||
assert out.stdout.strip() == "v0.699.0"
|
||||
|
||||
def test_latest_release_tag_empty_repo(self, tmp_path: Path):
|
||||
repo = tmp_path / "empty"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q", "-b", "master")
|
||||
assert tag_repair.latest_release_tag(repo) is None
|
||||
def test_latest_tag_default_is_main_namespace(tmp_path):
|
||||
"""The default lookup ignores the TUI build namespace entirely."""
|
||||
repo = _repo_with_tags(tmp_path, ["v0.9.0", "tui-v0.10.0", "v0.10.0"])
|
||||
assert latest_release_tag(repo) == "v0.10.0"
|
||||
|
||||
|
||||
class TestEnsureTag:
|
||||
|
||||
def test_ensure_tags_fetched_tip_when_bump_sha_unusable(self, tmp_path: Path):
|
||||
repo = tmp_path / "r"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q", "-b", "master")
|
||||
_git(repo, "config", "user.email", "t@example.invalid")
|
||||
_git(repo, "config", "user.name", "t")
|
||||
tip = _commit(repo, "chore(release): auto-bump version to 0.315.0")
|
||||
plan = tag_repair.plan_ensure_tag(
|
||||
version="0.315.0", bump_sha="?", repo=repo
|
||||
)
|
||||
assert plan["action"] == "tag"
|
||||
assert plan["target"] == tip
|
||||
|
||||
tag_repair.ensure_tag(version="0.315.0", bump_sha="?", repo=repo, push=False)
|
||||
assert _git(repo, "rev-parse", "refs/tags/tui-v0.315.0^{}") == tip
|
||||
|
||||
def test_ensure_prefers_valid_bump_sha(self, tmp_path: Path):
|
||||
repo = tmp_path / "r"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q", "-b", "master")
|
||||
_git(repo, "config", "user.email", "t@example.invalid")
|
||||
_git(repo, "config", "user.name", "t")
|
||||
_commit(repo, "a")
|
||||
bump = _commit(repo, "chore(release): auto-bump version to 1.0.0")
|
||||
head_before = _git(repo, "rev-parse", "HEAD")
|
||||
plan = tag_repair.plan_ensure_tag(version="1.0.0", bump_sha=bump, repo=repo)
|
||||
assert plan["action"] == "tag"
|
||||
assert plan["target"] == bump
|
||||
tag_repair.ensure_tag(version="1.0.0", bump_sha=bump, repo=repo, push=False)
|
||||
assert _git(repo, "rev-parse", "refs/tags/tui-v1.0.0^{}") == bump
|
||||
assert _git(repo, "rev-parse", "HEAD") == head_before # nothing moved
|
||||
|
||||
def test_ensure_idempotent_when_tag_exists(self, tmp_path: Path):
|
||||
repo = tmp_path / "r"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q", "-b", "master")
|
||||
_git(repo, "config", "user.email", "t@example.invalid")
|
||||
_git(repo, "config", "user.name", "t")
|
||||
tip = _commit(repo, "c1")
|
||||
tag_repair.ensure_tag(version="0.2.0", bump_sha="", repo=repo, push=False)
|
||||
later = _commit(repo, "c2")
|
||||
plan = tag_repair.plan_ensure_tag(version="0.2.0", bump_sha="", repo=repo)
|
||||
assert plan["action"] == "noop"
|
||||
tag_repair.ensure_tag(version="0.2.0", bump_sha="", repo=repo, push=False)
|
||||
assert _git(repo, "rev-parse", "refs/tags/tui-v0.2.0^{}") == tip
|
||||
assert _git(repo, "rev-parse", "HEAD") == later
|
||||
def test_latest_tag_ignores_tui_only_state(tmp_path):
|
||||
"""A repo whose newest tag is a TUI tag still yields the main v* tag."""
|
||||
repo = _repo_with_tags(tmp_path, ["v0.9.0", "tui-v0.50.0"])
|
||||
assert latest_release_tag(repo) == "v0.9.0"
|
||||
|
||||
|
||||
class TestDeadlockSelfHeal:
|
||||
def test_version_ahead_of_tag_plans_repair_not_skip(self, tmp_path: Path):
|
||||
"""The deadlock state: master's version is ahead of the latest tag
|
||||
and the tui tag for the master version is missing. The plan must
|
||||
be a repair (tag the fetched tip), never a skip."""
|
||||
repo = tmp_path / "r"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q", "-b", "master")
|
||||
_git(repo, "config", "user.email", "t@example.invalid")
|
||||
_git(repo, "config", "user.name", "t")
|
||||
_commit(repo, "c1")
|
||||
_git(repo, "tag", "tui-v0.310.0")
|
||||
_commit(repo, "chore(release): auto-bump version to 0.314.0")
|
||||
plan = tag_repair.self_heal_plan(
|
||||
version="0.314.0", latest_release_tag="v0.310.0", repo=repo
|
||||
)
|
||||
assert plan["action"] == "repair"
|
||||
assert plan["tag"] == "tui-v0.314.0"
|
||||
assert plan["skip"] is False
|
||||
|
||||
def test_converged_state_plans_noop(self, tmp_path: Path):
|
||||
repo = tmp_path / "r"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q", "-b", "master")
|
||||
_git(repo, "config", "user.email", "t@example.invalid")
|
||||
_git(repo, "config", "user.name", "t")
|
||||
_commit(repo, "c1")
|
||||
_git(repo, "tag", "tui-v0.314.0")
|
||||
plan = tag_repair.self_heal_plan(
|
||||
version="0.314.0", latest_release_tag="v0.314.0", repo=repo
|
||||
)
|
||||
assert plan["action"] == "noop"
|
||||
|
||||
def test_version_not_ahead_plans_noop(self, tmp_path: Path):
|
||||
repo = tmp_path / "r"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q", "-b", "master")
|
||||
_git(repo, "config", "user.email", "t@example.invalid")
|
||||
_git(repo, "config", "user.name", "t")
|
||||
_commit(repo, "c1")
|
||||
_git(repo, "tag", "tui-v0.320.0")
|
||||
plan = tag_repair.self_heal_plan(
|
||||
version="0.314.0", latest_release_tag="v0.320.0", repo=repo
|
||||
)
|
||||
assert plan["action"] == "noop"
|
||||
def test_latest_tag_namespaces_are_selectable(tmp_path):
|
||||
"""The TUI workflow can ask for its own namespace explicitly."""
|
||||
repo = _repo_with_tags(tmp_path, ["v0.9.0", "tui-v0.50.0"])
|
||||
assert latest_release_tag(repo, namespace="tui") == "tui-v0.50.0"
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_cli_plan_json(self, tmp_path: Path, capsys):
|
||||
repo = tmp_path / "r"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q", "-b", "master")
|
||||
_git(repo, "config", "user.email", "t@example.invalid")
|
||||
_git(repo, "config", "user.name", "t")
|
||||
_commit(repo, "c1")
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(REPO / "scripts" / "tag_repair.py"),
|
||||
"--plan", "--version", "0.315.0", "--bump-sha", ""],
|
||||
cwd=str(repo), capture_output=True, text=True,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
plan = json.loads(proc.stdout)
|
||||
assert plan["action"] == "tag"
|
||||
assert plan["tag"] == "tui-v0.315.0"
|
||||
def test_latest_tag_empty_repo_is_none(tmp_path):
|
||||
repo = tmp_path / "empty"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "-C", str(repo), "init", "-q", "-b", "master"], check=True)
|
||||
assert latest_release_tag(repo) is None
|
||||
|
||||
|
||||
# --- CLI level: the workflow invokes `tag_repair.py --latest-tag --namespace
|
||||
# main` through argparse. Run 33866 (2026-10-24) died here — the namespace
|
||||
# existed only as a function parameter, argparse rejected `--namespace main`
|
||||
# and the whole release step aborted. These tests run the CLI subprocess with
|
||||
# the exact argv the workflow uses; they stay frozen as regression teeth.
|
||||
|
||||
|
||||
def _run_cli(args, repo):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(ROOT / "scripts" / "tag_repair.py"), *args,
|
||||
"--repo", str(repo)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def test_cli_latest_tag_workflows_argv_main(tmp_path):
|
||||
"""Exact workflow argv resolves the MAIN-line tag through argparse."""
|
||||
repo = _repo_with_tags(tmp_path, ["v0.9.0", "tui-v0.50.0"])
|
||||
proc = _run_cli(["--latest-tag", "--namespace", "main"], repo)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert proc.stdout.strip() == "v0.9.0"
|
||||
|
||||
|
||||
def test_cli_latest_tag_namespace_tui(tmp_path):
|
||||
"""--namespace tui reaches the TUI build line through argparse."""
|
||||
repo = _repo_with_tags(tmp_path, ["v0.9.0", "tui-v0.50.0"])
|
||||
proc = _run_cli(["--latest-tag", "--namespace", "tui"], repo)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert proc.stdout.strip() == "tui-v0.50.0"
|
||||
|
||||
|
||||
def test_cli_latest_tag_without_flag_is_main(tmp_path):
|
||||
"""No --namespace still means main — pre-existing callers keep behavior."""
|
||||
repo = _repo_with_tags(tmp_path, ["v0.9.0", "tui-v0.50.0"])
|
||||
proc = _run_cli(["--latest-tag"], repo)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert proc.stdout.strip() == "v0.9.0"
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Red-first parity battery, wave B (Gitea #2575): the Go tool-executor
|
||||
engine vs the Python oracle through the LocalToolRuntime delegation facade.
|
||||
|
||||
The four read-only file operations (read_file, list_directory, glob_files,
|
||||
grep_files) resolve through src/go/tool-executor by default; the Python
|
||||
bodies in tool_executor.py stay as the oracle/escape hatch
|
||||
(RUNE_TOOL_EXECUTOR_ENGINE=python). This battery pins the facade to the
|
||||
oracle envelope-for-envelope over fixture calls, checks the fail-loud
|
||||
refusal when the engine binary is missing, and receipts that run_command
|
||||
NEVER delegates (mutating — the approval door owns it).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(_ROOT / "src"))
|
||||
|
||||
from rune.local_tool_runtime import ( # noqa: E402
|
||||
_GO_EXECUTOR_OPS,
|
||||
_GoDelegatingExecutor,
|
||||
_go_execute_tool,
|
||||
_lte_go_engine_bin,
|
||||
_lte_go_engine_enabled,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sandbox(tmp_path, monkeypatch):
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "docs" / "a.txt").write_text("alpha line\nbeta line\ngamma line\n")
|
||||
(tmp_path / "docs" / "b.py").write_text("def one():\n return 1\n")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _oracle(tool_name, arguments):
|
||||
from rune.tool_executor import execute_tool
|
||||
|
||||
old = os.environ.get("RUNE_TOOL_EXECUTOR_ENGINE")
|
||||
os.environ["RUNE_TOOL_EXECUTOR_ENGINE"] = "python"
|
||||
inner = _GoDelegatingExecutor(execute_tool)
|
||||
try:
|
||||
return inner(tool_name, arguments)
|
||||
finally:
|
||||
if old is None:
|
||||
os.environ.pop("RUNE_TOOL_EXECUTOR_ENGINE", None)
|
||||
else:
|
||||
os.environ["RUNE_TOOL_EXECUTOR_ENGINE"] = old
|
||||
|
||||
|
||||
def _facade(tool_name, arguments):
|
||||
return _GoDelegatingExecutor(lambda n, a: (_ for _ in ()).throw(
|
||||
AssertionError("inner executor must not be reached for read-only ops"))
|
||||
)(tool_name, arguments)
|
||||
|
||||
|
||||
CASES = [
|
||||
("read_file", {"path": "docs/a.txt"}),
|
||||
("read_file", {"path": "docs/a.txt", "offset": 2, "limit": 1}),
|
||||
("list_directory", {"path": "docs"}),
|
||||
("list_directory", {"path": "docs", "pattern": "*.txt"}),
|
||||
("glob_files", {"pattern": "docs/*.py"}),
|
||||
("glob_files", {"pattern": "docs/*"}),
|
||||
("grep_files", {"pattern": "line", "path": "docs"}),
|
||||
("grep_files", {"pattern": "line", "path": "docs", "include": "*.txt"}),
|
||||
("read_file", {"path": "docs/missing.txt"}),
|
||||
("list_directory", {"path": "docs/missing-dir"}),
|
||||
("grep_files", {"pattern": "zzz-no-match", "path": "docs"}),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("tool_name,arguments", CASES)
|
||||
def test_go_delegation_matches_python_oracle_envelope(sandbox, tool_name, arguments):
|
||||
assert _jbytes(_facade(tool_name, dict(arguments))) == _jbytes(
|
||||
_oracle(tool_name, dict(arguments))
|
||||
)
|
||||
|
||||
def _jbytes(value):
|
||||
return json.dumps(value, sort_keys=True, ensure_ascii=True)
|
||||
|
||||
def test_default_engine_is_go():
|
||||
os.environ.pop("RUNE_TOOL_EXECUTOR_ENGINE", None)
|
||||
assert _lte_go_engine_enabled() is True
|
||||
|
||||
def test_python_escape_hatch_reaches_inner_executor(sandbox):
|
||||
from rune.tool_executor import execute_tool
|
||||
|
||||
os.environ["RUNE_TOOL_EXECUTOR_ENGINE"] = "python"
|
||||
try:
|
||||
seen = []
|
||||
def inner(name, args):
|
||||
seen.append(name)
|
||||
return {"success": True, "result": "oracle"}
|
||||
out = _GoDelegatingExecutor(inner)("read_file", {"path": "docs/a.txt"})
|
||||
finally:
|
||||
os.environ.pop("RUNE_TOOL_EXECUTOR_ENGINE", None)
|
||||
assert seen == ["read_file"]
|
||||
assert out == {"success": True, "result": "oracle"}
|
||||
|
||||
def test_fail_loud_when_engine_binary_missing(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"rune.local_tool_runtime._lte_go_engine_bin",
|
||||
lambda: str(tmp_path / "nope"),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="go engine binary missing"):
|
||||
_go_execute_tool("read_file", {"path": "whatever"})
|
||||
|
||||
def test_run_command_never_delegates_even_in_go_mode():
|
||||
# run_command is mutating — the approval door owns every dispatch.
|
||||
assert "run_command" not in _GO_EXECUTOR_OPS
|
||||
seen = []
|
||||
def inner(name, args):
|
||||
seen.append(name)
|
||||
return {"success": True, "result": "authority path"}
|
||||
os.environ.pop("RUNE_TOOL_EXECUTOR_ENGINE", None)
|
||||
out = _GoDelegatingExecutor(inner)("run_command", {"command": "echo hi"})
|
||||
assert seen == ["run_command"]
|
||||
assert out == {"success": True, "result": "authority path"}
|
||||
|
||||
def test_runtime_wraps_executor_and_keeps_attribute_seam():
|
||||
from rune.local_tool_runtime import LocalToolRuntime
|
||||
|
||||
class Transport:
|
||||
def supports_tools(self, model):
|
||||
return False
|
||||
def supports_thinking(self, model):
|
||||
return False
|
||||
|
||||
def executor(name, args):
|
||||
return {"success": True, "result": "inner"}
|
||||
|
||||
runtime = LocalToolRuntime(
|
||||
transport=Transport(), executor=executor, model="qwen3:8b"
|
||||
)
|
||||
assert isinstance(runtime.executor, _GoDelegatingExecutor)
|
||||
assert getattr(runtime.executor, "_uses_tool_authority", "sentinel") == "sentinel"
|
||||
assert _lte_go_engine_bin().endswith(os.path.join("src", "go", "tool-executor", "tool-executor"))
|
||||
@@ -16,6 +16,14 @@ from rune import structural_model as sm # noqa: E402
|
||||
|
||||
|
||||
# ── 18K: workspace lifecycle ─────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _python_engine(monkeypatch):
|
||||
"""Oracle suite: pins the Python bodies via monkeypatched seams; the
|
||||
Go delegation is pinned by tests/test_forge_client_go_parity.py."""
|
||||
monkeypatch.setenv("RUNE_FORGE_CLIENT_ENGINE", "python")
|
||||
|
||||
|
||||
def test_18k_lifecycle_order_enforced():
|
||||
rec = sm.workspace_record(contract_id="c-1", construction_base="abc123",
|
||||
runtime_generation="dep-927548e",
|
||||
|
||||
+23
-20
@@ -26,18 +26,14 @@ import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"rune.dev/rune/tui/themes"
|
||||
)
|
||||
|
||||
// The V-B captured confirm palette (research/factory-visual-round
|
||||
// 02-inward CB-05/CB-18, owner-viewed in the amalgamation): gold
|
||||
// border on dark amber, gold text. Contrast of #FFDC64 on #281E0A is
|
||||
// >9:1 (the WCAG AA lift owed elsewhere does not regress here).
|
||||
const (
|
||||
goldBorder = "#C8A028" // rgb(200,160,40) — the THICK border
|
||||
goldBG = "#281E0A" // rgb(40,30,10) — the confirm background
|
||||
goldText = "#FFDC64" // rgb(255,220,100) — the confirm text
|
||||
)
|
||||
|
||||
// Button labels — the keyboard spellings visible on the card (the
|
||||
// visible keyboard-equivalent law). The geometry accessors below are
|
||||
// the ONE source for both the rendered row and the click regions, so
|
||||
@@ -144,17 +140,16 @@ func previewOf(name string, args, toolResult any) []string {
|
||||
return clipRows([]string{"$ " + cmd})
|
||||
}
|
||||
}
|
||||
if name == "write_file" || name == "edit_file" {
|
||||
if args != nil {
|
||||
if content := argString(args.(map[string]any), "content"); content != "" {
|
||||
n := strings.Count(content, "\n") + 1
|
||||
return clipRows([]string{strconv.Itoa(n) + " rows of new content (no diff prepared)"})
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsWriteTool reports the D49 write family: capability is pane-wide,
|
||||
// so these tools are never a permission gate — a pending write card
|
||||
// is work promotion (organizational), never a face block.
|
||||
func IsWriteTool(name string) bool {
|
||||
return name == "write_file" || name == "edit_file"
|
||||
}
|
||||
|
||||
func argString(args map[string]any, key string) string {
|
||||
if s, ok := args[key].(string); ok {
|
||||
return s
|
||||
@@ -185,18 +180,26 @@ func clipRows(rows []string) []string {
|
||||
|
||||
var cardStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.ThickBorder()).
|
||||
BorderForeground(lipgloss.Color(goldBorder)).
|
||||
Background(lipgloss.Color(goldBG)).
|
||||
Foreground(lipgloss.Color(goldText))
|
||||
BorderForeground(lipgloss.Color(themes.RoleHex(themes.RoleConfirmBorder))).
|
||||
Background(lipgloss.Color(themes.RoleHex(themes.RoleConfirmBG))).
|
||||
Foreground(lipgloss.Color(themes.RoleHex(themes.RoleConfirmText)))
|
||||
|
||||
// plainStyle is the degraded card (terminals narrower than the
|
||||
// border needs): the same content, no ANSI geometry.
|
||||
var plainStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(goldBorder))
|
||||
Foreground(lipgloss.Color(themes.RoleHex(themes.RoleConfirmBorder)))
|
||||
|
||||
// Header is the card's first content row (the amalgamation's
|
||||
// ⚠ CONFIRM law, naming the tool and target).
|
||||
// Header is the card's first content row: the ⚠ CONFIRM law naming
|
||||
// the tool and target. The D49 wording law: a write tool's card
|
||||
// reads as WORK promotion (capability is pane-wide), never as the
|
||||
// permission vocabulary; genuinely gated tools keep APPROVAL.
|
||||
func (c *Card) Header() string {
|
||||
if IsWriteTool(c.Tool) {
|
||||
if c.Target != "" {
|
||||
return "⚠ WORK · " + c.Tool + " · " + c.Target + " — writes run in every pane (D49)"
|
||||
}
|
||||
return "⚠ WORK · " + c.Tool + " — writes run in every pane (D49)"
|
||||
}
|
||||
if c.Target != "" {
|
||||
return "⚠ APPROVAL · " + c.Tool + " · " + c.Target
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package approvals
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// D55 (the D49 consent=promotion follow-through): the approval card
|
||||
// is NOT a permission gate for write tools — capability is pane-wide.
|
||||
// The card's write/edit preview special-case (the fabricated
|
||||
// "N rows of new content" gate shape) is retired, and the write
|
||||
// card's wording reads as WORK promotion, not approval.
|
||||
|
||||
// TestWritePreviewSpecialCaseRetired: a pending write_file with no
|
||||
// core-prepared diff must NOT fabricate the "rows of new content"
|
||||
// permission-gate preview — the card shows the header + target, the
|
||||
// honest shape.
|
||||
func TestWritePreviewSpecialCaseRetired(t *testing.T) {
|
||||
blk := map[string]any{
|
||||
"pending_approval": map[string]any{
|
||||
"call_id": "call-d55",
|
||||
"name": "write_file",
|
||||
"arguments": map[string]any{
|
||||
"path": "store/migrate.py",
|
||||
"content": "one\ntwo\nthree\n",
|
||||
},
|
||||
},
|
||||
}
|
||||
c := Parse(blk)
|
||||
if c == nil {
|
||||
t.Fatal("fixture: the write card must parse")
|
||||
}
|
||||
joined := strings.Join(c.Preview, "\n")
|
||||
if strings.Contains(joined, "rows of new content") {
|
||||
t.Fatalf("the write/edit preview special-case is retired (D49: writes are not permission-gated): %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteCardReadsAsWork: a write/edit card's header names the WORK
|
||||
// (promotion wording, D49), never the permission vocabulary; a
|
||||
// genuinely gated tool (run_command) keeps the APPROVAL wording.
|
||||
func TestWriteCardReadsAsWork(t *testing.T) {
|
||||
w := &Card{Tool: "write_file", Target: "store/migrate.py"}
|
||||
h := w.Header()
|
||||
if !strings.Contains(h, "WORK") {
|
||||
t.Fatalf("a write card must read as work promotion (D49): %q", h)
|
||||
}
|
||||
if strings.Contains(h, "APPROVAL") {
|
||||
t.Fatalf("a write card must not read as a permission gate: %q", h)
|
||||
}
|
||||
if !strings.Contains(h, "every pane") {
|
||||
t.Fatalf("the D49 capability wording must be present: %q", h)
|
||||
}
|
||||
rc := &Card{Tool: "run_command", Target: "pytest -x"}
|
||||
if !strings.Contains(rc.Header(), "APPROVAL") {
|
||||
t.Fatalf("a gated tool keeps the approval wording: %q", rc.Header())
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsWriteToolNamesThePaneWideFamily: the D49 write family is
|
||||
// exactly write_file and edit_file.
|
||||
func TestIsWriteToolNamesThePaneWideFamily(t *testing.T) {
|
||||
for _, name := range []string{"write_file", "edit_file"} {
|
||||
if !IsWriteTool(name) {
|
||||
t.Fatalf("%s is the D49 write family", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"run_command", "git_commit", "read_file"} {
|
||||
if IsWriteTool(name) {
|
||||
t.Fatalf("%s is not in the write family", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func TestCardRendersGoldConfirmStyling(t *testing.T) {
|
||||
if !strings.Contains(joined, "┏") || !strings.Contains(joined, "┗") {
|
||||
t.Fatal("the confirm card must draw the V-B THICK box (┏…┗)")
|
||||
}
|
||||
if !strings.Contains(joined, "⚠ APPROVAL · write_file") {
|
||||
if !strings.Contains(joined, "⚠ WORK · write_file") {
|
||||
t.Fatal("the header must name the tool (the amalgamation's CONFIRM law)")
|
||||
}
|
||||
if !strings.Contains(joined, BtnYesLabel) || !strings.Contains(joined, BtnNoLabel) {
|
||||
|
||||
@@ -191,6 +191,12 @@ type Bridge struct {
|
||||
ctxUsed int64
|
||||
ctxLimit int64
|
||||
|
||||
// governor (D56): the latest status round's dispatch-governor
|
||||
// readout — measured tok/s and lanes in use vs the learned cap.
|
||||
govTok float64
|
||||
govLanes int
|
||||
govCap int
|
||||
|
||||
// chatDepth is the chat-channel window depth (messages) — the
|
||||
// scrollback deeper-page knob (depth.go), grow-only, wake-armed.
|
||||
chatDepth int
|
||||
@@ -257,6 +263,7 @@ func (b *Bridge) pollOnce(p *tea.Program) {
|
||||
b.record(pongMsg(b.nextID(), nonce))
|
||||
b.record(snapshotMsg(b.nextID(), body))
|
||||
b.setCtx(body.Context.Used, body.Context.Limit)
|
||||
b.setGovernor(body.Governor.TokensPerSec, body.Governor.LanesActive, body.Governor.LanesCap)
|
||||
|
||||
msgs, err := b.serve.Conversation(ctx, DefaultChannel, convWindow)
|
||||
if err != nil {
|
||||
@@ -373,6 +380,22 @@ func (b *Bridge) Ctx() (used, limit int64) {
|
||||
return b.ctxUsed, b.ctxLimit
|
||||
}
|
||||
|
||||
// setGovernor stores the latest status round's governor readout (D56).
|
||||
func (b *Bridge) setGovernor(tokPerSec float64, lanes, cap int) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.govTok, b.govLanes, b.govCap = tokPerSec, lanes, cap
|
||||
}
|
||||
|
||||
// Governor reports the dispatch governor's honest live state: the
|
||||
// measured tok/s (0 = nothing measured yet — the bar renders '—',
|
||||
// never a guess) and the lanes in use against the learned cap.
|
||||
func (b *Bridge) Governor() (tokPerSec float64, lanes, cap int) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.govTok, b.govLanes, b.govCap
|
||||
}
|
||||
|
||||
// Token reports the honest connection state for the identity line.
|
||||
func (b *Bridge) Token() string {
|
||||
b.mu.Lock()
|
||||
|
||||
@@ -43,6 +43,16 @@ type StatusBody struct {
|
||||
Used int64 `json:"used"`
|
||||
Limit int64 `json:"limit"`
|
||||
} `json:"context"`
|
||||
// Governor (D56): the dispatch governor's honest live state —
|
||||
// tok/s measured (0 = nothing measured yet), lanes in use vs cap.
|
||||
Governor GovernorState `json:"governor"`
|
||||
}
|
||||
|
||||
// GovernorState is the /v1/status governor section the TUI renders.
|
||||
type GovernorState struct {
|
||||
TokensPerSec float64 `json:"tokens_per_sec"`
|
||||
LanesActive int `json:"lanes_active"`
|
||||
LanesCap int `json:"lanes_cap"`
|
||||
}
|
||||
|
||||
// ConvMessage is one GET /v1/conversation window entry — the durable
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package bridge
|
||||
|
||||
// d56_governor_test.go — the D56 governor readout's seam laws on BOTH
|
||||
// paths: /v1/status's governor section lands in Governor() (serve),
|
||||
// and the library seam carries the StatusRow governor fields the same
|
||||
// way (the info bar reads both).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"rune.dev/rune/tui/runtime/lib"
|
||||
)
|
||||
|
||||
func TestServeSeamCarriesGovernor(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"identity": "rune", "role": "assistant", "current_task": nil,
|
||||
"context": map[string]int{"used": 1, "limit": 2},
|
||||
"governor": map[string]any{"tokens_per_sec": 38.6, "lanes_active": 2, "lanes_cap": 5},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
b := New(NewServeClient(srv.URL), 5*time.Millisecond)
|
||||
b.Start(nil)
|
||||
t.Cleanup(b.Close)
|
||||
waitFor(t, "governor readout (serve seam)", func() bool {
|
||||
tok, lanes, cap := b.Governor()
|
||||
return int(tok+0.5) == 39 && lanes == 2 && cap == 5
|
||||
})
|
||||
}
|
||||
|
||||
func TestLibrarySeamCarriesGovernor(t *testing.T) {
|
||||
fl := &fakeLib{
|
||||
st: lib.StatusRow{Identity: "rune", GovTokPerSec: 12.5, GovLanes: 1, GovCap: 3},
|
||||
}
|
||||
b := NewLibrary(fl, 5*time.Millisecond)
|
||||
b.Start(nil)
|
||||
t.Cleanup(b.Close)
|
||||
waitFor(t, "governor readout (lib seam)", func() bool {
|
||||
tok, lanes, cap := b.Governor()
|
||||
return int(tok) == 12 && lanes == 1 && cap == 3
|
||||
})
|
||||
}
|
||||
|
||||
func TestGovernorDefaultsHonestZero(t *testing.T) {
|
||||
// a bridge that never polled reports zeros — never a guessed rate
|
||||
b := New(NewServeClient("http://127.0.0.1:1"), time.Hour)
|
||||
tok, lanes, cap := b.Governor()
|
||||
if tok != 0 || lanes != 0 || cap != 0 {
|
||||
t.Fatalf("unpolled bridge governor = %v/%d/%d, want zeros", tok, lanes, cap)
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ func (b *Bridge) pollLibrary(p *tea.Program) {
|
||||
b.record(pongMsg(b.nextID(), nonce))
|
||||
b.record(libSnapshotMsg(b.nextID(), st))
|
||||
b.setCtx(st.CtxUsed, st.CtxLimit)
|
||||
b.setGovernor(st.GovTokPerSec, st.GovLanes, st.GovCap)
|
||||
|
||||
taskRows, _, err := b.libSrc.Tasks(ctx)
|
||||
var ownerOrder []string
|
||||
|
||||
@@ -508,7 +508,7 @@ func boxAround(rows []string, w int, styled bool, border lipgloss.Style) []strin
|
||||
func semantic_stripANSI(s string) string { return stripANSI(s) }
|
||||
|
||||
var assistantHeaderStyle = lipgloss.NewStyle().
|
||||
Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#7EE787")).Bold(true)
|
||||
Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color(themes.Tone("assistant"))).Bold(true)
|
||||
|
||||
// assistantRailStyle is the rail's speaker-coloured bar (B2.3).
|
||||
var assistantRailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
|
||||
|
||||
+19
-19
@@ -240,22 +240,22 @@ func ApplyCalm(on bool) {
|
||||
func refreshRoleStyles() {
|
||||
bg := lipgloss.Color(themes.RoleHex(themes.RoleBackground))
|
||||
dim := lipgloss.Color(themes.RoleHex(themes.RoleMetaDim))
|
||||
userStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#7FD4FF"))
|
||||
assistantStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#7EE787"))
|
||||
systemStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#A0A0B2"))
|
||||
confirmStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#E3B341"))
|
||||
researchStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#58A6FF"))
|
||||
errorStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#F87171"))
|
||||
toolStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#B8B85E"))
|
||||
userStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color(themes.Tone("user")))
|
||||
assistantStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color(themes.Tone("assistant")))
|
||||
systemStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color(themes.Tone("system")))
|
||||
confirmStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color(themes.Tone("confirm")))
|
||||
researchStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color(themes.Tone("research")))
|
||||
errorStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color(themes.Tone("error")))
|
||||
toolStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color(themes.Tone("tool")))
|
||||
dimStyle = lipgloss.NewStyle().Background(bg).Foreground(dim)
|
||||
runeStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#C4A0FF"))
|
||||
runeStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color(themes.Tone("rune")))
|
||||
tsStyle = lipgloss.NewStyle().Background(bg).Foreground(dim)
|
||||
accentLiveStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
|
||||
userBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF9ED8"))
|
||||
userBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.Tone("user-border")))
|
||||
assistantBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
|
||||
confirmBorderStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
|
||||
assistantHeaderStyle = lipgloss.NewStyle().
|
||||
Background(bg).Foreground(lipgloss.Color("#7EE787")).Bold(true)
|
||||
Background(bg).Foreground(lipgloss.Color(themes.Tone("assistant"))).Bold(true)
|
||||
assistantRailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
|
||||
narrationBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleMetaDim)))
|
||||
}
|
||||
@@ -1043,17 +1043,17 @@ var (
|
||||
// assistant effectively invisible). Families kept: cyan=you,
|
||||
// green=rune/results (D35), grey=system, gold=confirm,
|
||||
// blue=research, red=errors, olive=tool.
|
||||
userStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#7FD4FF"))
|
||||
assistantStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#7EE787"))
|
||||
systemStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#A0A0B2"))
|
||||
confirmStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#E3B341"))
|
||||
researchStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#58A6FF"))
|
||||
errorStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#F87171"))
|
||||
toolStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#B8B85E"))
|
||||
userStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color(themes.Tone("user")))
|
||||
assistantStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color(themes.Tone("assistant")))
|
||||
systemStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color(themes.Tone("system")))
|
||||
confirmStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color(themes.Tone("confirm")))
|
||||
researchStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color(themes.Tone("research")))
|
||||
errorStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color(themes.Tone("error")))
|
||||
toolStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color(themes.Tone("tool")))
|
||||
dimStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color(themes.RoleHex(themes.RoleMetaDim)))
|
||||
// runeStyle is Rune's identity marker (D35): the runic glyph rides
|
||||
// the existing purple accent family (135 — the focused-border tone).
|
||||
runeStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#C4A0FF"))
|
||||
runeStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color(themes.Tone("rune")))
|
||||
// tsStyle is the dim timestamp beside each message (D35; the D43
|
||||
// addendum's emphasis pass: Faint on top of the floor-legal dim
|
||||
// tone — the terminal's own de-emphasis keeps the stamp unobtrusive
|
||||
@@ -1066,7 +1066,7 @@ var (
|
||||
// the PINK FAMILY — the same family as the composer's lit outline;
|
||||
// identity keeps speaking by side and content color (D35). Both
|
||||
// tones ≥4.5:1 on the base (the readability floor).
|
||||
userBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF9ED8"))
|
||||
userBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.Tone("user-border")))
|
||||
assistantBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
|
||||
// confirmBorderStyle: the D43.9 law — ALL boxes carry the solid
|
||||
// PINK FAMILY outline (the earlier gold was the outline-color
|
||||
|
||||
@@ -179,6 +179,9 @@ func killGroup(cmd *exec.Cmd) error {
|
||||
// Anything else — serve fallback, a seam that cannot build, a face
|
||||
// that never boots — is RED.
|
||||
func TestBootSmokeFaceReachesLibConnected(t *testing.T) {
|
||||
if raceEnabled {
|
||||
t.Skip("the real-face PTY family is the documented full-race flake (D52) — passes -short and outside race")
|
||||
}
|
||||
if testing.Short() {
|
||||
t.Skip("boots the real face (build + pty); skipped in -short")
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ import (
|
||||
)
|
||||
|
||||
func TestConsentCardPTYFullFlow(t *testing.T) {
|
||||
if raceEnabled {
|
||||
t.Skip("the real-face PTY family is the documented full-race flake (D52) — passes -short and outside race")
|
||||
}
|
||||
if testing.Short() {
|
||||
t.Skip("boots the real face (build + pty); skipped in -short")
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ import (
|
||||
)
|
||||
|
||||
func TestD50PtyEnterDispatchAndPinPersistence(t *testing.T) {
|
||||
if raceEnabled {
|
||||
t.Skip("the real-face PTY family is the documented full-race flake (D52) — passes -short and outside race")
|
||||
}
|
||||
if testing.Short() {
|
||||
t.Skip("boots the real face (build + pty); skipped in -short")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"time"
|
||||
|
||||
"rune.dev/rune/tui/laneconc"
|
||||
"rune.dev/rune/tui/runtime/lib"
|
||||
)
|
||||
|
||||
// D55 coherence catch (item d, the PTY proof): chatDispatch's context
|
||||
// backstop must NOT die when the tea.Cmd closure returns — the Cmd
|
||||
// hands off to the streaming goroutine, so the cancel belongs to the
|
||||
// GOROUTINE's lifetime, not the closure's. The defect canceled the
|
||||
// 30-minute backstop the instant the Cmd returned, killing every real
|
||||
// streamed dispatch with "context canceled".
|
||||
|
||||
type hangStreamCompleter struct {
|
||||
mu sync.Mutex
|
||||
canceled bool
|
||||
}
|
||||
|
||||
func (h *hangStreamCompleter) Complete(ctx context.Context, req lib.Completion) (lib.CompletionResult, error) {
|
||||
<-ctx.Done()
|
||||
return lib.CompletionResult{}, ctx.Err()
|
||||
}
|
||||
|
||||
func (h *hangStreamCompleter) CompleteStream(ctx context.Context, req lib.Completion, onDelta func(string)) (lib.CompletionResult, error) {
|
||||
<-ctx.Done()
|
||||
h.mu.Lock()
|
||||
h.canceled = ctx.Err() == context.Canceled
|
||||
h.mu.Unlock()
|
||||
return lib.CompletionResult{}, ctx.Err()
|
||||
}
|
||||
|
||||
func (h *hangStreamCompleter) wasCanceled() bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.canceled
|
||||
}
|
||||
|
||||
func TestDispatchBackstopSurvivesTheCmdReturn(t *testing.T) {
|
||||
configuredHome(t)
|
||||
hang := &hangStreamCompleter{}
|
||||
m := sized(t, initialModel(), 80, 24)
|
||||
m.disp = laneconc.New(hang)
|
||||
|
||||
cmd := m.dispatchSend("the stream must outlive the cmd")
|
||||
if cmd == nil {
|
||||
t.Fatal("fixture: the dispatch must arm")
|
||||
}
|
||||
batch, ok := cmd().(tea.BatchMsg)
|
||||
if !ok {
|
||||
t.Fatalf("fixture: the dispatch must batch, got %T", cmd())
|
||||
}
|
||||
var start chatStreamStartMsg
|
||||
for _, c := range batch {
|
||||
msg := c()
|
||||
if s, ok := msg.(chatStreamStartMsg); ok {
|
||||
start = s
|
||||
}
|
||||
}
|
||||
if start.done == nil {
|
||||
t.Fatal("fixture: the batch must carry the stream start")
|
||||
}
|
||||
// the Cmd has RETURNED — the closure's lifetime ended; the stream
|
||||
// must still be in flight on the goroutine's own backstop.
|
||||
select {
|
||||
case rep := <-start.done:
|
||||
t.Fatalf("the turn must still be in flight after the Cmd returned, got %+v", rep)
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
if hang.wasCanceled() {
|
||||
t.Fatal("the dispatch context was canceled when the Cmd returned — the backstop must ride the goroutine's lifetime (D55 catch)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rune.dev/rune/tui/chatpane"
|
||||
"rune.dev/rune/tui/lanes"
|
||||
"rune.dev/rune/tui/semantic"
|
||||
)
|
||||
|
||||
// D55 coherence pass: THE MOTION LAW'S THREE INVARIANTS, ASSERTED IN
|
||||
// ONE PLACE (the D53+D54 integration contract) —
|
||||
//
|
||||
// 1. the wave is GATED (motionAllowed closes every animated path)
|
||||
// 2. the CHATPANE IS STATIC (its render never consumes a frame —
|
||||
// reading content never moves, the ND synthesis's core harm)
|
||||
// 3. ONE REGION PER FRAME (the activity pane is the face's only
|
||||
// animating surface; the shell's SetStill arbitration rests
|
||||
// everything else while it moves — asserted at the wiring level
|
||||
// by the waves_wiring tests)
|
||||
func TestD55MotionLawCoherence(t *testing.T) {
|
||||
// Invariant 2 — the chatpane is static: identical input renders
|
||||
// byte-identically no matter how many times or "frames" apart.
|
||||
pane := chatpane.New()
|
||||
pane.Append(chatpane.Message{Role: chatpane.RoleUser, Content: "reading content must never move"})
|
||||
first := strings.Join(pane.Render(80, 24, false), "\n")
|
||||
for frame := 0; frame < 5; frame++ {
|
||||
if again := strings.Join(pane.Render(80, 24, false), "\n"); again != first {
|
||||
t.Fatalf("the chatpane consumed a frame — chat motion is forbidden (invariant 2)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renderContent(m model) string {
|
||||
return strings.TrimSpace(semantic.StripANSI(m.face.Render().Content))
|
||||
}
|
||||
|
||||
func TestD55MotionLawActivityGate(t *testing.T) {
|
||||
configuredHome(t)
|
||||
m := sized(t, initialModel(), 110, 30)
|
||||
|
||||
// Invariant 1 — the gate freezes the activity strip through the
|
||||
// face's own law plumbing.
|
||||
m.face.SetActivityMotionAllowed(false)
|
||||
m.face.SetActivity(lanes.ActivitySignal{TurnActive: true}, 0)
|
||||
frozen := renderContent(m)
|
||||
m.face.SetActivity(lanes.ActivitySignal{TurnActive: true}, 37)
|
||||
if renderContent(m) != frozen {
|
||||
t.Fatal("a gated activity strip must be frame-frozen (invariant 1)")
|
||||
}
|
||||
|
||||
// Invariant 3 — with the gate open the wave moves, and the
|
||||
// activity pane is the ONLY surface that does (the chatpane above
|
||||
// never moved; the still arbitration rests the rail cluster).
|
||||
m.face.SetActivityMotionAllowed(true)
|
||||
m.face.SetActivity(lanes.ActivitySignal{TurnActive: true}, 0)
|
||||
r0 := renderContent(m)
|
||||
m.face.SetActivity(lanes.ActivitySignal{TurnActive: true}, 64)
|
||||
if renderContent(m) == r0 {
|
||||
t.Fatal("the open gate's wave must move — the activity pane is the one animating region (invariant 3)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package main
|
||||
|
||||
// d55_pty_longanswer_test.go — the D55 fresh long-answer PTY proof
|
||||
// (the D53+D54 coherence pass item d): the REAL face on a REAL
|
||||
// pseudo-terminal at 110 columns, pointed at the LIVE core
|
||||
// (RUNE_LIB_CORE → 127.0.0.1:8376), running a REAL streamed 27B turn.
|
||||
// The proof asserts the three coherence claims over the LIVE leg:
|
||||
//
|
||||
// 1. FORMAT HOLDS — box tops and bottoms stay balanced at 110 cols
|
||||
// (the long-answer format break never returns);
|
||||
// 2. SUMMARY-FIRST HOLDS — the 8-block threshold's one-line summary
|
||||
// box opens the long answer ("Summary: N blocks");
|
||||
// 3. NO DEADLINE ERROR — the streamed turn COMPLETES (the streaming
|
||||
// landing 1bbd48fb retired the deadline class; "deadline exceeded"
|
||||
// must never render).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"rune.dev/rune/tui/semantic"
|
||||
)
|
||||
|
||||
// openPtySized opens a pty at an explicit size (the D55 proof runs at
|
||||
// 110 columns — the coherence width).
|
||||
func openPtySized(rows, cols int) (master, slave *os.File, err error) {
|
||||
master, err = os.OpenFile("/dev/ptmx", os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
n, err := unix.IoctlGetInt(int(master.Fd()), unix.TIOCGPTN)
|
||||
if err != nil {
|
||||
master.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := unix.IoctlSetPointerInt(int(master.Fd()), unix.TIOCSPTLCK, 0); err != nil {
|
||||
master.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
slave, err = os.OpenFile(fmt.Sprintf("/dev/pts/%d", n), os.O_RDWR|syscall.O_NOCTTY, 0)
|
||||
if err != nil {
|
||||
master.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := unix.IoctlSetWinsize(int(master.Fd()), unix.TIOCSWINSZ,
|
||||
&unix.Winsize{Row: uint16(rows), Col: uint16(cols)}); err != nil {
|
||||
master.Close()
|
||||
slave.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return master, slave, nil
|
||||
}
|
||||
|
||||
func TestD55PtyLongAnswerLive27B(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("drives the LIVE core through a real 27B turn; skipped in -short")
|
||||
}
|
||||
if os.Getenv("RUNE_LIVE_PTY_PROOF") == "" {
|
||||
t.Skip("an opt-in live proof (RUNE_LIVE_PTY_PROOF=1), never a default suite member — it runs a real 30-minute 27B turn")
|
||||
}
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skipf("pty boot method is linux (/dev/ptmx); not %s", runtime.GOOS)
|
||||
}
|
||||
core := os.Getenv("RUNE_LIB_CORE")
|
||||
if core == "" {
|
||||
core = "http://127.0.0.1:8376"
|
||||
}
|
||||
resp, err := http.Get(core + "/v1/status")
|
||||
if err != nil {
|
||||
t.Skipf("live core not reachable at %s: %v", core, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("cannot locate module root")
|
||||
}
|
||||
tuiRoot := filepath.Dir(filepath.Dir(filepath.Dir(thisFile)))
|
||||
repoRoot := filepath.Dir(tuiRoot)
|
||||
|
||||
bin := filepath.Join(t.TempDir(), "rune-tui")
|
||||
build := exec.Command("go", "build", "-o", bin, "./cmd/rune-tui")
|
||||
build.Dir = tuiRoot
|
||||
if out, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("face build failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
master, slave, err := openPtySized(40, 110)
|
||||
if err != nil {
|
||||
t.Fatalf("pty: %v", err)
|
||||
}
|
||||
defer master.Close()
|
||||
|
||||
sandbox := t.TempDir()
|
||||
sel := providerSelection{Provider: "local",
|
||||
Model: "hf.co/DavidAU/Qwen3.6-27B-Fable-Fusion-711-Uncensored-Heretic-NM-DAU-NEO-MAX-MTP-GGUF:IQ3_M",
|
||||
SelectedAt: "2026-10-24T00:00:00Z"}
|
||||
if err := saveProviderSelection(sandbox, sel); err != nil {
|
||||
t.Fatalf("provider record: %v", err)
|
||||
}
|
||||
cmd := exec.Command(bin)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = slave, slave, slave
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0}
|
||||
cmd.Env = append(os.Environ(),
|
||||
"RUNE_LIB_REPO="+repoRoot,
|
||||
"RUNE_LIB_CORE="+core,
|
||||
"RUNE_HOME="+sandbox,
|
||||
"RUNE_MEMORY_DIR="+filepath.Join(sandbox, "memory"),
|
||||
"TERM=xterm-256color",
|
||||
)
|
||||
if err := cmd.Start(); err != nil {
|
||||
slave.Close()
|
||||
t.Fatalf("face start: %v", err)
|
||||
}
|
||||
slave.Close()
|
||||
exited := make(chan error, 1)
|
||||
go func() { exited <- cmd.Wait() }()
|
||||
|
||||
var seen strings.Builder
|
||||
buf := make([]byte, 8192)
|
||||
deadline := time.Now().Add(30 * time.Minute)
|
||||
booted := false
|
||||
sent := false
|
||||
complete := false
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case err := <-exited:
|
||||
t.Fatalf("face exited (rc=%v) mid-proof — output:\n%.4000s",
|
||||
err, semantic.StripANSI(seen.String()))
|
||||
default:
|
||||
}
|
||||
_ = master.SetReadDeadline(time.Now().Add(1 * time.Second))
|
||||
n, rerr := master.Read(buf)
|
||||
if n > 0 {
|
||||
seen.Write(buf[:n])
|
||||
}
|
||||
if rerr != nil && !os.IsTimeout(rerr) {
|
||||
t.Fatalf("pty read: %v — output:\n%.4000s", rerr, semantic.StripANSI(seen.String()))
|
||||
}
|
||||
plain := semantic.StripANSI(seen.String())
|
||||
if !booted && strings.Contains(plain, "┌") {
|
||||
booted = true
|
||||
time.Sleep(2 * time.Second)
|
||||
// the boot composer is the CHAT composer already ("type in
|
||||
// chat") — type the prompt at human pace (a fat write and
|
||||
// a fast drip both drop bytes through the renderer). The
|
||||
// prompt carries NO digits: the nav digit keys are focus
|
||||
// steals even mid-composition (the D50 focus law).
|
||||
prompt := "answer in nine numbered sections, each with its own heading and two sentences: the history of computing machines from the abacus to the large language model era"
|
||||
for _, r := range prompt {
|
||||
if _, err := master.Write([]byte(string(r))); err != nil {
|
||||
t.Fatalf("prompt write: %v", err)
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
if _, err := master.Write([]byte("\r")); err != nil {
|
||||
t.Fatalf("enter write: %v", err)
|
||||
}
|
||||
sent = true
|
||||
continue
|
||||
}
|
||||
if sent && !complete && strings.Contains(plain, "Summary: ") &&
|
||||
strings.Contains(plain, "The blocks follow.") {
|
||||
complete = true
|
||||
// let the block stream settle behind the summary box
|
||||
time.Sleep(10 * time.Second)
|
||||
plain = semantic.StripANSI(seen.String())
|
||||
if strings.Contains(plain, "deadline exceeded") || strings.Contains(plain, "context deadline") {
|
||||
t.Fatalf("the streamed 27B turn must complete without the deadline class:\n%.4000s", plain)
|
||||
}
|
||||
// the capture accumulates repaints; the balance check
|
||||
// rides the FINAL settled tail (the last screenful)
|
||||
tail := plain
|
||||
if len(tail) > 4000 {
|
||||
tail = tail[len(tail)-4000:]
|
||||
}
|
||||
tops := strings.Count(tail, "┌")
|
||||
bottoms := strings.Count(tail, "└")
|
||||
if tops == 0 || tops != bottoms {
|
||||
t.Fatalf("format must hold at 110 cols (balanced boxes): %d tops, %d bottoms:\n%.4000s",
|
||||
tops, bottoms, plain)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !booted {
|
||||
t.Fatalf("the face never booted:\n%.4000s", semantic.StripANSI(seen.String()))
|
||||
}
|
||||
if !complete {
|
||||
os.WriteFile("/tmp/d55_pty_capture.txt", []byte(seen.String()), 0o600)
|
||||
t.Fatalf("the streamed 27B turn never completed within the budget — output:\n%.4000s",
|
||||
semantic.StripANSI(seen.String()))
|
||||
}
|
||||
_ = cmd.Process.Kill()
|
||||
<-exited
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rune.dev/rune/tui/approvals"
|
||||
)
|
||||
|
||||
// D55 (the D49 consent=promotion follow-through): a pending WRITE
|
||||
// card never face-blocks the composer — capability is pane-wide, so
|
||||
// a chat-origin write leaves the conversation open and the next send
|
||||
// dispatches. A genuinely gated card (run_command) keeps the A2 law.
|
||||
|
||||
// TestWriteCardNeverBlocksTheFace: while a write_file card is
|
||||
// pending, dispatchSend does NOT emit the A2 typed refusal — the
|
||||
// user's message goes through (the write resolves without holding
|
||||
// the conversation hostage).
|
||||
func TestWriteCardNeverBlocksTheFace(t *testing.T) {
|
||||
configuredHome(t)
|
||||
m := sized(t, initialModel(), 80, 24)
|
||||
m.apprv = &approvals.Card{CallID: "call-d55", Tool: "write_file",
|
||||
Target: "store/migrate.py"}
|
||||
|
||||
m.dispatchSend("keep chatting while the write lands")
|
||||
txt := chatBubbleText(m)
|
||||
if strings.Contains(txt, "approval pending — resolve the card first") {
|
||||
t.Fatalf("a chat-origin write must never face-block (D49):\n%s", txt)
|
||||
}
|
||||
if !strings.Contains(txt, "keep chatting while the write lands") {
|
||||
t.Fatalf("the user's message must land:\n%s", txt)
|
||||
}
|
||||
// the offline face's honest seam error may follow, but the refusal
|
||||
// must not — the user bubble landed before it.
|
||||
}
|
||||
|
||||
// TestGatedCardStillBlocksTheFace: the A2 law holds for genuinely
|
||||
// gated tools — while a run_command card is pending, a new send is
|
||||
// the typed refusal.
|
||||
func TestGatedCardStillBlocksTheFace(t *testing.T) {
|
||||
configuredHome(t)
|
||||
m := sized(t, initialModel(), 80, 24)
|
||||
m.apprv = &approvals.Card{CallID: "call-d55", Tool: "run_command",
|
||||
Target: "pytest -x"}
|
||||
|
||||
m.dispatchSend("again")
|
||||
txt := chatBubbleText(m)
|
||||
if !strings.Contains(txt, "approval pending — resolve the card first") {
|
||||
t.Fatalf("the A2 typed refusal must hold for gated tools:\n%s", txt)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user