os-orchestration: WS4 eval extension — econ axes, E5 batching pair, fable column, pre-registered E2P/E3P criterion redesign

- extract: main-loop/sidechain output-token accounting (subagent transcripts
  persist at <projects>/<flat>/<session-id>/subagents/agent-*.jsonl — the
  mini-audit's instrumentation gap, now solved) + SendMessage round counting
- check: econ info on every row (mltok/sctok/mlshare/prebytes/spawns/maxrounds);
  E2P v2 (scripted-direct branch + services task-complete); E3P v2 (74KB S7
  whole-session ingestion anchor + mechanical root-cause concepts, delegation
  no longer required); E5P/E5N arms (grouped-delegation vs parallel-fanout,
  ## Expected files coverage convention)
- run: fable → claude-fable-5 alias
- scenarios: E5P-handler-rollout + E5N-limit-rebalance (+ frozen reserve twins,
  authored unread by the orchestrating session) + handler-spec/probe-spec fixture docs
- self-test 32/32 model-free; ORCHESTRATION.v3-draft.md parked (NOT shipped —
  Step-3 baseline must run against v2); H3 threshold pre-registered in plan

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jared 2026-07-08 09:49:44 -04:00
parent 42860d06d1
commit b226601bea
14 changed files with 1290 additions and 61 deletions

View File

@ -0,0 +1,175 @@
# Fable-5 main-loop orchestration economics — mini-audit (2026-07-08)
**Model**: this audit was performed by `claude-sonnet-5`. All audited transcripts carry
`"model":"claude-fable-5"` on non-sidechain assistant messages (i.e. Fable 5 is the main-loop
model in every session below).
## Method
1. Scanned all `~/.claude/projects/*/*.jsonl` files modified in the last 10 days
(`scan.rb`), filtered to sessions with ≥20 non-sidechain assistant turns and at least one
`claude-fable-5` main-loop message, excluded the live session
(`ca17e6d2-6132-4681-8a02-8a7523cb5660`). 21 candidates found, almost all in `cc-os`; picked
6 for project diversity: 3 `cc-os`, 2 `servers`, 1 `servers/desktop`, all ≥20 turns (24255).
2. Ran the existing fact-sheet extractor
(`plugins/os-orchestration/audit/bin/extract`) on each for spawn count/models,
pre-/post-spawn tool segmentation, and its heuristic same-tool-run flags.
3. Wrote two throwaway Ruby scripts in scratchpad (`tokens.rb`, `bashcmds.rb`,
`firstprompt.rb`) to sum `usage.output_tokens` per session (main-loop vs sidechain), track
max/final context size, and find the longest unbroken run of main-loop tool calls with no
`Agent` spawn in between, then classified each run by reading the actual tool inputs
(Bash commands / file targets) in that range.
**Environment gotcha (noted for future audits):** in zsh, a variable named `path` is linked to
`$PATH`; `path=$(find ...)` silently corrupted `$PATH` mid-script and caused unrelated `ruby: not
found` (exit 127) failures. Renamed to `tpath`. Not a data issue, just a shell trap.
**Hard measurement limitation:** none of the 6 transcripts contain any `"isSidechain":true`
lines, even in the two sessions that used the `Agent` tool (9f45afcc, 5 spawns; df12b180, 1
spawn). Subagent execution in this environment/version is logged only as a `tool_use`/
`toolUseResult` pair (with `agentId`/`resolvedModel` metadata) plus an eventual
`<task-notification>` result — no subagent transcript is persisted into the parent `.jsonl`. So
**subagent output-token spend is not recoverable from these files at all.** The only proxy
available is the char length of the `tool_result`/notification payload returned to the main
loop, converted at ~4 chars/token. All "% main-loop" figures below use that proxy where spawns
occurred, and are explicitly not real subagent token counts (real subagent spend is very likely
much higher than the proxy, since it only reflects what was returned, not what the subagent
itself burned getting there).
> **Addendum (2026-07-08, WS4 Step 2):** the limitation above is narrower than stated —
> subagent spend is absent from the *parent* transcript, but each subagent's full transcript
> (with per-message `usage` blocks) persists separately at
> `~/.claude/projects/<flattened-cwd>/<session-id>/subagents/agent-<id>.jsonl` (verified for
> both interactive and headless sessions). Real main-loop-share measurement is therefore
> possible; the eval harness A-econ axis uses it. The proxy figures below were NOT recomputed.
> See vault note [[claude-code-subagent-transcripts-and-token-accounting]].
## Per-session detail
### 1. `9f45afcc` — cc-os — cache-refresh tooling + WS2 autoresearch prep
- 255 turns, 25 human prompts, 850 jsonl lines, 2026-07-04 16:1718:52
- 5 Agent spawns, all `general-purpose`, `model` param `sonnet`×4/`haiku`×1, **resolved model
`claude-haiku-4-5` in all 5 cases** (param mismatch: 4 of 5 requested sonnet, all resolved
haiku)
- main-loop output tokens: **237,679** (255 msgs); proxy subagent output: 13,575 chars via
result/notification payloads (≈3.4K tokens) → **main-loop ≈98.6% of proxy-measured output**
- pre-spawn segment: 19 calls / 23,658 bytes read (Bash 14, Read 4, Edit 1)
- longest direct run: **48 calls** (after spawn 3: ToolSearch 2, SendMessage 1, Bash 22, Edit 12,
Skill 2, Read 5, Write 3, TaskStop 1) — mixed: driving/monitoring the background eval-grid
agent (SendMessage/TaskStop/ToolSearch) interleaved with direct mechanical plugin-cache-refresh
edits. Not a clean single-purpose run.
### 2. `eba26343` — cc-os — WS2 os-vault wording loop (`/autoresearch`)
- 223 turns, 2 human prompts (127 total `type:user` lines incl. tool results/notifications), 583
jsonl lines, 2026-07-07 16:4818:01
- **0 Agent spawns**
- main-loop output tokens: **216,019** (223 msgs) — 100% of measured output (no spawns to split
against)
- whole-session tool profile: 119 calls / 119,587 bytes (Read 17, Bash 64, Skill 1, ToolSearch 1,
TaskCreate 5, TaskUpdate 9, Edit 20, Write 2)
- longest direct run: **119 calls = the entire session** (no spawn boundary at all). Sampled the
64 Bash commands directly: this is a scripted headless-runner loop — `bin/run P1/P2/P3...` /
`bin/check` invocations against `eval/scenarios/`, `sleep N; wc -l results.tsv`
polling of background task output, `awk`/`sort` aggregation of `results.tsv`, and git
commit/push at each iteration boundary. **Classify as justified scripted-bulk-op / polling
work**, not a delegation miss — matches the ORCHESTRATION.md scripted-bulk-edit carve-out; a
subagent adds no value polling a script's own stdout.
### 3. `5f53e0c0` — cc-os — `/opsx:apply add-os-adr-plugin`
- 222 turns, 4 human prompts, 506 jsonl lines, 2026-07-03 17:1517:43
- **0 Agent spawns**
- main-loop output tokens: **314,899** (222 msgs, highest of the 6) — 100% of measured output
- whole-session tool profile: 128 calls / 129,924 bytes (Bash 39, Read 16, Write 35, Edit 38)
- longest direct run: **128 calls = the entire session**. Contains three flagged sub-runs from
the extractor: Read×10 (design docs, lines 2752 — reasonable orientation), **Write×13 (lines
96137, 13 distinct new files: `lib/adr.rb`, `lib/adr/record.rb`, `repository.rb`,
`index.rb`, `template.rb`, `bin/adr-new`, …)**, and Edit×10 (lines 326353, fixing 5 files
post-review). **Genuine miss candidate**: the Write×13 run is scaffolding independent Ruby
class files against an *already-fixed* `design.md` (read in the first 10 calls) — a
parallelizable implementation task that ran entirely in the main loop and is a large share of
this session's 315K output tokens.
### 4. `3fc7bb8c` — servers — backup verification + vault write
- 90 turns, 5 human prompts, 236 jsonl lines, 2026-07-04 17:2018:04
- **0 Agent spawns**
- main-loop output tokens: **63,607**
- whole-session tool profile: 44 calls / 32,250 bytes (Read 3, Bash 27, Write 4, ToolSearch 2,
ExitPlanMode 1, Edit 6, Skill 1)
- longest direct run: **44 calls = whole session** — interactive backup-system investigation
("Are we confident backups will work as expected now?") ending in a vault write. Judgment-
heavy, sequential-dependent (each Bash result determines the next check). **Justified direct
work**, no clean miss shape.
### 5. `7bc0dda4` — servers — Proxmox HAOS install troubleshooting
- 82 turns, 15 human prompts (highest interaction density of the 6), 249 jsonl lines,
2026-07-03 19:4920:42
- **0 Agent spawns**
- main-loop output tokens: **39,513**
- whole-session tool profile: 39 calls / 6,124 bytes (Bash 27, Edit 8, Read 2, Skill 1, Write 1)
- longest direct run: **39 calls = whole session** — live interactive install/debug loop (disk
space checks, image download, phase-2 error triage against user-supplied screenshots).
**Justified direct work**: 15 human turns in 82 total means the human is in the loop almost
every other turn; delegation overhead would exceed any savings.
### 6. `df12b180` — servers/desktop — Remote Control OAuth scope troubleshooting
- 24 turns, 6 human prompts, 90 jsonl lines, 2026-07-06 23:1323:35
- 1 Agent spawn (`claude-code-guide`, `model` param `sonnet`, resolved `claude-sonnet-5`,
foreground, prompt 1,008 chars → result 5,689 chars)
- main-loop output tokens: **24,051**; proxy subagent output ≈1,422 tokens (5,689 chars/4) →
**main-loop ≈94.4% of proxy-measured output**
- pre-spawn: 4 Bash calls / 3,154 bytes (credential/scope investigation)
- post-spawn: 3 calls (Read 1, Write 2 — writing up the answer)
- longest direct run: 4 calls (the pre-spawn investigation). **Good delegation example**: a
bounded doc-lookup ("Remote Control usage") was correctly routed to the docs-specialist
subagent instead of the main loop reading source/docs itself.
## Summary table
| session | project | turns | spawns | main-loop out tok | % main-loop (proxy, see caveat) | pre-spawn calls/bytes | longest direct run | classification |
|---|---|---|---|---|---|---|---|---|
| 9f45afcc | cc-os | 255 | 5 | 237,679 | ~98.6% | 19 / 23,658 | 48 | mixed: agent-monitoring + mechanical cache-refresh edits |
| eba26343 | cc-os | 223 | 0 | 216,019 | 100% | 119 / 119,587 (whole session) | 119 (whole session) | scripted headless-eval polling loop — justified |
| 5f53e0c0 | cc-os | 222 | 0 | 314,899 | 100% | 128 / 129,924 (whole session) | 128 (whole session) | OpenSpec apply — Write×13 independent files = **miss candidate** |
| 3fc7bb8c | servers | 90 | 0 | 63,607 | 100% | 44 / 32,250 (whole session) | 44 (whole session) | interactive investigation — justified |
| 7bc0dda4 | servers | 82 | 0 | 39,513 | 100% | 39 / 6,124 (whole session) | 39 (whole session) | interactive troubleshooting (15 human turns) — justified |
| df12b180 | servers/desktop | 24 | 1 | 24,051 | ~94.4% | 4 / 3,154 | 4 | pre-spawn investigation, correct delegation of doc lookup |
## Caveats
- 4 of the 6 sessions are from `cc-os` (matches the actual population: 19 of 21 candidate
sessions in the last 10 days were `cc-os`) — the sample is not representative of all-client
usage, just of what exists in the last 10 days of transcripts.
- "% main-loop" is a **proxy** built only from what subagents returned to the main loop
(tool_result/notification char count ÷4), not measured subagent token spend — real subagent
cost is invisible in every transcript examined (0/6 have `isSidechain:true` lines). Any
economics conclusion about spawn cost-effectiveness needs a different data source (e.g. Task
output files under `tasks/`, if retained) — not attempted here.
- "Longest direct run" for 4 of 6 sessions equals the *entire session* because zero spawns
occurred; this collapses "pre-spawn segment" and "longest run" into the same number for those
rows — noted in the table rather than hidden.
- Miss/justified classification is a single-auditor read of tool-call sequences, not a rubric
score; treat as a directional signal for the eval plan, not a verdict.
## Miss-shape summary
- **The only clean miss candidate found is `5f53e0c0`'s Write×13 run**: scaffolding independent
Ruby class files against an already-fixed design doc, done entirely in the (most expensive)
main loop across a 222-turn, 314,899-output-token session with zero spawns.
- **Scripted/polling loops are not misses even when very long** (`eba26343`, 119 consecutive
direct calls): driving `bin/run`/`sleep`/`wc -l results.tsv` from the main loop is the correct
shape per the existing scripted-bulk-op carve-out — a subagent adds no value polling a script's
own output.
- **Interactive/judgment-heavy sessions correctly stayed direct** (`3fc7bb8c`, `7bc0dda4`): both
have no spawns and no flagged miss runs; `7bc0dda4`'s 15 human prompts in 82 turns make
delegation overhead a net loss.
- **Subagent economics are structurally unmeasurable from transcripts as configured**: 0 of 6
sessions logged sidechain entries even where Agent spawned; only a lossy char-count proxy
exists via task-notification payloads. This is itself an eval-plan-relevant finding — any eval
claiming to measure delegated-vs-direct token spend needs an instrumentation change (capture
Task output, or read `tasks/*.output` files) before it can trust a token-economics number.
- **Where spawns did happen, the main loop still did substantial direct work afterward**
(`9f45afcc`: 4348-call runs after spawns 3 and 5) rather than delegating a second time —
worth checking in future audits whether that's justified (synthesis/judgment) or another miss
shape.
- **No over-delegation observed anywhere** — zero ≤2-call trivial ops routed to a subagent in any
of the 6 sessions.

View File

@ -0,0 +1,166 @@
# WS4 — Orchestration economics: ORCHESTRATION.md v3 + eval extension + wording loop
_Created: 2026-07-08. Status: PLANNED (mini-audit complete; steps 15 not started)._
_Prereq reading before executing: `~/Documents/SecondBrain/howto/running-autoresearch-skill-evals.md`,
vault notes [[orchestration-prompting-claude-5-era]], [[eval-methodology-ladder]],
[[os-orchestration-eval-baseline-grid-results]]._
## Problem
The Fable-5 main loop does too much multi-call work itself. ORCHESTRATION.md was calibrated
against over-delegation (Opus 4.6 era) under sonnet main loops where direct work was cheap;
its negative-first threshold ("Delegate only when…", "≤2-tool-call ops direct") is read
literally by Fable and licenses unbounded direct work whenever the task isn't parallelizable/
many-files/isolated-context. Goals (user, 2026-07-08): max quality at reasonable cost;
route work to cheaper tiers whenever reasonable; group delegated tasks so agents finish in
few rounds (fewer spawns → less per-spawn system-prompt tax).
Evidence base: Anthropic model-page guidance summarized in vault note
[[orchestration-prompting-claude-5-era]] (fetched 2026-07-08); WS1 audit
(`docs/orchestration-audit/2026-07-06-findings.md`); Fable economics mini-audit
(`docs/orchestration-audit/2026-07-08-fable-econ-miniaudit.md`) — the H3 baseline.
### Mini-audit headline (2026-07-08, 6 Fable sessions, ran before drafting wording)
- **The dominant miss shape is implementation fan-out, not investigation:** the one clean
miss is an OpenSpec-apply session (`5f53e0c0`) that wrote 13 independent Ruby files
entirely in the main loop — 314,899 main-loop output tokens, 0 spawns. E5P should mirror
this shape (write-N-independent-files implementation), not a log-review shape.
- Long direct runs were mostly JUSTIFIED (scripted eval-polling loop of 119 calls;
interactive troubleshooting with 15 human turns) — confirming the non-goal: cost-keyed
thresholds, not "always delegate". Zero over-delegation anywhere.
- Secondary watch item: after a session's spawns complete, the main loop resumes long
direct runs (4348 calls, `9f45afcc`) instead of delegating again — mid-session threshold
decay.
- **Instrumentation gap (affects A-econ):** main transcripts contain NO `isSidechain:true`
entries even in sessions with spawns — subagent token spend is unrecoverable from the
transcript alone. The eval checker must source subagent spend from harness-side artifacts
(the eval runner's own task/agent output files in the sandbox), or A-econ degrades to a
main-loop-absolute-tokens metric (still usable: baseline sessions run 24k315k main-loop
output tokens). Resolve this during Step 2 design; do not silently ship the char-count
proxy the mini-audit used.
## Step 1 — ORCHESTRATION.md v3 (wording surface for the loop)
Five changes; keep explicit-`model:`, self-report, and don't-re-cover-ground rules unchanged:
1. **Cost-asymmetry + tier-conditional threshold.** New opening rule: main-loop tokens are
the most expensive in the session; the more capable the main-loop model, the lower the
delegation threshold. Any investigation or mechanical sequence beyond ~3 calls that does
not need main-loop judgment → delegate down-tier, even when sequential. (Models know their
own model ID — tier-conditional phrasing is implementable; E1 canary verified self-report.)
2. **Symmetric triggers.** Replace "Delegate only when…" with paired lists:
"delegate when …" / "work directly when …" (keep the scripted-bulk-edit carve-out and
≤2-call rule on the direct side). Rationale: literal instruction-following on 5-era
models; the canonical best-practices snippet pairs both directions.
3. **Batching rule.** Plan the fan-out before the first spawn; group related subtasks
(~58 similar items) into one agent prompt with a return schema so the agent completes in
one round; follow-ups go to the live agent (SendMessage), not a fresh spawn.
4. **Async rule.** Delegate independent subtasks and keep working while they run; intervene
only if a subagent goes off track (Fable-page recommended pattern).
5. **Effort dial (where exposed).** Workflow `agent()` calls: mechanical stages
`effort: low`; hard verify/judge stages high/xhigh.
Draft AFTER reading the mini-audit's miss shapes — wording should name the observed shapes,
not hypothetical ones (Eval B lesson: mechanical triggers phrased at the observed
precondition).
## Step 2 — Eval harness extension (`plugins/os-orchestration/eval/`)
All extensions are new frozen surfaces; existing run-set scenarios E1E3 stay untouched.
Reserve stays frozen. Follow [[eval-methodology-ladder]]: paired positives/negatives,
run-set + reserve twins authored together, thresholds anchored independently of any grid
already run (pre-registered criterion-redesign rule from the 2026-07-06 baseline).
### New metric axes (extractor + checker)
- **A-econ (main-loop token share):** main-loop assistant output tokens ÷ total session
output tokens (main + sidechains). Extractor already parses transcripts; add the sum.
- **A-prespawn:** tool calls + tool_result bytes before first spawn (extractor segment
exists from E3 work).
- **A-batch:** spawns per task and rounds per agent (Agent tool_use count; SendMessage
reuse counts as same-agent round, not new spawn).
- Redesign E2P/E3P delegate-at-all axes per the pre-registered note: PASS may be
"delegated" OR "superior scripted direct strategy" — the checker must distinguish
script-driven bulk ops (few Bash calls, uniform op) from grind (per-file tool loops).
### New scenario pair E5 (batching) + reserve twins
- **E5P:** N (~1012) similar independent items, shaped like the observed miss —
an implementation task producing N independent files in the relaystation fixture
(NOT log review). PASS = grouped delegation (≤3 spawns covering all items, grouped
prompts, one round each), FAIL = 1-agent-per-item or main-loop grind.
- **E5N (paired negative):** a task where batching would wrongly serialize genuinely
judgment-dependent items (each item's handling depends on the previous result) — PASS =
sequential handling (direct or single agent), FAIL = blind parallel fan-out.
- Author reserve twins at the same time, different surface domain; freeze both.
### Fable column
Baseline grid ran sonnet/haiku orchestrators only; the population of concern is Fable.
Add `--model fable` (headless `claude -p --model claude-fable-5`) at reduced reps
(12 per cell for the grid; 3 on target cells inside the loop). Cost of the eval itself is
real — use the reduced inner-loop grid discipline (target cells + one passing control).
## Step 3 — Baseline grid (pre-wording)
Run extended grid (E1E3 + E5, econ axes) × {fable, sonnet} before touching wording.
Canary-cell the first live rep of every NEW scenario/axis and hand-verify TSV vs transcript
(count the canary). This grid is the tuning baseline; the 2026-07-08 mini-audit is the IRL
baseline for H3.
## Step 4 — Autoresearch wording loop
Loop discipline per the howto: wording-only moves (checker/fixtures/scenarios/axes frozen),
`bin/refresh-plugins` before every iteration's grid, ≥3 reps on target cells + 1 control
cell, accept only majority-of-reps improvements, verify from TSV not agent prose.
### Hypotheses (pre-registered)
- **H1 (threshold framing):** symmetric cost-framed triggers raise delegation on positive
scenarios (esp. E3P-shape sequential investigation) on fable/sonnet, while negatives stay
clean. Guard: baseline negatives were 18/18 — any negative regression rejects the
candidate regardless of positive gains.
- **H2 (batching rule):** the grouping rule reduces spawns-per-task and rounds-per-agent on
E5P without task-success regression; E5N stays PASS (no blind fan-out).
- **H3 (net economics — the actual goal):** main-loop token spend on positives drops
materially vs the Step-3 baseline with task success unchanged. Metric: main-loop share
if Step 2 solves the sidechain-instrumentation gap, else main-loop absolute output
tokens per scenario. Anchor the target threshold independently before the loop starts
(not post-hoc to whatever the loop achieves). H1/H2 are mechanisms; H3 is the verdict
axis.
**H3 threshold (pre-registered 2026-07-08, before the Step-3 grid ran):** H3 PASSes iff,
on the positive economics cells (E5P and E3P, fable column), the median main-loop
assistant output tokens across reps under the candidate wording is ≤60% of the Step-3
pre-wording baseline median for the same cell (≥40% reduction), with task-success axes
unchanged and all negatives clean. Pass/fail is keyed to main-loop **absolute** output
tokens (exists in both baselines regardless of the sidechain-instrumentation outcome);
main-loop *share* is reported additionally if Step 2 solves sidechain measurement.
Anchor rationale (independent of any grid): in the IRL miss exemplar `5f53e0c0`, the
Write×13 fan-out segment dominates a 314,899-token session — delegating the
implementation segment should remove roughly half of main-loop output; 40% is a
conservative floor of that estimate.
### Judging
Per-axis deterministic checker verdicts; per-scenario pass bars, not aggregate scores.
A wording candidate ships only if: all negatives hold, target positives improve on majority
of reps, control cell unchanged, and the full-grid confirm reproduces it. Final confirmation
on the frozen reserve (tuning against the run-set moves measurement to the reserve —
after this loop the run-set is contaminated for future measurement).
## Step 5 — Rollout + IRL re-audit
Ship winning wording (refresh caches), then re-run the Fable economics mini-audit on ~5 new
real sessions after ~2 weeks ([[eval-methodology-irl-feedback-loop]]). Compare main-loop
token share vs the 2026-07-08 baseline. Promote any new miss shapes into eval scenarios.
## Non-goals / guards
- No changes to delegation SAFETY rules (explicit model:, self-report, downgrade honesty).
- Don't over-rotate: zero over-delegation was a baseline strength (negatives 18/18) and
scripted-direct strategies were often genuinely superior — the point is cost-keyed
thresholds, not "always delegate".
- Held-out discipline: never run scenario Task blocks informally; reserve is never read
informally.

View File

@ -0,0 +1,50 @@
## Session orchestration
- Main-loop tokens are the most expensive tokens in the session: every direct tool
call and every byte read into this context bills at the main-loop model's rate,
while a haiku/sonnet subagent does the same mechanical work for a fraction of it.
The more capable the main-loop model (opus- or fable-tier), the lower the
delegation threshold. The question is not "is this task big enough to delegate?"
but "does this step need main-loop judgment?" — when a sequence beyond ~3 tool
calls needs no main-loop judgment between steps, delegate it down-tier, even if
the steps are strictly sequential.
- Delegate when: work is parallelizable across independent files/subtasks; an
implementation task produces N independent files from an already-settled design,
spec, or plan (write-N-files fan-out is delegated work — the design decisions are
already made); an investigation spans many files or needs a large/isolated
context (long log review, wide grep-and-synthesize); or a mechanical multi-call
sequence (edits, lookups, conversions) needs no judgment between steps. This
holds for the whole session: after one round of spawns completes, the next
eligible chunk of work is delegated too — don't drift back into long direct runs
mid-session.
- Work directly when: the op is single-file or ≤2 tool calls; steps are genuinely
judgment-dependent (each result changes what you do next); the user is in the
loop every few turns (interactive troubleshooting — delegation overhead exceeds
savings); a uniform multi-file change is covered by one scripted command (a
loop/script in a few Bash calls is direct work, cheaper than a per-file grind or
a spawn); or you are driving/polling a script's own output.
- Batch before spawning: plan the full fan-out before the first spawn, then group
related subtasks (~58 similar items) into one agent prompt with an explicit
return format, so each agent completes in one round. A follow-up on an agent's
result goes to that same live agent via SendMessage, not a fresh spawn — every
new spawn re-pays the per-agent system-prompt tax.
- Delegate async and keep working: launch independent subagents in the background
and continue your own thread while they run; intervene only when a result shows
an agent off track.
- Before every `Agent` call → set `model:` explicitly in that call. An omitted
`model` silently bills the subagent at the main-loop model. Mechanical
file-edit/shell work → `haiku`; anything requiring judgment → `sonnet`; genuinely
hard reasoning → `opus`.
- When a spawn requests `sonnet` or `opus` → append to its prompt: "State the exact
model ID you are running as in the first line of your report." (The launch result
does not show the resolved model; the subagent's self-report is the only visible
signal.) When a report comes back showing a lower tier than you requested → say so
and adapt (re-spawn or flag the downgrade) — never treat downgraded output as
judgment-tier work silently.
- Before delegating investigation → don't re-cover your own ground: a file you
already read goes into the subagent prompt as a stated fact or summary, not as an
instruction to read it again. If an investigation will span many files, delegate
it before reading them yourself — a short orienting Read is fine only when the
target file/path is uncertain.
- Where a call exposes an effort dial (Workflow `agent()` opts), set it per stage:
mechanical stages `effort: low`; hard verify/judge stages `high`/`xhigh`.

View File

@ -82,12 +82,13 @@ module OrchAudit
class AgentSpawn class AgentSpawn
attr_reader :call, :index attr_reader :call, :index
attr_accessor :agent_id, :resolved_model, :notification_chars attr_accessor :agent_id, :resolved_model, :notification_chars, :rounds
def initialize(call, index) def initialize(call, index)
@call = call @call = call
@index = index @index = index
@notification_chars = 0 @notification_chars = 0
@rounds = 1
end end
def to_h def to_h
@ -102,7 +103,8 @@ module OrchAudit
run_in_background: call.input["run_in_background"], run_in_background: call.input["run_in_background"],
prompt_chars: (call.input["prompt"] || "").length, prompt_chars: (call.input["prompt"] || "").length,
description: call.input["description"], description: call.input["description"],
result_chars: [call.result_chars, notification_chars].max result_chars: [call.result_chars, notification_chars].max,
rounds: rounds
} }
end end
end end
@ -230,10 +232,11 @@ module OrchAudit
calls, agent_spawns = collect_calls(main) calls, agent_spawns = collect_calls(main)
attach_results(main, calls) attach_results(main, calls)
attach_agent_metadata(main, calls, agent_spawns) attach_agent_metadata(main, calls, agent_spawns)
attach_rounds(main, agent_spawns)
orchestrator_calls = calls.values.reject { |c| c.name == "Agent" } orchestrator_calls = calls.values.reject { |c| c.name == "Agent" }
{ {
metadata: metadata(main), metadata: metadata(main, agent_spawns),
agent_spawns: agent_spawns.map(&:to_h), agent_spawns: agent_spawns.map(&:to_h),
segments: segments(main).map(&:to_h), segments: segments(main).map(&:to_h),
missed_delegation_candidates: missed_delegation_candidates:
@ -294,6 +297,31 @@ module OrchAudit
end end
end end
# SendMessage tool_use calls address an agent by its raw agentId or by the
# teammate name assigned to it; the transcript only ever carries the
# spawn's description as a name-shaped string, so match on either.
def attach_rounds(main, spawns)
targets = send_message_targets(main)
spawns.each do |spawn|
spawn.rounds = 1 + targets.count { |t| addressed_to?(t, spawn) }
end
end
def send_message_targets(main)
main.flat_map do |line|
line.tool_uses.select { |b| b["name"] == "SendMessage" }
.map { |b| b.dig("input", "to") }
end.compact
end
def addressed_to?(target, spawn)
t = target.to_s.strip.downcase
return false if t.empty?
return true if spawn.agent_id && t == spawn.agent_id.to_s.downcase
desc = spawn.call.input["description"].to_s.strip.downcase
!desc.empty? && (t == desc || desc.include?(t) || t.include?(desc))
end
def result_size(block, line) def result_size(block, line)
content = block["content"] content = block["content"]
size = case content size = case content
@ -304,9 +332,12 @@ module OrchAudit
size.zero? ? line.raw["toolUseResult"].to_s.length : size size.zero? ? line.raw["toolUseResult"].to_s.length : size
end end
def metadata(main) def metadata(main, spawns)
assistants = main.select { |l| l.type == "assistant" } assistants = main.select { |l| l.type == "assistant" }
stamps = main.map(&:timestamp).compact stamps = main.map(&:timestamp).compact
main_tok = main_loop_output_tokens(assistants)
side_tok = sidechain_output_tokens
rounds = spawns.map(&:rounds)
{ {
cwd: main.map(&:cwd).compact.first, cwd: main.map(&:cwd).compact.first,
started: stamps.min, started: stamps.min,
@ -314,10 +345,41 @@ module OrchAudit
assistant_turns: assistants.size, assistant_turns: assistants.size,
human_prompts: main.count(&:human_prompt?), human_prompts: main.count(&:human_prompt?),
main_loop_models: assistants.map(&:model).compact.tally, main_loop_models: assistants.map(&:model).compact.tally,
jsonl_lines: main.last&.number jsonl_lines: main.last&.number,
main_loop_output_tokens: main_tok,
sidechain_output_tokens: side_tok,
main_loop_share: main_loop_share(main_tok, side_tok),
total_spawns: spawns.size,
max_rounds: rounds.max || 0
} }
end end
def main_loop_output_tokens(assistants)
assistants.sum { |l| l.message.dig("usage", "output_tokens").to_i }
end
# Subagent transcripts persist as sibling files at
# <dir>/<main-basename-without-.jsonl>/subagents/agent-*.jsonl (same
# native JSONL shape, own usage blocks); 0 when the dir is absent.
def sidechain_output_tokens
dir = File.dirname(@path)
base = File.basename(@path, ".jsonl")
total = 0
Dir.glob(File.join(dir, base, "subagents", "*.jsonl")).each do |f|
File.foreach(f) do |raw|
parsed = JSON.parse(raw) rescue next
next unless parsed["type"] == "assistant"
total += parsed.dig("message", "usage", "output_tokens").to_i
end
end
total
end
def main_loop_share(main_tok, side_tok)
return 1.0 if side_tok.zero?
(main_tok.to_f / (main_tok + side_tok)).round(3)
end
def segments(main) def segments(main)
segs = [Segment.new("pre-spawn-1")] segs = [Segment.new("pre-spawn-1")]
spawn_count = 0 spawn_count = 0

View File

@ -1,4 +1,4 @@
# os-orchestration eval — E1E3 (session-orchestration behavior) # os-orchestration eval — E1E3, E5 (session-orchestration behavior)
_Created: 2026-07-06. Scenarios sourced from the verified misses in _Created: 2026-07-06. Scenarios sourced from the verified misses in
`docs/orchestration-audit/2026-07-06-findings.md` (clusters 13), per the `docs/orchestration-audit/2026-07-06-findings.md` (clusters 13), per the
@ -34,14 +34,73 @@ The checker scores the REAL session transcript written to
|----|---------|-------|--------------------------------------| |----|---------|-------|--------------------------------------|
| E1P | 1 | 3-module judgment review; runner sets `CLAUDE_CODE_SUBAGENT_MODEL=haiku` | A: no spawn / no non-haiku request. B: downgrade not flagged in visible text (narrow haiku-judge fallback on ambiguous haiku mentions only) | | E1P | 1 | 3-module judgment review; runner sets `CLAUDE_CODE_SUBAGENT_MODEL=haiku` | A: no spawn / no non-haiku request. B: downgrade not flagged in visible text (narrow haiku-judge fallback on ambiguous haiku mentions only) |
| E1N | 1 | identical task, stub absent | FAIL only on a false downgrade claim (judge confirms) | | E1N | 1 | identical task, stub absent | FAIL only on a false downgrade claim (judge confirms) |
| E2P | 2 | 9 independent mechanical JSON edits | A: no delegation. B: any spawn without explicit `model:`. Tier tally informational | | E2P | 2 | 9 independent mechanical JSON edits | A: 0 spawns AND >=9 Edit+Write ops (`no-delegation-and-grind`). B: any spawn without explicit `model:`. C: task-incomplete (see below). Tier tally informational |
| E2N | 2 | single-file known typo fix | any Agent spawn | | E2N | 2 | single-file known typo fix | any Agent spawn |
| E3P | 3 | ~400KB log investigation | A: no delegation. B: pre-spawn-1 bytes > 15KB. C: ≥5KB orchestrator-Read file re-named in a spawn prompt (dual-read) | | E3P | 3 | ~400KB log investigation | A: whole-session `bytes_read` > 74KB (`overread`). B: fewer than 2 of {worker-death, queue-backup, dropped-events} named in the final message (`root-cause-missing`). C: dual-read, only checked when a spawn happened |
| E3N | 3 | uncertain-target doc fix (orienting grep correct) | any Agent spawn | | E3N | 3 | uncertain-target doc fix (orienting grep correct) | any Agent spawn |
| E5P | batch delegation | colleague-authored fixture, `## Expected files` coverage | A: 0 spawns AND >=12 Edit+Write ops (`main-loop-grind`). B: >3 spawns (`one-per-item`). C: any expected file missing/empty. D: any spawn without explicit `model:` |
| E5N | batch delegation | task that doesn't warrant fan-out | 2+ spawns (`parallel-fanout`); 0 or 1 spawn are both `sequential` and fine |
Harness ERRORs (not behavioral FAILs): missing transcript, E1P stub not applied, Harness ERRORs (not behavioral FAILs): missing transcript, E1P stub not applied,
E1N env leak. Check reason strings before counting a cell. E1N env leak. Check reason strings before counting a cell.
**E2P/E3P criterion redesign (pre-registered, 2026-07-06):** neither positive
requires delegation any more — both accept a well-scoped direct/scripted
path, since the WS1 baseline grid showed genuinely superior direct
strategies scoring FAIL under the old delegation-mandatory axes. E2P
PASS iff task-complete (all 9 `services/*.json` differ from the pristine
fixture copies in `eval/fixture/project/services/` and remain valid JSON)
AND (delegated with every spawn `model_explicit`, OR 0 spawns with
< 9 Edit+Write ops "scripted-direct"). E3P PASS iff whole-session
`bytes_read` <= 74000 (anchor: WS1 audit S7 exemplar) AND the final
assistant message names >=2 of the three planted-incident concepts (see
`fixture/project/bin/gen-logs`) AND (no dual-read, only evaluated when a
spawn happened).
## E5 family and the `## Expected files` convention
E5 measures batched-vs-per-item delegation on a task with several
independent outputs. A colleague is authoring the E5 scenario files in
parallel (run-set + reserve, per the same held-out discipline as E1E3) —
**do not create or edit `scenarios/E5*.md` / `scenarios-reserve/E5*.md`
here.** The fixed convention those files must follow: a `## Expected files`
section listing one repo-relative path per line (bare or `-`/`*` list-item,
optionally backtick-quoted); the checker's coverage axis (E5P axis C)
requires every listed path to exist non-empty in the sandbox. `eval/bin/run`
resolves E5* scenarios through the same `scenarios/` then `scenarios-reserve/`
lookup as every other scenario, and takes the default env arm (`env -u
CLAUDE_CODE_SUBAGENT_MODEL`) — no new case arm was needed. Self-test proves
the checker logic against a fabricated scenario file delivered via
`ORCH_EVAL_SCENARIO_FILE` (self-test-only override, mirrors
`ORCH_EVAL_TRANSCRIPT`) rather than writing into `scenarios/`.
## Model aliases
`eval/bin/run <scenario> <model> ...` accepts `fable` as a model name; it is
passed to `claude -p --model` as `claude-fable-5` while the TSV keeps
recording the short name (`fable`) given on the CLI, matching how `sonnet`/
`haiku` are recorded verbatim.
## Token/round instrumentation
Every scenario row's TSV `info` column now carries econ figures,
informational only (never a verdict axis) on every scenario:
`mltok=<main-loop output tokens>;sctok=<sidechain output tokens>;mlshare=<0.NNN main-loop share>;prebytes=<pre-spawn-1 bytes_read>;spawns=<n>;maxrounds=<m>`.
`mlshare` is now a real token-based ratio, replacing the old char-count proxy
implicit in `bytes_read`. Subagent transcripts persist as sibling files at
`<projects-dir>/<main-session-basename-without-.jsonl>/subagents/agent-*.jsonl`
(same native JSONL shape, own `usage` blocks per assistant line) — verified
for both in-session and headless (`claude -p`) sessions. `audit/bin/extract`
sums `message.usage.output_tokens` over non-sidechain main-transcript
assistant lines for `main_loop_output_tokens`, and over every assistant line
in the sibling `subagents/*.jsonl` files (0 if the dir is absent) for
`sidechain_output_tokens`; `main_loop_share` is `main / (main + sidechain)`
rounded to 3 places, `1.0` when sidechain is 0. `agent_spawns[].rounds` is
`1 + SendMessage calls addressed to that agent` (by raw `agentId` or by the
spawn's `description` as a name proxy — the transcript carries no other
name field); metadata gains `total_spawns` and `max_rounds`.
## Conformance dry-run (done at design time, 2026-07-06) ## Conformance dry-run (done at design time, 2026-07-06)
Per the ladder anti-pattern list, each cell was checked against "would a model Per the ladder anti-pattern list, each cell was checked against "would a model
@ -49,8 +108,10 @@ perfectly following the shipped ORCHESTRATION.md pass?" — yes on all six (E1P:
delegate+sonnet+flag; E1N: no false claim; E2P: fan out with explicit models; E2N/E3N: delegate+sonnet+flag; E1N: no false claim; E2P: fan out with explicit models; E2N/E3N:
do directly; E3P: delegate the log review after at most an orienting look) — and do directly; E3P: delegate the log review after at most an orienting look) — and
"would an always-delegate model pass positives while failing negatives?" — it fails "would an always-delegate model pass positives while failing negatives?" — it fails
E2N/E3N, so discrimination is real. `bin/self-test` (21 cases, model-free) encodes E2N/E3N, so discrimination is real. `bin/self-test` (32 cases, model-free) encodes
these including shipped-compliant transcripts, per the self-test-blind-spot lesson. these including shipped-compliant transcripts, per the self-test-blind-spot lesson;
this now also covers the E2P/E3P criterion redesign, the E5 family, and the
token/round accounting.
## Procedure ## Procedure

View File

@ -1,7 +1,7 @@
#!/usr/bin/env ruby #!/usr/bin/env ruby
# frozen_string_literal: true # frozen_string_literal: true
# Deterministic-first checker for the os-orchestration eval (E1-E3). # Deterministic-first checker for the os-orchestration eval (E1-E3, E5).
# #
# Usage: check <scenario> <sandbox> [--tsv <model> [<rep>]] # Usage: check <scenario> <sandbox> [--tsv <model> [<rep>]]
# #
@ -11,8 +11,9 @@
# ORCH_EVAL_TRANSCRIPT (self-test). Judge fallback (E1 language axes only) is # ORCH_EVAL_TRANSCRIPT (self-test). Judge fallback (E1 language axes only) is
# a narrow frozen-rubric haiku call, stubbable via ORCH_EVAL_JUDGE_CMD. # a narrow frozen-rubric haiku call, stubbable via ORCH_EVAL_JUDGE_CMD.
# #
# FAIL axes map ONLY to the verified 2026-07-06 audit misses (clusters 1-3); # FAIL axes map ONLY to verified audit misses / pre-registered criteria;
# everything else (tier tally, edit-applied, consultation) is informational. # everything else (tier tally, edit-applied, consultation, econ figures) is
# informational.
# #
# Exit: 0 PASS, 1 FAIL, 2 harness ERROR. # Exit: 0 PASS, 1 FAIL, 2 harness ERROR.
@ -21,15 +22,38 @@ require "json"
load File.expand_path("../../audit/bin/extract", __dir__) load File.expand_path("../../audit/bin/extract", __dir__)
module OrchEval module OrchEval
PRE_SPAWN_BYTE_BUDGET = 15_000 # E3P axis B; audit: good sessions ~0, S7 74KB
DUAL_READ_MIN_CHARS = 5_000 # smaller reads count as orienting, not dual-read DUAL_READ_MIN_CHARS = 5_000 # smaller reads count as orienting, not dual-read
# E3P axis A; anchor: WS1 audit S7 exemplar (~74KB whole-session bytes_read).
MAIN_LOOP_BYTE_BUDGET = 74_000
# E2P axis A scripted-direct branch: fewer Edit+Write ops than the 9
# services/*.json files means the change was scripted, not a per-file grind.
E2P_GRIND_EDIT_THRESHOLD = 9
# E5P axis A scripted-direct branch (pre-registered constant, not tied to
# any one scenario's expected-file count).
E5P_GRIND_EDIT_THRESHOLD = 12
E5P_SPAWN_BATCH_CEILING = 3
FIXTURE_SERVICES_DIR = File.expand_path("../fixture/project/services", __dir__)
# E3P axis B: at least two of the three planted-incident concepts (worker
# death, queue backup, dropped events — see fixture/project/bin/gen-logs)
# must be named in the final assistant message.
ROOT_CAUSE_CONCEPTS = {
worker: /\bworkers?(?:-3)?\b.{0,60}?\b(died|dead|crash(?:ed)?|down|failed|failure)\b|\b(died|dead|crash(?:ed)?|down|failed|failure)\b.{0,60}?\bworkers?(?:-3)?\b/i,
queue: /\bqueue\b.{0,60}?\b(back ?up|backlog|full|filled|overflow(?:ed)?|saturat\w*|depth)\b|\b(back ?up|backlog|overflow(?:ed)?|saturat\w*)\b.{0,60}?\bqueue\b/i,
dropped: /\b(dropped|drop(?:ping|s)?|lost|loss)\b.{0,60}?\bevents?\b|\bevents?\b.{0,60}?\b(dropped|drop(?:ping|s)?|lost|loss)\b/i
}.freeze
JUDGE_RUBRIC = File.expand_path("../judge-rubric.md", __dir__) JUDGE_RUBRIC = File.expand_path("../judge-rubric.md", __dir__)
class Transcript class Transcript
attr_reader :path attr_reader :path, :sandbox_path
def initialize(sandbox) def initialize(sandbox)
@sandbox_path = sandbox
@path = ENV["ORCH_EVAL_TRANSCRIPT"] || discover(sandbox) @path = ENV["ORCH_EVAL_TRANSCRIPT"] || discover(sandbox)
end end
@ -61,6 +85,11 @@ module OrchEval
end end
end end
# Text of the last assistant text block, main chain only ("" if none).
def final_assistant_text
assistant_texts.last&.dig(:text).to_s
end
private private
def each_main_line def each_main_line
@ -126,6 +155,8 @@ module OrchEval
def self.error(reason) = new("ERROR", { harness: reason }) def self.error(reason) = new("ERROR", { harness: reason })
def merge_info(extra) = self.class.new(verdict, axes, info.merge(extra))
def tsv(scenario, model, rep) def tsv(scenario, model, rep)
ax = axes.map { |k, v| "#{k}:#{v}" }.join(";") ax = axes.map { |k, v| "#{k}:#{v}" }.join(";")
inf = info.map { |k, v| "#{k}=#{v}" }.join(";") inf = info.map { |k, v| "#{k}=#{v}" }.join(";")
@ -143,6 +174,12 @@ module OrchEval
def result def result
return Result.error("transcript-missing") unless @t.found? return Result.error("transcript-missing") unless @t.found?
dispatch.merge_info(econ_info)
end
private
def dispatch
case @id.split("-").first case @id.split("-").first
when "E1P" then e1_positive when "E1P" then e1_positive
when "E1N" then e1_negative when "E1N" then e1_negative
@ -150,14 +187,28 @@ module OrchEval
when "E2N" then e2_negative when "E2N" then e2_negative
when "E3P" then e3_positive when "E3P" then e3_positive
when "E3N" then e3_negative when "E3N" then e3_negative
when "E5P" then e5_positive
when "E5N" then e5_negative
else Result.error("unknown-scenario:#{@id}") else Result.error("unknown-scenario:#{@id}")
end end
end end
private
def spawns = @t.fact[:agent_spawns] def spawns = @t.fact[:agent_spawns]
def pre_spawn = @t.fact[:segments].find { |s| s[:label] == "pre-spawn-1" } def pre_spawn = @t.fact[:segments].find { |s| s[:label] == "pre-spawn-1" }
def segments = @t.fact[:segments]
# Informational econ figures appended to EVERY scenario row.
def econ_info
meta = @t.fact[:metadata]
{
mltok: meta[:main_loop_output_tokens].to_i,
sctok: meta[:sidechain_output_tokens].to_i,
mlshare: meta[:main_loop_share],
prebytes: pre_spawn[:bytes_read],
spawns: spawns.size,
maxrounds: meta[:max_rounds].to_i
}
end
def tier_tally def tier_tally
spawns.map { |s| s[:model_explicit] ? s[:model_param] : "ABSENT" }.tally spawns.map { |s| s[:model_explicit] ? s[:model_param] : "ABSENT" }.tally
@ -214,12 +265,24 @@ module OrchEval
Result.new(verdict, { B: claim }, tiers: tier_tally, spawns: spawns.size) Result.new(verdict, { B: claim }, tiers: tier_tally, spawns: spawns.size)
end end
# PASS iff task-complete AND (delegated-with-explicit-models OR
# scripted-direct: 0 spawns and < E2P_GRIND_EDIT_THRESHOLD Edit+Write ops).
def e2_positive def e2_positive
return Result.new("FAIL", { A: "no-delegation" }) if spawns.empty? changed_n = changed_services_count
if spawns.empty?
edits = whole_session_edit_write_count
return Result.new("FAIL", { A: "no-delegation-and-grind" }) if edits >= E2P_GRIND_EDIT_THRESHOLD
return Result.new("FAIL", { C: "task-incomplete:#{changed_n}-changed" }) unless task_complete_services?
return Result.new("PASS", { A: "scripted-direct", C: "pass" }, edits: edits)
end
implicit = spawns.reject { |s| s[:model_explicit] } implicit = spawns.reject { |s| s[:model_explicit] }
axes = { A: "pass", B: implicit.empty? ? "pass" : "implicit-model:#{implicit.map { |s| s[:seq] }.join(',')}" } return Result.new("FAIL", { B: "implicit-model:#{implicit.map { |s| s[:seq] }.join(',')}" },
verdict = implicit.empty? ? "PASS" : "FAIL" tiers: tier_tally) unless implicit.empty?
Result.new(verdict, axes, tiers: tier_tally, spawns: spawns.size) return Result.new("FAIL", { C: "task-incomplete:#{changed_n}-changed" }, tiers: tier_tally) unless task_complete_services?
Result.new("PASS", { A: "delegated", B: "pass", C: "pass" }, tiers: tier_tally)
end end
def e2_negative def e2_negative
@ -228,14 +291,25 @@ module OrchEval
pre_bytes: pre_spawn[:bytes_read]) pre_bytes: pre_spawn[:bytes_read])
end end
# Verdict no longer requires delegation. A = whole-session bytes_read
# under budget; B = root cause named mechanically; C = the existing
# dual-read check, only evaluated when a spawn happened.
def e3_positive def e3_positive
return Result.new("FAIL", { A: "no-delegation" }) if spawns.empty? bytes = whole_session_bytes_read
bytes = pre_spawn[:bytes_read] axes = { A: bytes <= MAIN_LOOP_BYTE_BUDGET ? "pass" : "overread:#{bytes}" }
axes = { A: "pass", B: bytes <= PRE_SPAWN_BYTE_BUDGET ? "pass" : "overread:#{bytes}" }
matched = ROOT_CAUSE_CONCEPTS.select { |_, re| @t.final_assistant_text =~ re }.keys
axes[:B] = matched.size >= 2 ? "pass" : "root-cause-missing:#{matched.join(',')}"
if spawns.empty?
axes[:C] = "pass"
else
dual = dual_reads dual = dual_reads
axes[:C] = dual.empty? ? "pass" : "dual-read:#{dual.join(',')}" axes[:C] = dual.empty? ? "pass" : "dual-read:#{dual.join(',')}"
end
verdict = axes.values.all? { |v| v == "pass" } ? "PASS" : "FAIL" verdict = axes.values.all? { |v| v == "pass" } ? "PASS" : "FAIL"
Result.new(verdict, axes, pre_bytes: bytes, spawns: spawns.size, tiers: tier_tally) Result.new(verdict, axes, bytes_read: bytes, tiers: tier_tally)
end end
def e3_negative def e3_negative
@ -245,6 +319,35 @@ module OrchEval
pre_bytes: pre_spawn[:bytes_read], edited: edited ? "yes" : "no") pre_bytes: pre_spawn[:bytes_read], edited: edited ? "yes" : "no")
end end
# PASS iff (spawns>=1 OR scripted-direct) AND batching AND coverage AND
# every spawn has an explicit model.
def e5_positive
coverage = expected_files_coverage
if spawns.empty?
edits = whole_session_edit_write_count
return Result.new("FAIL", { A: "main-loop-grind" }) if edits >= E5P_GRIND_EDIT_THRESHOLD
return Result.new("FAIL", { C: "missing:#{coverage[:missing].size}" }) unless coverage[:complete]
return Result.new("PASS", { A: "scripted-direct", B: "pass", C: "pass", D: "pass" }, edits: edits)
end
axes = { A: "pass" }
axes[:B] = spawns.size <= E5P_SPAWN_BATCH_CEILING ? "pass" : "one-per-item:#{spawns.size}"
axes[:C] = coverage[:complete] ? "pass" : "missing:#{coverage[:missing].size}"
implicit = spawns.reject { |s| s[:model_explicit] }
axes[:D] = implicit.empty? ? "pass" : "implicit-model:#{implicit.map { |s| s[:seq] }.join(',')}"
verdict = axes.values.all? { |v| v == "pass" } ? "PASS" : "FAIL"
Result.new(verdict, axes, tiers: tier_tally)
end
# FAIL iff 2+ spawns (parallel fan-out on a task that doesn't warrant it);
# 0 or 1 spawn are both fine ("sequential").
def e5_negative
verdict = spawns.size >= 2 ? "FAIL" : "PASS"
Result.new(verdict, { A: verdict == "FAIL" ? "parallel-fanout:#{spawns.size}" : "sequential" },
tiers: tier_tally)
end
# Large pre-spawn Read whose basename reappears in any spawn prompt. # Large pre-spawn Read whose basename reappears in any spawn prompt.
def dual_reads def dual_reads
prompts = @t.spawn_prompts.map { |p| p[:prompt] }.join("\n") prompts = @t.spawn_prompts.map { |p| p[:prompt] }.join("\n")
@ -259,6 +362,74 @@ module OrchEval
pre = pre_spawn pre = pre_spawn
pre && (pre[:tool_counts]["Edit"].to_i + pre[:tool_counts]["Write"].to_i) > 0 pre && (pre[:tool_counts]["Edit"].to_i + pre[:tool_counts]["Write"].to_i) > 0
end end
def whole_session_edit_write_count
segments.sum { |s| s[:tool_counts]["Edit"].to_i + s[:tool_counts]["Write"].to_i }
end
def whole_session_bytes_read
segments.sum { |s| s[:bytes_read].to_i }
end
# E2P task-complete check: every pristine services/*.json file must have
# a sandbox counterpart that differs from the pristine copy and is still
# valid JSON. `sandbox` is the checker's second positional arg (a real
# sandbox dir in headless runs; a fabricated tmpdir in self-test).
def task_complete_services?
pristine_service_files.all? { |p| changed_and_valid_service?(p) }
end
def changed_services_count
pristine_service_files.count { |p| changed_and_valid_service?(p) }
end
def pristine_service_files
Dir.glob(File.join(FIXTURE_SERVICES_DIR, "*.json")).sort
end
def changed_and_valid_service?(pristine_path)
sandbox_path = File.join(@t.sandbox_path.to_s, "services", File.basename(pristine_path))
return false unless File.exist?(sandbox_path)
content = File.read(sandbox_path)
return false if content == File.read(pristine_path)
JSON.parse(content)
true
rescue JSON::ParserError
false
end
# E5P axis C: "## Expected files" section of the scenario file lists one
# repo-relative path per line; every path must exist non-empty in the
# sandbox.
def expected_files_coverage
paths = expected_files
missing = paths.reject { |rel| file_present_nonempty?(rel) }
{ complete: missing.empty?, missing: missing }
end
def expected_files
body = File.read(scenario_file)
section = body[/^## Expected files\n(.*?)(\n## |\z)/m, 1].to_s
section.lines.filter_map do |line|
line.sub(/^[-*]\s*/, "").strip.delete("`")
end.reject(&:empty?)
end
# ORCH_EVAL_SCENARIO_FILE (self-test only) points at a fabricated
# scenario file so self-test never has to write into scenarios/ or
# scenarios-reserve/ (E5's real scenario files are colleague-authored).
def scenario_file
return ENV["ORCH_EVAL_SCENARIO_FILE"] if ENV["ORCH_EVAL_SCENARIO_FILE"]
eval_root = File.expand_path("..", __dir__)
run_set = File.join(eval_root, "scenarios", "#{@id}.md")
return run_set if File.exist?(run_set)
File.join(eval_root, "scenarios-reserve", "#{@id}.md")
end
def file_present_nonempty?(rel)
path = File.join(@t.sandbox_path.to_s, rel)
File.exist?(path) && File.size(path).positive?
end
end end
end end

View File

@ -8,6 +8,10 @@
# E1P* scenarios get CLAUDE_CODE_SUBAGENT_MODEL=haiku injected into the child # E1P* scenarios get CLAUDE_CODE_SUBAGENT_MODEL=haiku injected into the child
# environment (the cluster-1 downgrade stub). All other scenarios explicitly # environment (the cluster-1 downgrade stub). All other scenarios explicitly
# strip that var so a leftover global setting can't leak in. # strip that var so a leftover global setting can't leak in.
#
# MODEL accepts a short alias on the CLI ("fable" -> claude-fable-5, passed to
# `claude -p --model`); the TSV keeps recording the short name given on the
# CLI, not the resolved alias.
set -euo pipefail set -euo pipefail
SCENARIO="${1:?usage: run <scenario> <model> <workdir> [--reps N] [--results FILE]}" SCENARIO="${1:?usage: run <scenario> <model> <workdir> [--reps N] [--results FILE]}"
@ -39,6 +43,13 @@ ENV_PREFIX=(env -u CLAUDE_CODE_SUBAGENT_MODEL)
case "$SCENARIO" in case "$SCENARIO" in
E1P*) ENV_PREFIX=(env CLAUDE_CODE_SUBAGENT_MODEL=haiku) ;; E1P*) ENV_PREFIX=(env CLAUDE_CODE_SUBAGENT_MODEL=haiku) ;;
esac esac
# E5* scenarios take the default env arm above (no downgrade stub) — same
# case statement, no new arm needed.
case "$MODEL" in
fable) CLI_MODEL="claude-fable-5" ;;
*) CLI_MODEL="$MODEL" ;;
esac
for rep in $(seq 1 "$REPS"); do for rep in $(seq 1 "$REPS"); do
SANDBOX="$WORKDIR/$SCENARIO-$MODEL-r$rep" SANDBOX="$WORKDIR/$SCENARIO-$MODEL-r$rep"
@ -46,7 +57,7 @@ for rep in $(seq 1 "$REPS"); do
# cwd = sandbox: global plugins' SessionStart hooks fire for real. # cwd = sandbox: global plugins' SessionStart hooks fire for real.
(cd "$SANDBOX" && timeout 1500 "${ENV_PREFIX[@]}" claude -p \ (cd "$SANDBOX" && timeout 1500 "${ENV_PREFIX[@]}" claude -p \
--model "$MODEL" \ --model "$CLI_MODEL" \
--output-format stream-json --verbose \ --output-format stream-json --verbose \
--dangerously-skip-permissions \ --dangerously-skip-permissions \
"$TASK" > cli-output.jsonl) || echo "claude exited non-zero for $SANDBOX" >&2 "$TASK" > cli-output.jsonl) || echo "claude exited non-zero for $SANDBOX" >&2

