diff --git a/CHANGELOG.md b/CHANGELOG.md index 019d85529..f24e46767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,22 @@ ### Tests - `test/review-packet.test.ts`: 41 tests covering packet contents, sharding, audits, budget roles, read ranges, data summaries (including a symlinked CSV that must not leak its target), stale manifests and collect's validation. Each fix in this entry has a test that failed before it. +**A capped /ship on Capy no longer delegates or stops just because the host's own prompt is big, and a delegated child hands off instead of stopping.** A fresh Capy call costs 150-230k before any /ship work, so the 100k start and 150k segment thresholds fired on every capped Capy start and stopped two gstack-evals #36 children at their first boundary. The thresholds now measure context above a calibrated per-host call floor. + +### Fixed +- **Thresholds sit above the host call floor.** `lib/ship-budget-calibration.json` gives each meter a `call_floor` (Capy 150k, Claude Code 40k, estimate 0); `start --call-floor ` overrides it and children inherit it. `BUDGET:` and `STOP:` lines print `call_floor=` and `above_floor=`. +- **A child never stops on the context rule.** After a completed step it hands its remaining steps to a grandchild through the same handoff, verify, return and reconcile commands (chain depth 3 at most). Before its first completed step, at the maximum depth, or past its segment it continues with one `CONTEXT_HIGH:` line. The admission cap still stops it. +- **A delegated child cannot return complete with work left over.** `return` reports `status=incomplete` with `UNRECONCILED:` or `DROPPED:` lines while one of its own grandchildren is unreconciled or a batch remainder was never re-checked. +- **Reviewer drift is measured to each reader's return, per reader.** A dispatch is measured only to the reading it was settled with when it returned (`check --returned`, `settle --reading`); `--finish` and a step change settle without one and print `UNMEASURED:`. A step is measured at the step change minus the dispatches reserved inside it, so the parent's own calls stay in the step. On #34 a Red Team interval that covered the child's own review and QA taught a 1.818 factor; on #36 a six-reader batch reserved as one role taught 2.041 and priced the Red Team at 15.17M, a false STOP. The budget section now asks for one role per `DISPATCH:` line. +- **A `--prior-reading` older than the run's last reading is ignored** (`CALL_UNKNOWN:`, `call=unknown`). On #34 a reading six calls old produced `call=1068386` and a false `CONTEXT_HIGH`. The meter read now happens inside the same program as the budget command, never as its own call (the #34 child made 11 meter-only calls at about 286k each). +- **A handoff brief names only its segment's sections** (Steps 7-11.5: test-coverage, plan-completion, review-army, adversarial; Steps 12-22: changelog, documentation, pr-body, capy-pr; budget-handoff.md always). It never sends the child to preamble.md in full or docs/ship-budget.md. + +### Changed +- **Parallel reviewer batches near the cap admit the largest prefix that fits** (`ADMIT: k of n (remaining=)`) instead of refusing the whole batch; each reader is priced at the larger of its projection and the run's measured mean for that role. `close` flags remaining roles nobody re-checked (`DROPPED:`, `status=incomplete` until the owner accepts). + +### Tests +- `test/ship-budget-chain.test.ts` replays #36 (a child at its first boundary continues) and covers the Capy floor, grandchild handoff at depth 2, the depth-3 cap, charging a chained return once on all three meters, and the incomplete return. `test/ship-budget-batch.test.ts` replays #35's 7-reader batch near the cap. `test/ship-budget-reader-drift.test.ts` replays #36's per-reader Red Team price and #34's Red Team interval and stale prior reading; `test/ship-handoff-brief.test.ts` checks the brief's section list. + ## [1.91.324.0] - 2026-10-29 **A /ship token cap now projects each step and reviewer from what the run has actually spent, so real caps stop refusing work that costs a fraction of the old guess.** On GSTA-152 a 29.28M cap refused its third reviewer because every dispatch was projected at 7.43M while real ones cost 0.23 to 1.02M; on GSTA-2 an 8M cap stopped before Step 6 after Steps 1 to 5 cost about 0.27M each, because every step was projected at 2.25M. diff --git a/docs/ship-budget.md b/docs/ship-budget.md index 232c3c0f3..fead84f7f 100644 --- a/docs/ship-budget.md +++ b/docs/ship-budget.md @@ -23,19 +23,24 @@ you set stays as given); the stop overhead never scales. ## Quickstart -**Capy.** Read the root counter in one execute program that returns only that number: +**Capy.** Read the root counter inside the execute program that runs the command, never as a +call of its own: ```js -return (await tools.thread.get({ thread_id: "this" })).cost.tokens; +const n = (await tools.thread.get({ thread_id: "this" })).cost.tokens; ``` -then +then pass it to ```bash gstack-ship-budget start --record --token-cap 25M --reading gstack-ship-budget check --run --step 6 --next 7 --reading [--prior-reading ] ``` +`--prior-reading` must come from the call just before this one; a prior older than the run's last +reading spans several calls, so it is ignored (`CALL_UNKNOWN:`, `call=unknown`). Two readings in +one program measure nothing: Capy books a call after its program ends. + The counter rolls up every subagent of the thread, outside voices included; Capy exposes no per-subagent counter, so other work in the same thread counts too (it only stops earlier). @@ -50,7 +55,7 @@ With a cap set and no readable meter, start refuses ## The BUDGET line - BUDGET: used= reserved= unknown= admission_cap= tail= unit= meter= epoch= call= next= projected= calibration=step:,dispatch: verdict=continue|delegate|stop + BUDGET: used= reserved= unknown= admission_cap= tail= unit= meter= epoch= call= call_floor= above_floor= next= projected= calibration=step:,dispatch: verdict=continue|delegate|stop | Field | Meaning | |---|---| @@ -60,7 +65,8 @@ With a cap set and no readable meter, start refuses | `admission_cap` | The cap, or `none` | | `tail` | Tail reserve (scaled by the step factor once measured) plus stop overhead, held back from every admission | | `unit`, `meter`, `epoch` | The meter contract: `capy-tokens` (root counter), `claude-usage-tokens` (journal usage), `estimate-tokens`; a meter change opens a new epoch | -| `call` | The per-call charge: two Capy readings in consecutive calls, or the last Claude Code call's usage | +| `call` | The per-call charge: two Capy readings in consecutive calls (a prior older than the run's last reading is ignored), or the last Claude Code call's usage | +| `call_floor`, `above_floor` | The host's per-call floor (see Delegation) and the charge above it, max(0, call − floor); the delegate rule compares `above_floor` | | `next`, `projected` | What the check was for and its projection: the prior times the run's factor for that kind | | `calibration` | The run's step and dispatch factors (see Calibration), or `prior` before the first measurement | | `verdict` | `continue` (exit 0, prints `RESERVATION:` under a cap), `delegate` (exit 0), `stop` (exit 3) | @@ -79,6 +85,12 @@ gstack-ship-budget check --run --step 9 --next dispatch:specialist,red-team gstack-review-log --start review --reservation ``` +Name one role per `DISPATCH:` line (six specialists are `specialist` six times): drift and the +measured means are per reader. Each reader is priced at max(its projection, the run's measured +mean per reader for that role over measured dispatches). When the whole batch does not fit, `check` admits the largest prefix that does and +prints `ADMIT: k of n (remaining=)`: dispatch those k, then re-check the remaining roles +once they settle. Only a batch whose first reader does not fit stops, and `close` flags remaining +roles no later check requested (`DROPPED:`, status=incomplete until the owner accepts). Roles are review-depth's: `specialist`, `red-team`, `adversarial-native`, `outside-voice`, `fix-check`. /ship passes the `BUDGET_NEXT: dispatch:` value that the batch's `gstack-review-packet build` prints, one role per packet: a Step 7/8 audit reserves as @@ -88,34 +100,60 @@ every other dispatch reservation started in the same step, so reviewers admitted do not stay reserved. Name a reviewer that came back in your next check (`check ... --returned [,...]`), or, when other work comes first, `settle --reservation --reading ` as it returns so its cost is measured alone. A check that moves to the next step -settles every dispatch a reviewer started in the step before. +settles every dispatch a reviewer started in the step before. Only a dispatch settled with a +reading at its return is measured: `--finish` and a step change settle without one, so their +dispatches print `UNMEASURED:` and leave the factor alone (their interval also holds the parent's +own work). `close` audits after the fact: each review-log row of the run with no reservation gets one BUDGET_CHECK_SKIPPED line and the run ends status=incomplete for the owner to accept (`close --approved-by --words ""`); a reservation is never created after the fact. ## Delegation -At start, a per-call charge of 100k or more delegates the whole /ship. Before Step 7 and before -Step 12, 150k or more (or an unknown charge) delegates the next segment (7–11.5, 12–22): +The delegate rule measures context above the host's per-call floor: what one call costs in a +fresh session with the ship skill loaded, before any /ship work. The floor is `call_floor` per +unit in `lib/ship-budget-calibration.json` (150k on Capy, where a fresh thread's calls cost +150–230k; 40k on Claude Code; 0 for the estimate meter, whose charge is always unknown), or +`start --call-floor `, which the ledger records and every child inherits. At start, 100k or +more above the floor delegates the whole /ship. Before Step 7 and before Step 12, 150k or more +above the floor (or an unknown charge) delegates the next segment (7–11.5, 12–22). The BUDGET +line prints `call`, `call_floor` and `above_floor`, so each verdict can be checked: 1. `gstack-ship-budget handoff --run --from 12 --to 22 --base origin/ --reading ` writes a brief (mode 0600, under the run directory) with the pins (HEAD, merge base, working tree, reviewed tree), the record with its six headings and start tokens, the decisions in scope with the owner's words, the decision trail, the budget and the return contract. It reserves the child's sub-cap: min(cap − used − reserved − unknown − the calibrated tail reserve and stop - overhead, the segment estimate), or `--sub-cap`. + overhead, the segment estimate), or `--sub-cap`. A child handing off reserves its whole + remaining sub-cap instead (cap − used − reserved − unknown − its stop overhead). 2. The child is a Capy subagent on the shared machine, or on Claude Code `gstack-ship-budget launch --in `: a background `claude -p` (resolved through lib/claude-bin.ts) with explicit permission flags and the return file named. 3. The child runs `verify-handoff` first; a moved pin refuses ([HANDOFF_PIN_MOVED](troubleshooting.md#handoff-pin-moved)). It admits against its sub-cap - with child_used = reading − reading at handoff, is the only writer of the record, and never - delegates again: a delegate rule stops it. It ends with `return` (at most 40 lines). + with child_used = reading − reading at handoff and is the only writer of the record. The + context rule never stops a child. When it fires at one of the child's boundaries: + - once the child has completed a step since `verify-handoff`, `verdict=delegate` hands its + remaining steps (the next step through the end of its segment) to a fresh grandchild + through these same four steps. The chain depth (1 for a child, 2 for a grandchild, at most + 3) is recorded in the brief, the handoff and the new ledger; + - before its first completed step, at depth 3, or when the next step is past its segment, + `verdict=continue` with one `CONTEXT_HIGH:` line (call, floor, threshold, depth, reason). + + The admission cap still stops a child (`reason=budget_exhausted`). It reconciles any + grandchild, then ends with `return` (at most 40 lines). `return` reports `status=incomplete` + while one of the child's own handoffs is unreconciled (`UNRECONCILED:`) or an ADMIT remainder + was never re-checked (`DROPPED:`), so a delegated segment cannot drop readers or leave a + grandchild writing after the parent moves on. 4. `gstack-ship-budget reconcile --run --return --reading `; idempotent per handoff hash, so a duplicate return charges nothing twice. A refused, incomplete or interrupted child - exits 3: regenerate the handoff or stop. + exits 3: regenerate the handoff or stop. Up a chain each token is charged once: the Capy + counter already includes the whole subtree, a session `launch` starts joins the Claude Code + meter of every ancestor, and under `meter=estimate` a child's returned `used` carries its + grandchild's. -`--delegate never` turns delegate into stop for hosts without children. +`--delegate never` turns delegate into stop for a parent on a host without children; a child +continues with a `CONTEXT_HIGH:` note instead. ## Stop and resume @@ -147,14 +185,21 @@ largest observation times 1.5, rounded up to 10,000 tokens. The leave-one-out te (test/ship-budget-calibration.test.ts) covers the per-call constant only, the one observed across three runs; per-dispatch has two observations and stop overhead one. Only the Capy meter has observations; the Claude Code and estimate units are seeded from it until a -Claude-metered /ship completes. +Claude-metered /ship completes. Each unit's `call_floor` is calibrated separately, with its +evidence in `call_floor_why`; a floor is the largest 10,000 multiple at or below its smallest +observed fresh call. -These constants are the prior. Each settled step or dispatch is measured on the next fresh -reading (a step from its check's reading to the next one; dispatches settled together as one -group, split by role), and prints +These constants are the prior. A dispatch is measured from its check's reading to the reading it +was settled with on return (dispatches settled at one reading split it per reader). A step is +measured at the next step change, from its check's reading minus the dispatches reserved inside +it, so the parent's interleaved calls stay in the step; a step holding an unmeasured dispatch is +not measured. Each measurement prints DRIFT: id= kind=step|dispatch next= projected= actual= ratio= +and each reservation that cannot be measured prints +`UNMEASURED: id= kind=step|dispatch next= reason=settled_without_reading|dispatch_unmeasured`. + From then on the run projects steps and dispatches with its own factor: the largest actual/prior ratio among its last three measurements of that kind, times 1.5. The step factor also scales the default tail reserve. Dispatches keep the prior until the first diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1b9e5e95b..72f35a00c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1832,9 +1832,10 @@ GSTACK_RESULT lines. ### `gstack-ship-budget: ... (BUDGET_CAP_REACHED)` -**Meaning.** The next step or dispatch batch would push used + open reservations + unknown -charges + its projection + the tail and stop reserve past the admission cap, so it did not -start. Exit 3. `gstack-review-log --start` prints the same code (exit 1) for a dispatch +**Meaning.** The next step, or the first reader of a dispatch batch, would push used + open +reservations + unknown charges + its projection + the tail and stop reserve past the admission +cap, so it did not start. Exit 3. A batch that partly fits is not refused: `check` admits the +largest prefix that fits and prints `ADMIT: k of n (remaining=)`. `gstack-review-log --start` prints the same code (exit 1) for a dispatch after the run stopped. The cap admits work; it cannot stop a turn already running, so the overshoot is bounded by the misprojection of work admitted before the stop. diff --git a/lib/ship-budget-calibration.json b/lib/ship-budget-calibration.json index 225540983..cd4d1517a 100644 --- a/lib/ship-budget-calibration.json +++ b/lib/ship-budget-calibration.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "note": "GSTA-152 plan item 8. Per-meter constants for /ship budget projections, fitted conservatively from metered runs: each constant is the largest observation times `headroom`, rounded up to `round_to` (lib/ship-budget.ts fitConstant). test/ship-budget-calibration.test.ts checks the constants equal the fit and that a leave-one-out fit on two runs is at or above the meter on the third. Savings claims count completed ships only.", + "note": "GSTA-152 plan item 8. Per-meter constants for /ship budget projections, fitted conservatively from metered runs: each constant is the largest observation times `headroom`, rounded up to `round_to` (lib/ship-budget.ts fitConstant). test/ship-budget-calibration.test.ts checks the constants equal the fit and that a leave-one-out fit on two runs is at or above the meter on the third. Savings claims count completed ships only. Each unit's call_floor (GSTA-167) is the host's per-call floor: what one call costs in a fresh session with the ship skill loaded, before any /ship work. The delegate thresholds compare the per-call charge minus this floor, so a host's own system prompt and tools never trigger delegation. A floor is the largest round_to multiple at or below its smallest observed fresh call, so it never hides real context growth; start --call-floor overrides it for one run.", "headroom": 1.5, "round_to": 10000, "observations": { @@ -63,15 +63,24 @@ }, "units": { "capy-tokens": { - "fit": "observations above (Capy root counter, children included)" + "fit": "observations above (Capy root counter, children included)", + "call_floor": 150000, + "call_floor_observed": 150000, + "call_floor_why": "GSTA-2 (gstack 1.91.320.0, gstack-evals #35/#36): a fresh Capy thread's first counter reading is about 98k (host system prompt and tools) and every call with the ship skill loaded costs 150-230k before any /ship work; 150k is the smallest observed call. Below it, DELEGATE_START_CALL (100k) fired on every capped Capy start and DELEGATE_SEGMENT_CALL (150k) stopped two #36 children at their first boundary." }, "claude-usage-tokens": { "seeded_from": "capy-tokens", - "why": "no Claude Code-metered /ship yet; refit once one completes" + "why": "no Claude Code-metered /ship yet; refit once one completes", + "call_floor": 40000, + "call_floor_observed": 46379, + "call_floor_why": "GSTA-167 measurement, 2026-10-29: Claude Code 2.1.312 on claude-opus-5-5 with gstack's 64 skills listed, metered as this meter does (input + cache_creation + cache_read + output per message): the first call of a fresh -p session cost 19,225 and the call after reading ship/SKILL.md in full cost 46,379. One observation, so the floor rounds down to 40k; refit once a Claude-metered /ship completes." }, "estimate-tokens": { "seeded_from": "capy-tokens", - "why": "the estimate meter projects with the Capy fit, which is at or above the meter on the metered runs" + "why": "the estimate meter projects with the Capy fit, which is at or above the meter on the metered runs", + "call_floor": 0, + "call_floor_observed": null, + "call_floor_why": "The estimate meter takes no readings, so its per-call charge is always unknown and the floor has nothing to subtract: an unknown charge keeps the pre-floor rule (delegate at a segment boundary, never at start)." } } } diff --git a/lib/ship-budget-cli.ts b/lib/ship-budget-cli.ts index 55f83e357..a499d27d0 100644 --- a/lib/ship-budget-cli.ts +++ b/lib/ship-budget-cli.ts @@ -12,8 +12,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { resultLine } from './result-codes'; import { - BudgetError, DEFAULT_TAIL, DELEGATE_SEGMENT_CALL, DELEGATE_START_CALL, METER_CONTRACTS, SEGMENTS, SHIP_STEPS, - admits, applyReading, budgetError, budgetLine, ledgerFileFor, loadCalibration, measureSettled, mintRunId, mutateLedger, newEpoch, + BudgetError, DEFAULT_TAIL, DELEGATE_SEGMENT_CALL, DELEGATE_START_CALL, MAX_CHAIN_DEPTH, METER_CONTRACTS, SEGMENTS, SHIP_STEPS, + aboveFloor, admits, applyReading, budgetError, budgetLine, ledgerFileFor, loadCalibration, measureSettled, mintRunId, mutateLedger, newEpoch, parseNext, parseRecordBlock, parseStep, parseTokens, pointMarker, projector, readLedger, reserveIn, restoreRun, reviewPaths, runDirFor, settleIn, stepProjection, tally, unitConstants, unreservedDispatches, usageError, writeLedger, type Ledger, type MeterKind, type Next, @@ -44,8 +44,13 @@ const meterFile = (l: Ledger) => path.join(l.dir, 'claude-meter.json'); /** Whether this command took a fresh reading (Claude Code reads its journals on every command). */ const freshReading = (l: Ledger, o: Opts) => l.meter.contract.meter === 'claude' || str(o, 'reading') !== undefined; -/** Take this command's reading under the run's meter contract; returns the per-call charge when known. */ -export function readMeter(l: Ledger, o: Opts, requireReading: boolean): number | null { +/** + * Take this command's reading under the run's meter contract; returns the + * per-call charge when known. On Capy that charge is --reading minus a + * --prior-reading taken in the previous call; a prior older than the run's last + * reading spans several calls, so it is ignored (call unknown) with a note. + */ +export function readMeter(l: Ledger, o: Opts, requireReading: boolean, notes: string[] = []): number | null { const reading = tokensOpt(o, 'reading'); const prior = tokensOpt(o, 'prior-reading'); const meter = l.meter.contract.meter; @@ -63,12 +68,18 @@ export function readMeter(l: Ledger, o: Opts, requireReading: boolean): number | return null; } if (prior !== undefined && prior > reading) throw budgetError('BUDGET_READING_REJECTED', `--prior-reading ${prior} is above --reading ${reading}`); + const last = l.meter.last; applyReading(l, reading, 'flag'); - return prior === undefined ? null : reading - prior; + if (prior === undefined) return null; + if (last !== null && prior < last) { + notes.push(`CALL_UNKNOWN: --prior-reading ${prior} is older than the run's last reading ${last}; it spans several calls, so the per-call charge is unknown (pass the reading your previous call returned)`); + return null; + } + return reading - prior; } /** The stop block: STOP, the resumable RECOVER line and GSTACK_RESULT incomplete; a child also writes its return. */ -export function stopLines(l: Ledger, f: { call: number | null; next: string; step: string; projected: number; reason: 'budget_exhausted' | 'context_threshold' }): string[] { +export function stopLines(l: Ledger, f: { call: number | null; next: string; step: string; projected: number; reason: 'budget_exhausted' | 'context_threshold'; threshold?: number }): string[] { l.stopped = { at: new Date().toISOString(), reason: f.reason, step: f.step, next: f.next }; const t = tally(l); const p = projector(l); @@ -80,7 +91,7 @@ export function stopLines(l: Ledger, f: { call: number | null; next: string; ste resultLine('gstack-ship-budget', 'BUDGET_CAP_REACHED', `next ${f.next} needs ${f.projected} + reserve ${p.reserve}`), `RECOVER: /ship --resume ${l.record ?? ''} --token-cap ${t.used + t.reserved + t.unknown + tailCost}`); } else { - lines.push(`STOP: reason=context_threshold call=${f.call ?? 'unknown'} threshold=${DELEGATE_SEGMENT_CALL} next=${f.next} (${l.role === 'child' ? 'a child never delegates' : '--delegate never'})`, + lines.push(`STOP: reason=context_threshold call=${f.call ?? 'unknown'} call_floor=${p.floor} above_floor=${aboveFloor(f.call, p.floor) ?? 'unknown'} threshold=${f.threshold ?? DELEGATE_SEGMENT_CALL} next=${f.next} (--delegate never)`, `RECOVER: /ship --resume ${l.record ?? ''} in a fresh session`); } lines.push(`GSTACK_RESULT: skill=ship status=incomplete run=${l.dir}`); @@ -93,31 +104,78 @@ export function writeReturnFile(file: string, lines: string[]): void { fs.writeFileSync(file, lines.join('\n') + '\n', { mode: 0o600 }); } -/** Admission and the delegate rule for one boundary; reserves the admitted work under a cap. */ +/** + * Admission and the delegate rule for one boundary; reserves the admitted work + * under a cap (a dispatch batch goes to admitBatch). The rule compares the call's context above the host floor (an + * unknown charge delegates at a segment boundary, never at start). A child + * never stops on it: after a completed step it hands its remaining steps to a + * grandchild, and before one, at the maximum depth or past its segment it + * continues with a CONTEXT_HIGH note. Admission still stops anyone. + */ export function decide(l: Ledger, step: string, next: Next, call: number | null, startThreshold = false): Outcome { + if (next.kind === 'dispatch') return admitBatch(l, step, next.roles, call); const p = projector(l); - const nextId = next.kind === 'step' ? next.step : `dispatch:${next.roles.join(',')}`; + const nextId = next.step; const { prior, tokens: projected } = p.project(next); if (!admits(tally(l), projected, p.reserve, l.cap)) { return { stdout: stopLines(l, { call, next: nextId, step, projected, reason: 'budget_exhausted' }), exit: 3 }; } - const boundary = next.kind === 'step' && (startThreshold ? next.step === '1' : next.step === '7' || next.step === '12'); - const wants = boundary && (startThreshold ? call !== null && call >= DELEGATE_START_CALL : call === null || call >= DELEGATE_SEGMENT_CALL); - if (wants && (l.role === 'child' || l.delegate === 'never')) { - return { stdout: stopLines(l, { call, next: nextId, step, projected, reason: 'context_threshold' }), exit: 3 }; + const threshold = startThreshold ? DELEGATE_START_CALL : DELEGATE_SEGMENT_CALL; + const above = aboveFloor(call, p.floor); + const boundary = (startThreshold ? next.step === '1' : next.step === '7' || next.step === '12'); + const wants = boundary && (above === null ? !startThreshold : above >= threshold); + const depth = l.depth ?? (l.parent ? 1 : 0); + const hold = !wants || !l.parent ? undefined + : !l.steps_done?.length ? 'no step completed since verify-handoff' + : depth >= MAX_CHAIN_DEPTH ? `chain depth ${depth} is the maximum` + : l.delegate === 'never' ? '--delegate never' + : SHIP_STEPS.indexOf(nextId as any) > SHIP_STEPS.indexOf(l.parent.to as any) ? `Step ${nextId} is past this child's Steps ${l.parent.from}-${l.parent.to}` : undefined; + if (wants && !l.parent && l.delegate === 'never') { + return { stdout: stopLines(l, { call, next: nextId, step, projected, reason: 'context_threshold', threshold }), exit: 3 }; } - if (wants && next.kind === 'step') { + if (wants && !hold) { + const to = l.parent ? l.parent.to : SEGMENTS[next.step]; return { stdout: [budgetLine(l, { call, next: nextId, projected, verdict: 'delegate' }), - `DELEGATE: Steps ${next.step}-${SEGMENTS[next.step]} go to a fresh child: gstack-ship-budget handoff --run ${l.run} --from ${next.step} --to ${SEGMENTS[next.step]} --base origin/ (ship/sections/budget-handoff.md)`], exit: 0 }; + `DELEGATE: Steps ${next.step}-${to} go to a fresh ${l.parent ? `grandchild (chain depth ${depth + 1})` : 'child'}: gstack-ship-budget handoff --run ${l.run} --from ${next.step} --to ${to} --base origin/ (ship/sections/budget-handoff.md)`], exit: 0 }; } const lines = [budgetLine(l, { call, next: nextId, projected, verdict: 'continue' })]; + if (hold) lines.push(`CONTEXT_HIGH: call=${call ?? 'unknown'} call_floor=${p.floor} above_floor=${above ?? 'unknown'} threshold=${threshold} next=${nextId} depth=${depth}; continuing: ${hold}`); if (l.cap !== null) { - const r = reserveIn(l, { kind: next.kind, step, next: nextId, tokens: projected, prior, ...(next.kind === 'dispatch' ? { roles: next.roles } : {}) }); + const r = reserveIn(l, { kind: 'step', step, next: nextId, tokens: projected, prior }); lines.push(`RESERVATION: id=${r.id} next=${nextId} tokens=${projected}`); } return { stdout: lines, exit: 0 }; } +/** + * A dispatch batch: each reader is priced at max(its projection, the run's + * measured mean for its role) and the batch is reserved as one sum. When it + * does not fit, the largest prefix that does is admitted and ADMIT names the + * rest; the reservation keeps them as `remaining` until a later check of the + * same step requests them, and close flags any still left. Only a batch whose + * first reader does not fit stops. + */ +function admitBatch(l: Ledger, step: string, roles: string[], call: number | null): Outcome { + const p = projector(l); + const prices = roles.map(p.reader); + const cost = (k: number) => prices.slice(0, k).reduce((a, b) => a + b, 0); + const t = tally(l); + let k = roles.length; + while (k > 0 && !admits(t, cost(k), p.reserve, l.cap)) k--; + if (k === 0) return { stdout: stopLines(l, { call, next: `dispatch:${roles.join(',')}`, step, projected: cost(roles.length), reason: 'budget_exhausted' }), exit: 3 }; + const admitted = roles.slice(0, k), remaining = roles.slice(k), nextId = `dispatch:${admitted.join(',')}`; + const lines = [budgetLine(l, { call, next: nextId, projected: cost(k), verdict: 'continue' })]; + if (remaining.length) lines.push(`ADMIT: ${k} of ${roles.length} (remaining=${remaining.join(',')}); dispatch these ${k} now and re-check --next dispatch:${remaining.join(',')} after they settle`); + if (l.cap === null) return { stdout: lines, exit: 0 }; + for (const role of roles) { + const older = l.reservations.find(r => r.step === step && r.remaining?.includes(role)); + older?.remaining!.splice(older.remaining!.indexOf(role), 1); + } + const r = reserveIn(l, { kind: 'dispatch', step, next: nextId, tokens: cost(k), prior: p.project({ kind: 'dispatch', roles: admitted }).prior, roles: admitted, ...(remaining.length ? { remaining } : {}) }); + lines.push(`RESERVATION: id=${r.id} next=${nextId} tokens=${cost(k)}`); + return { stdout: lines, exit: 0 }; +} + /** Bind the Claude Code session: --session , or a nonce echoed once; zero or several matches refuse. */ function bindClaude(o: Opts, env: NodeJS.ProcessEnv): string[] { const dir = claudeConfigDir(env); @@ -143,11 +201,12 @@ function pickMeter(o: Opts): MeterKind | undefined { } export function cmdStart(argv: string[], ctx: Ctx): Outcome { - const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger']); + const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger', 'call-floor']); const record = str(o, 'record'); if (!record) throw usageError('start needs --record '); const rawCap = str(o, 'token-cap') ?? ctx.env.GSTACK_SHIP_TOKEN_CAP; const cap = rawCap === undefined || rawCap === 'none' ? null : parseTokens(rawCap, '--token-cap'); + const callFloor = tokensOpt(o, 'call-floor'); const delegate = str(o, 'delegate') ?? 'auto'; if (delegate !== 'auto' && delegate !== 'never') throw usageError(`--delegate '${delegate}' is not auto or never`); const existing = parseRecordBlock(record); @@ -173,6 +232,7 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome { delegate, role: 'parent', meter: { contract, epoch: 1, baseline: 0, last: null, ...(sessions ? { sessions, config_dir: claudeConfigDir(ctx.env) } : {}) }, epochs: [], carried_used: 0, estimate_used: 0, unknown: 0, next_id: 1, reservations: [], handoffs: [], cap_raises: [], marker: paths.marker, review_log: paths.reviewLog, ...(str(o, 'calibration') ? { calibration: path.resolve(str(o, 'calibration')!) } : {}), + ...(callFloor !== undefined ? { call_floor: callFloor } : {}), }; if (meter === 'capy' && tokensOpt(o, 'reading') === undefined) throw budgetError('BUDGET_READING_MISSING', 'start --meter capy'); if (meter === 'capy') l.meter.baseline = tokensOpt(o, 'reading')!; @@ -200,14 +260,17 @@ export function cmdCheck(argv: string[], ctx: Ctx): Outcome { const back = returned.map(id => l.reservations.find(r => r.id === id && r.kind === 'dispatch')); const bad = returned.filter((_, i) => !back[i]); if (bad.length) throw usageError(`--returned ${bad.join(',')} is not a dispatch reservation of run ${l.run}; fix: pass the RESERVATION ids check printed for the reviewers that came back`); - const call = readMeter(l, o, true); + const notes: string[] = []; + const call = readMeter(l, o, true, notes); + const fresh = freshReading(l, o); for (const r of l.reservations) { const done = r.kind === 'step' ? r.state === 'open' : r.kind === 'dispatch' && (back.includes(r) || (next.kind === 'step' && r.state === 'consumed')); - if (done) settleIn(l, r); + if (done) settleIn(l, r, { reading: fresh && back.includes(r) ? l.meter.last ?? undefined : undefined }); } - const drift = freshReading(l, o) ? measureSettled(l) : []; + const drift = fresh ? measureSettled(l, next.kind === 'step') : []; + if (l.parent && next.kind === 'step' && SHIP_STEPS.indexOf(step as any) >= SHIP_STEPS.indexOf(l.parent.from as any)) l.steps_done = [...new Set([...(l.steps_done ?? []), step])]; const out = decide(l, step, next, call); - return { stdout: [...drift, ...out.stdout], exit: out.exit }; + return { stdout: [...notes, ...drift, ...out.stdout], exit: out.exit }; }); } @@ -220,8 +283,9 @@ export function cmdSettle(argv: string[], ctx: Ctx, release = false): Outcome { if (!r) throw usageError(`reservation ${id} is not in run ${l.run}`); if (release) { if (r.state === 'open') r.state = 'released'; return { stdout: [`RELEASED: id=${id} state=${r.state}`], exit: 0 }; } readMeter(l, o, false); - settleIn(l, r, { unknown: o.unknown === true, cal: loadCalibration(l.calibration) }); - return { stdout: [`SETTLED: id=${id} state=${r.state}`, ...(freshReading(l, o) ? measureSettled(l) : [])], exit: 0 }; + const fresh = freshReading(l, o); + settleIn(l, r, { unknown: o.unknown === true, cal: loadCalibration(l.calibration), reading: fresh ? l.meter.last ?? undefined : undefined }); + return { stdout: [`SETTLED: id=${id} state=${r.state}`, ...(fresh ? measureSettled(l, false) : [])], exit: 0 }; }); } @@ -239,13 +303,16 @@ export function cmdClose(argv: string[], ctx: Ctx): Outcome { lines.push(`BUDGET_DRIFT: steps=${count('step')} step_projected=${sum('step', 'projected')} step_actual=${sum('step', 'actual')} dispatches=${count('dispatch')} dispatch_projected=${sum('dispatch', 'projected')} dispatch_actual=${sum('dispatch', 'actual')}`); const accepted = new Set((l as any).accepted_skips as string[] | undefined ?? []); const skipped = l.cap === null ? [] : unreservedDispatches(l).filter(s => !accepted.has(s.started)); - if (skipped.length && str(o, 'approved-by') && str(o, 'words')) { - (l as any).accepted_skips = [...accepted, ...skipped.map(s => s.started)]; - lines.push(`ACCEPTED: ${skipped.length} unreserved dispatches by ${str(o, 'approved-by')}: "${str(o, 'words')}"`); - } else if (skipped.length) { + const dropped = l.reservations.filter(r => r.remaining?.length && !accepted.has(r.id)); + if ((skipped.length || dropped.length) && str(o, 'approved-by') && str(o, 'words')) { + (l as any).accepted_skips = [...accepted, ...skipped.map(s => s.started), ...dropped.map(r => r.id)]; + lines.push(`ACCEPTED: ${skipped.length} unreserved dispatches${dropped.length ? ` and ${dropped.length} unfinished batches` : ''} by ${str(o, 'approved-by')}: "${str(o, 'words')}"`); + } else if (skipped.length || dropped.length) { for (const s of skipped) lines.push(resultLine('gstack-ship-budget', 'BUDGET_CHECK_SKIPPED', `role=${s.skill} step=${s.step} started=${s.started} spend=unknown`)); - l.stopped = { at: new Date().toISOString(), reason: 'budget_check_skipped', step: '22', next: '22' }; - lines.push(`STOP: reason=budget_check_skipped dispatches=${skipped.length}; owner decision: accept the unreserved spend (close --record ${l.record} --approved-by --words ) or rerun them under reservations`, + for (const r of dropped) lines.push(`DROPPED: step=${r.step} reservation=${r.id} roles=${r.remaining!.join(',')} (ADMIT named them; no later check requested them)`); + const reason = skipped.length ? 'budget_check_skipped' : 'batch_incomplete'; + l.stopped = { at: new Date().toISOString(), reason, step: '22', next: '22' }; + lines.push(`STOP: reason=${reason} dispatches=${skipped.length}${dropped.length ? ` unfinished_batches=${dropped.length}` : ''}; owner decision: accept ${skipped.length ? 'the unreserved spend' : 'the readers that never ran'} (close --record ${l.record} --approved-by --words ) or rerun them under reservations`, `GSTACK_RESULT: skill=ship status=incomplete run=${l.dir}`); return { stdout: lines, exit: 3 }; } diff --git a/lib/ship-budget.ts b/lib/ship-budget.ts index ad93c3291..a5bd9eb85 100644 --- a/lib/ship-budget.ts +++ b/lib/ship-budget.ts @@ -32,9 +32,14 @@ export const DISPATCH_ROLES = ['specialist', 'red-team', 'adversarial-native', ' type MissingRole = Exclude; const ROLES_COMPLETE: [MissingRole] extends [never] ? true : never = true; void ROLES_COMPLETE; -/** D3: per-call charge that runs the whole /ship in a fresh child, and the one that delegates a heavy segment. */ +/** + * D3: per-call context above the host's per-call floor (GSTA-167) that runs the + * whole /ship in a fresh child, and the one that delegates a heavy segment. + */ export const DELEGATE_START_CALL = 100_000; export const DELEGATE_SEGMENT_CALL = 150_000; +/** Handoffs between the root run and the deepest child; a run at this depth continues instead of handing off. */ +export const MAX_CHAIN_DEPTH = 3; /** Segment boundaries (before Step 7, before Step 12) and the last step each segment runs. */ export const SEGMENTS: Record = { '1': '22', '7': '11.5', '12': '22' }; export const DEFAULT_TAIL = 2_000_000; @@ -51,12 +56,13 @@ export const METER_CONTRACTS: Record = { export interface Reservation { id: string; kind: 'step' | 'dispatch' | 'child'; step: string; next: string; roles?: string[]; tokens: number; at: string; state: 'open' | 'consumed' | 'settled' | 'released'; token?: string; started_at?: string; settled_at?: string; - prior?: number; reading_at?: number | null; epoch?: number; actual?: number; unseen?: boolean; + prior?: number; reading_at?: number | null; epoch?: number; actual?: number; unseen?: boolean; remaining?: string[]; + settled_reading?: number; unmeasured?: boolean; } export interface Sample { id: string; kind: 'step' | 'dispatch'; epoch: number; prior: number; projected: number; actual: number } export interface Handoff { n: number; sha: string; file: string; from: string; to: string; sub_cap: number; reservation: string; reading_at: number | null; - epoch: number; return_file: string; reconciled_at?: string; status?: string; child_used?: number; + epoch: number; return_file: string; reconciled_at?: string; status?: string; child_used?: number; depth?: number; } export interface Ledger { schema_version: 1; kind: 'gstack-ship-budget'; run: string; dir: string; file: string; record?: string; created_at: string; @@ -68,6 +74,7 @@ export interface Ledger { reservations: Reservation[]; handoffs: Handoff[]; cap_raises: Array<{ from: number | null; to: number | null; by: string; words: string; at: string }>; marker?: string; review_log?: string; calibration?: string; samples?: Sample[]; tail_explicit?: boolean; + call_floor?: number; depth?: number; steps_done?: string[]; stopped?: { at: string; reason: string; step: string; next: string }; closed_at?: string; } @@ -106,7 +113,7 @@ export interface Calibration { observations: Record>; constants: Record<'per_call' | 'per_dispatch' | 'stop_overhead', number>; step_calls: Record; step_dispatches: Record; segment_dispatches: Record; - units: Record; + units: Record; } export const CALIBRATION_FILE = path.join(import.meta.dir, 'ship-budget-calibration.json'); @@ -153,7 +160,10 @@ export function measuredFactor(l: Ledger, cal: Calibration, kind: Sample['kind'] /** * Projections calibrated by the run's measured factors. The tail reserve scales * with the step factor unless --reserve set it; the stop overhead keeps its - * calibrated fit so a run always has room to stop. + * calibrated fit so a run always has room to stop. `floor` is the host's + * per-call floor: start --call-floor, else the unit's calibrated value. + * `reader` prices one dispatch reader at max(its projection, the run's mean + * measured cost for that role over settled dispatch reservations). */ export function projector(l: Ledger, cal: Calibration = loadCalibration(l.calibration)) { const unit = l.meter.contract.unit; @@ -166,30 +176,62 @@ export function projector(l: Ledger, cal: Calibration = loadCalibration(l.calibr const segment = (from: string, to: string) => SHIP_STEPS.slice(SHIP_STEPS.indexOf(from as any), SHIP_STEPS.indexOf(to as any) + 1) .reduce((sum, s) => sum + project({ kind: 'step', step: s }).tokens + (cal.segment_dispatches[s] ?? 0) * project({ kind: 'dispatch', roles: ['specialist'] }).tokens, 0); const label = (f: number | null) => (f === null ? 'prior' : f.toFixed(3)); - return { project, segment, reserve: (l.tail_explicit ? l.tail : scale(l.tail, step)) + l.stop_overhead, label: `step:${label(step)},dispatch:${label(dispatch)}` }; + const measured = new Map(); + for (const r of l.reservations) { + if (r.kind !== 'dispatch' || r.actual === undefined || r.unseen || r.epoch !== l.meter.epoch) continue; + for (const role of r.roles ?? []) measured.set(role, [...(measured.get(role) ?? []), r.actual / r.roles!.length]); + } + const reader = (role: string) => { + const seen = measured.get(role) ?? []; + return Math.max(project({ kind: 'dispatch', roles: [role] }).tokens, seen.length ? Math.ceil(seen.reduce((a, b) => a + b, 0) / seen.length) : 0); + }; + return { project, segment, reader, reserve: (l.tail_explicit ? l.tail : scale(l.tail, step)) + l.stop_overhead, label: `step:${label(step)},dispatch:${label(dispatch)}`, + floor: l.call_floor ?? cal.units[unit]?.call_floor ?? 0 }; } /** - * Measure settled reservations against the current (fresh) reading: a step - * from its reading to now; settled dispatches as one group from the earliest - * of their readings, split by role. A charge the meter cannot see (settled - * --unknown, or a Claude-metered outside voice) is never measured. Returns one - * DRIFT line per measurement. + * Measure settled reservations against the current (fresh) reading (GSTA-167). + * A dispatch is measured only to the reading it was settled with when it + * returned (settle --reading, check --returned): dispatches settled at the same + * reading form one group from the earliest of their readings, split per reader + * (one role per reader). One settled without a reading (review-log --finish, a + * step change) is UNMEASURED: its interval also holds the parent's own work. + * A step is measured on a step change, from its reading to now minus the + * dispatches reserved after it, so the parent's interleaved calls stay in the + * step; a window holding an unmeasured dispatch leaves the step unmeasured. A + * charge the meter cannot see (settled --unknown, or a Claude-metered outside + * voice) is never measured. Returns one DRIFT or UNMEASURED line each. */ -export function measureSettled(l: Ledger): string[] { +export function measureSettled(l: Ledger, stepChange = true): string[] { const last = l.meter.last; if (last === null) return []; - const pending = l.reservations.filter(r => r.state === 'settled' && r.kind !== 'child' && r.actual === undefined && !r.unseen + const pending = l.reservations.filter(r => r.state === 'settled' && r.kind !== 'child' && r.actual === undefined && !r.unseen && !r.unmeasured && typeof r.reading_at === 'number' && r.epoch === l.meter.epoch && last >= r.reading_at); - const dispatches = pending.filter(r => r.kind === 'dispatch'); - const from = Math.min(...dispatches.map(r => r.reading_at!)); - const roles = dispatches.reduce((n, r) => n + (r.roles?.length ?? 1), 0); - return pending.map(r => { - const actual = r.kind === 'dispatch' ? Math.round(((last - from) * (r.roles?.length ?? 1)) / roles) : last - r.reading_at!; + const lines: string[] = []; + const measure = (r: Reservation, actual: number) => { r.actual = actual; (l.samples ??= []).push({ id: r.id, kind: r.kind as Sample['kind'], epoch: l.meter.epoch, prior: r.prior ?? r.tokens, projected: r.tokens, actual }); - return `DRIFT: id=${r.id} kind=${r.kind} next=${r.next} projected=${r.tokens} actual=${actual} ratio=${(actual / Math.max(1, r.tokens)).toFixed(2)}`; - }); + lines.push(`DRIFT: id=${r.id} kind=${r.kind} next=${r.next} projected=${r.tokens} actual=${actual} ratio=${(actual / Math.max(1, r.tokens)).toFixed(2)}`); + }; + const unmeasured = (r: Reservation, reason: string) => { + r.unmeasured = true; + lines.push(`UNMEASURED: id=${r.id} kind=${r.kind} next=${r.next} reason=${reason}`); + }; + const dispatches = pending.filter(r => r.kind === 'dispatch'); + for (const r of dispatches) if (r.settled_reading === undefined) unmeasured(r, 'settled_without_reading'); + for (const end of new Set(dispatches.filter(r => !r.unmeasured).map(r => r.settled_reading!))) { + const group = dispatches.filter(r => r.settled_reading === end); + const from = Math.min(...group.map(r => r.reading_at!)); + const readers = group.reduce((n, r) => n + (r.roles?.length ?? 1), 0); + for (const r of group) measure(r, Math.round(((end - from) * (r.roles?.length ?? 1)) / readers)); + } + if (!stepChange) return lines; + for (const r of pending.filter(x => x.kind === 'step')) { + const inside = l.reservations.slice(l.reservations.indexOf(r) + 1).filter(d => d.kind === 'dispatch' && d.state !== 'released' && !d.unseen && d.epoch === l.meter.epoch); + if (inside.some(d => d.actual === undefined)) { unmeasured(r, 'dispatch_unmeasured'); continue; } + measure(r, Math.max(0, last - r.reading_at! - inside.reduce((n, d) => n + d.actual!, 0))); + } + return lines; } /** Steps from..to inclusive plus their usual reviewer batches at the prior: a child's segment estimate. */ @@ -265,9 +307,12 @@ export function admits(t: { used: number; reserved: number; unknown: number }, p export function budgetLine(l: Ledger, f: { call: number | null; next: string; projected: number; verdict: string }): string { const t = tally(l); const p = projector(l); - return `BUDGET: used=${t.used} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap ?? 'none'} tail=${p.reserve} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} call=${f.call ?? 'unknown'} next=${f.next} projected=${f.projected} calibration=${p.label} verdict=${f.verdict}`; + return `BUDGET: used=${t.used} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap ?? 'none'} tail=${p.reserve} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} call=${f.call ?? 'unknown'} call_floor=${p.floor} above_floor=${aboveFloor(f.call, p.floor) ?? 'unknown'} next=${f.next} projected=${f.projected} calibration=${p.label} verdict=${f.verdict}`; } +/** The per-call context above the host floor that the delegate thresholds compare; null when the charge is unknown. */ +export const aboveFloor = (call: number | null, floor: number) => (call === null ? null : Math.max(0, call - floor)); + export function reserveIn(l: Ledger, r: Omit): Reservation { const row: Reservation = { ...r, id: `${l.run}-r${l.next_id++}`, at: new Date().toISOString(), state: 'open', reading_at: l.meter.last, epoch: l.meter.epoch }; l.reservations.push(row); @@ -275,10 +320,11 @@ export function reserveIn(l: Ledger, r: Omit } /** Settle at completion: the meter already holds the spend; estimate and unseen charges are booked here. */ -export function settleIn(l: Ledger, r: Reservation, opts: { unknown?: boolean; cal?: Calibration } = {}): void { +export function settleIn(l: Ledger, r: Reservation, opts: { unknown?: boolean; cal?: Calibration; reading?: number } = {}): void { if (r.state === 'settled' || r.state === 'released') return; r.state = 'settled'; r.settled_at = new Date().toISOString(); + if (opts.reading !== undefined) r.settled_reading = opts.reading; if (opts.unknown) { l.unknown += r.tokens; r.unseen = true; return; } if (l.meter.contract.meter === 'estimate') { l.estimate_used += r.tokens; return; } const unseen = l.meter.contract.meter === 'claude' ? (r.roles ?? []).filter(x => x === 'outside-voice').length : 0; diff --git a/lib/ship-handoff.ts b/lib/ship-handoff.ts index 3ff8dd4cc..6fa465a64 100644 --- a/lib/ship-handoff.ts +++ b/lib/ship-handoff.ts @@ -6,7 +6,9 @@ * trail, the budget and the return contract; its sha256 goes in the parent's * ledger with a reservation for the child's whole sub-cap. The child verifies * the brief before anything else and admits against its sub-cap with - * child_used = reading - reading_at_handoff; it never delegates again. It + * child_used = reading - reading_at_handoff. The context rule never stops a + * child (GSTA-167): once it has completed a step it hands its remaining steps + * to a grandchild the same way, up to MAX_CHAIN_DEPTH handoffs from the root. It * returns at most 40 lines through a return file; the parent reconciles once * per handoff hash (a duplicate return charges nothing twice) and reads the * record, never the child's transcript. @@ -19,7 +21,7 @@ import { resolveClaudeCommand } from './claude-bin'; import { wtreeFingerprint } from './script-invocation'; import { resultLine } from './result-codes'; import { - SHIP_STEPS, budgetError, budgetLine, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker, + MAX_CHAIN_DEPTH, SHIP_STEPS, budgetError, budgetLine, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker, projector, readLedger, reserveIn, runDirFor, segmentEstimate, tally, usageError, writeLedger, type Handoff, type Ledger, } from './ship-budget'; import { loadMeterState, saveMeterState } from './claude-usage'; @@ -49,20 +51,33 @@ export function recordSection(text: string, name: string): string[] | undefined return lines.slice(at, end < 0 ? lines.length : end); } +/** The ship sections each step reads (ship/sections/manifest.json); conditional ones (apple-release, measure, greptile, shared-code-reuse, handoff) load only when their trigger fires. */ +const STEP_SECTIONS: Record = { + '4': ['tests'], '5': ['tests'], '6': ['tests'], '6.5': ['greptile-early'], '7': ['test-coverage'], '8': ['plan-completion'], '9': ['review-army'], + '11': ['adversarial'], '13': ['changelog'], '14.5': ['documentation'], '18': ['pr-body', 'capy-pr'], '19': ['pr-body', 'capy-pr'], +}; + +/** The sections a child running Steps from..to reads (GSTA-167): its own steps' only. */ +export function segmentSections(from: string, to: string): string[] { + return [...new Set(SHIP_STEPS.slice(SHIP_STEPS.indexOf(from as any), SHIP_STEPS.indexOf(to as any) + 1).flatMap(s => STEP_SECTIONS[s] ?? []))]; +} + function brief(l: Ledger, h: Omit, pins: Record, recordText: string, o: Record): string { const reviews = recordSection(recordText, 'Reviews') ?? []; const tokens = [...new Set(reviews.join('\n').match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g) ?? [])]; const block = { version: 1, parent_run: l.run, parent_ledger: l.file, from: h.from, to: h.to, sub_cap: h.sub_cap, parent_tail: l.tail, unit: l.meter.contract.unit, meter: l.meter.contract.meter, epoch: h.epoch, reading_at: h.reading_at, ...pins, record: l.record, - return_file: h.return_file, trail: o.trail ?? null, trail_run: o.trailRun ?? null, launch_file: o.launch ?? null, start_tokens: tokens }; + depth: h.depth, return_file: h.return_file, trail: o.trail ?? null, trail_run: o.trailRun ?? null, launch_file: o.launch ?? null, start_tokens: tokens }; return [ `# /ship handoff ${h.n}: Steps ${h.from}-${h.to}`, '', '```gstack-handoff', JSON.stringify(block, null, 2), '```', '', '## Start', '', `1. Run \`gstack-ship-budget verify-handoff --in \` (on Capy add \`--reading \`) before anything else. On a refusal, return its lines and stop.`, block.trail ? `2. Run \`gstack-decision-trail start ${block.trail} \` before any trail write.` : '2. No decision trail was named.', - `3. Run /ship --continue for Steps ${h.from}-${h.to} only, under the printed child run: \`check\` at every boundary and before every dispatch batch, obeying \`verdict=\`. You never delegate; a delegate rule stops you.`, + `3. Run /ship --continue for Steps ${h.from}-${h.to} only, under the printed child run: \`check\` at every boundary and before every dispatch batch, obeying \`verdict=\`. The context rule never stops you: \`verdict=delegate\` hands your remaining steps to a fresh child the same way (ship/sections/budget-handoff.md), and a \`CONTEXT_HIGH:\` note means continue. This run is at chain depth ${h.depth} of ${MAX_CHAIN_DEPTH}.`, block.launch_file ? `4. Source the launch file \`${block.launch_file}\` in every shell that runs a gstack command (its path only; never print its values).` : '4. No GSTACK_LAUNCH_FILE was named.', '', + '## Read', '', + `Only ship/SKILL.md's step list and, under ship/sections/, ${[...segmentSections(h.from, h.to), 'budget-handoff'].map(s => s + '.md').join(', ')}; a conditional section only when its manifest.json trigger fires. Never read ship/sections/preamble.md in full or docs/ship-budget.md: budget-handoff.md is the whole budget procedure.`, '', '## Record', '', `You are the only writer of \`${l.record}\` during this segment. It keeps its six headings (${RECORD_HEADINGS.join(', ')}). Original start tokens: ${tokens.join(', ') || 'none'}.`, '', '## Decisions and approvals in scope (verbatim from the record)', '', ...(recordSection(recordText, 'Decisions') ?? ['(none recorded)']), '', '## Budget', '', `Sub-cap ${h.sub_cap} ${block.unit} (meter=${block.meter}); the parent keeps a ${l.tail} tail reserve. Ledger: ${l.file}.`, '', @@ -77,35 +92,36 @@ export function cmdHandoff(argv: string[], ctx: Ctx): Outcome { if (SHIP_STEPS.indexOf(from as any) > SHIP_STEPS.indexOf(to as any)) throw usageError(`--from ${from} is after --to ${to}`); const base = str(o, 'base'); if (!base) throw usageError('handoff needs --base origin/ (the merge base is pinned against it)'); - return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => { - if (l.role === 'child') throw usageError('a child never delegates again; stop instead'); + const file = ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env); + const child = str(o, 'child-session'); + const out = mutateLedger(file, ctx.env, (l): Outcome => { + const depth = l.depth ?? (l.parent ? 1 : 0); + if (depth >= MAX_CHAIN_DEPTH) throw usageError(`run ${l.run} is at chain depth ${depth}, the maximum (${MAX_CHAIN_DEPTH}); continue instead`); + const at = (s: string) => SHIP_STEPS.indexOf(s as any); + if (l.parent && (at(from) < at(l.parent.from) || at(to) > at(l.parent.to))) throw usageError(`--from ${from} --to ${to} is outside this child's Steps ${l.parent.from}-${l.parent.to}`); if (!l.record || !fs.existsSync(l.record)) throw usageError(`the run has no invocation record at ${l.record}`); const recordText = fs.readFileSync(l.record, 'utf8'); const missing = RECORD_HEADINGS.filter(h => !recordSection(recordText, h)); if (missing.length) throw usageError(`record ${l.record} lacks headings: ${missing.join(', ')}; fix: keep the six headings the ship skill names`); - const child = str(o, 'child-session'); - if (child && l.meter.contract.meter === 'claude') { - l.meter.sessions = [...new Set([...(l.meter.sessions ?? []), child])]; - const state = loadMeterState(path.join(l.dir, 'claude-meter.json')); - if (state) { state.sessions = l.meter.sessions; saveMeterState(path.join(l.dir, 'claude-meter.json'), state); } - } const call = readMeter(l, o, true); const cal = loadCalibration(l.calibration); const t = tally(l); const room = l.cap === null ? Infinity : l.cap - t.used - t.reserved - t.unknown - projector(l, cal).reserve; - const subCap = tokensOpt(o, 'sub-cap') ?? Math.min(room, segmentEstimate(cal, l.meter.contract.unit, from, to)); + const subCap = tokensOpt(o, 'sub-cap') ?? (l.parent && l.cap !== null ? room : Math.min(room, segmentEstimate(cal, l.meter.contract.unit, from, to))); if (subCap <= 0 || subCap > room) return { stdout: stopLines(l, { call, next: from, step: from, projected: subCap, reason: 'budget_exhausted' }), exit: 3 }; const pins = pinsNow(ctx.cwd, base, ctx.env); const reviewed = str(o, 'reviewed-tree') ?? null; const r = reserveIn(l, { kind: 'child', step: from, next: `child:${from}-${to}`, tokens: subCap }); const n = l.handoffs.length + 1; - const draft = { n, file: path.join(l.dir, `handoff-${n}.md`), from, to, sub_cap: subCap, reservation: r.id, reading_at: l.meter.contract.meter === 'estimate' ? null : l.meter.last, epoch: l.meter.epoch, return_file: path.join(l.dir, `return-${n}.txt`) }; + const draft = { n, file: path.join(l.dir, `handoff-${n}.md`), from, to, sub_cap: subCap, reservation: r.id, depth: depth + 1, reading_at: l.meter.contract.meter === 'estimate' ? null : l.meter.last, epoch: l.meter.epoch, return_file: path.join(l.dir, `return-${n}.txt`) }; const text = brief(l, draft, { ...pins, base_ref: base, reviewed_tree: reviewed }, recordText, { trail: str(o, 'trail'), trailRun: str(o, 'trail-run'), launch: ctx.env.GSTACK_LAUNCH_FILE }); fs.writeFileSync(draft.file, text, { mode: 0o600 }); const sha = sha256(text); l.handoffs.push({ ...draft, sha }); return { stdout: [`HANDOFF: ${draft.file} sha256=${sha} sub_cap=${subCap}`, `RETURN_FILE: ${draft.return_file}`, budgetLine(l, { call, next: from, projected: subCap, verdict: 'delegate' })], exit: 0 }; }); + if (child && out.exit === 0) joinMeter(file, child, ctx.env); + return out; } function handoffBlock(text: string): any { @@ -139,7 +155,8 @@ export function cmdVerifyHandoff(argv: string[], ctx: Ctx): Outcome { const dir = runDirFor(ctx.env, run); const child: Ledger = { schema_version: 1, kind: 'gstack-ship-budget', run, dir, file: path.join(dir, 'budget.json'), record: block.record, created_at: new Date().toISOString(), - cap: parent.cap === null ? null : block.sub_cap, tail: 0, stop_overhead: parent.stop_overhead, delegate: 'never', role: 'child', + cap: parent.cap === null ? null : block.sub_cap, tail: 0, stop_overhead: parent.stop_overhead, delegate: parent.delegate, role: 'child', + depth: h!.depth ?? (parent.depth ?? 0) + 1, ...(parent.call_floor !== undefined ? { call_floor: parent.call_floor } : {}), parent: { run: parent.run, ledger: parent.file, handoff_sha: sha, from: block.from, to: block.to, return_file: block.return_file }, meter: { contract: parent.meter.contract, epoch: 1, baseline: parent.meter.contract.meter === 'capy' ? block.reading_at ?? 0 : 0, last: null, ...(parent.meter.contract.meter === 'claude' ? { sessions: [str(o, 'session') ?? ''], config_dir: parent.meter.config_dir } : {}) }, @@ -163,9 +180,15 @@ export function cmdReturn(argv: string[], ctx: Ctx): Outcome { readMeter(l, o, false); const head = git(ctx.cwd, ['rev-parse', 'HEAD']); const tree = pinsNow(ctx.cwd, 'HEAD', ctx.env).wtree; - const lines = [`RETURN: handoff=${l.parent.handoff_sha} status=${status} steps=${l.parent.from}-${to} head=${head} tree=${tree}`, + const accepted = new Set((l as any).accepted_skips as string[] | undefined ?? []); + const unreconciled = l.handoffs.filter(h => !h.reconciled_at); + const dropped = l.reservations.filter(r => r.remaining?.length && !accepted.has(r.id)); + const result = status === 'complete' && (unreconciled.length || dropped.length) ? 'incomplete' : status; + const lines = [`RETURN: handoff=${l.parent.handoff_sha} status=${result} steps=${l.parent.from}-${to} head=${head} tree=${tree}`, + ...unreconciled.map(h => `UNRECONCILED: handoff=${h.sha} steps=${h.from}-${h.to} (reconcile --run ${l.run} --return ${h.return_file} before returning)`), + ...dropped.map(r => `DROPPED: step=${r.step} reservation=${r.id} roles=${r.remaining!.join(',')} (ADMIT named them; no later check requested them)`), `RECORD_CHANGED: ${str(o, 'changed') ?? 'none'}`, budgetLine(l, { call: null, next: '-', projected: 0, verdict: 'continue' }), - `GSTACK_RESULT: skill=ship status=${status} run=${l.dir}`]; + `GSTACK_RESULT: skill=ship status=${result} run=${l.dir}`]; writeReturnFile(l.parent.return_file, lines); l.closed_at = new Date().toISOString(); return { stdout: lines, exit: 0 }; @@ -229,17 +252,27 @@ export function cmdLaunch(argv: string[], ctx: Ctx): Outcome { const session = crypto.randomUUID(); const prompt = `Load gstack. Your Claude Code session id is ${session}. Run /ship --continue ${path.resolve(file)}: first gstack-ship-budget verify-handoff --in ${path.resolve(file)} --session ${session}, then only the steps the brief names, then gstack-ship-budget return as its return contract says.`; const log = path.join(path.dirname(block.return_file), `child-${session}.log`); - return mutateLedger(block.parent_ledger, ctx.env, l => { - if (l.meter.contract.meter === 'claude') { + joinMeter(block.parent_ledger, session, ctx.env); + const out = fs.openSync(log, 'a', 0o600); + const child = spawn(claude.command, [...claude.argsPrefix, '-p', '--session-id', session, ...CHILD_PERMISSION_ARGS, prompt], { cwd: ctx.cwd, env: ctx.env, detached: true, stdio: ['ignore', out, out] }); + child.unref(); + fs.closeSync(out); + return { stdout: [`CHILD: session=${session} pid=${child.pid} log=${log} return=${block.return_file}`], exit: 0 }; +} + +/** + * Claude Code: a child's session joins the meter of the run that launched it and + * of every ancestor up the chain, so each run meters its whole subtree once. + */ +function joinMeter(file: string, session: string, env: NodeJS.ProcessEnv): void { + for (let next: string | undefined = file; next;) { + next = mutateLedger(next, env, l => { + if (l.meter.contract.meter !== 'claude') return undefined; l.meter.sessions = [...new Set([...(l.meter.sessions ?? []), session])]; const meter = path.join(l.dir, 'claude-meter.json'); const state = loadMeterState(meter); if (state) { state.sessions = l.meter.sessions; saveMeterState(meter, state); } - } - const out = fs.openSync(log, 'a', 0o600); - const child = spawn(claude.command, [...claude.argsPrefix, '-p', '--session-id', session, ...CHILD_PERMISSION_ARGS, prompt], { cwd: ctx.cwd, env: ctx.env, detached: true, stdio: ['ignore', out, out] }); - child.unref(); - fs.closeSync(out); - return { stdout: [`CHILD: session=${session} pid=${child.pid} log=${log} return=${block.return_file}`], exit: 0 }; - }); + return l.parent?.ledger; + }); + } } diff --git a/ship/sections/budget-handoff.md b/ship/sections/budget-handoff.md index eca673d3d..d0928ceb8 100644 --- a/ship/sections/budget-handoff.md +++ b/ship/sections/budget-handoff.md @@ -9,15 +9,15 @@ Field-by-field reference: docs/ship-budget.md. Exit 0 continue or delegate, 3 st 2 usage; any non-zero exit means the next step does not start. **Meter.** Read one per command: -- Capy: in one execute program return only `(await tools.thread.get({ thread_id: "this" })).cost.tokens` - and pass it as `--reading `. For the per-call charge, read it again in your next - call and pass the earlier value as `--prior-reading `. +- Capy: read `(await tools.thread.get({ thread_id: "this" })).cost.tokens` in the same program as the + `gstack-ship-budget` command, never its own call: `--reading `. `--prior-reading ` (per-call + charge) is your previous call's reading; one older than the run's last reading is ignored. - Claude Code: `start --session `, or echo the nonce the refusal prints once and rerun `start --nonce `; the helper reads the journals itself (never `--reading`). - Elsewhere: `--meter estimate` (printed `meter=estimate`), only when the owner accepts it. **Every boundary.** `gstack-ship-budget check --run --step --next --reading `. -Before each dispatch batch, reserve the batch as one sum, then pass the printed id once +Before each dispatch batch, reserve the batch as one sum, one role per `DISPATCH:` line, then pass the printed id once (`READING` is the current counter reading; `RESERVATION_ID` is the id the check prints; `--next` is the `BUDGET_NEXT:` value the batch's `gstack-review-packet build` printed, one role per packet, never packet names): @@ -27,10 +27,11 @@ role per packet, never packet names): ~/.claude/skills/gstack/bin/gstack-review-log --start review --reservation "$RESERVATION_ID" ``` -`--finish` settles it and the pass's other dispatch reservations. Admitting reviewers one at -a time, name each one that came back in the next check's `--returned `. A dispatch with no `review-log --start` settles with -`gstack-ship-budget settle --run --reservation ` when it returns -(`--unknown` when the meter cannot see it), or `release` if it never ran. +On `ADMIT: k of n (remaining=)`, dispatch only those k and re-check the remaining roles +after they settle; `close` flags roles never re-checked. `--finish` settles the reservation and the pass's other dispatch reservations. +Settle each reader when it returns: the next check's `--returned `, or +`gstack-ship-budget settle --run --reservation --reading ` (`--unknown` when the meter +cannot see it); `release` one that never ran. Only these are measured: `--finish` or a step change settles without a reading. **verdict=stop.** Print the STOP, RECOVER and GSTACK_RESULT lines as your last output and end status=incomplete. Raising the cap is the owner's call: `/ship --resume @@ -47,9 +48,11 @@ fresh child; you wait and advance only after reconciling it: GSTACK_LAUNCH_FILE, run /ship --continue "; on Claude Code `gstack-ship-budget launch --in ` (background, explicit permissions). 3. The child runs `verify-handoff --in ` first (`--reading ` on Capy); a - `HANDOFF_PIN_MOVED` refusal returns status=refused. It admits against its sub-cap, - never delegates, and ends with `gstack-ship-budget return --run --to - --changed ` (at most 40 lines). + `HANDOFF_PIN_MOVED` refusal returns status=refused. It admits against its sub-cap and + never stops on the context rule: after a completed step, `verdict=delegate` hands its + remaining steps to a grandchild through these four steps (depth 3 at most); a + `CONTEXT_HIGH:` note means continue. It reconciles any grandchild, then ends with + `gstack-ship-budget return --run --to --changed ` (at most 40 lines). 4. `gstack-ship-budget reconcile --run --return --reading `. Read the record, never the child's transcript. Exit 3 (refused, incomplete or interrupted): regenerate the handoff or stop. diff --git a/ship/sections/budget-handoff.md.tmpl b/ship/sections/budget-handoff.md.tmpl index 84680877d..7c40d8d7e 100644 --- a/ship/sections/budget-handoff.md.tmpl +++ b/ship/sections/budget-handoff.md.tmpl @@ -7,15 +7,15 @@ Field-by-field reference: docs/ship-budget.md. Exit 0 continue or delegate, 3 st 2 usage; any non-zero exit means the next step does not start. **Meter.** Read one per command: -- Capy: in one execute program return only `(await tools.thread.get({ thread_id: "this" })).cost.tokens` - and pass it as `--reading `. For the per-call charge, read it again in your next - call and pass the earlier value as `--prior-reading `. +- Capy: read `(await tools.thread.get({ thread_id: "this" })).cost.tokens` in the same program as the + `gstack-ship-budget` command, never its own call: `--reading `. `--prior-reading ` (per-call + charge) is your previous call's reading; one older than the run's last reading is ignored. - Claude Code: `start --session `, or echo the nonce the refusal prints once and rerun `start --nonce `; the helper reads the journals itself (never `--reading`). - Elsewhere: `--meter estimate` (printed `meter=estimate`), only when the owner accepts it. **Every boundary.** `gstack-ship-budget check --run --step --next --reading `. -Before each dispatch batch, reserve the batch as one sum, then pass the printed id once +Before each dispatch batch, reserve the batch as one sum, one role per `DISPATCH:` line, then pass the printed id once (`READING` is the current counter reading; `RESERVATION_ID` is the id the check prints; `--next` is the `BUDGET_NEXT:` value the batch's `gstack-review-packet build` printed, one role per packet, never packet names): @@ -25,10 +25,11 @@ role per packet, never packet names): ~/.claude/skills/gstack/bin/gstack-review-log --start review --reservation "$RESERVATION_ID" ``` -`--finish` settles it and the pass's other dispatch reservations. Admitting reviewers one at -a time, name each one that came back in the next check's `--returned `. A dispatch with no `review-log --start` settles with -`gstack-ship-budget settle --run --reservation ` when it returns -(`--unknown` when the meter cannot see it), or `release` if it never ran. +On `ADMIT: k of n (remaining=)`, dispatch only those k and re-check the remaining roles +after they settle; `close` flags roles never re-checked. `--finish` settles the reservation and the pass's other dispatch reservations. +Settle each reader when it returns: the next check's `--returned `, or +`gstack-ship-budget settle --run --reservation --reading ` (`--unknown` when the meter +cannot see it); `release` one that never ran. Only these are measured: `--finish` or a step change settles without a reading. **verdict=stop.** Print the STOP, RECOVER and GSTACK_RESULT lines as your last output and end status=incomplete. Raising the cap is the owner's call: `/ship --resume @@ -45,9 +46,11 @@ fresh child; you wait and advance only after reconciling it: GSTACK_LAUNCH_FILE, run /ship --continue "; on Claude Code `gstack-ship-budget launch --in ` (background, explicit permissions). 3. The child runs `verify-handoff --in ` first (`--reading ` on Capy); a - `HANDOFF_PIN_MOVED` refusal returns status=refused. It admits against its sub-cap, - never delegates, and ends with `gstack-ship-budget return --run --to - --changed ` (at most 40 lines). + `HANDOFF_PIN_MOVED` refusal returns status=refused. It admits against its sub-cap and + never stops on the context rule: after a completed step, `verdict=delegate` hands its + remaining steps to a grandchild through these four steps (depth 3 at most); a + `CONTEXT_HIGH:` note means continue. It reconciles any grandchild, then ends with + `gstack-ship-budget return --run --to --changed ` (at most 40 lines). 4. `gstack-ship-budget reconcile --run --return --reading `. Read the record, never the child's transcript. Exit 3 (refused, incomplete or interrupted): regenerate the handoff or stop. diff --git a/test/ship-budget-batch.test.ts b/test/ship-budget-batch.test.ts new file mode 100644 index 000000000..b65822eab --- /dev/null +++ b/test/ship-budget-batch.test.ts @@ -0,0 +1,93 @@ +/** + * GSTA-167 (gstack-evals #35, gate class): full-depth Step 9 launched 7 Opus + * readers in parallel at about 0.76M each, and the shared counter went 13.2M -> + * 18.5M before the parent's poll saw it. A dispatch batch now prices each reader + * at max(its projection, the run's measured mean for that role), admits the + * largest prefix that fits near the cap (ADMIT: k of n), and keeps the rest as + * remaining until a later check requests them; close flags readers never re-checked. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { loadCalibration, unitConstants } from '../lib/ship-budget'; +import { budgetHarness, type Harness } from './helpers/ship-budget-harness'; + +const cal = loadCalibration(); +const K = unitConstants(cal, 'capy-tokens'); +const harnesses: Harness[] = []; +const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; }; +afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); }); + +const READERS = ['specialist', 'specialist', 'specialist', 'specialist', 'specialist', 'red-team', 'adversarial-native']; +const AT_35 = 13_200_000; +/** Room for exactly three readers at the prior above the #35 reading, with 1M to spare. */ +const CAP_35 = AT_35 + 3 * K.per_dispatch + K.stop_overhead + 1_000_000; + +function start(h: Harness, cap: number) { + const r = h.sb(['start', '--record', h.record(), '--token-cap', String(cap), '--reading', '0', '--reserve', '0']); + expect(r.code, r.out + r.err).toBe(0); + return { run: h.field(r.out, 'BUDGET_RUN', 'id'), ledger: h.field(r.out, 'BUDGET_RUN', 'ledger') }; +} +const batch = (h: Harness, run: string, roles: string[], reading: number, extra: string[] = []) => + h.sb(['check', '--run', run, '--step', '9', '--next', 'dispatch:' + roles.join(','), '--reading', String(reading), ...extra]); + +describe('parallel dispatch admission near the cap', () => { + test('#35 replay: a 7-reader batch near the cap admits the largest prefix and names the rest', () => { + const h = fresh(); + const s = start(h, CAP_35); + const c = batch(h, s.run, READERS, AT_35); + expect(c.code, c.out + c.err).toBe(0); + expect(c.out).toMatch(/^ADMIT: 3 of 7 \(remaining=specialist,specialist,red-team,adversarial-native\)/m); + expect(h.field(c.out, 'BUDGET', 'verdict')).toBe('continue'); + expect(h.field(c.out, 'RESERVATION', 'next')).toBe('dispatch:specialist,specialist,specialist'); + expect(Number(h.field(c.out, 'RESERVATION', 'tokens'))).toBe(3 * K.per_dispatch); + const r = h.ledger(s.ledger).reservations.find((x: any) => x.kind === 'dispatch'); + expect(r).toMatchObject({ roles: ['specialist', 'specialist', 'specialist'], remaining: ['specialist', 'specialist', 'red-team', 'adversarial-native'] }); + }); + + test('a role\'s measured mean overrides a lower projection', () => { + const h = fresh(); + const s = start(h, 200_000_000); + let id = h.field(batch(h, s.run, ['red-team'], 0).out, 'RESERVATION', 'id'); + let reading = 3_000_000; + for (let i = 0; i < 4; i++, reading += 500_000) id = h.field(batch(h, s.run, ['specialist'], reading, ['--returned', id]).out, 'RESERVATION', 'id'); + const c = batch(h, s.run, ['red-team', 'specialist'], reading, ['--returned', id]); + expect(c.code, c.out + c.err).toBe(0); + const f = (500_000 / K.per_dispatch) * cal.headroom; + const specialist = Math.ceil((K.per_dispatch * f) / cal.round_to) * cal.round_to; + expect(specialist).toBeLessThan(3_000_000); + expect(Number(h.field(c.out, 'BUDGET', 'projected'))).toBe(3_000_000 + specialist); + }); + + test('when not even the first reader fits, the batch stops as before', () => { + const h = fresh(); + const s = start(h, CAP_35); + const c = batch(h, s.run, READERS, CAP_35 - K.stop_overhead - K.per_dispatch + 1); + expect(c.code).toBe(3); + expect(c.out).toContain('STOP: reason=budget_exhausted'); + expect(c.out).not.toContain('ADMIT:'); + expect(Number(h.field(c.out, 'BUDGET', 'projected'))).toBe(READERS.length * K.per_dispatch); + expect(h.ledger(s.ledger).reservations.filter((x: any) => x.kind === 'dispatch')).toHaveLength(0); + }); + + test('the remainder re-check admits after the first readers settle; close flags readers never re-checked', () => { + const h = fresh(); + const s = start(h, CAP_35); + const first = h.field(batch(h, s.run, READERS, AT_35).out, 'RESERVATION', 'id'); + expect(h.sb(['settle', '--run', s.run, '--reservation', first, '--reading', String(AT_35 + 3 * 760_000)]).code).toBe(0); + const rest = batch(h, s.run, READERS.slice(3), AT_35 + 3 * 760_000); + expect(rest.code, rest.out + rest.err).toBe(0); + expect(rest.out).not.toContain('ADMIT:'); + expect(h.field(rest.out, 'RESERVATION', 'next')).toBe('dispatch:' + READERS.slice(3).join(',')); + expect(h.ledger(s.ledger).reservations.find((x: any) => x.id === first).remaining).toEqual([]); + expect(h.sb(['close', '--run', s.run, '--reading', String(AT_35 + 4_000_000)]).code).toBe(0); + + const d = start(h, CAP_35); + expect(batch(h, d.run, READERS, AT_35).out).toContain('ADMIT: 3 of 7'); + const close = h.sb(['close', '--run', d.run, '--reading', String(AT_35 + 3_000_000)]); + expect(close.code).toBe(3); + expect(close.out).toMatch(/^DROPPED: step=9 reservation=\S+ roles=specialist,specialist,red-team,adversarial-native/m); + expect(close.out.trim().split('\n').at(-1)).toMatch(/^GSTACK_RESULT: skill=ship status=incomplete run=/); + const accepted = h.sb(['close', '--run', d.run, '--reading', String(AT_35 + 3_000_000), '--approved-by', 'Garry', '--words', 'three readers are enough']); + expect(accepted.code).toBe(0); + expect(accepted.out).toContain('ACCEPTED: 0 unreserved dispatches and 1 unfinished batches by Garry'); + }); +}); diff --git a/test/ship-budget-calibration.test.ts b/test/ship-budget-calibration.test.ts index 579c666af..2d09e0d57 100644 --- a/test/ship-budget-calibration.test.ts +++ b/test/ship-budget-calibration.test.ts @@ -29,6 +29,15 @@ describe('ship-budget calibration', () => { } }); + test('every meter unit has its own per-call floor: the largest round_to multiple at or below its observed fresh call, justified', () => { + for (const unit of ['capy-tokens', 'claude-usage-tokens', 'estimate-tokens']) { + const u = cal.units[unit]!; + expect(u.call_floor_why?.length ?? 0, unit).toBeGreaterThan(80); + const observed = u.call_floor_observed ?? 0; + expect(u.call_floor, unit).toBe(Math.floor(observed / cal.round_to) * cal.round_to); + } + }); + test('every meter unit resolves to constants (calibration is per meter)', () => { for (const unit of ['capy-tokens', 'claude-usage-tokens', 'estimate-tokens']) { expect(unitConstants(cal, unit).per_dispatch).toBeGreaterThan(0); diff --git a/test/ship-budget-chain.test.ts b/test/ship-budget-chain.test.ts new file mode 100644 index 000000000..c6a4286a1 --- /dev/null +++ b/test/ship-budget-chain.test.ts @@ -0,0 +1,286 @@ +/** + * GSTA-167: per-call thresholds measure context above the host's per-call + * floor (a fresh Capy thread's first call costs 150-230k before any work, + * GSTA-2 on gstack 1.91.320.0), and a child never stops on the context rule. + * Before its first completed step, or at the maximum chain depth, it continues + * with a CONTEXT_HIGH note; after one it hands its remaining steps to a + * grandchild through the same handoff, verify, return and reconcile commands. + * The token admission cap still stops a child. Replays gstack-evals #36, where + * two handoff children stopped at the Step 7/12 boundary before doing any work. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { loadCalibration, MAX_CHAIN_DEPTH, stepProjection, unitConstants } from '../lib/ship-budget'; +import { budgetHarness, type Harness } from './helpers/ship-budget-harness'; + +const cal = loadCalibration(); +const FLOOR = cal.units['capy-tokens']!.call_floor!; +const K = unitConstants(cal, 'capy-tokens'); +const FIXTURE = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures', 'claude-usage-2.1.292.json'), 'utf8')); +const harnesses: Harness[] = []; +const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; }; +afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); }); + +function start(h: Harness, args: string[]) { + const r = h.sb(['start', '--record', h.record(), ...args]); + expect(r.code, r.out + r.err).toBe(0); + return { r, run: h.field(r.out, 'BUDGET_RUN', 'id'), ledger: h.field(r.out, 'BUDGET_RUN', 'ledger') }; +} + +const readingArgs = (reading: number | null) => (reading === null ? [] : ['--reading', String(reading)]); + +/** Hand Steps from-to off from a run and verify the brief as the next run in the chain. */ +function handOff(h: Harness, run: string, from: string, to: string, reading: number | null, verifyArgs: string[] = readingArgs(reading)) { + const ho = h.sb(['handoff', '--run', run, '--from', from, '--to', to, '--base', 'origin/main', ...readingArgs(reading)]); + expect(ho.code, ho.out + ho.err).toBe(0); + const m = ho.out.match(/^HANDOFF: (\S+) sha256=\S+ sub_cap=(\d+)$/m)!; + const v = h.sb(['verify-handoff', '--in', m[1]!, ...verifyArgs]); + expect(v.code, v.out + v.err).toBe(0); + return { ho, v, brief: m[1]!, subCap: Number(m[2]), returnFile: ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!, run: h.field(v.out, 'BUDGET_RUN', 'id'), ledger: h.field(v.out, 'BUDGET_RUN', 'ledger') }; +} + +function check(h: Harness, run: string, step: string, next: string, extra: string[] = []) { + return h.sb(['check', '--run', run, '--step', step, '--next', next, ...extra]); +} + +describe('per-call thresholds above the host floor', () => { + test('Capy floor: a fresh thread\'s first call of 150-230k does not delegate at start; 260k does', () => { + expect(FLOOR).toBe(150_000); + const h = fresh(); + for (const [call, verdict] of [[150_000, 'continue'], [230_000, 'continue'], [260_000, 'delegate']] as const) { + const s = start(h, ['--token-cap', '60M', '--reading', String(98_000 + call), '--prior-reading', '98000']); + expect(h.field(s.r.out, 'BUDGET', 'call')).toBe(String(call)); + expect(h.field(s.r.out, 'BUDGET', 'call_floor')).toBe(String(FLOOR)); + expect(h.field(s.r.out, 'BUDGET', 'above_floor')).toBe(String(call - FLOOR)); + expect(h.field(s.r.out, 'BUDGET', 'verdict')).toBe(verdict); + expect(s.r.out.includes('--from 1 --to 22')).toBe(verdict === 'delegate'); + } + }); + + test('at a segment boundary, 150k above the floor delegates and less continues; an unknown charge still delegates', () => { + const h = fresh(); + const s = start(h, ['--token-cap', '80M', '--reading', '1000000']); + const low = check(h, s.run, '6', '7', ['--reading', '1230000', '--prior-reading', '1000000']); + expect(low.code, low.out + low.err).toBe(0); + expect(h.field(low.out, 'BUDGET', 'above_floor')).toBe('80000'); + expect(h.field(low.out, 'BUDGET', 'verdict')).toBe('continue'); + const high = check(h, s.run, '11.5', '12', ['--reading', '2000000', '--prior-reading', '1700000']); + expect(h.field(high.out, 'BUDGET', 'above_floor')).toBe('150000'); + expect(h.field(high.out, 'BUDGET', 'verdict')).toBe('delegate'); + const unknown = check(h, s.run, '11.5', '12', ['--reading', '2100000']); + expect(h.field(unknown.out, 'BUDGET', 'above_floor')).toBe('unknown'); + expect(h.field(unknown.out, 'BUDGET', 'verdict')).toBe('delegate'); + }); + + test('--call-floor overrides the calibrated floor and is recorded in the ledger', () => { + const h = fresh(); + const s = start(h, ['--token-cap', '60M', '--reading', '218000', '--prior-reading', '98000', '--call-floor', '0']); + expect(h.field(s.r.out, 'BUDGET', 'call_floor')).toBe('0'); + expect(h.field(s.r.out, 'BUDGET', 'verdict')).toBe('delegate'); + expect(h.ledger(s.ledger).call_floor).toBe(0); + const bad = h.sb(['start', '--record', h.record(), '--token-cap', '60M', '--reading', '0', '--call-floor', 'lots']); + expect(bad.code).toBe(2); + expect(bad.err).toContain("--call-floor 'lots' is not a token count"); + }); +}); + +describe('a child never stops on the context rule', () => { + test('#36 replay: a child at its first boundary with zero completed steps continues with one CONTEXT_HIGH note', () => { + const h = fresh(); + const s = start(h, ['--token-cap', '400M', '--reading', '98000']); + for (const [from, to, step, at] of [['7', '11.5', '6', 1_000_000], ['12', '22', '11.5', 2_000_000]] as const) { + const c = handOff(h, s.run, from, to, at); + const r = check(h, c.run, step, from, ['--reading', String(at + 200_000)]); + expect(r.code, r.out + r.err).toBe(0); + expect(h.field(r.out, 'BUDGET', 'verdict')).toBe('continue'); + const notes = r.out.split('\n').filter(l => l.startsWith('CONTEXT_HIGH:')); + expect(notes).toHaveLength(1); + expect(notes[0]).toContain('no step completed since verify-handoff'); + expect(r.out).toContain(`RESERVATION: id=${c.run}-r1 next=${from}`); + expect(r.out).not.toContain('STOP:'); + expect(r.out).not.toContain('GSTACK_RESULT'); + expect(fs.existsSync(c.returnFile)).toBe(false); + } + }); + + test('a child with a completed step hands its remaining steps to a grandchild at depth 2, sub-cap = its remaining sub-cap', () => { + const h = fresh(); + const s = start(h, ['--token-cap', '200M', '--reading', '98000']); + const child = handOff(h, s.run, '1', '22', 200_000); + expect(h.ledger(child.ledger).depth).toBe(1); + expect(check(h, child.run, '1', '2', ['--reading', '600000']).code).toBe(0); + const d = check(h, child.run, '6', '7', ['--reading', '1500000']); + expect(d.code, d.out + d.err).toBe(0); + expect(h.field(d.out, 'BUDGET', 'verdict')).toBe('delegate'); + expect(d.out).toContain(`gstack-ship-budget handoff --run ${child.run} --from 7 --to 22`); + expect(d.out).not.toContain('RESERVATION'); + const room = ['admission_cap', 'used', 'reserved', 'unknown', 'tail'].map(k => Number(h.field(d.out, 'BUDGET', k))); + const grand = handOff(h, child.run, '7', '22', 1_500_000); + expect(grand.subCap).toBe(room[0]! - room[1]! - room[2]! - room[3]! - room[4]!); + expect(h.field(grand.v.out, 'BUDGET', 'admission_cap')).toBe(String(grand.subCap)); + const g = h.ledger(grand.ledger); + expect(g).toMatchObject({ role: 'child', depth: 2, parent: { run: child.run, ledger: child.ledger, from: '7', to: '22' } }); + expect(h.ledger(child.ledger).handoffs[0]).toMatchObject({ depth: 2, from: '7', to: '22' }); + const text = fs.readFileSync(grand.brief, 'utf8'); + expect(text).toContain('"depth": 2'); + expect(text).not.toContain('a delegate rule stops you'); + }); + + test(`at the maximum chain depth (${MAX_CHAIN_DEPTH}) a child continues and its handoff refuses`, () => { + expect(MAX_CHAIN_DEPTH).toBe(3); + const h = fresh(); + const s = start(h, ['--token-cap', '900M', '--reading', '0']); + let run = s.run; + let reading = 100_000; + const chain: string[] = []; + for (const [from, done] of [['1', '1'], ['2', '2'], ['3', '3']] as const) { + const c = handOff(h, run, from, '22', (reading += 100_000)); + expect(check(h, c.run, done, String(Number(done) + 1), ['--reading', String((reading += 100_000))]).code).toBe(0); + run = c.run; + chain.push(c.ledger); + } + expect(chain.map(f => h.ledger(f).depth)).toEqual([1, 2, 3]); + const r = check(h, run, '6', '7', ['--reading', String((reading += 100_000))]); + expect(r.code, r.out + r.err).toBe(0); + expect(h.field(r.out, 'BUDGET', 'verdict')).toBe('continue'); + expect(r.out).toMatch(/^CONTEXT_HIGH: .*chain depth 3 is the maximum/m); + const refused = h.sb(['handoff', '--run', run, '--from', '7', '--to', '22', '--base', 'origin/main', '--reading', String(reading)]); + expect(refused.code).toBe(2); + expect(refused.err).toContain('chain depth 3'); + }); + + test('the token admission cap still stops a child that would otherwise hand off', () => { + const h = fresh(); + const s = start(h, ['--token-cap', '80M', '--reading', '0']); + const ho = h.sb(['handoff', '--run', s.run, '--from', '1', '--to', '22', '--base', 'origin/main', '--reading', '100000', '--sub-cap', '5M']); + const brief = ho.out.match(/^HANDOFF: (\S+)/m)![1]!; + const returnFile = ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!; + const child = h.field(h.sb(['verify-handoff', '--in', brief, '--reading', '110000']).out, 'BUDGET_RUN', 'id'); + expect(check(h, child, '1', '2', ['--reading', '120000']).code).toBe(0); + const stop = check(h, child, '6', '7', ['--reading', '2120000']); + expect(stop.code).toBe(3); + expect(stop.out).toContain('STOP: reason=budget_exhausted'); + expect(stop.out).toContain('(BUDGET_CAP_REACHED)'); + expect(stop.out).not.toContain('context_threshold'); + expect(fs.readFileSync(returnFile, 'utf8')).toMatch(/^RETURN: handoff=[0-9a-f]{64} status=incomplete steps=1-22 next=7/); + }); +}); + +describe('a chained return is charged once', () => { + test('Capy meter: root, child and grandchild reconcile, each token counted once and a duplicate reconcile charges nothing', () => { + const h = fresh(); + const s = start(h, ['--token-cap', '200M', '--reading', '1000000']); + const child = handOff(h, s.run, '1', '22', 1_100_000, ['--reading', '1200000']); + expect(check(h, child.run, '1', '2', ['--reading', '1500000']).code).toBe(0); + expect(h.field(check(h, child.run, '6', '7', ['--reading', '2000000']).out, 'BUDGET', 'verdict')).toBe('delegate'); + const grand = handOff(h, child.run, '7', '22', 2_100_000, ['--reading', '2200000']); + expect(check(h, grand.run, '7', '8', ['--reading', '3000000']).code).toBe(0); + expect(h.sb(['return', '--run', grand.run, '--to', '22', '--changed', 'Reviews', '--reading', '6000000']).code).toBe(0); + const mid = h.sb(['reconcile', '--run', child.run, '--return', grand.returnFile, '--reading', '6100000']); + expect(mid.code, mid.out + mid.err).toBe(0); + expect(mid.out).toContain('child=complete child_used=4000000'); + expect(h.field(mid.out, 'BUDGET', 'used')).toBe('5000000'); + expect(h.field(mid.out, 'BUDGET', 'reserved')).toBe('0'); + const midAgain = h.sb(['reconcile', '--run', child.run, '--return', grand.returnFile, '--reading', '6100000']); + expect(midAgain.out).toContain('nothing charged twice'); + expect(h.field(midAgain.out, 'BUDGET', 'used')).toBe('5000000'); + expect(h.sb(['return', '--run', child.run, '--to', '22', '--changed', 'Reviews', '--reading', '6200000']).code).toBe(0); + const top = h.sb(['reconcile', '--run', s.run, '--return', child.returnFile, '--reading', '6300000']); + expect(top.code, top.out + top.err).toBe(0); + expect(top.out).toContain('child=complete child_used=5200000'); + expect(h.field(top.out, 'BUDGET', 'reserved')).toBe(String(stepProjection(cal, 'capy-tokens', '1'))); + expect(h.sb(['reconcile', '--run', s.run, '--return', child.returnFile, '--reading', '6300000']).out).toContain('nothing charged twice'); + const close = h.sb(['close', '--run', s.run, '--reading', '6300000']); + expect(close.out).toContain('BUDGET_FINAL: used=5300000 reserved=0'); + }); + + test("estimate meter: the grandchild's settled spend reaches the root once, through the child's return", () => { + const h = fresh(); + const s = start(h, ['--token-cap', '400M', '--meter', 'estimate']); + const child = handOff(h, s.run, '1', '22', null); + expect(check(h, child.run, '1', '2').code).toBe(0); + expect(h.field(check(h, child.run, '6', '7').out, 'BUDGET', 'verdict')).toBe('delegate'); + const grand = handOff(h, child.run, '7', '22', null); + expect(check(h, grand.run, '7', '8').code).toBe(0); + expect(check(h, grand.run, '8', '9').code).toBe(0); + const gret = h.sb(['return', '--run', grand.run, '--to', '22', '--changed', 'Reviews']); + const grandUsed = Number(h.field(gret.out, 'BUDGET', 'used')); + expect(grandUsed).toBe(stepProjection(cal, 'estimate-tokens', '8')); + const childOwn = stepProjection(cal, 'estimate-tokens', '2'); + for (let i = 0; i < 2; i++) expect(h.sb(['reconcile', '--run', child.run, '--return', grand.returnFile]).code).toBe(0); + const cret = h.sb(['return', '--run', child.run, '--to', '22', '--changed', 'Reviews']); + expect(Number(h.field(cret.out, 'BUDGET', 'used'))).toBe(childOwn + grandUsed); + const top = h.sb(['reconcile', '--run', s.run, '--return', child.returnFile]); + expect(Number(h.field(top.out, 'BUDGET', 'used'))).toBe(childOwn + grandUsed); + const again = h.sb(['reconcile', '--run', s.run, '--return', child.returnFile]); + expect(Number(h.field(again.out, 'BUDGET', 'used'))).toBe(childOwn + grandUsed); + }); + + test("Claude Code meter: a grandchild launched by a child joins every ancestor's meter, so the root sees its usage", () => { + const h = fresh(); + const project = path.join(h.root, 'claude', 'projects', '-tmp-repo'); + fs.mkdirSync(project, { recursive: true }); + fs.writeFileSync(path.join(project, `${FIXTURE.sessions.parent}.jsonl`), FIXTURE.journals.parent.map((l: string) => l + '\n').join('')); + const s = start(h, ['--token-cap', '400M', '--session', FIXTURE.sessions.parent]); + const child = handOff(h, s.run, '1', '22', null, ['--session', 'child-session-1']); + expect(check(h, child.run, '1', '2').code).toBe(0); + expect(h.field(check(h, child.run, '6', '7').out, 'BUDGET', 'verdict')).toBe('delegate'); + const ho = h.sb(['handoff', '--run', child.run, '--from', '7', '--to', '22', '--base', 'origin/main']); + const brief = ho.out.match(/^HANDOFF: (\S+)/m)![1]!; + const fake = path.join(h.root, 'fake-claude'); + fs.writeFileSync(fake, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + const launched = h.sb(['launch', '--in', brief], { GSTACK_CLAUDE_BIN: fake }); + expect(launched.code, launched.out + launched.err).toBe(0); + const session = h.field(launched.out, 'CHILD', 'session'); + expect(h.ledger(child.ledger).meter.sessions).toContain(session); + expect(h.ledger(s.ledger).meter.sessions).toEqual([FIXTURE.sessions.parent, session]); + }); +}); + +describe('a child returns complete only when its own work is finished', () => { + test('a child that returns before reconciling its grandchild returns incomplete and names the handoff', () => { + const h = fresh(); + const s = start(h, ['--token-cap', '200M', '--reading', '98000']); + const child = handOff(h, s.run, '1', '22', 200_000); + expect(check(h, child.run, '1', '2', ['--reading', '600000']).code).toBe(0); + expect(h.field(check(h, child.run, '6', '7', ['--reading', '1500000']).out, 'BUDGET', 'verdict')).toBe('delegate'); + const grand = handOff(h, child.run, '7', '22', 1_500_000); + const ret = h.sb(['return', '--run', child.run, '--to', '22', '--changed', 'Reviews', '--reading', '1600000']); + expect(ret.code, ret.out + ret.err).toBe(0); + expect(ret.out).toMatch(/^RETURN: handoff=[0-9a-f]{64} status=incomplete steps=1-22/); + expect(ret.out).toContain(`UNRECONCILED: handoff=${h.ledger(child.ledger).handoffs[0].sha} steps=7-22`); + expect(ret.out).toContain(grand.returnFile); + const top = h.sb(['reconcile', '--run', s.run, '--return', child.returnFile, '--reading', '1700000']); + expect(top.code).toBe(3); + expect(top.out).toContain('child=incomplete'); + }); + + test('a child with an ADMIT remainder it never re-checked returns incomplete with a DROPPED line', () => { + const h = fresh(); + const s = start(h, ['--token-cap', '900M', '--reading', '0', '--reserve', '0']); + const subCap = K.stop_overhead + Math.floor(1.5 * K.per_dispatch); + const ho = h.sb(['handoff', '--run', s.run, '--from', '7', '--to', '11.5', '--base', 'origin/main', '--reading', '100000', '--sub-cap', String(subCap)]); + expect(ho.code, ho.out + ho.err).toBe(0); + const child = h.field(h.sb(['verify-handoff', '--in', ho.out.match(/^HANDOFF: (\S+)/m)![1]!, '--reading', '100000']).out, 'BUDGET_RUN', 'id'); + const b = check(h, child, '9', 'dispatch:specialist,specialist,red-team', ['--reading', '100000']); + expect(b.code, b.out + b.err).toBe(0); + expect(b.out).toMatch(/^ADMIT: 1 of 3 \(remaining=specialist,red-team\)/m); + const ret = h.sb(['return', '--run', child, '--to', '11.5', '--reading', '100000']); + expect(ret.out).toMatch(/^RETURN: handoff=[0-9a-f]{64} status=incomplete steps=7-11.5/); + expect(ret.out).toMatch(/^DROPPED: step=9 reservation=\S+ roles=specialist,red-team/m); + expect(ret.out.trim().split('\n').at(-1)).toMatch(/^GSTACK_RESULT: skill=ship status=incomplete run=/); + }); + + test("a grandchild handoff outside the child's own Steps refuses and reserves nothing", () => { + const h = fresh(); + const s = start(h, ['--token-cap', '200M', '--reading', '98000']); + const child = handOff(h, s.run, '7', '11.5', 200_000); + for (const [from, to] of [['7', '22'], ['6', '11.5']]) { + const r = h.sb(['handoff', '--run', child.run, '--from', from!, '--to', to!, '--base', 'origin/main', '--reading', '300000']); + expect(r.code).toBe(2); + expect(r.err).toContain(`--from ${from} --to ${to} is outside this child's Steps 7-11.5`); + } + expect(h.ledger(child.ledger).reservations.filter((r: any) => r.kind === 'child')).toHaveLength(0); + }); +}); diff --git a/test/ship-budget-projection.test.ts b/test/ship-budget-projection.test.ts index a0fd46ad8..8411a618b 100644 --- a/test/ship-budget-projection.test.ts +++ b/test/ship-budget-projection.test.ts @@ -158,7 +158,7 @@ describe('dispatch reservations settle when the reviewer returns (GST-101)', () expect(bad.err).toContain('--returned'); }); - test('--finish settles every consumed dispatch reservation of the pass, and the next reading measures them as one group', () => { + test('--finish settles every consumed dispatch reservation of the pass; settled without a reading, they stay unmeasured (GSTA-167)', () => { const h = fresh(); const s = started(h, '80M'); const tokens: string[] = []; @@ -175,7 +175,8 @@ describe('dispatch reservations settle when the reviewer returns (GST-101)', () expect(dispatches().map((r: any) => r.state)).toEqual(['settled', 'settled', 'settled']); const next = h.sb(['check', '--run', s.run, '--step', '9', '--next', '10', '--reading', String(reading)]); expect(h.field(next.out, 'BUDGET', 'reserved')).toBe('0'); - expect(dispatches().map((r: any) => r.actual)).toEqual([500_000, 500_000, 500_000]); + expect(dispatches().map((r: any) => [r.actual, r.unmeasured])).toEqual([[undefined, true], [undefined, true], [undefined, true]]); + expect(next.out.split('\n').filter(l => l.startsWith('UNMEASURED:') && l.includes('kind=dispatch'))).toHaveLength(3); }); test('moving to the next step settles dispatch reservations a reviewer consumed in the step before', () => { @@ -185,7 +186,8 @@ describe('dispatch reservations settle when the reviewer returns (GST-101)', () expect(h.reviewLog(['--start', 'adversarial-review', '--reservation', id]).code).toBe(0); const c = h.sb(['check', '--run', s.run, '--step', '11', '--next', '11.5', '--reading', '600000']); expect(c.code).toBe(0); - expect(h.ledger(s.ledger).reservations.find((r: any) => r.id === id)).toMatchObject({ state: 'settled', actual: 600_000 }); + expect(h.ledger(s.ledger).reservations.find((r: any) => r.id === id)).toMatchObject({ state: 'settled', unmeasured: true }); + expect(c.out).toContain(`UNMEASURED: id=${id} kind=dispatch next=dispatch:outside-voice reason=settled_without_reading`); }); test('close prints projected against actual for the run', () => { diff --git a/test/ship-budget-reader-drift.test.ts b/test/ship-budget-reader-drift.test.ts new file mode 100644 index 000000000..23c1e0dcd --- /dev/null +++ b/test/ship-budget-reader-drift.test.ts @@ -0,0 +1,173 @@ +/** + * GSTA-167 PR 3a: dispatch drift is measured per reader and only to the reading + * a dispatch was settled with when it returned, never across the parent's own + * work; the parent's interleaved calls stay in the step's sample. The per-call + * charge takes a prior reading from the previous call only. Replays + * gstack-evals #36 (a six-reader batch taught a 2.041 factor and priced the Red + * Team at 15.17M, a false STOP with 14.4M of sub-cap unused) and #34 (the Red + * Team's reservation interval covered the child's own review and QA, and a + * prior reading six calls old fired CONTEXT_HIGH at call=1068386); evidence in + * handoffs/GSTA-167/replay-36.md and replay-34.md in the GStack Minder drive. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { loadCalibration, unitConstants } from '../lib/ship-budget'; +import { budgetHarness, type Harness } from './helpers/ship-budget-harness'; + +const cal = loadCalibration(); +const K = unitConstants(cal, 'capy-tokens'); +const harnesses: Harness[] = []; +const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; }; +afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); }); + +/** Opus medium readers on #34/#36 cost about 1.7M each (replay-36: ~1.69M per specialist). */ +const READER = 1_690_000; +const SIX = Array(6).fill('specialist'); +const roundUp = (n: number) => Math.ceil(n / cal.round_to) * cal.round_to; +/** The per-reader price after a measured batch: the prior times actual/(prior x readers) times the headroom. */ +const priced = (actual: number, readers: number) => roundUp(K.per_dispatch * ((actual / (K.per_dispatch * readers)) * cal.headroom)); + +function start(h: Harness, cap: number, reading = 0) { + const r = h.sb(['start', '--record', h.record(), '--token-cap', String(cap), '--reading', String(reading), '--reserve', '0']); + expect(r.code, r.out + r.err).toBe(0); + return { run: h.field(r.out, 'BUDGET_RUN', 'id'), ledger: h.field(r.out, 'BUDGET_RUN', 'ledger') }; +} +function ok(h: Harness, args: string[]) { + const r = h.sb(args); + expect(r.code, r.out + r.err).toBe(0); + return r; +} +const check = (h: Harness, run: string, step: string, next: string, reading: number, extra: string[] = []) => + ok(h, ['check', '--run', run, '--step', step, '--next', next, '--reading', String(reading), ...extra]); + +describe('per-reader dispatch drift (#36)', () => { + test('#36 replay: six readers measured at their return price the Red Team per reader, so it is admitted instead of a 15.17M STOP', () => { + const h = fresh(); + const cap = 35_244_164; + const s = start(h, cap); + let reading = 10_710_000; + const first = check(h, s.run, '9', 'dispatch:' + SIX.join(','), reading); + expect(first.out).toMatch(/^ADMIT: 2 of 6 /m); + const r1 = h.field(first.out, 'RESERVATION', 'id'); + expect(h.reviewLog(['--start', 'review', '--reservation', r1]).code).toBe(0); + reading += 2 * READER; + const rest = check(h, s.run, '9', 'dispatch:' + SIX.slice(2).join(','), reading, ['--returned', r1]); + expect(rest.out).toMatch(new RegExp(`^DRIFT: id=${r1} kind=dispatch next=dispatch:specialist,specialist projected=${2 * K.per_dispatch} actual=${2 * READER} ratio=0\\.23$`, 'm')); + expect(rest.out).not.toContain('ADMIT:'); + expect(Number(h.field(rest.out, 'BUDGET', 'projected'))).toBe(4 * priced(2 * READER, 2)); + const r2 = h.field(rest.out, 'RESERVATION', 'id'); + reading += 4 * READER; + const red = check(h, s.run, '9', 'dispatch:red-team', reading, ['--returned', r2]); + expect(h.field(red.out, 'BUDGET', 'verdict')).toBe('continue'); + expect(Number(h.field(red.out, 'BUDGET', 'projected'))).toBe(priced(2 * READER, 2)); + expect(Number(h.field(red.out, 'BUDGET', 'projected'))).toBeLessThan(3_000_000); + expect(red.out).not.toContain('STOP:'); + }); + + test('a measured role prices its next reader at the larger of the per-reader projection and its per-reader mean', () => { + const h = fresh(); + const s = start(h, 200_000_000); + const a = h.field(check(h, s.run, '9', 'dispatch:' + SIX.join(','), 0).out, 'RESERVATION', 'id'); + const b = check(h, s.run, '9', 'dispatch:red-team', 6 * READER, ['--returned', a]); + const r = h.ledger(s.ledger).reservations.find((x: any) => x.id === a); + expect(r.actual).toBe(6 * READER); + expect(Number(h.field(b.out, 'BUDGET', 'projected'))).toBe(priced(6 * READER, 6)); + const red = h.field(b.out, 'RESERVATION', 'id'); + const heavy = 3_910_000; + const c = check(h, s.run, '9', 'dispatch:red-team,specialist', 6 * READER + heavy, ['--returned', red]); + const factor = Math.max(READER / K.per_dispatch, heavy / K.per_dispatch) * cal.headroom; + expect(Number(h.field(c.out, 'BUDGET', 'projected'))).toBe(Math.max(roundUp(K.per_dispatch * factor), heavy) + Math.max(roundUp(K.per_dispatch * factor), READER)); + }); +}); + +describe('a dispatch is measured to its return, never across the parent\'s own work (#34)', () => { + test('#34 replay: a Red Team settled by the step change after the child\'s review and QA is UNMEASURED, so the voices keep the per-reader factor', () => { + const h = fresh(); + const s = start(h, 200_000_000); + check(h, s.run, '8', '9', 3_500_000); + const specialists = h.field(check(h, s.run, '9', 'dispatch:' + SIX.join(','), 4_000_000).out, 'RESERVATION', 'id'); + expect(h.reviewLog(['--start', 'review', '--reservation', specialists]).code).toBe(0); + let reading = 4_000_000 + 6 * READER; + const redCheck = check(h, s.run, '9', 'dispatch:red-team', reading, ['--returned', specialists]); + const red = h.field(redCheck.out, 'RESERVATION', 'id'); + expect(h.reviewLog(['--start', 'review', '--reservation', red]).code).toBe(0); + reading += READER + 3_000_000; + const step = check(h, s.run, '9', '10', reading); + expect(step.out).toMatch(new RegExp(`^UNMEASURED: id=${red} kind=dispatch next=dispatch:red-team reason=settled_without_reading$`, 'm')); + expect(step.out).not.toMatch(new RegExp(`^DRIFT: id=${red} `, 'm')); + expect(step.out).toMatch(/^UNMEASURED: id=\S+ kind=step next=9 reason=dispatch_unmeasured$/m); + expect(h.ledger(s.ledger).reservations.find((x: any) => x.id === red)).toMatchObject({ state: 'settled', unmeasured: true }); + check(h, s.run, '10', '11', reading + 400_000); + const voices = check(h, s.run, '11', 'dispatch:adversarial-native,outside-voice', reading + 800_000); + expect(Number(h.field(voices.out, 'BUDGET', 'projected'))).toBe(2 * priced(6 * READER, 6)); + }); + + test('settled with a reading at its return, the Red Team measures only its own interval; the parent\'s QA after it lands in Step 9\'s sample', () => { + const h = fresh(); + const s = start(h, 200_000_000); + check(h, s.run, '8', '9', 3_500_000); + const red = h.field(check(h, s.run, '9', 'dispatch:red-team', 4_000_000).out, 'RESERVATION', 'id'); + expect(h.reviewLog(['--start', 'review', '--reservation', red]).code).toBe(0); + const settled = ok(h, ['settle', '--run', s.run, '--reservation', red, '--reading', String(4_000_000 + READER)]); + expect(settled.out).toMatch(new RegExp(`^DRIFT: id=${red} kind=dispatch next=dispatch:red-team projected=${K.per_dispatch} actual=${READER} ratio=0\\.23$`, 'm')); + const step = check(h, s.run, '9', '10', 4_000_000 + READER + 3_000_000); + expect(step.out).toMatch(/^DRIFT: id=\S+ kind=step next=9 projected=\d+ actual=3500000 ratio=\d+\.\d\d$/m); + expect(step.out).not.toContain('UNMEASURED:'); + }); + + test('--finish settles the pass without a reading, so its dispatches are UNMEASURED and the factor stays at the prior', () => { + const h = fresh(); + const s = start(h, 200_000_000); + const id = h.field(check(h, s.run, '9', 'dispatch:' + SIX.join(','), 0).out, 'RESERVATION', 'id'); + const token = h.reviewLog(['--start', 'review', '--reservation', id]).out.trim(); + const row = JSON.stringify({ skill: 'review', timestamp: new Date().toISOString(), status: 'clean', issues_found: 0, critical: 0, informational: 0 }); + expect(h.reviewLog([row, '--finish', token]).code).toBe(0); + const next = check(h, s.run, '9', 'dispatch:red-team', 6 * READER + 5_000_000); + expect(next.out).toMatch(new RegExp(`^UNMEASURED: id=${id} kind=dispatch `, 'm')); + expect(h.field(next.out, 'BUDGET', 'calibration')).toEndWith('dispatch:prior'); + expect(Number(h.field(next.out, 'BUDGET', 'projected'))).toBe(K.per_dispatch); + }); +}); + +describe('the per-call charge takes a prior reading from the previous call only (#34)', () => { + test('a prior reading older than the run\'s last reading is ignored: call=unknown, never a six-call span', () => { + const h = fresh(); + const s = start(h, 60_000_000, 1_000_000); + check(h, s.run, '4', '5', 8_900_000); + const stale = check(h, s.run, '5', '6', 9_162_515, ['--prior-reading', '8094129']); + expect(h.field(stale.out, 'BUDGET', 'call')).toBe('unknown'); + expect(stale.out).toMatch(/^CALL_UNKNOWN: --prior-reading 8094129 is older than the run's last reading 8900000; /m); + expect(stale.out).not.toContain('1068386'); + const fine = check(h, s.run, '6', '6.5', 9_450_000, ['--prior-reading', '9162515']); + expect(h.field(fine.out, 'BUDGET', 'call')).toBe('287485'); + expect(fine.out).not.toContain('CALL_UNKNOWN'); + }); + + test('#34 replay: a child passing the parent\'s handoff reading as its prior gets call=unknown, not 1068386', () => { + const h = fresh(); + const s = start(h, 400_000_000, 2_110_477); + const ho = ok(h, ['handoff', '--run', s.run, '--from', '7', '--to', '11.5', '--base', 'origin/main', '--reading', '8094129']); + const brief = ho.out.match(/^HANDOFF: (\S+) /m)![1]!; + const v = ok(h, ['verify-handoff', '--in', brief, '--reading', '8800000']); + const child = h.field(v.out, 'BUDGET_RUN', 'id'); + const c = check(h, child, '6.5', '7', 9_162_515, ['--prior-reading', '8094129']); + expect(h.field(c.out, 'BUDGET', 'call')).toBe('unknown'); + expect(c.out).not.toContain('call=1068386'); + }); +}); + +describe('the budget section states both contracts', () => { + const tmpl = fs.readFileSync(path.join(import.meta.dir, '..', 'ship', 'sections', 'budget-handoff.md.tmpl'), 'utf8'); + test('the meter read happens inside the same program as the gstack-ship-budget command, never as its own call', () => { + expect(tmpl).not.toContain('pass the earlier value'); + expect(tmpl).toMatch(/same (?:execute )?program as the\s+(?:`)?gstack-ship-budget(?:`)? command, never (?:as )?its own call/); + expect(tmpl).toMatch(/--prior-reading[\s\S]{0,200}previous call/); + expect(tmpl).toMatch(/older than the run's last reading is ignored/); + }); + test('a batch names one role per reader and each reader is settled with a reading when it returns', () => { + expect(tmpl).toMatch(/one role per (?:reader|`DISPATCH:` line)/); + expect(tmpl).toMatch(/Settle each reader when it returns[\s\S]{0,200}--reservation --reading /); + expect(tmpl).toMatch(/Only these are measured: `--finish` or a step change settles without a reading/); + }); +}); diff --git a/test/ship-budget.test.ts b/test/ship-budget.test.ts index a138a66b1..c26066937 100644 --- a/test/ship-budget.test.ts +++ b/test/ship-budget.test.ts @@ -54,14 +54,14 @@ describe('parsing and the admission rule', () => { }); describe('reservations', () => { - test('a parallel batch is reserved as one sum and refused whole when it would overshoot', () => { + test('a parallel batch is reserved as one sum; when it would overshoot only the prefix that fits is admitted', () => { const h = fresh(); const cap = 2 * K.per_dispatch + K.stop_overhead + 1_000_000; const s = started(h, ['--token-cap', String(cap), '--reading', '0', '--reserve', '0']); const over = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist,red-team,fix-check', '--reading', '0']); - expect(over.code).toBe(3); - expect(over.out).toContain('(BUDGET_CAP_REACHED)'); - expect(h.ledger(s.ledger).reservations.filter((r: any) => r.kind === 'dispatch')).toHaveLength(0); + expect(over.code).toBe(0); + expect(over.out).toContain('ADMIT: 2 of 3 (remaining=fix-check)'); + expect(h.ledger(s.ledger).reservations.filter((r: any) => r.kind === 'dispatch').map((r: any) => [r.tokens, r.roles])).toEqual([[2 * K.per_dispatch, ['specialist', 'red-team']]]); }); test('#31 replay: a 25M cap stops before the dispatch that would cross it', () => { @@ -92,8 +92,8 @@ describe('reservations', () => { const c = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '0']); const id = h.field(c.out, 'RESERVATION', 'id'); expect(Number(h.field(c.out, 'RESERVATION', 'tokens'))).toBe(K.per_dispatch); - h.sb(['settle', '--run', s.run, '--reservation', id]); const spent = 2 * K.per_dispatch; + h.sb(['settle', '--run', s.run, '--reservation', id, '--reading', String(spent)]); const next = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:red-team', '--reading', String(spent)]); expect(Number(h.field(next.out, 'BUDGET', 'used'))).toBe(spent); const projected = Number(h.field(next.out, 'BUDGET', 'projected')); @@ -176,10 +176,11 @@ describe('meters and exit codes', () => { }); describe('delegation', () => { - test('start from a long-lived context (call >= 100k) delegates the whole /ship', () => { + test('start from a long-lived context (100k or more above the host floor) delegates the whole /ship', () => { const h = fresh(); - const s = started(h, ['--token-cap', '60M', '--reading', '9000000', '--prior-reading', '8880000']); - expect(h.field(s.r.out, 'BUDGET', 'call')).toBe('120000'); + const s = started(h, ['--token-cap', '60M', '--reading', '9000000', '--prior-reading', '8740000']); + expect(h.field(s.r.out, 'BUDGET', 'call')).toBe('260000'); + expect(h.field(s.r.out, 'BUDGET', 'above_floor')).toBe('110000'); expect(h.field(s.r.out, 'BUDGET', 'verdict')).toBe('delegate'); expect(s.r.out).toContain('--from 1 --to 22'); }); @@ -202,7 +203,7 @@ describe('delegation', () => { test('full chain: delegate, handoff, verify, child checks, 40-line return, idempotent reconcile, close', () => { const h = fresh(); const s = started(h, ['--token-cap', '80M', '--reading', '1000000']); - const d = h.sb(['check', '--run', s.run, '--step', '6', '--next', '7', '--reading', '1200000', '--prior-reading', '1000000']); + const d = h.sb(['check', '--run', s.run, '--step', '6', '--next', '7', '--reading', '1200000', '--prior-reading', '800000']); expect(h.field(d.out, 'BUDGET', 'verdict')).toBe('delegate'); const ho = h.sb(['handoff', '--run', s.run, '--from', '7', '--to', '11.5', '--base', 'origin/main', '--reading', '1300000']); expect(ho.code).toBe(0); @@ -221,7 +222,9 @@ describe('delegation', () => { expect(h.field(v.out, 'BUDGET', 'used')).toBe('100000'); const cc = h.sb(['check', '--run', child, '--step', '8', '--next', '9', '--reading', '5000000']); expect(cc.code).toBe(0); - expect(h.sb(['check', '--run', child, '--step', '11', '--next', '12', '--reading', '6000000']).out).toContain('a child never delegates'); + const past = h.sb(['check', '--run', child, '--step', '11', '--next', '12', '--reading', '6000000']); + expect(past.code).toBe(0); + expect(past.out).toMatch(/^CONTEXT_HIGH: .*Step 12 is past this child's Steps 7-11\.5/m); const ret = h.sb(['return', '--run', child, '--to', '11.5', '--changed', 'Reviews,Checks', '--reading', '9000000']); expect(ret.code).toBe(0); expect(ret.out.trim().split('\n').length).toBeLessThanOrEqual(40); diff --git a/test/ship-handoff-brief.test.ts b/test/ship-handoff-brief.test.ts new file mode 100644 index 000000000..33be61e5e --- /dev/null +++ b/test/ship-handoff-brief.test.ts @@ -0,0 +1,54 @@ +/** + * GSTA-167 PR 3a: a handoff child reads only its segment's sections. On #34 + * and #36 the Steps 7-11.5 child booted at 1.29M by reading the whole ship + * skill, preamble.md and docs/ship-budget.md before its first step; the brief + * now names ship/SKILL.md's step list, the segment's sections and + * budget-handoff.md, and nothing else. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import { segmentSections } from '../lib/ship-handoff'; +import { budgetHarness, type Harness } from './helpers/ship-budget-harness'; + +const harnesses: Harness[] = []; +const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; }; +afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); }); + +function briefFor(from: string, to: string): string { + const h = fresh(); + const s = h.sb(['start', '--record', h.record(), '--token-cap', '400M', '--reading', '98000']); + expect(s.code, s.out + s.err).toBe(0); + const ho = h.sb(['handoff', '--run', h.field(s.out, 'BUDGET_RUN', 'id'), '--from', from, '--to', to, '--base', 'origin/main', '--reading', '1000000']); + expect(ho.code, ho.out + ho.err).toBe(0); + return fs.readFileSync(ho.out.match(/^HANDOFF: (\S+) /m)![1]!, 'utf8'); +} +const readSection = (text: string) => text.slice(text.indexOf('## Read'), text.indexOf('\n## ', text.indexOf('## Read') + 1)); + +describe('the handoff brief names only the segment\'s sections', () => { + test('Steps 7-11.5: the step list plus test-coverage, plan-completion, review-army and adversarial', () => { + const read = readSection(briefFor('7', '11.5')); + expect(read).toContain("ship/SKILL.md's step list"); + for (const s of ['test-coverage.md', 'plan-completion.md', 'review-army.md', 'adversarial.md', 'budget-handoff.md']) expect(read).toContain(s); + for (const s of ['changelog.md', 'documentation.md', 'pr-body.md', 'capy-pr.md', 'tests.md']) expect(read).not.toContain(s); + expect(read).toMatch(/Never read ship\/sections\/preamble\.md in full or docs\/ship-budget\.md/); + }); + + test('Steps 12-22: the step list plus changelog, documentation, pr-body and capy-pr', () => { + const read = readSection(briefFor('12', '22')); + for (const s of ['changelog.md', 'documentation.md', 'pr-body.md', 'capy-pr.md', 'budget-handoff.md']) expect(read).toContain(s); + for (const s of ['test-coverage.md', 'plan-completion.md', 'review-army.md', 'adversarial.md']) expect(read).not.toContain(s); + expect(read).toMatch(/Never read ship\/sections\/preamble\.md in full or docs\/ship-budget\.md/); + }); + + test('a grandchild segment names only the sections of its own steps', () => { + expect(segmentSections('9', '11.5')).toEqual(['review-army', 'adversarial']); + expect(segmentSections('19', '22')).toEqual(['pr-body', 'capy-pr']); + expect(segmentSections('1', '22')).toEqual(['tests', 'greptile-early', 'test-coverage', 'plan-completion', 'review-army', 'adversarial', 'changelog', 'documentation', 'pr-body', 'capy-pr']); + }); + + test('every section the brief can name is a registered ship section', () => { + const manifest = JSON.parse(fs.readFileSync(`${import.meta.dir}/../ship/sections/manifest.json`, 'utf8')); + const ids = new Set(manifest.sections.map((s: any) => s.id)); + for (const id of segmentSections('1', '22')) expect(ids.has(id)).toBe(true); + }); +}); diff --git a/test/size-ledger/ship/gst-101-ship-budget-calibration.json b/test/size-ledger/ship/gst-101-ship-budget-calibration.json index b07b26cdb..87614af86 100644 --- a/test/size-ledger/ship/gst-101-ship-budget-calibration.json +++ b/test/size-ledger/ship/gst-101-ship-budget-calibration.json @@ -1,4 +1,4 @@ { - "bytes": 146, - "why": "GST-101: budget-handoff names check --returned for reviewers admitted one at a time" + "bytes": 535, + "why": "GST-101: budget-handoff names check --returned for reviewers admitted one at a time; GSTA-167: a child never stops on the context rule and hands off after a completed step, and an ADMIT k of n batch re-checks its remaining readers" } diff --git a/test/size-ledger/ship/gsta-167-handoff-floor.json b/test/size-ledger/ship/gsta-167-handoff-floor.json new file mode 100644 index 000000000..c9a798034 --- /dev/null +++ b/test/size-ledger/ship/gsta-167-handoff-floor.json @@ -0,0 +1,4 @@ +{ + "bytes": 73, + "why": "GSTA-167 PR 3a: budget-handoff meter read inside the budget command's program, one role per DISPATCH line, settle readers with a reading at return" +}