diff --git a/DECISIONS.md b/DECISIONS.md index ebb7afbde..be15df695 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,94 @@ # DECISIONS +2026-09-16 cubs-halogen-probe-1 (FRONT-12 bootstrap): the dotfiles half of the +CUBS campaign is ONE store executable and 120 generated kit entries, and +nothing else in this repository moves. `pkgs/cubs-iteration` is a +`writeShellApplication` (bash, coreutils, curl, findutils, git, gnugrep, +gnused, jq, python3, util-linux, `pkgs.llm-agents.pi`) in the drain.sh lineage: +flock, the receipt as the done marker, wait-with-deadline exit 69 for a +Halogen that is not ok/idle inside 20 min (never fed to the fuse), one fresh +`pi -p --mode json` process per task on a git worktree of `~/agency/`, +the campaign's diff guard, the task's validation command, exactly one repair +process, commit on pass and never a push, a 3-consecutive-fail fuse (exit 2), +a SIGTERM trap that commits WIP inside the kernel's 30 s grace. Doc: +`docs/local-ai/cubs-campaign.md`. Oracle: +`tests/tally-uplink/probe-cubs-iteration.sh` (MEASURED PASS, C1–C11). Six +lines are decided here. + +(1) **The kit entry's stdin is a POINTER, not the task.** A kit entry's +`stdin` is static bytes in a store file (lake `apps/uplink/src/kit.mjs` +returns the entry as written; `apps/uplink/src/uplink.mjs:623` hands +`entry.stdin ?? ""` to `exec.run`; tally `exec.rs:747` writes it after the +start marker). The lake's e2e kit puts the whole item JSON there +(`tools/e2e-check.mjs:414-470`) but materialises its kit per run; a reviewed +store kit that must not need a switch per day cannot. So `build:CUBS-` +hands `{"worklist": "~/mecattaf/cubs-campaign/worklists/current.jsonl", +"id": "CUBS-"}` and the executable resolves the line; `current.jsonl` is +what the morning review re-points. Nothing the acceptor's expansion provides +reaches stdin (`argv_ref = taskId`, `factory.ts:621`). + +(2) **N = 40 labels, `lib.range` over three cells each**, the scope()/eval() +cells the existing `noopEntry`, LOCAL-SMOKE untouched, `claude:headless` still +absent. N is a ceiling on labels the plan may mint, not work: a missing +worklist line is exit 65 naming the id. MEASURED: the rendered kit carries +123 entries; `nix build .#checks.x86_64-linux.tally-uplink-topology` → rc 0 +(it does not enumerate refs, so it needed no edit); the pinned lake's own +`readKit` resolves all 120 and refuses `build:CUBS-41`. + +(3) **The store `pi`, not `home/pi.nix`'s wrapper.** The wrapper's only work +is the `-e` roster, which is empty (`extensions = { }`; MEASURED: the +installed wrapper execs the store `pi` with no flags), and the executable +runs `--no-extensions` under a campaign-private `PI_CODING_AGENT_DIR` (the +`judge.sh` precedent). Carrying `pkgs.llm-agents.pi` is the same derivation +with no seam invented in pi.nix (DF-CUBS-5). + +(4) **The child's environment is empty, so the script carries everything.** +`exec.run` is `env_clear` + an empty `env_allowlist` (the LOCAL-SMOKE +reasoning, kept). MEASURED: `env -i /bin/sh -c 'echo $PATH'` → `/no-such-path`; +`getent` is glibc's and absent; the store bash has no `compgen`. HOME comes +from the passwd entry via the carried python3; the two profile bins are +appended LAST for a validation command that reaches for `nix develop` +(home/tally-filler.nix's reasoning against pinning a second nix). + +(5) **The receipt is the campaign's schema plus two fields.** The campaign +repo owns `tools/receipt.schema.json` (`schema_version 1`, `sha256:` digests, +`is_error_count`, `worktree_branch`, `validation.guard_exit`, +`observed_failure_mode` reserved for the morning review); the executable +writes that shape and adds `execution_id` (the kernel's `TALLY_EXECUTION_ID`, +so the usage line and the receipt name the execution back) and `exit_code`. +Its own mechanical reading of a failure goes to `notes`; per-attempt detail +to `attempts.json` beside it. The campaign's `tools/spec-diff-guard.sh` is +THE gate when it exists (allowed paths + new files, any `spec.md` frozen, +`git diff --check`, setup copies identical to upstream are not changes); +the built-in guard implements the same path rules and is the self-test's. +Only files inside allowed_paths + new_files are ever staged. + +(6) **A cancel keeps the WIP without moving the base.** On SIGTERM the +allowed files are committed as `[WIP, cancelled under lease]` and parked +under `refs/cubs-wip//`; the retry `reset --mixed`es the branch +to the persisted base with the WIP back in the working tree, rotates the Pi +session id (`-a1-r2`: `--session-id` RESUMES an existing session, and +the ruling is one fresh process), and a pass leaves exactly one commit above +the base. MEASURED in the probe: rc 143 in 137 ms with Pi mid-run. + +MEASURED, one real run (docs/local-ai/cubs-campaign.md): the built +executable under `env -i`, scratch everything, real Pi 0.85.1 on real +Halogen with a campaign-private models.json carrying `thinkingFormat qwen` ++ `supportsReasoningEffort true`, `--thinking low`, a one-line dictated edit: +pass first try in 17 s, 4 tool calls, prompt 12337 / completion 405 / +reasoning 74 tokens, commit on the task branch, receipt and usage line +written. The first Pi-with-tools run against Halogen recorded on this box; +a smoke, not a capability claim. + +NOT decided here: the coordinator switch and the non-dry arm (Tom's, doc +§"The two operator acts"), the evaluator lock (DF-U-D13-2 / DF-CUBS-1), a +floor redeploy for an `execute` member on `gpu-worker` (DF-CUBS-2), the +`context_window` row cell (DF-CUBS-3), the pi.nix compat fix (DF-CUBS-4), the +plan option (DF-U-D14-3, still null), and every existing kit entry. +MEASURED: `nix flake check --offline --no-build` is red on the untouched +`main` checkout today with the same `source.drv` error as on this branch, +so the FT-3 probe's K7 is pre-existing red and not this change. + 2026-09-13 flake checkouts no longer ride into host closures, chrome-stream is installed, and a switch refuses a stale raw-dotfiles checkout. diff --git a/DEFERRED.md b/DEFERRED.md index f6d23b177..bf7a4ef4f 100644 --- a/DEFERRED.md +++ b/DEFERRED.md @@ -70,3 +70,8 @@ clause asserting `model_split.opus == "UNKNOWN"` becomes wrong, and it is the assertion that must move, not the row. | DF-354-1 | Flipping the coordinator's `myUpdateAdopt.policy` from `"stage-only"` to `"rolling"` (`hosts/coordinator/default.nix`), which creates its `update-adopt-activate.timer` | #354's challenger correction 6: the coordinator's activate timer is enabled only after the downgrade guard is proven LIVE, not only hermetically. The live proof needs the NAS producer and the worker adopter deployed and an older worker candidate published, which no overnight lane may do, and this box runs Tom's agents with nobody reachable until morning | The orchestrator or Tom, after deploy: once `update-adopt status --json` on the worker shows `last_refusal.reason == "local-generation-newer"` for an older published candidate with `/run/current-system` unchanged, flip the word and switch the coordinator. Discharged when `systemctl list-timers update-adopt-activate.timer` on the coordinator lists it — then delete this row | +| DF-CUBS-1 | Passing `--evaluator-lock` to the served kernel for the CUBS campaign, so the lake's verdict and token cells mean something for it | DF-U-D13-2 still stands: with no lock every item that finishes inside its lease is `pass` on the lake (`docs/executor.md:44-47`). The campaign's proof is the commit + `receipt.json` + usage line by design (orchestrator default 2, `docs/local-ai/cubs-campaign.md`), so the lock is not on its critical path | Tom, with the tally-ts-sdk `apps/evaluator` delivery; then a reviewed pin of the lock path in `modules/tally-b.nix` | +| DF-CUBS-2 | A floor redeploy adding an `execute`-class member on `gpu-worker` (and the CUBS namespace), so the campaign's cards can say `executor: execute` | The deployed Worker routes `execute` to `gpu-coordinator` only (`apps/worker/src/floor.ts:87-125`, exact class match); `executor: review` on the cards lands the items on `gpu-worker` today without a redeploy. A redeploy is `wrangler deploy` under the TL-13 credential plus a pin bump and a switch | Tom, only if the routing workaround is refused (`tally-ts-sdk docs/deploy.md:305-326`) | +| DF-CUBS-3 | Correcting the `gpu-worker` row's `context_window 32768` in `modules/tally-b.nix` (Halogen serves 262144) | Informational only: nothing admits on it (`tally docs/rows.md:353-368`), and a served row is a kernel-visible edit the campaign does not need | A reviewed edit to `modules/tally-b.nix` if anything starts reading the cell | +| DF-CUBS-4 | The `thinkingFormat = "qwen"` + `supportsReasoningEffort = true` compat fix in `home/pi.nix`'s halogen provider, so `--thinking ` reaches the wire for every Pi user on the box | Shipped campaign-private in `~/mecattaf/cubs-campaign/pi/models.json` (`pkgs/local-ai-monthly/lib/judge.sh`'s precedent), so no switch is needed and receipts quote the same provider/model pair. Editing `home/pi.nix` changes every interactive Pi session and is a home-manager switch. MEASURED 2026-09-16 with the private copy: pi's `Usage.reasoning` is 10–21 tokens per turn at `--thinking low`, INFERRED consistent with the effort arriving; the worker's `serve_api:` journal line was not read | Tom, after that journal line (or the campaign's usage) confirms it; then a reviewed edit to `home/pi.nix` | +| DF-CUBS-5 | Wiring `cubs-iteration`'s Pi from `home/pi.nix`'s wrapper rather than from `pkgs.llm-agents.pi` | The wrapper is a `let` binding with an empty roster, and the executable runs `--no-extensions` under a private `PI_CODING_AGENT_DIR`; exporting the wrapper would be a pi.nix change with no behavioural gain | A later unit if the roster stops being empty and a campaign wants it | diff --git a/docs/local-ai/cubs-campaign.md b/docs/local-ai/cubs-campaign.md new file mode 100644 index 000000000..ad04b7d64 --- /dev/null +++ b/docs/local-ai/cubs-campaign.md @@ -0,0 +1,309 @@ +# cubs-halogen-probe-1: the `cubs-iteration` executable and the CUBS kit entries + +Written 2026-09-16 for the FRONT-12 bootstrap (dotfiles PR "cubs-halogen-probe-1: +cubs-iteration executable and kit entries"). What this repository ships for +the campaign, what it writes, how it exits, and the two acts only Tom takes. +The campaign itself — brief, cards, bundles, worklists, graders, the plan +script — lives in `~/mecattaf/cubs-campaign` and is not in this repository. + +MEASURED = observed on the coordinator or in a file today; INFERRED = derived +from code, not exercised. + +## What the campaign is, in one paragraph + +A 7-day capability probe of Halogen Flash (Qwen3.8-Flash-Next on +`worker:8731`) on the CUBS tree under `~/agency`, run as a serial backlog of +bounded ~40-minute iterations the rewrite kernel admits on the `gpu-worker` +row. The acceptor mints N independent `agent()` items labelled `build:CUBS-` +in one `parallel()`; the lake proposes them one at a time (level 1, cap 1); +the uplink resolves each label against the coordinator's kit and the kernel +runs the argv the kit names under a 2400 s lease. Every task carries a stated +prior and a falsifiable prediction; the mechanical verdict is a validation +command's exit code; Tom reviews every morning and arms the next day's +worklist. Termination of a task or a package is a legitimate outcome. + +## The three orchestrator defaults + +1. **Routing via `executor: review`** on the cards, so the deployed lake floor + (`apps/worker/src/floor.ts:87-125`, class match exact) lands the items on + `gpu-worker` without a Worker redeploy. An `execute`-class member on + `gpu-worker` is deferred (below). +2. **Envelope-pass semantics.** With no `--evaluator-lock` on the served + kernel, an item that finishes inside its lease closes `pass` on the lake + regardless of exit code (`docs/executor.md:44-47`). The campaign's PROOF is + therefore the commit on the task branch, `receipt.json`, and the usage + line, never the lake verdict. The executable never fabricates a pass: a + dead Halogen is an `outage` receipt, a red gate is a `fail` receipt. +3. **`--level 1`** (WIP cap 1): strictly serial, one of the 3N cells in flight. + +## The executable: `cubs-iteration` + +`pkgs/cubs-iteration/` — a `writeShellApplication` (the derivation's +shellcheck pass is its cheapest oracle) carrying bash, coreutils, curl, +findutils, gawk, git, gnugrep, gnused, jq, python3 with pytest (the +campaign's STDIN-CONTRACT: validation commands are written against a normal +PATH and WP7's graders are pytest), util-linux (`flock`) and +`pkgs.llm-agents.pi`, plus `cubs-helpers.py` (the event-stream +summariser and the built-in diff guard). It is the argv of every +`build:CUBS-` kit entry. + +**Why the store `pi` and not `home/pi.nix`'s wrapper.** The wrapper's only +work is to prepend the extension roster to interactive runs, and the roster +is empty (`home/pi.nix` `extensions = { }`; MEASURED: the installed wrapper +execs the store `pi` with no flags). The script runs Pi with +`--no-extensions` and a campaign-private `PI_CODING_AGENT_DIR`, so the kit +carries the derivation the wrapper wraps. + +**The child's environment is empty.** The kernel spawns the argv with +`env_clear` and the entry's empty `env_allowlist` (tally +`crates/tally-kernel/src/exec.rs:681-689`): the process sees +`TALLY_EXECUTION_ID` and `TALLY_USAGE_SOURCE_PATH` and nothing else. MEASURED: +`env -i /bin/sh -c 'echo $PATH'` → `/no-such-path`; `getent` and `compgen` +are absent under the store bash. So the script derives HOME from the passwd +entry through its own python3, carries every tool as a store path, and +appends `/etc/profiles/per-user/tom/bin:/run/current-system/sw/bin` LAST for +a validation command that reaches for `nix develop` (home/tally-filler.nix's +reasoning: a second pinned nix would be this repository deciding which nix +another tree's oracle runs). + +**One run, in order.** + +1. Read ONE JSON object on stdin: the kit's pointer + `{"worklist": , "id": "CUBS-"}` (resolved to the worklist line + with that id) or a full task line. Fields: `id`, `package`, `title`, + `repo` (a subdirectory of `~/agency`, each of which is its own git + repository — MEASURED: `~/agency` itself is not one), `bundle_path` + (absolute, `~/`, or relative to the campaign repo), `setup_cmd` + (string or null), `validation_cmd`, `allowed_paths`, `new_files` + (non-empty adds the `write` tool), `thinking`, `prior_p_pass`, + `predicted_failure`. A malformed object exits 65 naming the field. +2. `flock` on `~/.local/state/cubs-campaign/lock`; if `tasks//receipt.json` + already says `pass`, exit 0 without running anything (the receipt is the + done marker, `drain.sh` l.112). +3. The fuse gate: `/fuse` ≥ 3 → receipt `fuse`, exit 2, no Pi. +4. Preflight: campaign dir, `skill/system-prompt.md`, `pi/models.json` + declaring provider `halogen` with model `halogen-qwen3.8-flash-next`, + `pi/settings.json` (compaction `reserveTokens` / `keepRecentTokens` live + there, not in models.json), the bundle, the repo. Missing → exit 78, no receipt, the task stays runnable. + A `pending` receipt is written here. +5. Poll `GET worker:8731/health` (`curl --max-time 5`) every 10 s until + `status ok`, `busy false`, `engine.responds`; after 20 min → receipt + `outage`, exit 69. Record the id + from `/v1/models` and `/health.version`. +6. `git worktree add` of `~/agency/` at its current HEAD on branch + `campaign/cubs-halogen-probe-1/` under `/worktrees/`. A + retry reuses the worktree: the base sha is persisted, and a WIP commit a + cancel left on the branch is parked under `refs/cubs-wip//` and + `reset --mixed` back into the working tree, so HEAD is the base again. +7. `setup_cmd`, if any, inside the worktree (10 min timeout). +8. ONE fresh Pi process: + `pi -p --mode json --session-dir /sessions --session-id -a1 + --provider halogen --model halogen-qwen3.8-flash-next --thinking + --no-extensions --no-skills --no-prompt-templates --no-context-files + --no-approve --tools read,bash,edit,grep,find,ls[,write] + --system-prompt "" -- ""`, with + `PI_CODING_AGENT_DIR=~/mecattaf/cubs-campaign/pi`, `PI_TELEMETRY=0`, + `PI_OFFLINE=1`, stdin from `/dev/null` (`pi -p` reads a non-TTY stdin + to EOF as prompt text — the task JSON was this process's stdin and is + read in full first), under `timeout -k 30 1200` (the repair process + `-k 30 900`: a dropped Halogen connection leaves Pi's own auto-retry + hanging, so the budget is the rail), events streamed to + `/logs/-a1.jsonl`. A session id that already exists in the + session dir is rotated (`-a1-r2`) rather than resumed: one fresh + process, always. Pi's "No project session found with id …; creating a + new session" stderr line is the expected first-run notice. +9. The diff guard: the campaign's `tools/spec-diff-guard.sh --task + --worklist --upstream ~/agency/` when it exists (allowed paths + + new files, any `spec.md` frozen, the constitution frozen, `git diff + --check`, a setup copy identical to upstream is not a change, an empty + allowed diff fails); the built-in `cubs-helpers.py guard` with the same + path rules otherwise. Both read untracked files (`ls-files --others`). + Then the trailing-newline gate over the allowed touched files: `git diff + --check` does not report a missing final newline, and the smoke showed + Flash-Next's `edit` dropping it. +10. `validation_cmd` inside the worktree, `timeout 600`, transcript to + `/logs/-v1.log`. +11. On a red guard or validation: exactly ONE repair — a fresh Pi process + (`-a2`) fed the bundle, the guard's output, the diff (≤ 24 KB) and the + transcript tail (≤ 8 KB), never the first process's narration — then the + guard and validation again, then fail closed. +12. Pass: stage ONLY the files inside `allowed_paths + new_files`, commit + `": "` (identity `cubs-iteration`, body naming the bundle and + skill digests, model, repair count, execution id). Never a push. +13. Receipt, `ledger.jsonl` line, usage line; fuse reset to 0 on pass, + incremented on fail, untouched on outage or cancel. + +**SIGTERM** (the lease's rail: SIGTERM, 30 s checkpoint grace, SIGKILL): the +trap kills the running child (5 s, then KILL), stages and commits the allowed +files as `"<id>: <title> [WIP, cancelled under lease]"`, parks the commit +under `refs/cubs-wip/<id>/…`, writes a `cancelled` receipt and exits 143. +MEASURED in the probe: rc 143 in 137 ms with Pi mid-run. + +**Never touched:** `~/.local/state/tally-rewrite` (the usage line goes where +the kernel's resolved `TALLY_USAGE_SOURCE_PATH` says), the kernel socket, the +lake, any `spec/**/spec.md`, any remote. + +### `--dry` and `--help` + +`--dry` validates stdin and prints the plan as JSON (pi argv, worktree, +branch, guard, validation, preflight booleans, receipt status, fuse count) +without touching the state dir. MEASURED with the kit's own stdin under +`env -i TALLY_EXECUTION_ID=e TALLY_USAGE_SOURCE_PATH=/dev/null`: rc 0, +`resolved: false` naming the missing `worklists/current.jsonl` while the +campaign repo has none. `--help` prints the contract and the exit codes. + +### The receipt (`<state>/tasks/<id>/receipt.json`) + +The campaign's `tools/receipt.schema.json` shape, field for field, plus two +the executable adds: + +| field | source | +|---|---| +| `schema_version` 1, `task`, `package`, `repo` | the worklist line | +| `provider` halogen, `model` | `/v1/models` at task start, never the worklist | +| `health_version` | `/health.version` rendered `"api X engine Y"` | +| `campaign_sha`, `skill_digest`, `bundle_digest` | campaign HEAD; `sha256:` of the prompt and bundle bytes | +| `worktree_branch`, `worktree_path`, `base_sha` | the worktree | +| `tool_calls`, `is_error_count`, `repeated_identical_calls`, `tool_call_names` | `tool_execution_start/end` events, summed over both Pi processes; `tool_call_names.bash` is the bash count (the "validation command only" invariant is soft, so it is counted, not enforced); per attempt in `attempts.json` | +| `usage.{prompt_tokens, completion_tokens, reasoning_tokens, message_end_events}` | summed over `message_end` events (pi `Usage` input+cacheRead+cacheWrite / output / reasoning). The smoke found only `--thinking off` is a real cap on Flash-Next (low/medium barely move reasoning), so `task.thinking` stays the switch and the reasoning count is what the ledger reads | +| `diff_sha256` | sha256 of the allowed-files diff at commit time; null when empty | +| `commit_sha` | the one commit on the task branch, or null | +| `validation.{cmd, exit, transcript_digest, seconds, transcript_path, guard_exit}` | the last validation run and the guard's exit | +| `repair_count` 0 or 1, `terminal_status` pending/pass/fail/timeout/outage/fuse/cancelled, `wall_seconds` | the run | +| `prior_p_pass`, `predicted_failure` | copied from the worklist line | +| `observed_failure_mode` | null: the morning review's cell | +| `notes` | the executable's mechanical reading (`diff_guard: …`, `validation_failed (exit N)`, `outage_before_start`, `fuse_blown_before_start`, `cancelled …`) | +| `sessions` | the Pi session ids | +| `bash_call_count` | `bash` tool calls summed over both Pi processes | +| `stray_files` | untracked, non-ignored files outside allowed_paths + new_files at close (the guard fails on them; listed so the review sees what the model tried to create) | +| `reasoning_tokens` | top-level mirror of `usage.reasoning_tokens` for the ledger | +| `attempts_path` | `tasks/<id>/attempts.json` | +| `execution_id`, `exit_code` | ADDED: the kernel's `TALLY_EXECUTION_ID`; the exit the receipt describes | + +Per-attempt detail (events summary, guard, validation per attempt) is in +`attempts.json` beside it. The usage line appended at +`$TALLY_USAGE_SOURCE_PATH` is +`{"kind":"halogen-usage/1","execution_id","task","model","usage","seconds","terminal_status"}`, +so the kernel's `witness_record.usage_source` points at an artifact that +names the execution back (the LOCAL-SMOKE join, over a real run). + +### Exit codes + +| rc | meaning | receipt | fuse | +|---|---|---|---| +| 0 | pass, or an idempotent no-op on a passed task | `pass` | reset to 0 | +| 1 | fail: setup, guard or validation red after the one repair | `fail` | +1 | +| 124 | a Pi process hit its wall-clock budget (1200 s first attempt, 900 s repair); no repair is attempted after a timed-out first attempt | `timeout` | unchanged | +| 2 | fuse: the third consecutive fail, or the fuse already blown | `fuse` | +1 / unchanged | +| 64 | usage | none | — | +| 65 | the stdin JSON or worklist line is malformed | none | — | +| 69 | outage: Halogen not ok/idle within 20 min, or gone mid-run | `outage` | unchanged | +| 75 | another cubs-iteration holds the lock | none | — | +| 78 | campaign material missing | none (a `pending` one may exist) | — | +| 143 | cancelled by SIGTERM/SIGINT | `cancelled` | unchanged | + +The fuse is reset by removing `~/.local/state/cubs-campaign/fuse` (Tom's act +in the morning review). Because every non-pass exit still "finishes inside +the lease", the lake closes the item `pass` either way (default 2 above); a +blown fuse therefore burns through the remaining items in minutes with +`fuse` receipts, each of which is re-runnable once the fuse is reset and the +plan re-armed. + +## The kit entries (`home/tally-uplink.nix`) + +600 generated entries beside the untouched LOCAL-SMOKE trio, N = 200 +(a label ceiling sized for campaign days 1-7 without a second switch): + +| ref | argv | cwd | env_allowlist | usage_source | stdin | +|---|---|---|---|---|---| +| `build:CUBS-<n>` | `<store>/bin/cubs-iteration` | `~/mecattaf/cubs-campaign` | `[]` | `halogen-usage/1`, `<rewrite state>/uplink/usage/cubs-*.jsonl` | `{"id":"CUBS-<n>","worklist":"~/mecattaf/cubs-campaign/worklists/current.jsonl"}` | +| `scope(build:CUBS-<n>)`, `eval(build:CUBS-<n>)` | `/bin/sh -c true` | `/` | `[]` | `opaque-noop/1` | `""` | + +**Why stdin is a pointer.** A kit entry's `stdin` is static bytes in the +store kit file: the lake's `readKit` returns the entry as written +(`apps/uplink/src/kit.mjs`), the uplink hands `entry.stdin ?? ""` to +`exec.run` (`apps/uplink/src/uplink.mjs:623`), and the kernel writes it to +the child after its start marker (`exec.rs:747`). The lake's e2e kit does put +the whole item JSON there (`tools/e2e-check.mjs:414-470`), but that kit is +materialised per run by a script; this one is a reviewed store artifact that +a day's worklist must not force a switch to change. Nothing the acceptor's +expansion provides reaches stdin: `argv_ref` is the label +(`packages/planning/src/objects/factory.ts:621`) and the brief goes to the +lake. So the entry names the worklist and the id, and `current.jsonl` is +what the morning review re-points. + +MEASURED 2026-09-16: `nix eval …services.tally-uplink.kit` → +`/nix/store/…-tally-uplink-kit.json` with 123 entries; +`nix build .#checks.x86_64-linux.tally-uplink-topology` → rc 0 (the check +does not enumerate kit refs, so it needed no edit); +`tests/tally-uplink/probe-FT-3-kit.sh` K1–K6 green, K7 red only because +`nix flake check --offline --no-build` is red on the untouched `main` checkout +today with the same error (a `source.drv` dependency that cannot be built +offline) — pre-existing, not this change. + +## MEASURED: one real run against Halogen (2026-09-16 09:24Z) + +The built executable, under `env -i` with only the two `TALLY_` variables +and the scratch overrides (a scratch campaign dir carrying the real +`skill/system-prompt.md` and a `pi/models.json` copied from +`~/.pi/agent/models.json` with the compat fix applied; a scratch git repo; +a scratch state dir; nothing on the box touched), on a one-line dictated +edit with `--thinking low`: + +| | | +|---|---| +| wall | 17 s (health poll 1 s, Pi 15 s, validation < 1 s) | +| Pi tool calls | 4 (`read`, `edit` with exact oldText/newText, `bash` × 2 running the named validation command), 0 `isError`, 0 repeats | +| usage summed over 5 assistant `message_end` events | prompt 12337, completion 405, reasoning 74 (pi's `Usage` carries `reasoning`; per turn 10–21 tokens, INFERRED consistent with `reasoning_effort low` reaching the wire, not confirmed from the worker's journal) | +| gate | guard exit 0, `grep -qx hello src/hello.txt` exit 0, first try | +| artifacts | commit `CUBS-SMOKE-1: …` on the task branch, `receipt.json` `pass`, one usage line naming `execution_id smoke-exec-1` | +| model / version read at start | `halogen-qwen3.8-flash-next`, `api 0.7.0 engine 0.7.0` | +| Pi's final message | the bundle's output skeleton, filled (`VALIDATION: … EXIT: 0 FILES: src/hello.txt`) | + +This is the first Pi-with-tools run against Halogen recorded on this box +(the harness report found none), and it is a smoke, not a capability claim. + +## The oracle + +`bash tests/tally-uplink/probe-cubs-iteration.sh` — hermetic: a stub Pi that +emits a `--mode json` event stream and performs a scripted edit, a stub +Halogen (`python3 -m http.server` answering `/health` and `/v1/models`), +throwaway git repos as the CUBS tree, nothing under `~/.local/state` or +`~/agency`. Clauses C1–C11: usage and exit codes; `env -i --dry` on the kit's +own stdin; pass with commit, usage line, ledger line, idempotent rerun; fail +plus one repair with the repair prompt carrying diff and transcript; the fuse +and its reset; the `spec.md` guard; outage without touching the fuse; +SIGTERM → WIP commit + `cancelled` receipt inside 25 s and a clean retry that +rotates the session id and leaves ONE commit above the base; the events +summariser; `readKit` over all 600 refs with LOCAL-SMOKE kept and +`claude:headless` / `build:CUBS-201` refused; the topology check. +MEASURED 2026-09-16: `PROBE cubs-iteration: PASS`. + +## The two operator acts left to Tom + +1. **The coordinator switch** (`nixos-rebuild switch` from the merged + branch): installs the kit with the 120 entries so the first proposal + resolves — an unresolvable `argv_ref` is a `KitError` that ends the wake + with the item left `released` (`kit.mjs:56`, `uplink.mjs:616`). Until the + switch, the entries exist in a store file the running unit does not read. +2. **The non-dry arm** (`tally-plan-arm … --level 1` against the deployed lake + with `LAKE_TOKEN` read from `~/.local/state/tally-rewrite/lake-token` in + the shell, never echoed) after the dry run shows `needs: review`, 3N + items and `pending: null`. `services.tally-uplink.plan` stays null + (DF-U-D14-3): arming is Tom's act, not this module's. + +Before either: `worklists/current.jsonl`, `pi/models.json` and the day-01 +bundles must exist in the campaign repo (the executable exits 78 naming the +missing file otherwise), and the ~5-minute scratch-repo smoke of Pi with +tools against Halogen (the halogen-harness report's mandatory step) should +have been read. + +## DEFERRED + +| id | deferred | why it is barred here | who takes it, and when | +|---|---|---|---| +| DF-CUBS-1 | `--evaluator-lock` on the served kernel, so the lake's verdict and token cells mean something for the campaign | DF-U-D13-2 still stands; with no lock every item that finishes inside its lease is `pass` on the lake. The campaign's proof is the commit + receipt + usage line by design, so the lock is not on the critical path | Tom, with the tally-ts-sdk `apps/evaluator` delivery; then a pin of the lock in `modules/tally-b.nix` | +| DF-CUBS-2 | A floor redeploy adding an `execute`-class member on `gpu-worker` (and the CUBS namespace), so the cards can say `executor: execute` | The deployed Worker routes `execute` to `gpu-coordinator` only (`floor.ts:87-125`); `executor: review` on the cards lands the items on `gpu-worker` today without a redeploy (default 1). A redeploy is `wrangler deploy` under the TL-13 credential and a pin bump | Tom, if the routing workaround is refused; `docs/deploy.md:305-326` | +| DF-CUBS-3 | Correcting the `gpu-worker` row's `context_window 32768` in `modules/tally-b.nix:583-589` (Halogen serves 262144) | Informational only: nothing admits on it (`docs/rows.md:353-368`), and changing a served row is a kernel-visible edit the campaign does not need | A reviewed edit to `modules/tally-b.nix` after the campaign's first day, if anything starts reading the cell | +| DF-CUBS-4 | The `thinkingFormat "qwen"` + `supportsReasoningEffort true` compat fix in `home/pi.nix`'s halogen provider, so `--thinking low` reaches the wire for every Pi user on the box | Shipped campaign-private in `~/mecattaf/cubs-campaign/pi/models.json` (the `judge.sh` precedent) so no switch is needed and receipts still quote the same provider/model pair. Editing `home/pi.nix` changes every interactive Pi session and needs a home-manager switch | Tom, after the campaign's `message_end` usage or the worker's `serve_api:` journal line confirms the effort actually arrives; then a reviewed edit to `home/pi.nix` | +| DF-CUBS-5 | Wiring `cubs-iteration` into `home/tally-uplink.nix`'s kit from `home/pi.nix`'s wrapper rather than from `pkgs.llm-agents.pi` | The wrapper is a `let` binding, not an exported package, and its roster is empty; exporting it is a pi.nix change with no behavioural gain while `--no-extensions` is fixed | A later unit if the roster stops being empty and a campaign wants it | diff --git a/home/tally-uplink.nix b/home/tally-uplink.nix index ffbc2ca0f..606b81ee2 100644 --- a/home/tally-uplink.nix +++ b/home/tally-uplink.nix @@ -336,6 +336,80 @@ let stdin = ""; }; + # ------------------------------------------------------- THE CUBS CAMPAIGN + # + # cubs-halogen-probe-1 (FRONT-12 bootstrap; DECISIONS.md 2026-09-16; doc + # docs/local-ai/cubs-campaign.md). A 7-day capability probe of Halogen Flash + # on the CUBS tree, run as a serial backlog of ~40-minute iterations the + # kernel admits on the `gpu-worker` row. The acceptor mints N independent + # `agent()` items labelled `build:CUBS-<n>` in ONE parallel(); every one of + # them, with its `scope(...)` and `eval(...)` cells, needs a kit entry, so the + # 3N entries are generated here (launch-recipe §1: "Generate the 3N entries + # with lib.genList"). + # + # THE ARGV IS ONE STORE EXECUTABLE. pkgs/cubs-iteration: block on + # worker:8731/health until `busy:false`, one fresh `pi -p --mode json` + # process on the task's own git worktree, the diff guard, the task's + # validation command, one repair process, commit on pass (never a push), a + # receipt.json per task, and the one usage line at $TALLY_USAGE_SOURCE_PATH + # the witness_record's `usage_source` points at — the same join + # `localSmoke` above closes, over a real run. env_allowlist is EMPTY for the + # same reason it is for LOCAL-SMOKE: the child sees the two TALLY_ variables + # and nothing else, and every program it runs is a store path it carries. + # + # WHY `stdin` IS A POINTER AND NOT THE TASK. A kit entry's `stdin` is STATIC + # bytes in this store file (lake apps/uplink/src/kit.mjs — the entry is + # `{argv, cwd, env_allowlist, usage_source, stdin}`; apps/uplink/src/ + # uplink.mjs:623 hands `entry.stdin ?? ""` to exec.run; the kernel writes it + # to the child after its start marker, tally exec.rs:747). The lake's own + # e2e kit does put the whole item JSON there (tools/e2e-check.mjs:414-470), + # but that kit is MATERIALISED PER RUN by a script; this one is a reviewed + # store artifact that a day's worklist must not force a switch to change. + # So the entry hands over a pointer — {"worklist", "id"} — and + # cubs-iteration resolves the task line from + # ~/mecattaf/cubs-campaign/worklists/current.jsonl, which the morning review + # rewrites. Nothing the acceptor's expansion provides reaches stdin: the + # plan's brief goes to the lake, and `argv_ref` is the label + # (packages/planning/src/objects/factory.ts:621, `argv_ref = taskId`). + # + # `cwd` is the campaign repo, as the brief says; a run before that + # directory exists is a spawn failure the kernel attests, not a silent pass. + # + # N = 200 is a ceiling on labels, not a promise of work: a worklist line + # exists for an id or cubs-iteration exits 65 naming the id, and an item the + # plan never mints has an entry nobody resolves. 200 covers campaign days + # 1-7 (28 on day 1, ~15-30/day after) without a second coordinator switch. + cubsIteration = pkgs.callPackage ../pkgs/cubs-iteration { pi = pkgs.llm-agents.pi; }; + cubsCampaignDir = "${config.home.homeDirectory}/mecattaf/cubs-campaign"; + cubsWorklist = "${cubsCampaignDir}/worklists/current.jsonl"; + cubsCount = 200; + cubsEntry = id: { + argv = [ "${cubsIteration}/bin/cubs-iteration" ]; + cwd = cubsCampaignDir; + env_allowlist = [ ]; + usage_source = { + kind = "halogen-usage/1"; + path_glob = "${rewriteState}/uplink/usage/cubs-*.jsonl"; + }; + stdin = builtins.toJSON { + worklist = cubsWorklist; + inherit id; + } + "\n"; + }; + cubsEntries = lib.listToAttrs ( + lib.concatMap ( + n: + let + id = "CUBS-${toString n}"; + in + [ + (lib.nameValuePair "build:${id}" (cubsEntry id)) + (lib.nameValuePair "scope(build:${id})" (noopEntry "opaque-noop/1" "scope-noop")) + (lib.nameValuePair "eval(build:${id})" (noopEntry "opaque-noop/1" "eval-noop")) + ] + ) (lib.range 1 cubsCount) + ); + kitFile = pkgs.writeText "tally-uplink-kit.json" ( builtins.toJSON { _note = [ @@ -357,6 +431,12 @@ let "" "usage_source.kind is an OPAQUE label the kernel carries and never reads" "(tally docs/transport.md §2). It names no harness and nothing branches on it." + "" + "ENABLED: build:CUBS-1 .. build:CUBS-200 (+ scope/eval no-ops), the" + "cubs-halogen-probe-1 campaign (FRONT-12 bootstrap): one store executable," + "cubs-iteration, per item; stdin is a POINTER {worklist, id} into" + "~/mecattaf/cubs-campaign/worklists/current.jsonl, resolved by the script." + "docs/local-ai/cubs-campaign.md." ]; entries = { @@ -373,6 +453,7 @@ let "scope(build:LOCAL-SMOKE)" = noopEntry "opaque-noop/1" "scope-noop"; "eval(build:LOCAL-SMOKE)" = noopEntry "opaque-noop/1" "eval-noop"; } + // cubsEntries // lib.optionalAttrs enableClaudeSeat { "claude:headless" = claudeSeatEntry; }; } ); diff --git a/pkgs/cubs-iteration/cubs-helpers.py b/pkgs/cubs-iteration/cubs-helpers.py new file mode 100644 index 000000000..904eafdb4 --- /dev/null +++ b/pkgs/cubs-iteration/cubs-helpers.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""cubs-iteration's two pure helpers. No network, no git, no state. + + cubs-helpers.py events <pi-events.jsonl> + Summarise one `pi --mode json` event stream (pi docs/json.md): usage + summed over message_end events, tool-call and isError counts from + tool_execution_end, repeated identical calls (same toolName + same + canonical args seen before), the last assistant stopReason. Prints one + JSON object. Never fails on a truncated or empty stream: a partial run + still gets a summary, with `truncated` true. + + cubs-helpers.py guard <repo> <allowed_paths.json> [upstream-dir] < touched-files + The built-in diff guard (the campaign's tools/spec-diff-guard.sh rules): + every touched file must match allowed_paths + new_files, no spec.md, + no constitution, a setup copy identical to the upstream checkout is not + a change, an empty allowed diff fails. Prints {ok, violations, files, + changed, stray}; exit 1 on a violation. +""" +import hashlib +import json +import os +import re +import sys + + +# ----------------------------------------------------------------- events +def _num(value): + return value if isinstance(value, (int, float)) and not isinstance(value, bool) else 0 + + +def _usage_of(message): + usage = message.get("usage") if isinstance(message, dict) else None + if not isinstance(usage, dict): + return None + # pi's Usage is {input, output, cacheRead, cacheWrite, totalTokens, cost}; + # an OpenAI-shaped provider may leave prompt_tokens/completion_tokens and a + # reasoning count. Both spellings are read; the receipt keeps one. + prompt = _num(usage.get("input")) + _num(usage.get("cacheRead")) + _num(usage.get("cacheWrite")) + if prompt == 0: + prompt = _num(usage.get("prompt_tokens")) + completion = _num(usage.get("output")) or _num(usage.get("completion_tokens")) + reasoning = None + for key in ("reasoning", "reasoning_tokens", "reasoningTokens"): + if isinstance(usage.get(key), (int, float)): + reasoning = _num(usage.get(key)) + break + details = usage.get("completion_tokens_details") + if reasoning is None and isinstance(details, dict) and isinstance(details.get("reasoning_tokens"), (int, float)): + reasoning = _num(details.get("reasoning_tokens")) + return prompt, completion, reasoning + + +def events(path): + out = { + "events": 0, + "message_ends": 0, + "tool_calls": 0, + "tool_errors": 0, + "repeated_identical_calls": 0, + "tools_by_name": {}, + "usage": {"prompt_tokens": 0, "completion_tokens": 0, "reasoning_tokens": None}, + "stop_reason": None, + "error_message": None, + "agent_end": False, + "truncated": False, + "compactions": 0, + } + seen = set() + try: + fh = open(path, "r", encoding="utf-8", errors="replace") + except OSError as error: + out["truncated"] = True + out["error_message"] = f"cannot read {path}: {error}" + print(json.dumps(out)) + return 0 + with fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + out["truncated"] = True + continue + if not isinstance(event, dict): + continue + out["events"] += 1 + kind = event.get("type") + if kind == "tool_execution_start": + out["tool_calls"] += 1 + name = str(event.get("toolName")) + out["tools_by_name"][name] = out["tools_by_name"].get(name, 0) + 1 + key = name + "\0" + json.dumps(event.get("args"), sort_keys=True, separators=(",", ":")) + if key in seen: + out["repeated_identical_calls"] += 1 + seen.add(key) + elif kind == "tool_execution_end": + if event.get("isError") is True: + out["tool_errors"] += 1 + elif kind == "message_end": + message = event.get("message") or {} + if message.get("role") == "assistant": + out["message_ends"] += 1 + usage = _usage_of(message) + if usage is not None: + prompt, completion, reasoning = usage + out["usage"]["prompt_tokens"] += prompt + out["usage"]["completion_tokens"] += completion + if reasoning is not None: + out["usage"]["reasoning_tokens"] = (out["usage"]["reasoning_tokens"] or 0) + reasoning + if message.get("stopReason") is not None: + out["stop_reason"] = message.get("stopReason") + if message.get("errorMessage"): + out["error_message"] = str(message.get("errorMessage"))[:500] + elif kind == "compaction_end": + out["compactions"] += 1 + elif kind == "agent_end": + out["agent_end"] = True + if not out["agent_end"]: + out["truncated"] = True + print(json.dumps(out)) + return 0 + + +# ----------------------------------------------------------------- guard +# The same rules as the campaign's tools/spec-diff-guard.sh, so the built-in +# fallback and the campaign's grader agree on what a touched file may be: +# any file whose basename is spec.md is frozen (spec/**/spec.md relative to +# ~/agency, and the same rule inside every repo), so is the speckit +# constitution; a touched file outside allowed_paths + new_files is a +# violation unless it is byte-identical to the same path under the upstream +# checkout (a setup copy is not a change); an empty diff inside the allowed +# set fails. +FROZEN_BASENAME = "spec.md" +FROZEN_PATHS = {".specify/memory/constitution.md"} + + +def _glob_to_regex(pattern): + # `**/` crosses directories, `**` matches anything, `*` and `?` stay inside + # one path segment, everything else is literal (the campaign guard's + # grammar, verbatim). + out = "" + i = 0 + while i < len(pattern): + ch = pattern[i] + if pattern.startswith("**/", i): + out += "(?:.*/)?" + i += 3 + continue + if pattern.startswith("**", i): + out += ".*" + i += 2 + continue + if ch == "*": + out += "[^/]*" + elif ch == "?": + out += "[^/]" + else: + out += re.escape(ch) + i += 1 + return re.compile("^" + out + "$") + + +def _same_as_upstream(upstream, path): + if not upstream: + return False + up = os.path.join(upstream, path) + if not os.path.isfile(up) or not os.path.isfile(path): + return False + with open(up, "rb") as a, open(path, "rb") as b: + return hashlib.sha256(a.read()).hexdigest() == hashlib.sha256(b.read()).hexdigest() + + +def guard(repo, allowed_json, upstream=""): + try: + allowed = json.loads(allowed_json) + except json.JSONDecodeError as error: + print(json.dumps({"ok": False, "violations": [f"allowed_paths is not JSON: {error}"], "files": [], "changed": []})) + return 1 + if not isinstance(allowed, list) or not all(isinstance(p, str) for p in allowed): + print(json.dumps({"ok": False, "violations": ["allowed_paths is not a list of strings"], "files": [], "changed": []})) + return 1 + patterns = [_glob_to_regex(p.strip()) for p in allowed if p.strip()] + files = [line.rstrip("\n") for line in sys.stdin if line.strip()] + violations = [] + changed = [] + stray = [] + for path in files: + full = f"{repo}/{path}" + if os.path.basename(path) == FROZEN_BASENAME: + violations.append(f"{path}: spec/**/spec.md is never modified ({full})") + continue + if path in FROZEN_PATHS: + violations.append(f"{path}: the constitution is frozen") + continue + if any(rx.match(path) for rx in patterns): + changed.append(path) + continue + if _same_as_upstream(upstream, path): + continue + stray.append(path) + violations.append(f"{path}: outside allowed_paths") + if not violations and not changed: + violations.append("empty diff: no changed or new file inside allowed_paths") + result = {"ok": not violations, "violations": violations, "files": files, "changed": changed, "stray": stray} + print(json.dumps(result)) + return 0 if not violations else 1 + + +def main(argv): + if len(argv) >= 3 and argv[1] == "events": + return events(argv[2]) + if len(argv) >= 4 and argv[1] == "guard": + return guard(argv[2], argv[3], argv[4] if len(argv) > 4 else "") + sys.stderr.write(__doc__) + return 64 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/pkgs/cubs-iteration/cubs-iteration.sh b/pkgs/cubs-iteration/cubs-iteration.sh new file mode 100644 index 000000000..2a7c4989b --- /dev/null +++ b/pkgs/cubs-iteration/cubs-iteration.sh @@ -0,0 +1,908 @@ +# cubs-iteration — ONE bounded Halogen coding iteration on the CUBS tree, run +# as the argv of a `build:CUBS-<n>` kit entry under a tally-kernel lease. +# +# Campaign: cubs-halogen-probe-1 (FRONT-12 bootstrap). Brief: +# ~/mecattaf/cubs-campaign/README.md. Doc: docs/local-ai/cubs-campaign.md. +# Lineage: pkgs/academic-ocr-drain/drain.sh (flock, receipt as the done +# marker, wait-with-deadline exit 69, transient drops never fed to the fuse, +# 3-consecutive-failure fuse exit 2) and pkgs/local-ai-monthly/lib/judge.sh +# (a campaign-private PI_CODING_AGENT_DIR). +# +# THE CHILD'S ENVIRONMENT IS EMPTY. The kernel's exec.run spawns this argv +# with env_clear and an empty env_allowlist (tally +# crates/tally-kernel/src/exec.rs:681-689): the process sees +# TALLY_EXECUTION_ID and TALLY_USAGE_SOURCE_PATH and nothing else — no HOME, +# and PATH is whatever /bin/sh left (MEASURED `env -i /bin/sh -c 'echo $PATH'` +# -> /no-such-path). So HOME is derived from the passwd entry below, every +# tool is a store path from runtimeInputs, and the two profile bins are +# appended LAST, for `nix` in a task's validation_cmd (home/tally-filler.nix's +# reasoning: pinning a second nix into a lease script would be this +# repository deciding which nix another tree's oracle runs). +# +# STDIN is the kit entry's static `stdin` (lake apps/uplink/src/uplink.mjs:623 +# `stdin: entry.stdin ?? ""`; the kernel writes it after its start marker, +# exec.rs:747). A store kit cannot carry a day's task JSON, so the entry hands +# over a POINTER — {"worklist": <jsonl>, "id": "CUBS-<n>"} — and this script +# resolves the task line from the worklist. A full task object on stdin (the +# worklist line itself, as `--dry` self-tests use it) is accepted too. +# +# THE RECEIPT is ~/mecattaf/cubs-campaign/tools/receipt.schema.json's shape +# (the campaign owns the schema; this script owns the bytes), plus +# `execution_id` and `exit_code`. Per-attempt detail lives beside it in +# attempts.json. `observed_failure_mode` stays null for the morning review; +# the executable's own mechanical reading of a failure is in `notes`. +# +# THE GUARD is the campaign's tools/spec-diff-guard.sh when the task came from +# a worklist and that script exists (it is the campaign's grader: allowed +# paths, frozen spec.md, `git diff --check`, setup copies identical to the +# upstream checkout are not changes); otherwise the built-in guard in +# cubs-helpers.py, which implements the same path rules. Only files inside +# allowed_paths + new_files are ever staged and committed. +# +# EXIT CODES (also under --help): +# 0 pass (validated, committed, receipted) — or an idempotent no-op on a +# task whose receipt already says pass +# 1 fail (setup, guard or validation red after the one repair; receipt +# "fail") +# 124 timeout: a Pi process hit its wall-clock budget (receipt "timeout"; +# not a fail, not fuse fodder; no repair is attempted on a timed-out +# first attempt, the budget WAS the point) +# 2 fuse (third consecutive fail, or the fuse was already blown; receipt +# "fuse") — reset by removing <state>/fuse +# 64 usage +# 65 the stdin JSON or the worklist line is malformed +# 69 outage (Halogen not ok/idle within 20 min, or dropped mid-run; +# receipt "outage"; NEVER counts toward the fuse) +# 75 another cubs-iteration holds the state lock +# 78 campaign material missing (campaign dir, system prompt, models.json, +# bundle, repo) — no receipt, the task stays runnable +# 143 cancelled: SIGTERM/SIGINT (the lease's rail) — WIP committed, receipt +# "cancelled", within the kernel's 30 s checkpoint grace + +# ---------------------------------------------------------------- usage +usage() { + cat <<'EOF' +usage: cubs-iteration [--dry] [--help] < task-or-pointer.json + +Reads ONE JSON object on stdin: + pointer {"worklist": "<path>.jsonl", "id": "CUBS-<n>"} (the kit's form) + task {"id","package","title","repo","bundle_path","setup_cmd", + "validation_cmd","allowed_paths":[...],"new_files":[...], + "thinking","prior_p_pass","predicted_failure"} (a worklist line) + + --dry validate stdin, print the plan as JSON, run nothing, write nothing + --help this text + +Environment (only for hand runs and the self-test; the kernel clears it): + CUBS_CAMPAIGN_DIR default ~/mecattaf/cubs-campaign + CUBS_STATE_DIR default ~/.local/state/cubs-campaign + CUBS_AGENCY_ROOT default ~/agency (the CUBS repos live under it) + CUBS_HALOGEN_URL default http://worker:8731 + CUBS_PI_TIMEOUT seconds for the first Pi process, default 1200 + CUBS_PI_REPAIR_TIMEOUT seconds for the repair process, default 900 + CUBS_PI_BIN, CUBS_HEALTH_DEADLINE, CUBS_HEALTH_INTERVAL, + CUBS_VALIDATION_TIMEOUT the self-test's seams + TALLY_USAGE_SOURCE_PATH where the one usage line lands (the kernel sets it) + +Exit codes: 0 pass | 1 fail | 2 fuse | 64 usage | 65 bad stdin | 69 outage | + 75 lock held | 78 campaign material missing | 124 Pi timeout | + 143 cancelled +EOF +} + +DRY=0 +while [ $# -gt 0 ]; do + case "$1" in + --dry) DRY=1 ;; + -h | --help) usage; exit 0 ;; + *) printf 'cubs-iteration: unknown argument %s\n' "$1" >&2; usage >&2; exit 64 ;; + esac + shift +done + +# ---------------------------------------------------------------- environment +if [ -z "${HOME:-}" ]; then + # The passwd entry, through the python3 this package carries (getent is + # glibc's and not on the kernel's empty PATH). + HOME="$(python3 -c 'import os, pwd; print(pwd.getpwuid(os.getuid()).pw_dir)')" + export HOME +fi +export PATH="$PATH:/etc/profiles/per-user/tom/bin:/run/current-system/sw/bin" +export LANG="${LANG:-C.UTF-8}" + +expand_home() { + # A LITERAL tilde is what a worklist line or the kit's pointer carries + # ("~/mecattaf/cubs-campaign/..."); this is the one place it is expanded. + # shellcheck disable=SC2088 + case "$1" in + "~") printf '%s' "$HOME" ;; + "~/"*) printf '%s/%s' "$HOME" "${1#"~/"}" ;; + *) printf '%s' "$1" ;; + esac +} + +CAMPAIGN_DIR="$(expand_home "${CUBS_CAMPAIGN_DIR:-$HOME/mecattaf/cubs-campaign}")" +STATE="$(expand_home "${CUBS_STATE_DIR:-$HOME/.local/state/cubs-campaign}")" +AGENCY_ROOT="$(expand_home "${CUBS_AGENCY_ROOT:-$HOME/agency}")" +HALOGEN="${CUBS_HALOGEN_URL:-http://worker:8731}" +PI_TIMEOUT="${CUBS_PI_TIMEOUT:-1200}" +PI_REPAIR_TIMEOUT="${CUBS_PI_REPAIR_TIMEOUT:-900}" +# The self-test's seams (tests/tally-uplink/probe-cubs-iteration.sh): a stub +# Pi and shorter clocks. Under the kernel none of these exist in the +# environment, so the defaults are the campaign's numbers. +PI_BIN="${CUBS_PI_BIN:-pi}" +HEALTH_INTERVAL="${CUBS_HEALTH_INTERVAL:-10}" +HEALTH_DEADLINE="${CUBS_HEALTH_DEADLINE:-1200}" +VALIDATION_TIMEOUT="${CUBS_VALIDATION_TIMEOUT:-600}" + +PROVIDER="halogen" +MODEL_ROW="halogen-qwen3.8-flash-next" +BRANCH_PREFIX="campaign/cubs-halogen-probe-1" +CAMPAIGN_NAME="cubs-halogen-probe-1" +SYSTEM_PROMPT="$CAMPAIGN_DIR/skill/system-prompt.md" +PI_AGENT_DIR="$CAMPAIGN_DIR/pi" +CAMPAIGN_GUARD="$CAMPAIGN_DIR/tools/spec-diff-guard.sh" +DIFF_PROMPT_BYTES=24000 +TRANSCRIPT_PROMPT_BYTES=8000 +GIT_IDENTITY=(-c user.name=cubs-iteration -c user.email=cubs-iteration@localhost) + +CUBS_HELPERS="${CUBS_HELPERS:?cubs-iteration: CUBS_HELPERS (the python helper) is not set}" + +now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; } +now_s() { date +%s; } +log() { printf '%s cubs-iteration[%s] %s\n' "$(now_iso)" "${TASK_ID:-?}" "$*" >&2; } + +# Paths in a worklist line are absolute, `~/…`, or relative to the campaign +# repository (the kit's cwd) — "bundles/CUBS-1.md" is the day-01 shape. +campaign_path() { + case "$1" in + /*) printf '%s' "$1" ;; + "~"*) expand_home "$1" ;; + *) printf '%s/%s' "$CAMPAIGN_DIR" "$1" ;; + esac +} + +# ---------------------------------------------------------------- stdin -> task +input="$(cat)" +if ! printf '%s' "$input" | jq -e 'type == "object"' >/dev/null 2>&1; then + printf 'cubs-iteration: stdin is not one JSON object\n' >&2 + exit 65 +fi + +if printf '%s' "$input" | jq -e 'has("worklist")' >/dev/null; then + worklist="$(campaign_path "$(printf '%s' "$input" | jq -r '.worklist')")" + want_id="$(printf '%s' "$input" | jq -r '.id // empty')" + if [ -z "$want_id" ]; then + printf 'cubs-iteration: pointer carries no id\n' >&2 + exit 65 + fi + if [ ! -r "$worklist" ]; then + if [ "$DRY" = 1 ]; then + task="$(jq -cn --arg id "$want_id" --arg wl "$worklist" '{id: $id, _unresolved_worklist: $wl}')" + else + printf 'cubs-iteration: worklist %s is not readable\n' "$worklist" >&2 + exit 78 + fi + else + task="$(jq -c --arg id "$want_id" 'select(type == "object" and .id == $id)' "$worklist" 2>/dev/null | head -n 1 || true)" + if [ -z "$task" ]; then + printf 'cubs-iteration: %s carries no line with id %s\n' "$worklist" "$want_id" >&2 + exit 65 + fi + fi + TASK_SOURCE="$worklist" +else + task="$(printf '%s' "$input" | jq -c .)" + TASK_SOURCE="stdin" +fi + +task_str() { printf '%s' "$task" | jq -r --arg k "$1" '.[$k] // empty'; } + +TASK_ID="$(task_str id)" +if ! printf '%s' "$TASK_ID" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$'; then + printf 'cubs-iteration: task id %s is not a plain token\n' "${TASK_ID:-<empty>}" >&2 + exit 65 +fi + +# A pointer to a worklist that does not exist yet: --dry reports the plan it +# CAN compute and says the rest is unresolved. +if printf '%s' "$task" | jq -e 'has("_unresolved_worklist")' >/dev/null; then + jq -n --argjson task "$task" --arg state "$STATE" --arg campaign "$CAMPAIGN_DIR" \ + --arg agency "$AGENCY_ROOT" --arg halogen "$HALOGEN" \ + '{dry: true, resolved: false, + reason: ("worklist " + $task._unresolved_worklist + " is not readable; the task line cannot be resolved yet"), + id: $task.id, state_dir: $state, campaign_dir: $campaign, agency_root: $agency, halogen: $halogen}' + exit 0 +fi + +# Required fields, and the shape of each. +missing="$(printf '%s' "$task" | jq -r ' + [ (if (.package|type) != "string" then "package" else empty end), + (if (.title|type) != "string" then "title" else empty end), + (if (.repo|type) != "string" or (.repo|test("^[A-Za-z0-9._-]+$")|not) then "repo" else empty end), + (if (.bundle_path|type) != "string" then "bundle_path" else empty end), + (if has("setup_cmd") and ((.setup_cmd|type) != "string" and (.setup_cmd|type) != "null") then "setup_cmd" else empty end), + (if (.validation_cmd|type) != "string" or .validation_cmd == "" then "validation_cmd" else empty end), + (if (.allowed_paths|type) != "array" or (.allowed_paths|length) == 0 then "allowed_paths" else empty end), + (if has("new_files") and (.new_files|type) != "array" then "new_files" else empty end), + (if has("thinking") and ((.thinking|type) != "string" or (.thinking as $t | ["off","minimal","low","medium","high","xhigh","max"] | index($t)) == null) then "thinking" else empty end), + (if has("prior_p_pass") and (.prior_p_pass|type) != "number" then "prior_p_pass" else empty end) + ] | join(",")')" +if [ -n "$missing" ]; then + printf 'cubs-iteration: task %s: missing or malformed field(s): %s\n' "$TASK_ID" "$missing" >&2 + exit 65 +fi + +PACKAGE="$(task_str package)" +TITLE="$(task_str title)" +REPO="$(task_str repo)" +BUNDLE="$(campaign_path "$(task_str bundle_path)")" +SETUP_CMD="$(printf '%s' "$task" | jq -r '.setup_cmd // empty')" +VALIDATION_CMD="$(task_str validation_cmd)" +THINKING="$(printf '%s' "$task" | jq -r '.thinking // "low"')" +ALLOWED_JSON="$(printf '%s' "$task" | jq -c '(.allowed_paths) + (.new_files // [])')" +NEW_FILES_N="$(printf '%s' "$task" | jq '.new_files // [] | length')" +PRIOR_P_PASS="$(printf '%s' "$task" | jq -c '.prior_p_pass // null')" +PREDICTED_FAILURE="$(printf '%s' "$task" | jq -c '.predicted_failure // null')" + +TOOLS="read,bash,edit,grep,find,ls" +if [ "$NEW_FILES_N" -gt 0 ]; then TOOLS="$TOOLS,write"; fi + +case "$TASK_ID" in + CUBS-*) COMMIT_SUBJECT="$TASK_ID: $TITLE" ;; + *) COMMIT_SUBJECT="CUBS-$TASK_ID: $TITLE" ;; +esac + +REPO_DIR="$AGENCY_ROOT/$REPO" +WORKTREE="$STATE/worktrees/$TASK_ID" +BRANCH="$BRANCH_PREFIX/$TASK_ID" +TASK_DIR="$STATE/tasks/$TASK_ID" +RECEIPT="$TASK_DIR/receipt.json" +ATTEMPTS_FILE="$TASK_DIR/attempts.json" +SESSIONS="$STATE/sessions" +LOGS="$STATE/logs" +FUSE="$STATE/fuse" + +pi_argv() { + # $1 session id, $2 thinking. The prompt (last positional) is appended by + # the caller, so this list is the FIXED part every receipt can quote. + printf '%s\n' "$PI_BIN" -p --mode json --session-dir "$SESSIONS" --session-id "$1" \ + --provider "$PROVIDER" --model "$MODEL_ROW" --thinking "$2" \ + --no-extensions --no-skills --no-prompt-templates --no-context-files --no-approve \ + --tools "$TOOLS" +} + +guard_kind() { + if [ "$TASK_SOURCE" != stdin ] && [ -x "$CAMPAIGN_GUARD" ]; then + printf 'campaign:%s' "$CAMPAIGN_GUARD" + else + printf 'builtin:cubs-helpers.py guard' + fi +} + +# ---------------------------------------------------------------- --dry +if [ "$DRY" = 1 ]; then + receipt_status="absent" + if [ -r "$RECEIPT" ]; then + receipt_status="$(jq -r '.terminal_status // "unreadable"' "$RECEIPT" 2>/dev/null || echo unreadable)" + fi + fuse_count=0 + if [ -r "$FUSE" ]; then fuse_count="$(tr -dc 0-9 <"$FUSE")"; fi + argv_json="$(pi_argv "$TASK_ID-a1" "$THINKING" | jq -R . | jq -s '. + ["--system-prompt", "<contents of skill/system-prompt.md>", "--", "<contents of the bundle>"]')" + jq -n --argjson task "$task" --arg source "$TASK_SOURCE" \ + --arg state "$STATE" --arg campaign "$CAMPAIGN_DIR" --arg halogen "$HALOGEN" \ + --arg repo_dir "$REPO_DIR" --arg worktree "$WORKTREE" --arg branch "$BRANCH" \ + --arg bundle "$BUNDLE" --arg sys "$SYSTEM_PROMPT" --arg agent_dir "$PI_AGENT_DIR" \ + --arg tools "$TOOLS" --arg subject "$COMMIT_SUBJECT" --argjson argv "$argv_json" \ + --arg receipt_status "$receipt_status" --argjson fuse "${fuse_count:-0}" \ + --argjson pi_timeout "$PI_TIMEOUT" --argjson h_int "$HEALTH_INTERVAL" --argjson h_dead "$HEALTH_DEADLINE" \ + --argjson v_timeout "$VALIDATION_TIMEOUT" --arg guard "$(guard_kind)" --arg setup "$SETUP_CMD" \ + --argjson repo_ok "$(git -C "$REPO_DIR" rev-parse --git-dir >/dev/null 2>&1 && echo true || echo false)" \ + --argjson bundle_ok "$([ -r "$BUNDLE" ] && echo true || echo false)" \ + --argjson sys_ok "$([ -r "$SYSTEM_PROMPT" ] && echo true || echo false)" \ + --argjson models_ok "$([ -r "$PI_AGENT_DIR/models.json" ] && echo true || echo false)" \ + --argjson settings_ok "$([ -r "$PI_AGENT_DIR/settings.json" ] && echo true || echo false)" \ + '{dry: true, resolved: true, task: $task, task_source: $source, + plan: { + repo_dir: $repo_dir, worktree: $worktree, branch: $branch, + sessions: [($task.id + "-a1"), ($task.id + "-a2")], + tools: $tools, thinking: ($task.thinking // "low"), + pi_agent_dir: $agent_dir, system_prompt: $sys, bundle: $bundle, + pi_argv: $argv, pi_timeout_seconds: $pi_timeout, + setup_cmd: (if $setup == "" then null else $setup end), + guard: $guard, + validation_cmd: $task.validation_cmd, validation_timeout_seconds: $v_timeout, + commit_subject: $subject, halogen: $halogen, + health: {interval_seconds: $h_int, deadline_seconds: $h_dead, on_deadline: "exit 69, receipt outage"}, + repair: "one fresh Pi process (a2) fed bundle + diff + validation transcript, then fail closed", + fuse: "3 consecutive fails -> exit 2, receipt fuse" + }, + preflight: { + campaign_dir: $campaign, state_dir: $state, + repo_present: $repo_ok, bundle_present: $bundle_ok, + system_prompt_present: $sys_ok, models_json_present: $models_ok, settings_json_present: $settings_ok, + receipt_status: $receipt_status, fuse_count: $fuse + }}' + exit 0 +fi + +# ---------------------------------------------------------------- state, lock +mkdir -p "$STATE" "$STATE/worktrees" "$STATE/tasks" "$SESSIONS" "$LOGS" "$TASK_DIR" +exec 9>"$STATE/lock" +if ! flock -w 60 9; then + log "another cubs-iteration holds $STATE/lock" + exit 75 +fi + +# Idempotent on a pass: the receipt is the done marker (drain.sh l.112). +if [ -r "$RECEIPT" ] && [ "$(jq -r '.terminal_status // ""' "$RECEIPT" 2>/dev/null)" = "pass" ]; then + log "receipt already says pass; nothing to do" + exit 0 +fi + +STARTED_AT="$(now_iso)" +STARTED_S="$(now_s)" +EXECUTION_ID="${TALLY_EXECUTION_ID:-}" +USAGE_PATH="${TALLY_USAGE_SOURCE_PATH:-}" + +# Everything the receipt names, filled in as the run goes. +MODEL_ID="" +HEALTH_VERSION="" +CAMPAIGN_SHA="" +SKILL_DIGEST="" +BUNDLE_DIGEST="" +BASE_SHA="" +COMMIT_SHA="" +DIFF_SHA="" +REPAIR_COUNT=0 +ATTEMPTS_JSON="[]" +VALIDATION_JSON="null" +GUARD_JSON="null" +GUARD_EXIT="null" +NOTES="" +CHILD_PID="" +FINISHING=0 +LAST_SESSION="" +LAST_EVENTS="" +LAST_PI_RC=0 +LAST_TRANSCRIPT="" + +fuse_read() { + if [ -r "$FUSE" ]; then tr -dc 0-9 <"$FUSE"; else printf 0; fi +} +fuse_write() { printf '%s\n' "$1" >"$FUSE.tmp" && mv -f "$FUSE.tmp" "$FUSE"; } + +digest_file() { printf 'sha256:%s' "$(sha256sum "$1" | cut -d' ' -f1)"; } + +# The files the task may touch, as the worktree holds them now: changed +# tracked files plus untracked files (honouring .gitignore), filtered to +# allowed_paths + new_files. Setup copies and build products never enter it. +touched_files() { + { + git -C "$WORKTREE" diff --name-only HEAD 2>/dev/null + git -C "$WORKTREE" ls-files --others --exclude-standard 2>/dev/null + } | sort -u +} +# Untracked, non-ignored files outside allowed_paths + new_files at close: +# the guard fails on them; the receipt lists them so the review sees what +# the model tried to create. +stray_files() { + # exactly ONE JSON array, always: the guard exits 1 on any violation (a stray, + # or "empty diff" when nothing is untracked), which under pipefail made the + # old `|| echo '[]'` append a second value and broke `jq --argjson stray`. + local out + out="$(git -C "$WORKTREE" ls-files --others --exclude-standard 2>/dev/null \ + | (cd "$WORKTREE" && python3 "$CUBS_HELPERS" guard "$REPO" "$ALLOWED_JSON" "$REPO_DIR") 2>/dev/null \ + | jq -c '.stray // []' 2>/dev/null)" || true + printf '%s' "${out:-[]}" +} +allowed_files() { + # from the worktree: the helper compares relative paths against upstream. + touched_files | (cd "$WORKTREE" && python3 "$CUBS_HELPERS" guard "$REPO" "$ALLOWED_JSON" "$REPO_DIR") 2>/dev/null \ + | jq -r '.changed[]' 2>/dev/null || true +} +# The diff the receipt and the repair prompt read: HEAD vs the working tree, +# restricted to the allowed files, new files included. +worktree_diff() { + local files + files="$(allowed_files)" + [ -n "$files" ] || return 0 + printf '%s\n' "$files" | xargs -d '\n' git -C "$WORKTREE" add -N -- 2>/dev/null || true + printf '%s\n' "$files" | xargs -d '\n' git -C "$WORKTREE" diff --no-color HEAD -- 2>/dev/null || true +} +# sha256 of the current allowed diff; empty when the diff is empty (the +# schema spells that null). +diff_now() { + local d + d="$(worktree_diff | sha256sum | cut -d' ' -f1)" + [ "$d" = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" ] && d="" + printf '%s' "$d" +} +stage_allowed() { + local files + files="$(allowed_files)" + [ -n "$files" ] || return 1 + printf '%s\n' "$files" | xargs -d '\n' git -C "$WORKTREE" add -- 2>/dev/null + ! git -C "$WORKTREE" diff --cached --quiet +} + +# ---------------------------------------------------------------- the receipt +# $1 terminal_status, $2 exit code (recorded, not applied). The shape is the +# campaign's tools/receipt.schema.json. +write_receipt() { + local status="$1" code="$2" finished wall diff_sha sessions + finished="$(now_iso)" + wall=$(( $(now_s) - STARTED_S )) + # The diff the task produced: fixed by `diff_now` before a commit moves + # HEAD; otherwise read live (a cancel, a fail, an outage mid-run). + diff_sha="$DIFF_SHA" + if [ -z "$diff_sha" ] && [ -n "$BASE_SHA" ] && [ -e "$WORKTREE/.git" ]; then + diff_sha="$(diff_now)" + fi + sessions="$(printf '%s' "$ATTEMPTS_JSON" | jq -c '[.[].session]')" + local stray='[]' + if [ -n "$BASE_SHA" ] && [ -e "$WORKTREE/.git" ]; then stray="$(stray_files)"; fi + printf '%s\n' "$ATTEMPTS_JSON" | jq . >"$ATTEMPTS_FILE.tmp" && mv -f "$ATTEMPTS_FILE.tmp" "$ATTEMPTS_FILE" + local tmp="$RECEIPT.tmp" + jq -n \ + --arg task "$TASK_ID" --arg package "$PACKAGE" --arg repo "$REPO" \ + --arg provider "$PROVIDER" --arg model "$MODEL_ID" --arg health_version "$HEALTH_VERSION" \ + --arg campaign_sha "$CAMPAIGN_SHA" --arg skill_digest "$SKILL_DIGEST" --arg bundle_digest "$BUNDLE_DIGEST" \ + --arg worktree "$WORKTREE" --arg branch "$BRANCH" --arg base_sha "$BASE_SHA" \ + --argjson attempts "$ATTEMPTS_JSON" --argjson sessions "$sessions" \ + --arg diff_sha "$diff_sha" --arg commit_sha "$COMMIT_SHA" \ + --argjson validation "$VALIDATION_JSON" --argjson guard_exit "$GUARD_EXIT" --arg cmd "$VALIDATION_CMD" \ + --argjson repair_count "$REPAIR_COUNT" \ + --arg status "$status" --argjson wall "$wall" \ + --argjson prior "$PRIOR_P_PASS" --argjson predicted "$PREDICTED_FAILURE" \ + --arg notes "$NOTES" \ + --arg execution_id "$EXECUTION_ID" --argjson stray "$stray" --arg attempts_path "$ATTEMPTS_FILE" \ + --arg started "$STARTED_AT" --arg finished "$finished" --argjson code "$code" \ + '{ + schema_version: 1, + task: $task, package: $package, + provider: $provider, model: $model, + health_version: (if $health_version == "" then null else $health_version end), + campaign_sha: $campaign_sha, skill_digest: $skill_digest, bundle_digest: $bundle_digest, + worktree_branch: $branch, worktree_path: $worktree, repo: $repo, base_sha: $base_sha, + tool_calls: ([$attempts[].events.tool_calls // 0] | add // 0), + is_error_count: ([$attempts[].events.tool_errors // 0] | add // 0), + repeated_identical_calls: ([$attempts[].events.repeated_identical_calls // 0] | add // 0), + tool_call_names: ([$attempts[].events.tools_by_name // {}] | reduce .[] as $h ({}; . as $acc | $h | to_entries | reduce .[] as $e ($acc; .[$e.key] = ((.[$e.key] // 0) + $e.value)))), + bash_call_count: ([$attempts[].events.tools_by_name.bash // 0] | add // 0), + stray_files: $stray, + reasoning_tokens: (if ([$attempts[].events.usage.reasoning_tokens | select(. != null)] | length) > 0 + then ([$attempts[].events.usage.reasoning_tokens // 0] | add) else null end), + attempts_path: $attempts_path, + usage: { + prompt_tokens: ([$attempts[].events.usage.prompt_tokens // 0] | add // 0), + completion_tokens: ([$attempts[].events.usage.completion_tokens // 0] | add // 0), + reasoning_tokens: (if ([$attempts[].events.usage.reasoning_tokens | select(. != null)] | length) > 0 + then ([$attempts[].events.usage.reasoning_tokens // 0] | add) else null end), + message_end_events: ([$attempts[].events.message_ends // 0] | add // 0) + }, + diff_sha256: (if $diff_sha == "" then null else $diff_sha end), + commit_sha: (if $commit_sha == "" then null else $commit_sha end), + validation: (if $validation == null + then {cmd: $cmd, exit: null, transcript_digest: null, seconds: null, guard_exit: $guard_exit} + else ($validation + {guard_exit: $guard_exit}) end), + repair_count: $repair_count, + terminal_status: $status, wall_seconds: $wall, + prior_p_pass: $prior, predicted_failure: $predicted, + observed_failure_mode: null, + started_at: $started, finished_at: (if $status == "pending" then null else $finished end), + sessions: $sessions, + notes: $notes, + execution_id: (if $execution_id == "" then null else $execution_id end), + exit_code: $code + }' >"$tmp" && mv -f "$tmp" "$RECEIPT" + [ "$status" = pending ] && return 0 + jq -c . "$RECEIPT" >>"$STATE/ledger.jsonl" 2>/dev/null || true + # The one usage line the kernel's witness_record points at (home/tally- + # uplink.nix, the usage_source join): {task, model, usage, seconds} plus + # the execution id, so the artifact names the execution back. + if [ -n "$USAGE_PATH" ]; then + mkdir -p "$(dirname "$USAGE_PATH")" + jq -c --arg eid "$EXECUTION_ID" --arg status "$status" \ + '{kind: "halogen-usage/1", execution_id: $eid, task: .task, model: .model, + usage: {prompt_tokens: .usage.prompt_tokens, completion_tokens: .usage.completion_tokens, reasoning_tokens: .usage.reasoning_tokens}, + seconds: .wall_seconds, terminal_status: $status}' "$RECEIPT" >>"$USAGE_PATH" || true + fi + log "receipt $status (exit $code) -> $RECEIPT" +} + +finish() { + # $1 terminal_status, $2 exit code + FINISHING=1 + write_receipt "$1" "$2" + exit "$2" +} + +# ---------------------------------------------------------------- SIGTERM +# The lease's rail: SIGTERM, 30 s checkpoint grace, SIGKILL (tally +# docs/executor.md). Everything below finishes inside 25 s: the child gets 5 s +# to leave, the WIP commit and the receipt are milliseconds. +# shellcheck disable=SC2329 # invoked by the trap below +on_term() { + trap '' TERM INT + [ "$FINISHING" = 1 ] && exit 143 + FINISHING=1 + log "SIGTERM: cancelling under the lease" + if [ -n "$CHILD_PID" ] && kill -0 "$CHILD_PID" 2>/dev/null; then + kill -TERM "$CHILD_PID" 2>/dev/null || true + local i=0 + while [ "$i" -lt 10 ] && kill -0 "$CHILD_PID" 2>/dev/null; do sleep 0.5; i=$((i + 1)); done + kill -KILL "$CHILD_PID" 2>/dev/null || true + fi + if [ -n "$BASE_SHA" ] && [ -e "$WORKTREE/.git" ] && { DIFF_SHA="$(diff_now)"; stage_allowed; }; then + if git -C "$WORKTREE" "${GIT_IDENTITY[@]}" commit -q -m "$COMMIT_SUBJECT [WIP, cancelled under lease]" >/dev/null 2>&1; then + COMMIT_SHA="$(git -C "$WORKTREE" rev-parse HEAD 2>/dev/null || true)" + # Kept reachable under a private ref namespace; the retry resets the + # branch to its base with the WIP back in the working tree. + git -C "$WORKTREE" update-ref "refs/cubs-wip/$TASK_ID/$(now_s)" "$COMMIT_SHA" 2>/dev/null || true + fi + fi + NOTES="${NOTES:-cancelled under the lease (SIGTERM)}" + write_receipt cancelled 143 + exit 143 +} +trap on_term TERM INT + +# ---------------------------------------------------------------- fuse gate +fuse_now="$(fuse_read)" +if [ "${fuse_now:-0}" -ge 3 ]; then + NOTES="fuse_blown_before_start (consecutive_failures=$fuse_now; remove $FUSE to reset)" + log "$NOTES" + finish fuse 2 +fi + +# ---------------------------------------------------------------- preflight +preflight_fail() { log "$*"; exit 78; } +[ -d "$CAMPAIGN_DIR" ] || preflight_fail "campaign dir $CAMPAIGN_DIR is missing" +[ -r "$SYSTEM_PROMPT" ] || preflight_fail "system prompt $SYSTEM_PROMPT is missing" +[ -r "$PI_AGENT_DIR/models.json" ] || preflight_fail "campaign-private $PI_AGENT_DIR/models.json is missing" +# settings.json carries compaction.reserveTokens / keepRecentTokens (pi +# docs/settings.md), which models.json cannot; both ship in the campaign repo. +[ -r "$PI_AGENT_DIR/settings.json" ] || preflight_fail "campaign-private $PI_AGENT_DIR/settings.json is missing" +[ -r "$BUNDLE" ] || preflight_fail "bundle $BUNDLE is missing" +git -C "$REPO_DIR" rev-parse --git-dir >/dev/null 2>&1 || preflight_fail "$REPO_DIR is not a git repository" +if ! jq -e --arg p "$PROVIDER" --arg m "$MODEL_ROW" '.providers[$p].models | any(.id == $m)' \ + "$PI_AGENT_DIR/models.json" >/dev/null 2>&1; then + preflight_fail "$PI_AGENT_DIR/models.json does not declare provider $PROVIDER with model $MODEL_ROW" +fi + +CAMPAIGN_SHA="$(git -C "$CAMPAIGN_DIR" rev-parse HEAD 2>/dev/null || echo "0000000")" +SKILL_DIGEST="$(digest_file "$SYSTEM_PROMPT")" +BUNDLE_DIGEST="$(digest_file "$BUNDLE")" +write_receipt pending 0 + +# ---------------------------------------------------------------- halogen +health_ok() { + curl -fsS --max-time 5 "$HALOGEN/health" 2>/dev/null \ + | jq -e '.status == "ok" and .busy == false and ((.engine.responds) // true)' >/dev/null 2>&1 +} +health_reachable() { + curl -fsS --max-time 5 "$HALOGEN/health" 2>/dev/null | jq -e '.status == "ok"' >/dev/null 2>&1 +} +wait_for_halogen() { + local deadline + deadline=$(( $(now_s) + HEALTH_DEADLINE )) + until health_ok; do + if [ "$(now_s)" -ge "$deadline" ]; then + return 1 + fi + sleep "$HEALTH_INTERVAL" + done +} +log "waiting for $HALOGEN/health status ok, busy false (up to ${HEALTH_DEADLINE}s)" +if ! wait_for_halogen; then + NOTES="outage_before_start: $HALOGEN not ok/idle within ${HEALTH_DEADLINE}s" + log "$NOTES" + finish outage 69 +fi +MODEL_ID="$(curl -fsS --max-time 5 "$HALOGEN/v1/models" 2>/dev/null | jq -r '.data[0].id // empty' || true)" +# /health.version is an object on Halogen ({api, engine, match}); the schema +# wants a string. +HEALTH_VERSION="$(curl -fsS --max-time 5 "$HALOGEN/health" 2>/dev/null \ + | jq -r '.version | if type == "object" then ("api " + (.api|tostring) + " engine " + (.engine|tostring)) elif . == null then "" else tostring end' 2>/dev/null || true)" +[ -n "$MODEL_ID" ] || MODEL_ID="$MODEL_ROW" +log "halogen ready: model $MODEL_ID version ${HEALTH_VERSION:-?}" + +# ---------------------------------------------------------------- worktree +# From the repo's CURRENT HEAD, on the task's own branch. A retry (after an +# outage or a cancel) reuses the worktree: BASE_SHA is persisted at first +# creation, and a WIP commit the cancel left on the branch is moved back into +# the working tree (`reset --mixed` to the base) so HEAD is the base again and +# every diff, guard and commit reads from one point. +git -C "$REPO_DIR" worktree prune >/dev/null 2>&1 || true +if [ -f "$TASK_DIR/base_sha" ] && [ -d "$WORKTREE" ] && git -C "$WORKTREE" rev-parse --git-dir >/dev/null 2>&1; then + BASE_SHA="$(cat "$TASK_DIR/base_sha")" + if [ "$(git -C "$WORKTREE" rev-parse HEAD)" != "$BASE_SHA" ]; then + git -C "$WORKTREE" update-ref "refs/cubs-wip/$TASK_ID/$(now_s)" HEAD 2>/dev/null || true + git -C "$WORKTREE" reset -q --mixed "$BASE_SHA" + fi + log "reusing worktree $WORKTREE (base $BASE_SHA)" +else + rm -rf "$WORKTREE" + BASE_SHA="$(git -C "$REPO_DIR" rev-parse HEAD)" + if git -C "$REPO_DIR" show-ref --verify --quiet "refs/heads/$BRANCH"; then + git -C "$REPO_DIR" worktree add -q "$WORKTREE" "$BRANCH" + git -C "$WORKTREE" reset -q --hard "$BASE_SHA" + else + git -C "$REPO_DIR" worktree add -q -b "$BRANCH" "$WORKTREE" "$BASE_SHA" + fi + printf '%s\n' "$BASE_SHA" >"$TASK_DIR/base_sha" + log "worktree $WORKTREE on $BRANCH from $BASE_SHA" +fi + +# ---------------------------------------------------------------- setup +# The task's optional setup_cmd (e.g. WP2's linter copy), idempotent by the +# campaign's contract, run inside the worktree before every Pi process. +run_setup() { + [ -n "$SETUP_CMD" ] || return 0 + local rc=0 transcript="$LOGS/$TASK_ID-setup.log" + log "setup: $SETUP_CMD" + ( + cd "$WORKTREE" || exit 78 + timeout --foreground --kill-after=10 "$VALIDATION_TIMEOUT" bash -c "$SETUP_CMD" </dev/null >>"$transcript" 2>&1 + ) & + CHILD_PID=$! + wait "$CHILD_PID" || rc=$? + CHILD_PID="" + return "$rc" +} + +# ---------------------------------------------------------------- one Pi run +session_exists() { + # pi names a session <timestamp>_<id>.jsonl under --session-dir; a plain + # glob, because the store bash carries no `compgen` (MEASURED: "compgen: + # command not found" under pkgs.bash 5.3). + local f + for f in "$SESSIONS"/*_"$1".jsonl; do + [ -e "$f" ] && return 0 + done + return 1 +} + +# $1 attempt tag (a1|a2), $2 prompt file. Streams JSON events to +# logs/<id>-<tag>.jsonl. Leaves LAST_SESSION, LAST_EVENTS, LAST_PI_RC. +# +# STDIN IS /dev/null. `pi -p` reads a non-TTY stdin to EOF as part of the +# prompt (MEASURED in the campaign smoke): the task JSON arrived on THIS +# process's stdin and was read in full above, so Pi gets nothing. THE +# TIMEOUT is per attempt (20 min first, 15 min repair) with `-k 30`: a +# dropped Halogen connection leaves Pi's own auto-retry hanging +# indefinitely, and the lease's SIGKILL is not the rail this script should +# lean on. +run_pi() { + local tag="$1" prompt_file="$2" sid rc budget="$PI_TIMEOUT" + [ "$tag" = a2 ] && budget="$PI_REPAIR_TIMEOUT" + sid="$TASK_ID-$tag" + # ONE FRESH PROCESS: `--session-id` resumes a session that already exists in + # --session-dir, so a retry rotates the id rather than silently continuing + # yesterday's transcript. + local n=1 + while session_exists "$sid"; do + n=$((n + 1)); sid="$TASK_ID-$tag-r$n" + done + local out="$LOGS/$TASK_ID-$tag.jsonl" err="$LOGS/$TASK_ID-$tag.stderr" + [ "$n" -gt 1 ] && { out="$LOGS/$TASK_ID-$tag-r$n.jsonl"; err="$LOGS/$TASK_ID-$tag-r$n.stderr"; } + local -a argv + mapfile -t argv < <(pi_argv "$sid" "$THINKING") + log "pi $tag session $sid thinking $THINKING tools $TOOLS budget ${budget}s -> $out" + ( + cd "$WORKTREE" || exit 78 + PI_CODING_AGENT_DIR="$PI_AGENT_DIR" PI_TELEMETRY=0 PI_OFFLINE=1 \ + timeout --foreground --kill-after=30 "$budget" \ + "${argv[@]}" --system-prompt "$(cat "$SYSTEM_PROMPT")" -- "$(cat "$prompt_file")" \ + < /dev/null >"$out" 2>"$err" + ) & + CHILD_PID=$! + wait "$CHILD_PID" && rc=0 || rc=$? + CHILD_PID="" + LAST_SESSION="$sid"; LAST_EVENTS="$out"; LAST_PI_RC="$rc" + return 0 +} + +# ---------------------------------------------------------------- guard +# Sets GUARD_JSON and GUARD_EXIT; returns the guard's exit. Both guard kinds +# already read untracked files (ls-files --others / git status), and both +# are followed by the trailing-newline gate: `git diff --check` does not +# report a missing final newline, and a Flash-Next `edit` drops it often +# enough (campaign smoke) to be a gate rather than a note. +newline_violations() { + local f + while IFS= read -r f; do + [ -s "$WORKTREE/$f" ] || continue + if [ "$(tail -c 1 "$WORKTREE/$f" | od -An -c | tr -d ' ')" != '\n' ]; then + printf '%s: no trailing newline\n' "$f" + fi + done < <(allowed_files) +} +run_guard() { + local rc=0 out nl + case "$(guard_kind)" in + campaign:*) + out="$(cd "$WORKTREE" && bash "$CAMPAIGN_GUARD" --task "$TASK_ID" --worklist "$TASK_SOURCE" --upstream "$REPO_DIR" 2>&1)" || rc=$? + GUARD_JSON="$(jq -cn --arg out "$out" --argjson rc "$rc" --arg g "$CAMPAIGN_GUARD" \ + '{ok: ($rc == 0), guard: $g, exit: $rc, output: $out}')" + ;; + *) + out="$(touched_files | (cd "$WORKTREE" && python3 "$CUBS_HELPERS" guard "$REPO" "$ALLOWED_JSON" "$REPO_DIR"))" || rc=$? + GUARD_JSON="$(printf '%s' "$out" | jq -c --argjson rc "$rc" '. + {guard: "builtin", exit: $rc}' 2>/dev/null \ + || jq -cn --arg out "$out" --argjson rc "$rc" '{ok: false, guard: "builtin", exit: $rc, output: $out, violations: ["guard did not answer"]}')" + ;; + esac + if [ "$rc" = 0 ]; then + nl="$(newline_violations)" + if [ -n "$nl" ]; then + rc=1 + GUARD_JSON="$(printf '%s' "$GUARD_JSON" | jq -c --arg nl "$nl" '. + {ok: false, exit: 1, newline: ($nl | split("\n") | map(select(. != "")))}')" + fi + fi + GUARD_EXIT="$rc" + return "$rc" +} +guard_text() { + printf '%s' "$GUARD_JSON" | jq -r '[(.output // (.violations // [] | join("; "))), ((.newline // []) | join("; "))] | map(select(. != "")) | join("; ") | if . == "" then "guard failed" else . end' 2>/dev/null +} + +# ---------------------------------------------------------------- validation +# $1 attempt number. Runs task.validation_cmd inside the worktree under a 10 +# minute timeout; transcript to logs/<id>-v<n>.log. +run_validation() { + local n="$1" rc=0 t0 secs transcript + transcript="$LOGS/$TASK_ID-v$n.log" + t0="$(now_s)" + log "validation $n: $VALIDATION_CMD" + ( + cd "$WORKTREE" || exit 78 + timeout --foreground --kill-after=10 "$VALIDATION_TIMEOUT" bash -c "$VALIDATION_CMD" </dev/null >"$transcript" 2>&1 + ) & + CHILD_PID=$! + wait "$CHILD_PID" || rc=$? + CHILD_PID="" + secs=$(( $(now_s) - t0 )) + VALIDATION_JSON="$(jq -cn --arg cmd "$VALIDATION_CMD" --argjson exit "$rc" \ + --arg digest "$(digest_file "$transcript")" --argjson seconds "$secs" --arg transcript "$transcript" \ + '{cmd: $cmd, exit: $exit, transcript_digest: $digest, seconds: $seconds, transcript_path: $transcript}')" + LAST_TRANSCRIPT="$transcript" + return "$rc" +} + +record_attempt() { + # $1 tag + local events + events="$(python3 "$CUBS_HELPERS" events "$LAST_EVENTS" 2>/dev/null || echo '{}')" + ATTEMPTS_JSON="$(jq -cn --argjson prev "$ATTEMPTS_JSON" --arg tag "$1" --arg session "$LAST_SESSION" \ + --argjson pi_exit "$LAST_PI_RC" --arg log "$LAST_EVENTS" --argjson events "$events" \ + --argjson guard "$GUARD_JSON" --argjson validation "$VALIDATION_JSON" \ + '$prev + [{attempt: $tag, session: $session, pi_exit: $pi_exit, events_log: $log, events: $events, guard: $guard, validation: $validation}]')" +} + +# A failed attempt with Halogen gone is an outage, never a fail (drain.sh's +# daemon-drop rule, #157): the receipt says outage and the fuse is untouched. +outage_if_halogen_gone() { + if ! health_reachable; then + NOTES="outage_mid_run: $HALOGEN unreachable after attempt" + log "$NOTES" + finish outage 69 + fi +} + +fail_or_fuse() { + local consecutive + consecutive=$(( $(fuse_read) + 1 )) + fuse_write "$consecutive" + log "FAIL ($NOTES); consecutive failures: $consecutive" + if [ "$consecutive" -ge 3 ]; then + NOTES="$NOTES; fuse blown at $consecutive consecutive failures (remove $FUSE to reset)" + finish fuse 2 + fi + finish fail 1 +} + +# ---------------------------------------------------------------- attempt 1 +if ! run_setup; then + NOTES="setup_failed: $SETUP_CMD (see $LOGS/$TASK_ID-setup.log)" + fail_or_fuse +fi +run_pi a1 "$BUNDLE" +GUARD_JSON="null"; VALIDATION_JSON="null" +guard_ok=0; run_guard && guard_ok=1 +val_ok=0 +if [ "$guard_ok" = 1 ]; then + run_validation 1 && val_ok=1 +fi +record_attempt a1 + +# A Pi process that hit its budget: receipt "timeout", exit 124, the fuse +# untouched, no repair (the budget was the point; the lease has no room for +# a second 15 min process after a 20 min one anyway). +timeout_if_pi_expired() { + if [ "$LAST_PI_RC" = 124 ]; then + NOTES="pi_timeout: attempt $1 exceeded its budget (exit 124)" + log "$NOTES" + finish timeout 124 + fi +} + +if [ "$guard_ok" = 1 ] && [ "$val_ok" = 1 ]; then + pass=1 +else + pass=0 + if [ "$LAST_PI_RC" -ne 0 ]; then outage_if_halogen_gone; timeout_if_pi_expired a1; fi + # ------------------------------------------------------------ the ONE repair + # A fresh Pi process fed the bundle, the diff and the validation transcript + # (never the first process's narration), then fail closed. + REPAIR_COUNT=1 + repair_prompt="$TASK_DIR/repair-prompt.md" + worktree_diff >"$TASK_DIR/diff-a1.patch" + { + cat "$BUNDLE" + printf '\n\n## Repair\n\n' + printf 'A previous attempt at this task produced the diff below, and the gate did not pass. ' + printf 'Fix the work in place so that the validation command passes. Do not restart from scratch.\n\n' + printf '### Diff guard\n\n```\n%s\n```\n\n' "$(guard_text)" + printf '### Diff against the base commit (truncated to %s bytes)\n\n```diff\n' "$DIFF_PROMPT_BYTES" + head -c "$DIFF_PROMPT_BYTES" "$TASK_DIR/diff-a1.patch" + printf '\n```\n\n' + if [ -n "$LAST_TRANSCRIPT" ] && [ -r "$LAST_TRANSCRIPT" ]; then + printf '### Validation command\n\n %s\n\n### Validation transcript (last %s bytes)\n\n```\n' "$VALIDATION_CMD" "$TRANSCRIPT_PROMPT_BYTES" + tail -c "$TRANSCRIPT_PROMPT_BYTES" "$LAST_TRANSCRIPT" + printf '\n```\n' + else + printf '### Validation\n\nThe validation command was not run because the diff guard failed.\n' + fi + } >"$repair_prompt" + if ! run_setup; then + NOTES="setup_failed before repair: $SETUP_CMD" + fail_or_fuse + fi + run_pi a2 "$repair_prompt" + GUARD_JSON="null"; VALIDATION_JSON="null" + guard_ok=0; run_guard && guard_ok=1 + val_ok=0 + if [ "$guard_ok" = 1 ]; then + run_validation 2 && val_ok=1 + fi + record_attempt a2 + if [ "$guard_ok" = 1 ] && [ "$val_ok" = 1 ]; then + pass=1 + elif [ "$LAST_PI_RC" -ne 0 ]; then + outage_if_halogen_gone + timeout_if_pi_expired a2 + fi +fi + +# ---------------------------------------------------------------- verdict +if [ "$pass" = 1 ]; then + DIFF_SHA="$(diff_now)" + if stage_allowed && git -C "$WORKTREE" "${GIT_IDENTITY[@]}" commit -q -m "$COMMIT_SUBJECT" -m "campaign: $CAMPAIGN_NAME +bundle_digest: $BUNDLE_DIGEST +skill_digest: $SKILL_DIGEST +model: $MODEL_ID +repair_count: $REPAIR_COUNT +execution_id: ${EXECUTION_ID:-none}" >/dev/null 2>&1; then + COMMIT_SHA="$(git -C "$WORKTREE" rev-parse HEAD)" + else + NOTES="validation passed but nothing inside allowed_paths could be committed" + log "$NOTES" + fail_or_fuse + fi + fuse_write 0 + log "PASS: committed $COMMIT_SHA on $BRANCH (never pushed)" + finish pass 0 +fi + +# fail, and maybe the fuse +if [ "$guard_ok" != 1 ]; then + NOTES="diff_guard: $(guard_text)" +elif [ "$(printf '%s' "$ATTEMPTS_JSON" | jq -r '.[-1].events.stop_reason // ""')" = "length" ]; then + NOTES="validation_failed after finish_reason=length (exit $(printf '%s' "$VALIDATION_JSON" | jq -r .exit))" +elif [ "$LAST_PI_RC" -ne 0 ]; then + NOTES="pi_exit_$LAST_PI_RC then validation_failed (exit $(printf '%s' "$VALIDATION_JSON" | jq -r .exit))" +else + NOTES="validation_failed (exit $(printf '%s' "$VALIDATION_JSON" | jq -r .exit))" +fi +fail_or_fuse diff --git a/pkgs/cubs-iteration/default.nix b/pkgs/cubs-iteration/default.nix new file mode 100644 index 000000000..cb55c999a --- /dev/null +++ b/pkgs/cubs-iteration/default.nix @@ -0,0 +1,76 @@ +{ + lib, + writeShellApplication, + bash, + coreutils, + curl, + findutils, + gawk, + git, + gnugrep, + gnused, + jq, + python3, + util-linux, + pi, +}: +# cubs-iteration — ONE bounded Halogen coding iteration on the CUBS tree, the +# argv of every `build:CUBS-<n>` kit entry in home/tally-uplink.nix. +# +# Campaign cubs-halogen-probe-1 (FRONT-12 bootstrap). What it does, the receipt +# it writes, its exit codes and the two operator acts it waits on are in +# docs/local-ai/cubs-campaign.md; the script's own header is the short form. +# +# WHY A writeShellApplication AND NOT A writeShellScript LIKE tally-local-smoke. +# The kernel runs the argv with env_clear and no PATH (tally +# crates/tally-kernel/src/exec.rs:681-689), so every program the script names +# must be a store path it carries. `runtimeInputs` is exactly that carriage, +# and the derivation's shellcheck pass is the cheapest oracle a 600-line +# lease script can have. `inheritPath` stays true: the inherited PATH under +# the kernel is /bin/sh's `/no-such-path` (MEASURED), harmless, and the script +# itself appends the two profile bins LAST so a task's validation_cmd can +# reach `nix develop` — home/tally-filler.nix's reasoning, not a second nix. +# +# `pi` IS THE STORE PACKAGE, NOT home/pi.nix's WRAPPER. The wrapper's whole +# work is to prepend the `-e` roster to interactive runs, and the roster is +# EMPTY (home/pi.nix `extensions = { }`; MEASURED: the installed wrapper execs +# the store `pi` with no flags). This script runs Pi with `--no-extensions` +# and a campaign-private PI_CODING_AGENT_DIR (the judge.sh precedent), so +# the wrapper would add nothing and the kit carries the package the wrapper +# wraps: `pkgs.llm-agents.pi`, the same derivation home/pi.nix names. +# +# python3 carries pytest, not for cubs-helpers.py (stdlib only) but for the +# campaign's STDIN-CONTRACT: validation commands are written against a +# normal PATH and WP7's graders are pytest. gawk for the same reason. +let + python = python3.withPackages (ps: [ ps.pytest ]); +in +writeShellApplication { + name = "cubs-iteration"; + runtimeInputs = [ + bash + coreutils + curl + findutils + gawk + git + gnugrep + gnused + jq + python + util-linux # flock + pi + ]; + runtimeEnv = { + CUBS_HELPERS = "${./cubs-helpers.py}"; + }; + # SC2016: the script's jq filters are single-quoted strings full of `$var` + # jq bindings, which is jq's own syntax and not a shell expansion mistake. + excludeShellChecks = [ "SC2016" ]; + text = builtins.readFile ./cubs-iteration.sh; + meta = { + description = "one bounded Halogen coding iteration on the CUBS tree under a tally lease (cubs-halogen-probe-1)"; + mainProgram = "cubs-iteration"; + platforms = lib.platforms.linux; + }; +} diff --git a/tests/tally-uplink/probe-cubs-iteration.sh b/tests/tally-uplink/probe-cubs-iteration.sh new file mode 100644 index 000000000..765a37f1e --- /dev/null +++ b/tests/tally-uplink/probe-cubs-iteration.sh @@ -0,0 +1,464 @@ +#!/usr/bin/env bash +# cubs-halogen-probe-1 (FRONT-12 bootstrap) — the oracle for `cubs-iteration` +# and for the `build:CUBS-<n>` kit entries in home/tally-uplink.nix. +# +# WHAT IT PROVES, out of the TREE only: no network, no socket, no lake, no +# switch, no unit, nothing under ~/.local/state, nothing under ~/agency. The +# executable is built from this checkout; Pi and Halogen are STUBS in a scratch +# directory (a shell script that emits a `pi --mode json` event stream and +# performs a scripted edit; a `python3 -m http.server` answering /health and +# /v1/models); the CUBS repos are two throwaway git repositories. Every clause +# reads a file the real run would write — receipt.json, the usage line, the +# branch — never the script's own narration. +# +# C1 --help rc 0; a malformed stdin rc 65; an unknown flag rc 64. +# C2 the BUILT executable, under `env -i` with only the two variables +# exec.run exports, reads the kit's own stdin pointer and prints a plan +# (--dry, rc 0) — the shape the kernel will run it in. +# C3 PASS: receipt terminal_status pass, a commit "T1: …" on +# campaign/cubs-halogen-probe-1/T1 (never pushed), one usage line at +# TALLY_USAGE_SOURCE_PATH carrying the execution id, fuse 0, a ledger +# line; a second invocation is an idempotent rc 0 that runs no Pi. +# C4 FAIL + the ONE repair: a1 and a2 event logs, a repair prompt carrying +# the diff and the transcript, receipt fail with repair_count 1, fuse 1. +# C5 THE FUSE: two more fails -> the third exits 2 with receipt fuse; a +# passable task then exits 2 `fuse_blown_before_start`; removing +# <state>/fuse lets it pass and resets the count to 0. +# C6 THE GUARD: an edit to spec/**/spec.md fails the task by name even +# though the validation command would pass. +# C7 OUTAGE: Halogen busy past the deadline -> rc 69, receipt outage, the +# fuse untouched. +# C8 SIGTERM (the lease's rail): with Pi mid-run, TERM -> rc 143 inside +# 25 s, receipt cancelled, the WIP committed on the task branch; the +# retry reuses the worktree and base and passes. +# C8b THE RECEIPT SCHEMA: a tracked-file-only edit (nothing untracked) and a +# stray untracked file both leave a TERMINAL receipt (not pending) with +# stray_files a single JSON array, and each validates against the +# campaign's tools/receipt.schema.json (stdlib validator, below). +# C9 the events summariser: usage summed over message_end, tool counts, +# isError, repeated identical calls, from the stub's stream. +# C10 THE KIT: the pinned lake's own readKit resolves build:CUBS-1..200 and +# their scope()/eval() cells; argv[0] is an executable store path; stdin +# is a JSON pointer {worklist, id} naming the item; LOCAL-SMOKE and the +# claude:headless refusal are unchanged (FT-3's K2 still holds). +# C11 `nix build .#checks.x86_64-linux.tally-uplink-topology` -> rc 0. +# +# Usage: bash tests/tally-uplink/probe-cubs-iteration.sh [repo-path] +# rc 0 = every clause passed. rc 1 = a clause failed. rc 2 = the probe could not run. +set -uo pipefail + +repo="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +cd "$repo" || { echo "FAIL: cannot cd $repo"; exit 2; } +for tool in nix jq git python3 curl; do + command -v "$tool" >/dev/null || { echo "FAIL: no $tool on PATH"; exit 2; } +done + +fails=0 +ok() { printf 'ok %s\n' "$*"; } +bad() { printf 'FAIL %s\n' "$*"; fails=$((fails + 1)); } + +scratch="$(mktemp -d)" || exit 2 +HTTP_PID="" +trap '[ -n "$HTTP_PID" ] && kill "$HTTP_PID" 2>/dev/null; rm -rf "$scratch"' EXIT + +# ---- the executable, built from THIS checkout with the coordinator's pkgs. +bin="$(nix build --no-link --print-out-paths --impure --expr \ + "let f = builtins.getFlake \"path:${repo}\"; pkgs = f.nixosConfigurations.coordinator.pkgs; + in pkgs.callPackage ./pkgs/cubs-iteration { pi = pkgs.llm-agents.pi; }" 2>/dev/null)/bin/cubs-iteration" +[ -x "$bin" ] || { echo "FAIL: cannot build pkgs/cubs-iteration"; exit 2; } +echo "built $bin" + +# ---- C1 +if "$bin" --help >/dev/null 2>&1; then ok "C1 --help rc 0"; else bad "C1 --help"; fi +echo 'not json' | "$bin" --dry >/dev/null 2>&1; rc=$? +[ "$rc" = 65 ] && ok "C1 malformed stdin rc 65" || bad "C1 malformed stdin rc $rc (want 65)" +echo '{}' | "$bin" --bogus >/dev/null 2>&1; rc=$? +[ "$rc" = 64 ] && ok "C1 unknown flag rc 64" || bad "C1 unknown flag rc $rc (want 64)" + +# ---- the scratch estate: campaign, agency, state, halogen. +campaign="$scratch/campaign"; agency="$scratch/agency"; state="$scratch/state"; halogen_dir="$scratch/halogen" +mkdir -p "$campaign/skill" "$campaign/pi" "$campaign/bundles" "$campaign/worklists" "$agency" "$state" "$halogen_dir/v1" +printf '# campaign system prompt (stub)\nYour prose is not evidence.\n' >"$campaign/skill/system-prompt.md" +jq -n '{providers: {halogen: {api: "openai-completions", baseUrl: "http://stub/v1", models: [{id: "halogen-qwen3.8-flash-next"}]}}}' >"$campaign/pi/models.json" +jq -n '{compaction: {reserveTokens: 32768, keepRecentTokens: 20000}}' >"$campaign/pi/settings.json" +git -C "$campaign" init -q && git -C "$campaign" -c user.name=t -c user.email=t@t add -A && git -C "$campaign" -c user.name=t -c user.email=t@t commit -q -m init + +mkrepo() { # name, file, content + mkdir -p "$agency/$1/$(dirname "$2")" + printf '%s\n' "$3" >"$agency/$1/$2" + git -C "$agency/$1" init -q + git -C "$agency/$1" -c user.name=t -c user.email=t@t add -A + git -C "$agency/$1" -c user.name=t -c user.email=t@t commit -q -m init +} +mkrepo demo src/hello.txt "placeholder" +mkrepo spec specs/D01/spec.md "# D01 frozen" + +for t in T1 T2 T3 T4 T5 T6 T7 T8 T9 T10 T11 CUBS-90 CUBS-91; do printf '# bundle %s\n\nDo the task. Validation: see worklist.\n' "$t" >"$campaign/bundles/$t.md"; done +wl="$campaign/worklists/current.jsonl" +task_line() { # id repo validation_cmd allowed [package] + jq -cn --arg id "$1" --arg repo "$2" --arg v "$3" --argjson allowed "$4" --arg b "$campaign/bundles/$1.md" --arg pkg "${5:-WP-stub}" \ + '{id: $id, package: $pkg, title: ("stub task " + $id), repo: $repo, bundle_path: $b, validation_cmd: $v, + allowed_paths: $allowed, new_files: [], thinking: "low", prior_p_pass: 0.5, predicted_failure: "none"}' +} +{ + task_line T1 demo 'grep -q hello src/hello.txt' '["src/**"]' + task_line T2 demo 'false' '["src/**"]' + task_line T3 demo 'false' '["src/**"]' + task_line T4 demo 'false' '["src/**"]' + task_line T5 spec 'true' '["specs/**"]' + task_line T6 demo 'grep -q hello src/hello.txt' '["src/**"]' + task_line T7 demo 'grep -q hello src/hello.txt' '["src/**"]' + task_line T8 demo 'true' '["src/**"]' + task_line T9 demo 'grep -q hello src/hello.txt' '["src/**"]' + task_line T10 demo 'grep -q hello src/hello.txt' '["src/**"]' + task_line T11 demo 'true' '["src/**"]' + task_line CUBS-90 demo 'grep -q hello src/hello.txt' '["src/**"]' WP1 + task_line CUBS-91 demo 'grep -q hello src/hello.txt' '["src/**"]' WP1 +} >"$wl" + +# the stub Pi: writes a session file (so the fresh-process rotation is real), +# performs the scripted edit in its cwd, emits an event stream. +stub_pi="$scratch/pi" +cat >"$stub_pi" <<'EOF' +#!/usr/bin/env bash +set -u +sid=""; sdir="" +while [ $# -gt 0 ]; do + case "$1" in + --session-id) sid="$2"; shift ;; + --session-dir) sdir="$2"; shift ;; + esac + shift +done +mkdir -p "$sdir"; printf '{"type":"session","id":"%s"}\n' "$sid" >"$sdir/2026-09-16T00-00-00_$sid.jsonl" +# what the harness handed this process on fd 0, and whether settings.json was reachable +printf '%s\n' "$(readlink /proc/self/fd/0)" >"$sdir/stdin-of-$sid" +[ -r "${PI_CODING_AGENT_DIR:-/nonexistent}/settings.json" ] && printf 'yes\n' >"$sdir/settings-of-$sid" +case "${STUB_PI_ACTION:-edit}" in + edit) mkdir -p src; printf 'hello\n' >>src/hello.txt ;; + edit-no-newline) mkdir -p src; printf 'hello' >src/hello.txt ;; + edit-plus-stray) mkdir -p src; printf 'hello\n' >>src/hello.txt; printf 'junk\n' >junk.txt ;; + sleep) sleep 30 ;; + edit-spec) printf 'changed\n' >>specs/D01/spec.md ;; + edit-then-sleep) mkdir -p src; printf 'hello\n' >>src/hello.txt; sleep 60 ;; + none) : ;; +esac +cat <<'EVENTS' +{"type":"session","version":3,"id":"stub","timestamp":"t","cwd":"."} +{"type":"agent_start"} +{"type":"tool_execution_start","toolCallId":"1","toolName":"read","args":{"path":"src/hello.txt"}} +{"type":"tool_execution_end","toolCallId":"1","toolName":"read","result":"x","isError":false} +{"type":"tool_execution_start","toolCallId":"2","toolName":"read","args":{"path":"src/hello.txt"}} +{"type":"tool_execution_end","toolCallId":"2","toolName":"read","result":"x","isError":false} +{"type":"tool_execution_start","toolCallId":"3","toolName":"bash","args":{"command":"false"}} +{"type":"tool_execution_end","toolCallId":"3","toolName":"bash","result":"x","isError":true} +{"type":"message_end","message":{"role":"assistant","content":[],"usage":{"input":100,"output":40,"cacheRead":10,"cacheWrite":0,"totalTokens":150},"stopReason":"toolUse"}} +{"type":"message_end","message":{"role":"assistant","content":[],"usage":{"input":200,"output":60,"cacheRead":0,"cacheWrite":0,"totalTokens":260},"stopReason":"stop"}} +{"type":"agent_end","messages":[]} +EVENTS +EOF +chmod +x "$stub_pi" + +# the stub Halogen: /health and /v1/models as files. +set_health() { jq -n --argjson busy "$1" '{status: "ok", model: "halogen-qwen3.8-flash-next", busy: $busy, engine: {responds: true}, version: {api: "stub", engine: "stub"}}' >"$halogen_dir/health"; } +set_health false +printf '{"object":"list","data":[{"id":"halogen-qwen3.8-flash-next"}]}\n' >"$halogen_dir/v1/models" +port="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()')" +( cd "$halogen_dir" && exec python3 -m http.server --bind 127.0.0.1 "$port" >/dev/null 2>&1 ) & +HTTP_PID=$! +for _ in $(seq 1 50); do curl -fsS "http://127.0.0.1:$port/health" >/dev/null 2>&1 && break; sleep 0.1; done +curl -fsS "http://127.0.0.1:$port/health" >/dev/null 2>&1 || { echo "FAIL: stub halogen did not come up"; exit 2; } + +run() { # id [extra env...]; stdin = the kit's pointer form + local id="$1"; shift + jq -cn --arg wl "$wl" --arg id "$id" '{worklist: $wl, id: $id}' \ + | env -i HOME="$scratch/home" CUBS_CAMPAIGN_DIR="$campaign" CUBS_STATE_DIR="$state" CUBS_AGENCY_ROOT="$agency" \ + CUBS_HALOGEN_URL="http://127.0.0.1:$port" CUBS_PI_BIN="$stub_pi" CUBS_HEALTH_INTERVAL=1 CUBS_HEALTH_DEADLINE=3 \ + STUB_PI_ACTION="${STUB_PI_ACTION:-edit}" \ + TALLY_EXECUTION_ID="exec-$id" TALLY_USAGE_SOURCE_PATH="$state/usage/cubs-$id.jsonl" "$@" \ + "$bin" 2>>"$scratch/stderr.log" +} +mkdir -p "$scratch/home" +receipt() { jq -r "$2" "$state/tasks/$1/receipt.json" 2>/dev/null; } + +# ---- C2: the kit's own stdin, env -i, --dry. +kit="$(nix eval --raw ".#nixosConfigurations.coordinator.config.home-manager.users.tom.services.tally-uplink.kit" 2>/dev/null)" +[ -e "$kit" ] || nix build --no-link ".#nixosConfigurations.coordinator.config.home-manager.users.tom.services.tally-uplink.kit" >/dev/null 2>&1 +if [ -r "$kit" ]; then + out="$(jq -r '.entries["build:CUBS-1"].stdin' "$kit" | env -i TALLY_EXECUTION_ID=e TALLY_USAGE_SOURCE_PATH=/dev/null "$bin" --dry 2>&1)"; rc=$? + if [ "$rc" = 0 ] && printf '%s' "$out" | jq -e '.dry == true and .id == "CUBS-1" or (.task.id == "CUBS-1")' >/dev/null 2>&1; then + ok "C2 env -i --dry on the kit's stdin pointer: rc 0, id CUBS-1 ($(printf '%s' "$out" | jq -r 'if .resolved then "resolved" else "unresolved: " + .reason end'))" + else + bad "C2 env -i --dry rc $rc: $out" + fi +else + bad "C2 the kit did not evaluate" +fi + +# ---- C3: PASS. +STUB_PI_ACTION=edit run T1; rc=$? +[ "$rc" = 0 ] && ok "C3 T1 rc 0" || bad "C3 T1 rc $rc" +[ "$(receipt T1 .terminal_status)" = pass ] && ok "C3 receipt pass" || bad "C3 receipt: $(receipt T1 .terminal_status)" +subject="$(git -C "$agency/demo" log -1 --format=%s "campaign/cubs-halogen-probe-1/T1" 2>/dev/null)" +[ "$(receipt T1 .worktree_branch)" = "campaign/cubs-halogen-probe-1/T1" ] && ok "C3 receipt.worktree_branch" || bad "C3 worktree_branch $(receipt T1 .worktree_branch)" +[ "$subject" = "CUBS-T1: stub task T1" ] && ok "C3 commit on the task branch: $subject" || bad "C3 commit subject '$subject'" +[ "$(git -C "$agency/demo" rev-parse main 2>/dev/null || git -C "$agency/demo" rev-parse master)" = "$(receipt T1 .base_sha)" ] && ok "C3 base_sha is the repo HEAD" || bad "C3 base_sha" +[ "$(receipt T1 .commit_sha)" = "$(git -C "$agency/demo" rev-parse campaign/cubs-halogen-probe-1/T1)" ] && ok "C3 receipt.commit_sha = branch tip" || bad "C3 commit_sha" +if [ "$(wc -l <"$state/usage/cubs-T1.jsonl")" = 1 ] && [ "$(jq -r .execution_id "$state/usage/cubs-T1.jsonl")" = "exec-T1" ] \ + && [ "$(jq -r .usage.completion_tokens "$state/usage/cubs-T1.jsonl")" = 100 ]; then + ok "C3 one usage line, execution_id exec-T1: $(cat "$state/usage/cubs-T1.jsonl")" +else + bad "C3 usage line: $(cat "$state/usage/cubs-T1.jsonl" 2>&1)" +fi +[ "$(cat "$state/fuse")" = 0 ] && ok "C3 fuse 0" || bad "C3 fuse $(cat "$state/fuse" 2>&1)" +[ "$(wc -l <"$state/ledger.jsonl")" = 1 ] && ok "C3 one ledger line" || bad "C3 ledger lines $(wc -l <"$state/ledger.jsonl")" +# the campaign's tools/receipt.schema.json `required` list, plus the two this +# executable adds (execution_id, exit_code). +for f in schema_version task package provider model health_version campaign_sha skill_digest bundle_digest \ + worktree_branch tool_calls is_error_count repeated_identical_calls usage diff_sha256 commit_sha validation \ + repair_count terminal_status wall_seconds prior_p_pass predicted_failure started_at finished_at \ + bash_call_count stray_files reasoning_tokens attempts_path execution_id exit_code; do + jq -e --arg f "$f" 'has($f)' "$state/tasks/T1/receipt.json" >/dev/null || bad "C3 receipt lacks required field $f" +done +for f in model health_version campaign_sha skill_digest bundle_digest diff_sha256 wall_seconds prior_p_pass predicted_failure; do + v="$(receipt T1 ".$f")"; { [ -n "$v" ] && [ "$v" != null ]; } || bad "C3 receipt field $f is empty/null" +done +[ "$(receipt T1 .observed_failure_mode)" = null ] && ok "C3 observed_failure_mode left null for the review" || bad "C3 observed_failure_mode" +case "$(receipt T1 .skill_digest)" in sha256:????????????????????????????????????????????????????????????????) ok "C3 digests carry the sha256: prefix" ;; *) bad "C3 skill_digest $(receipt T1 .skill_digest)" ;; esac +[ "$(receipt T1 '.validation.guard_exit')" = 0 ] && ok "C3 validation.guard_exit 0" || bad "C3 guard_exit $(receipt T1 .validation.guard_exit)" +[ -s "$state/tasks/T1/attempts.json" ] && ok "C3 attempts.json beside the receipt" || bad "C3 attempts.json" +[ "$(receipt T1 .model)" = halogen-qwen3.8-flash-next ] && ok "C3 model read from /v1/models" || bad "C3 model $(receipt T1 .model)" +[ "$(cat "$state/sessions/stdin-of-T1-a1")" = /dev/null ] && ok "C3 Pi's stdin is /dev/null" || bad "C3 Pi stdin was $(cat "$state/sessions/stdin-of-T1-a1")" +[ -e "$state/sessions/settings-of-T1-a1" ] && ok "C3 PI_CODING_AGENT_DIR carries settings.json" || bad "C3 settings.json not reachable from PI_CODING_AGENT_DIR" +[ "$(receipt T1 '.tool_call_names.bash')" = 1 ] && [ "$(receipt T1 .bash_call_count)" = 1 ] && ok "C3 bash calls counted (tool_call_names.bash, bash_call_count)" || bad "C3 bash count $(receipt T1 .bash_call_count)" +[ "$(receipt T1 '.stray_files | length')" = 0 ] && ok "C3 stray_files empty on a clean pass" || bad "C3 stray_files $(receipt T1 .stray_files)" +a1_before="$(stat -c %Y "$state/logs/T1-a1.jsonl")" +sleep 1 +STUB_PI_ACTION=none run T1; rc=$? +if [ "$rc" = 0 ] && [ "$(stat -c %Y "$state/logs/T1-a1.jsonl")" = "$a1_before" ]; then ok "C3 second run idempotent (rc 0, no Pi)"; else bad "C3 idempotency rc $rc"; fi + +# ---- C4: FAIL + one repair. +STUB_PI_ACTION=edit run T2; rc=$? +[ "$rc" = 1 ] && ok "C4 T2 rc 1" || bad "C4 T2 rc $rc" +[ "$(receipt T2 .terminal_status)" = fail ] && ok "C4 receipt fail" || bad "C4 receipt $(receipt T2 .terminal_status)" +[ "$(receipt T2 .repair_count)" = 1 ] && ok "C4 repair_count 1" || bad "C4 repair_count $(receipt T2 .repair_count)" +[ -s "$state/logs/T2-a1.jsonl" ] && [ -s "$state/logs/T2-a2.jsonl" ] && ok "C4 a1 and a2 event logs" || bad "C4 event logs" +if grep -q '## Repair' "$state/tasks/T2/repair-prompt.md" && grep -q '^+hello' "$state/tasks/T2/repair-prompt.md" && grep -q 'Validation command' "$state/tasks/T2/repair-prompt.md"; then + ok "C4 repair prompt carries the diff and the transcript" +else + bad "C4 repair prompt" +fi +[ "$(jq 'length' "$state/tasks/T2/attempts.json")" = 2 ] && ok "C4 two attempts in attempts.json" || bad "C4 attempts" +[ "$(receipt T2 '.sessions | join(",")')" = "T2-a1,T2-a2" ] && ok "C4 sessions T2-a1,T2-a2" || bad "C4 sessions $(receipt T2 .sessions)" +[ "$(receipt T2 .validation.exit)" = 1 ] && ok "C4 validation exit 1 recorded" || bad "C4 validation $(receipt T2 .validation)" +[ "$(cat "$state/fuse")" = 1 ] && ok "C4 fuse 1" || bad "C4 fuse $(cat "$state/fuse")" +[ "$(receipt T2 .commit_sha)" = null ] && ok "C4 commit_sha null on fail" || bad "C4 commit on fail: $(receipt T2 .commit_sha)" + +# ---- C5: the fuse. +STUB_PI_ACTION=edit run T3; rc=$? +[ "$rc" = 1 ] && [ "$(cat "$state/fuse")" = 2 ] && ok "C5 T3 rc 1, fuse 2" || bad "C5 T3 rc $rc fuse $(cat "$state/fuse")" +STUB_PI_ACTION=edit run T4; rc=$? +[ "$rc" = 2 ] && [ "$(receipt T4 .terminal_status)" = fuse ] && ok "C5 T4 rc 2, receipt fuse" || bad "C5 T4 rc $rc $(receipt T4 .terminal_status)" +STUB_PI_ACTION=edit run T7; rc=$? +if [ "$rc" = 2 ] && [ "$(receipt T7 .terminal_status)" = fuse ] && receipt T7 .notes | grep -q fuse_blown_before_start && [ ! -e "$state/logs/T7-a1.jsonl" ]; then + ok "C5 T7 refused before start (rc 2, no Pi run)" +else + bad "C5 T7 rc $rc $(receipt T7 .notes)" +fi +rm -f "$state/fuse" +STUB_PI_ACTION=edit run T7; rc=$? +[ "$rc" = 0 ] && [ "$(cat "$state/fuse")" = 0 ] && [ "$(receipt T7 .terminal_status)" = pass ] && ok "C5 fuse removed -> T7 passes, fuse 0" || bad "C5 after reset rc $rc" + +# ---- C6: the guard. +STUB_PI_ACTION=edit-spec run T5; rc=$? +if [ "$rc" = 1 ] && receipt T5 .notes | grep -q 'spec.md is never modified'; then + ok "C6 spec/**/spec.md edit fails by name: $(receipt T5 .notes)" +else + bad "C6 guard rc $rc: $(receipt T5 .notes)" +fi +[ "$(receipt T5 .validation.exit)" = null ] && [ "$(receipt T5 .validation.guard_exit)" = 1 ] && ok "C6 validation never ran behind a red guard (exit null, guard_exit 1)" || bad "C6 validation $(receipt T5 .validation)" + +# ---- C6b: the trailing-newline gate. +rm -f "$state/fuse" # each guard clause stands alone: three in a row would blow the fuse +STUB_PI_ACTION=edit-no-newline run T9; rc=$? +if [ "$rc" = 1 ] && receipt T9 .notes | grep -q 'no trailing newline'; then + ok "C6 a touched file without a final newline fails the gate: $(receipt T9 .notes)" +else + bad "C6 newline gate rc $rc: $(receipt T9 .notes)" +fi + +# ---- C6c: stray files are listed, and the guard fails on them. +rm -f "$state/fuse" # each guard clause stands alone: three in a row would blow the fuse +STUB_PI_ACTION=edit-plus-stray run T10; rc=$? +if [ "$rc" = 1 ] && [ "$(receipt T10 '.stray_files | join(",")')" = "junk.txt" ] && receipt T10 .notes | grep -q 'junk.txt: outside allowed_paths'; then + ok "C6 stray untracked file fails the guard and is listed: $(receipt T10 -c .stray_files 2>/dev/null || receipt T10 '.stray_files | join(",")')" +else + bad "C6 stray rc $rc stray_files=$(receipt T10 '.stray_files | join(",")') notes=$(receipt T10 .notes)" +fi + +# ---- C6d: a Pi process that hits its budget is "timeout", not fail, not fuse. +fuse_before="$(cat "$state/fuse")" +STUB_PI_ACTION=sleep run T11 CUBS_PI_TIMEOUT=2; rc=$? +if [ "$rc" = 124 ] && [ "$(receipt T11 .terminal_status)" = timeout ] && [ "$(cat "$state/fuse")" = "$fuse_before" ] && [ ! -e "$state/logs/T11-a2.jsonl" ]; then + ok "C6 Pi over budget -> rc 124, receipt timeout, fuse untouched ($fuse_before), no repair" +else + bad "C6 timeout rc $rc status $(receipt T11 .terminal_status) fuse $(cat "$state/fuse")" +fi + +# ---- C7: outage. +fuse_before="$(cat "$state/fuse")" +set_health true +STUB_PI_ACTION=edit run T8; rc=$? +set_health false +[ "$rc" = 69 ] && [ "$(receipt T8 .terminal_status)" = outage ] && ok "C7 busy Halogen -> rc 69, receipt outage" || bad "C7 rc $rc $(receipt T8 .terminal_status)" +[ "$(cat "$state/fuse")" = "$fuse_before" ] && ok "C7 fuse untouched ($fuse_before)" || bad "C7 fuse moved" +[ ! -e "$state/logs/T8-a1.jsonl" ] && ok "C7 no Pi run during the outage" || bad "C7 Pi ran" +STUB_PI_ACTION=edit run T8; rc=$? +[ "$rc" = 0 ] && ok "C7 T8 retries to pass once Halogen is idle" || bad "C7 retry rc $rc" + +# ---- C8: SIGTERM. +STUB_PI_ACTION=edit-then-sleep run T6 & runner=$! +for _ in $(seq 1 100); do [ -e "$state/logs/T6-a1.jsonl" ] && [ -e "$agency/demo/.git" ] && break; sleep 0.1; done +sleep 0.5 +t0=$(date +%s%N) +# the script itself, not the subshell around the pipeline: the kernel signals +# the process group; here the executable is named by its own path. +pkill -TERM -f "$bin" || bad "C8 no cubs-iteration process to signal" +wait "$runner"; rc=$? +elapsed_ms=$(( ($(date +%s%N) - t0) / 1000000 )) +if [ "$rc" = 143 ] && [ "$elapsed_ms" -le 25000 ]; then ok "C8 TERM -> rc 143 in ${elapsed_ms} ms"; else bad "C8 TERM rc $rc in ${elapsed_ms} ms"; fi +[ "$(receipt T6 .terminal_status)" = cancelled ] && ok "C8 receipt cancelled" || bad "C8 receipt $(receipt T6 .terminal_status)" +wip="$(git -C "$agency/demo" log -1 --format=%s campaign/cubs-halogen-probe-1/T6 2>/dev/null)" +case "$wip" in *"WIP, cancelled under lease"*) ok "C8 WIP committed: $wip" ;; *) bad "C8 WIP commit '$wip'" ;; esac +base_before="$(cat "$state/tasks/T6/base_sha")" +STUB_PI_ACTION=edit run T6; rc=$? +if [ "$rc" = 0 ] && [ "$(cat "$state/tasks/T6/base_sha")" = "$base_before" ] && [ "$(receipt T6 .terminal_status)" = pass ] \ + && [ "$(receipt T6 '.sessions[0]')" = "T6-a1-r2" ] \ + && [ "$(git -C "$agency/demo" rev-parse "campaign/cubs-halogen-probe-1/T6~1")" = "$base_before" ]; then + ok "C8 retry reuses worktree and base, rotates the session id (T6-a1-r2), passes with ONE commit above the base" +else + bad "C8 retry rc $rc session $(receipt T6 '.sessions[0]') parent $(git -C "$agency/demo" rev-parse "campaign/cubs-halogen-probe-1/T6~1")" +fi +[ "$(git -C "$agency/demo" for-each-ref 'refs/cubs-wip/T6/' | wc -l)" -ge 1 ] && ok "C8 the WIP stays reachable under refs/cubs-wip/T6/" || bad "C8 refs/cubs-wip" + +# ---- C8b: terminal receipts validate against the campaign's receipt schema. +schema="${CUBS_RECEIPT_SCHEMA:-/home/tom/mecattaf/cubs-campaign/tools/receipt.schema.json}" +validate_receipt() { # receipt.json -> rc 0 valid; prints the violations otherwise + python3 - "$schema" "$1" <<'PY' +import json, re, sys +schema = json.load(open(sys.argv[1])); doc = json.load(open(sys.argv[2])); errs = [] +TYPES = {"object": dict, "array": list, "string": str, "boolean": bool, "null": type(None)} +def is_type(v, t): + if t == "integer": return isinstance(v, int) and not isinstance(v, bool) + if t == "number": return isinstance(v, (int, float)) and not isinstance(v, bool) + return isinstance(v, TYPES[t]) +def check(s, v, path): + if "type" in s: + ts = s["type"] if isinstance(s["type"], list) else [s["type"]] + if not any(is_type(v, t) for t in ts): errs.append(f"{path}: type {type(v).__name__} not in {ts}"); return + if "const" in s and v != s["const"]: errs.append(f"{path}: != const {s['const']!r}") + if "enum" in s and v not in s["enum"]: errs.append(f"{path}: {v!r} not in enum") + if isinstance(v, str) and "pattern" in s and not re.search(s["pattern"], v): errs.append(f"{path}: {v!r} !~ {s['pattern']}") + if isinstance(v, (int, float)) and not isinstance(v, bool): + if "minimum" in s and v < s["minimum"]: errs.append(f"{path}: {v} < minimum") + if "maximum" in s and v > s["maximum"]: errs.append(f"{path}: {v} > maximum") + if isinstance(v, dict): + for r in s.get("required", []): + if r not in v: errs.append(f"{path}: missing required {r}") + props = s.get("properties", {}); ap = s.get("additionalProperties", True) + for k, x in v.items(): + if k in props: check(props[k], x, f"{path}.{k}") + elif ap is False: errs.append(f"{path}: additional property {k}") + elif isinstance(ap, dict): check(ap, x, f"{path}.{k}") + if isinstance(v, list) and "items" in s: + for i, x in enumerate(v): check(s["items"], x, f"{path}[{i}]") +check(schema, doc, "$") +print("; ".join(errs)); sys.exit(1 if errs else 0) +PY +} +if [ -r "$schema" ]; then + rm -f "$state/fuse" + STUB_PI_ACTION=edit run CUBS-90; rc=$? + st="$(receipt CUBS-90 .terminal_status)" + if [ "$rc" = 0 ] && [ "$st" = pass ] && [ "$(receipt CUBS-90 '.stray_files | tojson')" = "[]" ] \ + && [ "$(receipt CUBS-90 .finished_at)" != null ]; then + ok "C8b tracked-file-only edit -> rc 0, terminal receipt pass, stray_files []" + else + bad "C8b tracked-only rc $rc status $st stray $(receipt CUBS-90 '.stray_files | tojson')" + fi + v="$(validate_receipt "$state/tasks/CUBS-90/receipt.json" 2>&1)" && ok "C8b CUBS-90 receipt validates against receipt.schema.json" || bad "C8b CUBS-90 schema: $v" + rm -f "$state/fuse" + STUB_PI_ACTION=edit-plus-stray run CUBS-91; rc=$? + st="$(receipt CUBS-91 .terminal_status)" + if [ "$rc" = 1 ] && [ "$st" = fail ] && [ "$(receipt CUBS-91 '.stray_files | tojson')" = '["junk.txt"]' ] \ + && [ "$(receipt CUBS-91 .finished_at)" != null ]; then + ok "C8b stray untracked file -> rc 1, terminal receipt fail, stray_files [\"junk.txt\"]" + else + bad "C8b stray rc $rc status $st stray $(receipt CUBS-91 '.stray_files | tojson')" + fi + v="$(validate_receipt "$state/tasks/CUBS-91/receipt.json" 2>&1)" && ok "C8b CUBS-91 receipt validates against receipt.schema.json" || bad "C8b CUBS-91 schema: $v" + rm -f "$state/fuse" +else + bad "C8b cannot read the receipt schema at $schema" +fi + +# ---- C9: the events summariser over the stub's stream. +ev="$(jq -c '.[0].events' "$state/tasks/T1/attempts.json")" +if printf '%s' "$ev" | jq -e '.tool_calls == 3 and .tool_errors == 1 and .repeated_identical_calls == 1 and .usage.prompt_tokens == 310 and .usage.completion_tokens == 100 and .stop_reason == "stop" and .agent_end == true' >/dev/null; then + ok "C9 events: $ev" +else + bad "C9 events: $ev" +fi + +# ---- C10: the kit, through the pinned lake's own readKit. +lake="$(nix eval --raw --impure --expr "(builtins.getFlake \"path:${repo}\").inputs.tally-lake.outPath" 2>/dev/null)" +node="$(nix eval --raw ".#nixosConfigurations.coordinator.config.home-manager.users.tom.services.tally-uplink.node" 2>/dev/null)/bin/node" +if [ -r "$kit" ] && [ -r "$lake/apps/uplink/src/kit.mjs" ] && [ -x "$node" ]; then + out="$("$node" --input-type=module -e " + import { readKit } from '${lake}/apps/uplink/src/kit.mjs' + import { accessSync, constants } from 'node:fs' + const kit = readKit(process.argv[1]) + for (let n = 1; n <= 200; n++) { + const id = 'CUBS-' + n + const w = kit.resolve('build:' + id) + for (const f of ['argv', 'cwd', 'env_allowlist', 'usage_source', 'stdin']) if (w[f] === undefined) throw new Error(id + ' missing ' + f) + if (!w.argv[0].startsWith('/nix/store/') || !w.argv[0].endsWith('/bin/cubs-iteration')) throw new Error(id + ' argv0 ' + w.argv[0]) + accessSync(w.argv[0], constants.X_OK) + if (w.env_allowlist.length !== 0) throw new Error(id + ' env_allowlist not empty') + if (w.usage_source.kind !== 'halogen-usage/1' || !w.usage_source.path_glob.includes('/uplink/usage/cubs-*.jsonl')) throw new Error(id + ' usage_source ' + JSON.stringify(w.usage_source)) + const p = JSON.parse(w.stdin) + if (p.id !== id || !p.worklist.endsWith('/mecattaf/cubs-campaign/worklists/current.jsonl')) throw new Error(id + ' stdin ' + w.stdin) + if (w.cwd !== '/home/tom/mecattaf/cubs-campaign') throw new Error(id + ' cwd ' + w.cwd) + for (const cell of ['scope(build:' + id + ')', 'eval(build:' + id + ')']) { + const e = kit.resolve(cell) + if (e.argv.join(' ') !== '/bin/sh -c true') throw new Error(cell + ' is not the noop') + } + } + kit.resolve('build:LOCAL-SMOKE') + let refused = '' + try { kit.resolve('claude:headless') } catch (e) { refused = e.message } + if (!refused) throw new Error('claude:headless resolved') + let extra = '' + try { kit.resolve('build:CUBS-201') } catch (e) { extra = e.message } + if (!extra) throw new Error('build:CUBS-201 resolved; N is 200') + console.log('REFS=' + kit.refs().length + ' ARGV0=' + kit.resolve('build:CUBS-1').argv[0]) + " "$kit" 2>&1)"; rc=$? + if [ "$rc" = 0 ]; then ok "C10 readKit(pinned lake): 200 CUBS items x 3 cells, LOCAL-SMOKE kept, claude:headless and CUBS-201 refused — $out"; else bad "C10 readKit: $out"; fi +else + bad "C10 cannot resolve kit ($kit), lake ($lake) or node ($node)" +fi + +# ---- C11: the topology check. +if nix build .#checks.x86_64-linux.tally-uplink-topology --no-link >/dev/null 2>&1; then + ok "C11 nix build .#checks.x86_64-linux.tally-uplink-topology" +else + bad "C11 tally-uplink-topology did not build" +fi + +if [ "$fails" -eq 0 ]; then + echo "PROBE cubs-iteration: PASS" + exit 0 +fi +echo "PROBE cubs-iteration: FAIL ($fails clause(s)); stderr at $scratch/stderr.log:" +tail -n 40 "$scratch/stderr.log" +exit 1