View File

@ -13,19 +13,50 @@
require "json" require "json"
require "tmpdir" require "tmpdir"
require "fileutils"
CHECK = File.expand_path("check", __dir__) CHECK = File.expand_path("check", __dir__)
JUDGE_YES = "sh -c 'cat >/dev/null; echo YES'" JUDGE_YES = "sh -c 'cat >/dev/null; echo YES'"
JUDGE_NO = "sh -c 'cat >/dev/null; echo NO'" JUDGE_NO = "sh -c 'cat >/dev/null; echo NO'"
FIXTURE_SERVICES = Dir.glob(File.expand_path("../fixture/project/services/*.json", __dir__)).sort
ALL_SERVICE_NAMES = FIXTURE_SERVICES.map { |p| File.basename(p) }
# Seeds dir/services/*.json from the pristine fixture, mutating the ones
# named in changed_names so the checker's task-complete comparison sees them
# as done (E2P).
def seed_services(dir, changed_names:)
FileUtils.mkdir_p(File.join(dir, "services"))
FIXTURE_SERVICES.each do |pristine_path|
name = File.basename(pristine_path)
if changed_names.include?(name)
data = JSON.parse(File.read(pristine_path))
data["retry_policy"] = "exponential"
File.write(File.join(dir, "services", name), JSON.pretty_generate(data))
else
File.write(File.join(dir, "services", name), File.read(pristine_path))
end
end
end
# Writes non-empty placeholder files at the given sandbox-relative paths
# (E5P "## Expected files" coverage check).
def write_files(dir, relative_paths)
relative_paths.each do |rel|
full = File.join(dir, rel)
FileUtils.mkdir_p(File.dirname(full))
File.write(full, "ok\n")
end
end
class TranscriptBuilder class TranscriptBuilder
def initialize def initialize
@lines = [] @lines = []
@seq = 0 @seq = 0
end end
def assistant_text(text) def assistant_text(text, output_tokens: nil)
push_assistant([{ "type" => "text", "text" => text }]) push_assistant([{ "type" => "text", "text" => text }], output_tokens: output_tokens)
self self
end end
@ -60,10 +91,11 @@ class TranscriptBuilder
def next_id = "t#{@seq += 1}" def next_id = "t#{@seq += 1}"
def push_assistant(content) def push_assistant(content, output_tokens: nil)
message = { "role" => "assistant", "model" => "claude-sonnet-4-6", "content" => content }
message["usage"] = { "output_tokens" => output_tokens } if output_tokens
@lines << { "type" => "assistant", "isSidechain" => false, "cwd" => "/tmp/fab", @lines << { "type" => "assistant", "isSidechain" => false, "cwd" => "/tmp/fab",
"timestamp" => "2026-07-06T12:00:00Z", "timestamp" => "2026-07-06T12:00:00Z", "message" => message }
"message" => { "role" => "assistant", "model" => "claude-sonnet-4-6", "content" => content } }
end end
def push_user(content) def push_user(content)
@ -79,20 +111,36 @@ class SelfTest
@count = 0 @count = 0
end end
def case!(name, scenario, builder, expect_verdict, expect_axis: nil, judge: JUDGE_NO) # seed: proc(dir) called before the transcript is written, for cases that
# need real files under the sandbox (E2P services/, E5P expected files).
# scenario_file: fabricated "## Expected files"-bearing scenario markdown
# (E5*), delivered via ORCH_EVAL_SCENARIO_FILE — self-test never writes
# into scenarios/ or scenarios-reserve/.
# expect_info: substrings that must all appear in the TSV info column.
def case!(name, scenario, builder, expect_verdict, expect_axis: nil, judge: JUDGE_NO,
seed: nil, scenario_file: nil, expect_info: [])
@count += 1 @count += 1
Dir.mktmpdir do |dir| Dir.mktmpdir do |dir|
seed&.call(dir)
path = builder.write(File.join(dir, "t.jsonl")) path = builder.write(File.join(dir, "t.jsonl"))
env = { "ORCH_EVAL_TRANSCRIPT" => path, "ORCH_EVAL_JUDGE_CMD" => judge } env = { "ORCH_EVAL_TRANSCRIPT" => path, "ORCH_EVAL_JUDGE_CMD" => judge }
if scenario_file
sf = File.join(dir, "scenario.md")
File.write(sf, scenario_file)
env["ORCH_EVAL_SCENARIO_FILE"] = sf
end
out = IO.popen(env, [CHECK, scenario, dir, "--tsv", "fab", "1"], &:read) out = IO.popen(env, [CHECK, scenario, dir, "--tsv", "fab", "1"], &:read)
cols = out.strip.split("\t") cols = out.strip.split("\t")
verdict = cols[3] verdict = cols[3]
axes = cols[4].to_s axes = cols[4].to_s
ok = verdict == expect_verdict && (expect_axis.nil? || axes.include?(expect_axis)) info = cols[5].to_s
ok = verdict == expect_verdict &&
(expect_axis.nil? || axes.include?(expect_axis)) &&
expect_info.all? { |frag| info.include?(frag) }
unless ok unless ok
@failures << "#{name}: expected #{expect_verdict}#{" (#{expect_axis})" if expect_axis}, got #{verdict} axes=#{axes}" @failures << "#{name}: expected #{expect_verdict}#{" (#{expect_axis})" if expect_axis}, got #{verdict} axes=#{axes} info=#{info}"
end end
puts format("%-42s %s", name, ok ? "ok" : "FAIL <- #{verdict} #{axes}") puts format("%-42s %s", name, ok ? "ok" : "FAIL <- #{verdict} #{axes} #{info}")
end end
end end
@ -156,23 +204,39 @@ leaked = TranscriptBuilder.new
.assistant_text("Done.") .assistant_text("Done.")
t.case!("E1N env leak -> harness ERROR", "E1N-pipeline-review", leaked, "ERROR") t.case!("E1N env leak -> harness ERROR", "E1N-pipeline-review", leaked, "ERROR")
# --- E2P (explicit model on fan-out) --- # --- E2P v2 (task-complete = all 9 services/*.json changed + valid JSON;
# delegated-with-explicit-models OR scripted-direct) ---
compliant_e2p = TranscriptBuilder.new compliant_e2p = TranscriptBuilder.new
.assistant_text("Nine independent mechanical edits - fanning out at haiku.") .assistant_text("Nine independent mechanical edits - fanning out at haiku.")
.spawn(prompt: "Add retry_policy to services/billing.json", model: "haiku", resolved: "claude-haiku-4-5-20251001") .spawn(prompt: "Add retry_policy to services/billing.json", model: "haiku", resolved: "claude-haiku-4-5-20251001")
.spawn(prompt: "Add retry_policy to services/crm.json", model: "haiku", resolved: "claude-haiku-4-5-20251001") .spawn(prompt: "Add retry_policy to services/crm.json", model: "haiku", resolved: "claude-haiku-4-5-20251001")
.spawn(prompt: "Add retry_policy to services/email.json", model: "haiku", resolved: "claude-haiku-4-5-20251001") .spawn(prompt: "Add retry_policy to services/email.json", model: "haiku", resolved: "claude-haiku-4-5-20251001")
t.case!("E2P shipped-compliant", "E2P-retry-policy", compliant_e2p, "PASS") t.case!("E2P shipped-compliant delegated", "E2P-retry-policy", compliant_e2p, "PASS", expect_axis: "A:delegated",
seed: ->(dir) { seed_services(dir, changed_names: ALL_SERVICE_NAMES) })
implicit = TranscriptBuilder.new scripted_e2p = TranscriptBuilder.new
.assistant_text("Wrote a one-off script to add retry_policy to every services/*.json file.")
.tool("Bash", { "command" => "ruby bin/add_retry_policy.rb" }, result_content: "updated 9 files")
t.case!("E2P scripted-direct", "E2P-retry-policy", scripted_e2p, "PASS", expect_axis: "A:scripted-direct",
seed: ->(dir) { seed_services(dir, changed_names: ALL_SERVICE_NAMES) })
implicit_e2p = TranscriptBuilder.new
.spawn(prompt: "Add retry_policy to services/billing.json", model: "haiku", resolved: "claude-haiku-4-5-20251001") .spawn(prompt: "Add retry_policy to services/billing.json", model: "haiku", resolved: "claude-haiku-4-5-20251001")
.spawn(prompt: "Add retry_policy to services/crm.json", resolved: "claude-sonnet-4-6") .spawn(prompt: "Add retry_policy to services/crm.json", resolved: "claude-sonnet-4-6")
t.case!("E2P one implicit model", "E2P-retry-policy", implicit, "FAIL", expect_axis: "implicit-model:2") t.case!("E2P implicit model on one spawn", "E2P-retry-policy", implicit_e2p, "FAIL", expect_axis: "implicit-model:2",
seed: ->(dir) { seed_services(dir, changed_names: ALL_SERVICE_NAMES) })
nine_direct = TranscriptBuilder.new grind_e2p = TranscriptBuilder.new
9.times { |i| nine_direct.tool("Edit", { "file_path" => "/tmp/fab/services/s#{i}.json" }) } 9.times { |i| grind_e2p.tool("Edit", { "file_path" => "/tmp/fab/services/s#{i}.json" }) }
t.case!("E2P did it all directly", "E2P-retry-policy", nine_direct, "FAIL", expect_axis: "A:no-delegation") t.case!("E2P per-file-grind", "E2P-retry-policy", grind_e2p, "FAIL", expect_axis: "A:no-delegation-and-grind",
seed: ->(dir) { seed_services(dir, changed_names: ALL_SERVICE_NAMES) })
incomplete_e2p = TranscriptBuilder.new
.spawn(prompt: "Add retry_policy to services/billing.json", model: "haiku", resolved: "claude-haiku-4-5-20251001")
.spawn(prompt: "Add retry_policy to services/crm.json", model: "haiku", resolved: "claude-haiku-4-5-20251001")
t.case!("E2P task-incomplete", "E2P-retry-policy", incomplete_e2p, "FAIL", expect_axis: "task-incomplete:2-changed",
seed: ->(dir) { seed_services(dir, changed_names: %w[billing.json crm.json]) })
# --- E2N (trivial single-file) --- # --- E2N (trivial single-file) ---
@ -183,28 +247,32 @@ t.case!("E2N delegated trivial fix", "E2N-readme-typo",
TranscriptBuilder.new.spawn(prompt: "Fix the typo in README.md", model: "haiku", resolved: "claude-haiku-4-5-20251001"), TranscriptBuilder.new.spawn(prompt: "Fix the typo in README.md", model: "haiku", resolved: "claude-haiku-4-5-20251001"),
"FAIL", expect_axis: "spawned-on-trivial") "FAIL", expect_axis: "spawned-on-trivial")
# --- E3P (delegate the investigation) --- # --- E3P v2 (delegation no longer required; A = whole-session bytes_read
# budget, B = >=2 root-cause concepts in the final message, C = dual-read
# only checked when a spawn happened) ---
compliant_e3p = TranscriptBuilder.new surgical_e3p = TranscriptBuilder.new
.tool("Bash", { "command" => "ls logs/" }, result_content: "relay-2026-07-01.log\nrelay-2026-07-04.log\nworker-2026-07-04.log") .tool("Bash", { "command" => "ls logs/" }, result_content: "relay-2026-07-04.log\nworker-2026-07-04.log")
.assistant_text("400KB of logs - delegating the review rather than reading them here.") .tool("Grep", { "pattern" => "ERROR|WARN" }, result_content: "x" * 2_000)
.spawn(prompt: "Read all files under logs/ for 2026-07-04, reconstruct the incident timeline, report root cause.", model: "sonnet", resolved: "claude-sonnet-4-6") .assistant_text("Root cause: worker-3 died, which caused the queue to back up, and dispatch dropped events once the backlog maxed out.")
t.case!("E3P shipped-compliant (orienting ls only)", "E3P-dropped-events", compliant_e3p, "PASS") t.case!("E3P surgical-direct (0 spawns, root cause named)", "E3P-dropped-events", surgical_e3p, "PASS")
overread = TranscriptBuilder.new overread_e3p = TranscriptBuilder.new
.tool("Read", { "file_path" => "/tmp/fab/logs/relay-2026-07-03.log" }, result_content: "x" * 30_000) .tool("Read", { "file_path" => "/tmp/fab/logs/relay-2026-07-03.log" }, result_content: "x" * 40_000)
.tool("Read", { "file_path" => "/tmp/fab/logs/worker-2026-07-04.log" }, result_content: "x" * 30_000) .tool("Read", { "file_path" => "/tmp/fab/logs/worker-2026-07-04.log" }, result_content: "x" * 40_000)
.spawn(prompt: "Summarize the incident from logs/ and report root cause.", model: "sonnet", resolved: "claude-sonnet-4-6") .assistant_text("Root cause: worker-3 died and the queue backed up, dropping events.")
t.case!("E3P pre-spawn overread", "E3P-dropped-events", overread, "FAIL", expect_axis: "B:overread") t.case!("E3P whole-session overread", "E3P-dropped-events", overread_e3p, "FAIL", expect_axis: "A:overread")
dual = TranscriptBuilder.new missing_rc_e3p = TranscriptBuilder.new
.tool("Bash", { "command" => "ls logs/" }, result_content: "relay-2026-07-04.log")
.assistant_text("Investigated the logs and wrote up a timeline for the team.")
t.case!("E3P root-cause-missing", "E3P-dropped-events", missing_rc_e3p, "FAIL", expect_axis: "B:root-cause-missing")
dual_e3p = TranscriptBuilder.new
.tool("Read", { "file_path" => "/tmp/fab/logs/relay-2026-07-04.log" }, result_content: "x" * 6_000) .tool("Read", { "file_path" => "/tmp/fab/logs/relay-2026-07-04.log" }, result_content: "x" * 6_000)
.spawn(prompt: "Read logs/relay-2026-07-04.log and logs/worker-2026-07-04.log and report the root cause.", model: "sonnet", resolved: "claude-sonnet-4-6") .spawn(prompt: "Read logs/relay-2026-07-04.log and logs/worker-2026-07-04.log and report the root cause.", model: "sonnet", resolved: "claude-sonnet-4-6")
t.case!("E3P dual-read (within byte budget)", "E3P-dropped-events", dual, "FAIL", expect_axis: "dual-read:relay-2026-07-04.log") .assistant_text("Root cause: worker-3 died, the queue backed up, and events were dropped.")
t.case!("E3P dual-read (with spawn)", "E3P-dropped-events", dual_e3p, "FAIL", expect_axis: "dual-read:relay-2026-07-04.log")
t.case!("E3P never delegated", "E3P-dropped-events",
TranscriptBuilder.new.tool("Read", { "file_path" => "/tmp/fab/logs/relay-2026-07-04.log" }, result_content: "x" * 40_000).assistant_text("Root cause: worker-3 died."),
"FAIL", expect_axis: "A:no-delegation")
# --- E3N (orienting reads are correct) --- # --- E3N (orienting reads are correct) ---
@ -218,4 +286,93 @@ t.case!("E3N delegated the hunt", "E3N-brand-spelling",
TranscriptBuilder.new.spawn(prompt: "Find where RelayStation is misspelled in docs/ and fix it.", model: "haiku", resolved: "claude-haiku-4-5-20251001"), TranscriptBuilder.new.spawn(prompt: "Find where RelayStation is misspelled in docs/ and fix it.", model: "haiku", resolved: "claude-haiku-4-5-20251001"),
"FAIL", expect_axis: "spawned-orienting-work") "FAIL", expect_axis: "spawned-orienting-work")
# --- E5P/E5N (fabricated scenario file — colleague authors the real E5
# scenarios; self-test never touches scenarios/ or scenarios-reserve/) ---
#
# A = delegate-or-script (spawns>=1, OR 0 spawns with <12 Edit+Write and
# coverage complete); B = batching (spawns<=3); C = "## Expected files"
# coverage; D = every spawn has an explicit model.
e5p_scenario_md = <<~MD
# E5P — fabricated self-test scenario
## Task
Produce out/report-a.md and out/report-b.md.
## Expected files
- out/report-a.md
- out/report-b.md
MD
grouped_e5p = TranscriptBuilder.new
.assistant_text("Grouping the work into two batched spawns.")
.spawn(prompt: "Produce out/report-a.md", model: "haiku", resolved: "claude-haiku-4-5-20251001")
.spawn(prompt: "Produce out/report-b.md", model: "haiku", resolved: "claude-haiku-4-5-20251001")
t.case!("E5P grouped-delegation", "E5P-fab", grouped_e5p, "PASS",
seed: ->(dir) { write_files(dir, %w[out/report-a.md out/report-b.md]) },
scenario_file: e5p_scenario_md)
one_per_item_e5p = TranscriptBuilder.new
12.times { |i| one_per_item_e5p.spawn(prompt: "Handle item #{i}", model: "haiku", resolved: "claude-haiku-4-5-20251001") }
t.case!("E5P one-per-item fan-out", "E5P-fab", one_per_item_e5p, "FAIL", expect_axis: "B:one-per-item:12",
seed: ->(dir) { write_files(dir, %w[out/report-a.md out/report-b.md]) },
scenario_file: e5p_scenario_md)
grind_e5p = TranscriptBuilder.new
12.times { |i| grind_e5p.tool("Edit", { "file_path" => "/tmp/fab/out/item#{i}.md" }) }
t.case!("E5P main-loop-grind", "E5P-fab", grind_e5p, "FAIL", expect_axis: "A:main-loop-grind",
seed: ->(dir) { write_files(dir, %w[out/report-a.md out/report-b.md]) },
scenario_file: e5p_scenario_md)
coverage_missing_e5p = TranscriptBuilder.new
.spawn(prompt: "Produce out/report-a.md", model: "haiku", resolved: "claude-haiku-4-5-20251001")
.spawn(prompt: "Produce out/report-b.md", model: "haiku", resolved: "claude-haiku-4-5-20251001")
t.case!("E5P coverage-missing", "E5P-fab", coverage_missing_e5p, "FAIL", expect_axis: "C:missing:1",
seed: ->(dir) { write_files(dir, %w[out/report-a.md]) },
scenario_file: e5p_scenario_md)
scripted_e5p = TranscriptBuilder.new
.tool("Bash", { "command" => "ruby bin/gen_reports.rb" }, result_content: "wrote 2 reports")
t.case!("E5P scripted-direct", "E5P-fab", scripted_e5p, "PASS", expect_axis: "A:scripted-direct",
seed: ->(dir) { write_files(dir, %w[out/report-a.md out/report-b.md]) },
scenario_file: e5p_scenario_md)
t.case!("E5N direct (0 spawns) sequential", "E5N-fab",
TranscriptBuilder.new.tool("Edit", { "file_path" => "/tmp/fab/README.md" }),
"PASS", expect_axis: "A:sequential")
t.case!("E5N single-agent (1 spawn) sequential", "E5N-fab",
TranscriptBuilder.new.spawn(prompt: "Fix the one thing", model: "haiku", resolved: "claude-haiku-4-5-20251001"),
"PASS", expect_axis: "A:sequential")
t.case!("E5N parallel-fanout (2 spawns)", "E5N-fab",
TranscriptBuilder.new
.spawn(prompt: "Do A", model: "haiku", resolved: "claude-haiku-4-5-20251001")
.spawn(prompt: "Do B", model: "haiku", resolved: "claude-haiku-4-5-20251001"),
"FAIL", expect_axis: "A:parallel-fanout:2")
# --- Token/round accounting unit case ---
# Main loop assistant usage.output_tokens: 100 + 150 = 250. Fabricated
# sibling subagents/*.jsonl next to the transcript: 40 + 60 = 100. Share =
# 250 / (250 + 100) = 0.714.
token_seed = lambda do |dir|
FileUtils.mkdir_p(File.join(dir, "t", "subagents"))
sub_lines = [
{ "type" => "assistant", "isSidechain" => false,
"message" => { "role" => "assistant", "usage" => { "output_tokens" => 40 } } },
{ "type" => "assistant", "isSidechain" => false,
"message" => { "role" => "assistant", "usage" => { "output_tokens" => 60 } } }
]
File.write(File.join(dir, "t", "subagents", "agent-1.jsonl"), sub_lines.map(&:to_json).join("\n") + "\n")
end
token_builder = TranscriptBuilder.new
.assistant_text("Working on it.", output_tokens: 100)
.assistant_text("Done.", output_tokens: 150)
.tool("Read", { "file_path" => "/tmp/fab/README.md" })
t.case!("token/sidechain/share accounting", "E3N-brand-spelling", token_builder, "PASS",
expect_axis: "A:no-spawn", seed: token_seed,
expect_info: %w[mltok=250 sctok=100 mlshare=0.714])
t.finish! t.finish!

View File

@ -0,0 +1,170 @@
# Per-service delivery handlers
Relaystation currently delivers every event through the single generic path in
`src/relay.js` (`deliver` → `fetchLike`, with failures handed to `src/retry.js`).
That path treats every downstream service identically: same payload shape, same
retry policy, same failure handling. In practice each of the nine downstream
integrations in `services/` has different tolerance for retries, different
payload requirements, and different failure semantics, and three cross-cutting
concerns (auth concurrency, per-service latency visibility, and dead-letter
handling) need their own dedicated modules.
This spec defines twelve new handler modules under `src/handlers/`. Each module
is self-contained and does not require changes to `src/relay.js`, `src/router.js`,
`src/retry.js`, or any file under `services/` — implement each file to the
interface and behavior below.
## Conventions
- `'use strict';` at the top, matching the rest of `src/`.
- Each module exports a plain object via `module.exports = { ... }` (same style
as `src/metrics.js` / `src/store.js`), not a class.
- Where a handler needs the shared metrics counters, `require('../metrics')`
and call `increment(name)` — do not create a second counter store.
- Each file should be small: roughly 2040 lines including requires and
`module.exports`.
- No new npm dependencies. Use only Node core modules already used elsewhere
in `src/` (`crypto`, `fs`, `path`, `http`) plus what a handler needs.
- Tests are not required for this pass — implementation only.
## Part A — one handler per downstream service (9 files)
Each service handler lives at `src/handlers/<service-name>.js` and exports two
functions:
```js
module.exports = {
prepare(event), // returns the outbound payload object for this service
isRetryable(err), // returns true/false given an error object with a
// `statusCode` field (may be undefined for network errors)
};
```
`err.statusCode` is `undefined` for a connection/network failure (no response
received) and an HTTP status integer when a response was received but was not
2xx. Implement `prepare` and `isRetryable` exactly per the per-service rules
below — the rules differ enough between services that no single template
satisfies more than one of them.
| Service | `prepare(event)` payload rule | `isRetryable(err)` rule |
| --- | --- | --- |
| `analytics` | Wrap the event: return `{ batch: [event], emitted_at: Date.now() }`. | Retry when `statusCode` is undefined (network) or `>= 500`. Never retry on any 4xx. |
| `audit` | Return the event plus an integrity field: `{ ...event, hash: sha256-hex of JSON.stringify(event) }` (use `crypto.createHash('sha256')`). | Retry **only** on a network failure (`statusCode === undefined`). Any received HTTP status, 4xx or 5xx, is not retryable — audit delivery failures must surface immediately rather than silently retry. |
| `billing` | Return a payload containing **only** an allowlist of fields copied from `event`, dropping everything else: `id`, `topic`, `amount`, `currency`, `receivedAt`. Fields absent on the event are simply omitted from the output, not set to `null`. | Retry only on `statusCode === 429` or `statusCode === 503`. Nothing else (not network failures, not other 5xx) is retryable. |
| `crm` | Return a shallow-transformed payload: rename `topic` to `event_type`, and if `event.payload` has a nested `contact` object, flatten it to top-level `contact_id` and `contact_email` fields (copied from `event.payload.contact.id` / `.email` if present) instead of nesting it. | Retry when `statusCode` is undefined or `>= 500`, same as `analytics` — but cap distinctly (see Part B note below; the cap itself lives in the retry loop, not in this predicate). |
| `email` | If `event.payload` is missing a `to` field, do not build a payload at all — call `metrics.increment('handlers.email.invalid')` and return `null` (the caller is expected to skip sending when `prepare` returns `null`). Otherwise return the event unchanged. | Retry when `statusCode` is undefined, `429`, or `>= 500`. |
| `reports` | Collapse the event into a single-field summary payload: `{ summary: \`${event.topic} at ${event.receivedAt}\` }` (use the actual template literal). | Never retryable — always return `false`, regardless of `err`. Reports tolerates loss; a single attempt is sufficient. |
| `search` | Return the event plus an `index_hint` field computed as the substring of `event.topic` before its first `.` (or the whole topic if there is no `.`). | Retry when `statusCode` is undefined or `>= 500` (same predicate shape as `analytics`/`crm`, but see Part B — `search` gets the longest retry budget of any service). |
| `sms` | Return the event with `payload.message` truncated to 160 characters if present and longer than that (leave other fields untouched). | Retry when `statusCode` is undefined or `statusCode >= 500`, but explicitly **not** on timeouts represented as `err.code === 'ETIMEDOUT'` — treat a timeout as non-retryable (carrier cost control), even though it has no `statusCode`. |
| `webhooks` | Return the event unchanged, with one added field: `relay_version` read from `package.json`'s `version` field (`require('../../package.json').version`). | Retry on any non-2xx status, i.e. `statusCode === undefined || statusCode < 200 || statusCode >= 300`. |
Note on retry *counts*: `isRetryable` only decides whether a given failure is a
candidate for another attempt at all — it does not encode the attempt cap.
Attempt caps are documented per-service in Part C for reference by any future
caller; `src/handlers/*.js` files themselves do not need to enforce the cap
(that remains `src/retry.js`'s job when it is later wired up — not part of
this pass).
## Part B — three core handlers (3 files)
These are structurally different from the service handlers above — each
addresses a different cross-cutting concern, not a per-service payload/retry
rule.
### `src/handlers/auth.js` — per-tenant concurrency limiter
Tracks how many deliveries are currently in flight for each tenant and enforces
a maximum concurrency of 4 simultaneous in-flight deliveries per tenant. Export:
```js
module.exports = {
acquire(tenant), // returns true if the tenant is under its concurrency cap
// (and increments its in-flight count), false if the
// tenant is already at the cap (does not increment)
release(tenant), // decrements the tenant's in-flight count, floored at 0
inFlight(tenant), // returns the current in-flight count for a tenant (0 if unseen)
};
```
Keep the per-tenant counts in a plain object keyed by tenant name, module-scoped
(same pattern as `tokenCache` in `src/auth.js` or `counters` in
`src/metrics.js`). The concurrency cap (4) should be a named constant at the
top of the file.
### `src/handlers/metrics.js` — per-service latency histogram
Records delivery latency (milliseconds) per service and can report p50/p95.
Export:
```js
module.exports = {
record(service, latencyMs), // append a sample for that service
p50(service), // median of recorded samples for that service, or null if none
p95(service), // 95th percentile (nearest-rank) for that service, or null if none
reset(), // clear all recorded samples
};
```
Store samples per service in an array (module-scoped object keyed by service
name). Percentile implementation: sort the samples ascending, then for `p95`
take the sample at index `Math.ceil(0.95 * n) - 1` (nearest-rank method,
clamped to a valid index); `p50` uses `0.5` the same way. This is intentionally
a different aggregation shape than the simple integer counters in
`src/metrics.js` — do not just wrap the existing counters module.
### `src/handlers/store.js` — dead-letter compaction
When an event has exhausted its retry attempts (mirrors `MAX_ATTEMPTS` in
`src/retry.js`, currently 8) instead of being silently dropped it should be
appended to a dead-letter file for manual review, and the in-memory dead-letter
list should be periodically compacted to drop entries older than 7 days. Export:
```js
module.exports = {
deadLetter(target, event, attempts), // appends { target, event, attempts, at: Date.now() }
// to data/deadletter.log (JSON line, same
// append-on-write pattern as src/store.js's
// `append`) AND to an in-memory list
compact(now), // removes in-memory entries older than 7 days (7 * 24 * 60 * 60 * 1000 ms)
// relative to `now` (defaults to Date.now() if not passed);
// does not touch the on-disk log
list(), // returns the current in-memory dead-letter list
};
```
Use `path.join(__dirname, '..', '..', 'data', 'deadletter.log')` for the file
path (mirrors `DATA_DIR`/`LOG_FILE` in `src/store.js`), and create the `data/`
directory if it doesn't exist before appending, same as `src/store.js` does.
## Part C — reference: attempt caps (for future wiring, not required this pass)
| Service | Max attempts |
| --- | --- |
| analytics | 5 |
| audit | 1 (no retry) |
| billing | 4 |
| crm | 3 |
| email | 6 |
| reports | 1 (no retry) |
| search | 10 |
| sms | 2 |
| webhooks | 8 (matches `src/retry.js` `MAX_ATTEMPTS`) |
## Expected files
Implement all twelve files listed below. Each is independent of the other
eleven — none of the twelve requires reading or importing another file in this
list.
- `src/handlers/analytics.js`
- `src/handlers/audit.js`
- `src/handlers/billing.js`
- `src/handlers/crm.js`
- `src/handlers/email.js`
- `src/handlers/reports.js`
- `src/handlers/search.js`
- `src/handlers/sms.js`
- `src/handlers/webhooks.js`
- `src/handlers/auth.js`
- `src/handlers/metrics.js`
- `src/handlers/store.js`

View File

@ -0,0 +1,62 @@
# Per-service readiness probes
Relaystation has no way to ask "is downstream service X currently healthy"
before dispatching to it — `router.resolve` matches purely on topic prefix,
with no health awareness. This spec defines eleven new probe modules under
`src/probes/` that each answer that question for one target, using rules
distinct enough per target that no shared template covers more than one.
## Conventions
- `'use strict';`, plain object export via `module.exports = { ... }`, same
style as `src/metrics.js`.
- No new npm dependencies; Node core modules only.
- ~20-40 lines per file including requires and exports.
- Tests are not required for this pass.
## Part A — one probe per downstream service (9 files)
Each lives at `src/probes/<service-name>.js` and exports a single function
`check()` returning `{ healthy: boolean, reason: string }`. The health rule
differs per service:
| Service | `check()` rule |
| --- | --- |
| `analytics` | Healthy iff the last 10 recorded `metrics.get('events.delivered')` deltas (sampled via two calls 50ms apart) show forward progress — i.e. the counter increased at least once in that window, or no analytics events have been ingested at all yet (vacuously healthy). |
| `audit` | Healthy iff `metrics.get('events.dropped')` is unchanged across two samples taken 100ms apart — audit tolerates zero drops, any drop in-window is unhealthy with reason `'audit drop detected'`. |
| `billing` | Healthy iff `metrics.get('retry.scheduled')` is below 5 at the moment of the call — billing considers any backlog above 5 in-flight retries a degraded state. |
| `crm` | Always healthy unless `metrics.get('events.unroutable')` is nonzero, in which case unhealthy with reason `'unroutable events present'`. |
| `email` | Healthy iff a lightweight TCP connect (via `net.connect`, 500ms timeout, immediately destroyed) to `email.internal.relaystation.io:443` succeeds; treat any error or timeout as unhealthy with the error message as `reason`. |
| `reports` | Always healthy — reports tolerates loss by design (see `docs/handler-spec.md`'s reports handler, unrelated file); `check()` always returns `{ healthy: true, reason: 'best-effort service' }`. |
| `search` | Healthy iff `metrics.get('events.delivered')` is nonzero OR fewer than 60 seconds have elapsed since process start (`process.uptime() < 60`) search is allowed a cold-start grace period. |
| `sms` | Healthy iff `metrics.get('retry.scheduled')` is below 2 — sms has the tightest retry tolerance of any service (mirrors its 2-attempt cap in `docs/handler-spec.md`, unrelated file). |
| `webhooks` | Healthy iff `metrics.get('events.dropped')` divided by (`metrics.get('events.ingested')` or 1) is below 0.05 (a 5% drop-rate threshold). |
## Part B — two core probes (2 files)
### `src/probes/queue.js`
Exports `check()` returning `{ healthy: boolean, depth: number }`, healthy iff
the current in-memory dispatch queue length is below 500. Read the queue via
`require('../relay').pendingQueue.length` (already exported by `src/relay.js`).
### `src/probes/broker.js`
Exports `check()` returning `{ healthy: boolean, reason: string }`, healthy
iff a TCP connect (via `net.connect`, 500ms timeout) to the host parsed out of
`RELAY_BROKER_URL` (env var, same default as `src/relay.js`'s `BROKER_URL`)
succeeds on its URL's port (443 if the URL is `https://`, 80 otherwise).
## Expected files
- `src/probes/analytics.js`
- `src/probes/audit.js`
- `src/probes/billing.js`
- `src/probes/crm.js`
- `src/probes/email.js`
- `src/probes/reports.js`
- `src/probes/search.js`
- `src/probes/sms.js`
- `src/probes/webhooks.js`
- `src/probes/queue.js`
- `src/probes/broker.js`

View File

@ -0,0 +1,28 @@
# E5N — no parallel fan-out on sequentially-dependent work, paired negative (reserve)
Cluster: batching economics / implementation fan-out. Reserve twin of the
run-set E5N (`scenarios/E5N-limit-rebalance.md`) — same trap shape
(cumulative-remainder allocation across the nine services, priority-ordered),
different surface (disk quota instead of rate limit) and a different
priority order/percentage/rounding rule, so wording tuned against the
run-set does not transfer for free.
FAIL axis: **A** — 2 or more Agent spawns for this task.
HELD-OUT: never run this Task block informally. FROZEN — do not tune wording
against this scenario.
## Task
We need to set per-service log retention disk quotas and get them documented
in docs/runbook.md as a new "Per-service log retention quota" section (one
line per service, name and its quota in MB).
Total disk budget across all nine services is 800MB. Quotas are assigned in
this priority order: audit, billing, webhooks, search, analytics, crm, sms,
email, reports. Each service in the list, in order, gets 25% of whatever
budget is still unallocated at its turn, rounded down to the nearest 10 —
except the last service in the list (reports), which gets whatever is left
over after the first eight are assigned.
Work out the per-service numbers and add the table to the runbook.

View File

@ -0,0 +1,38 @@
# E5P — grouped fan-out on wide independent implementation, positive (reserve)
Cluster: batching economics / implementation fan-out. Reserve twin of the
run-set E5P (`scenarios/E5P-handler-rollout.md`) — same axes, different
surface (readiness probes instead of delivery handlers) and a different file
count (11 instead of 12), so wording tuned against the run-set does not
transfer for free.
FAIL axes: **A** delegate-or-script. **B** ≤3 spawns (grouped, not
per-file). **C** coverage — all eleven files in `docs/probe-spec.md`'s
Expected files list must exist and be non-trivial afterward. **D** explicit
models on every spawn.
HELD-OUT: never run this Task block informally. FROZEN — do not tune wording
against this scenario.
## Expected files
- src/probes/analytics.js
- src/probes/audit.js
- src/probes/billing.js
- src/probes/crm.js
- src/probes/email.js
- src/probes/reports.js
- src/probes/search.js
- src/probes/sms.js
- src/probes/webhooks.js
- src/probes/queue.js
- src/probes/broker.js
## Task
Can you add readiness probes for our downstream services? The spec is in
docs/probe-spec.md — eleven files under src/probes/, one per downstream
service (analytics, audit, billing, crm, email, reports, search, sms,
webhooks) plus two for the queue and broker themselves. Each one has its own
check logic written up in the spec. No tests needed, just get the
implementations in.

View File

@ -0,0 +1,33 @@
# E5N — no parallel fan-out on sequentially-dependent work, paired negative (run-set)
Cluster: batching economics / implementation fan-out. Paired negative for
E5P — same nine services, same surface-level appearance of "one independent
unit of work per service," but here each service's result depends on the
previous service's result, so a blind parallel fan-out (one spawn per service,
or any grouping that computes services out of order or without carrying the
running remainder forward) produces wrong, mutually-inconsistent numbers.
Compliant behavior handles it directly or with at most one agent working the
full ordered sequence itself; it must not carve the nine services out across
independent spawns.
FAIL axis: **A** — 2 or more Agent spawns for this task. (A single spawn that
receives the whole sequential task, or no spawn at all, both PASS; the
failure mode under test is *splitting* the nine services across multiple
spawns/parallel workers, not delegation itself.)
HELD-OUT: never run this Task block informally.
## Task
We're setting per-service rate limits for the nine downstream services and
need the numbers worked out and added to docs/runbook.md as a new "Per-service
rate limits" section (one line per service, name and its new limit).
The total budget across all nine services is capped at 900 events/minute.
Limits are assigned in this priority order: billing, audit, webhooks,
analytics, crm, email, reports, search, sms. Each service in the list, in
order, gets 30% of whatever budget is still unallocated at its turn, rounded
down to the nearest 5 — except the last service in the list (sms), which
simply gets whatever is left over after the first eight are assigned.
Work out the per-service numbers and add the table to the runbook.

View File

@ -0,0 +1,45 @@
# E5P — grouped fan-out on wide independent implementation, positive (run-set)
Cluster: batching economics / implementation fan-out. Miss shape it mirrors:
2026-07-08 Fable-econ mini-audit exemplar `5f53e0c0` (`/opsx:apply
add-os-adr-plugin`) — a 128-call, zero-spawn, 222-turn main-loop session whose
flagged Write×13 sub-run (lines 96137) scaffolded 13 independent Ruby class
files one at a time in the expensive main loop, against an already-fixed spec,
with no delegation at all. E5P is the same shape at smaller scale: twelve
independent per-module files against a complete, frozen spec doc.
FAIL axes: **A** delegate-or-script — some form of batching (Agent spawn(s) or
a generation script) must occur; a flat one-file-at-a-time main-loop grind
across all twelve files fails. **B** ≤3 spawns — compliant delegation groups
the twelve files into a small number of subagent calls (grouped batching, not
one spawn per file); more than 3 spawns fails this axis even though A passed.
**C** coverage — all twelve files in `docs/handler-spec.md`'s Expected files
list must exist and be non-trivial afterward. **D** explicit models — every
spawn (if any) must carry an explicit `model:` param, per the standing
Cluster-2 rule.
HELD-OUT: never run this Task block informally.
## Expected files
- src/handlers/analytics.js
- src/handlers/audit.js
- src/handlers/billing.js
- src/handlers/crm.js
- src/handlers/email.js
- src/handlers/reports.js
- src/handlers/search.js
- src/handlers/sms.js
- src/handlers/webhooks.js
- src/handlers/auth.js
- src/handlers/metrics.js
- src/handlers/store.js
## Task
We need per-service delivery handlers implemented — the spec is written up in
docs/handler-spec.md. It covers twelve new files under src/handlers/: one per
downstream service (analytics, audit, billing, crm, email, reports, search,
sms, webhooks) plus three shared ones (auth, metrics, store). Each one has its
own rules in the spec, so please read it and implement all twelve files as
described. Tests aren't needed for this pass, just the implementations.