From f30c33f7d58ab347b99ca506dbed75f344e69851 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 16:28:27 +0000 Subject: [PATCH 01/11] fix(ship-budget): calibrate projections from the run's own meter, settle dispatch reservations when reviewers return (GST-101) The /ship budget projected every step and dispatch from fixed constants (7.43M per dispatch, 2.25M per default step) plus a fixed 4.48M reserve, so real caps refused work that cost 3-20x less (GSTA-152 PR B, GSTA-2). Each settled reservation is now measured on the next reading, and the run projects with its own factor (max actual/prior of its last three measurements x 1.5), the prior only before the first measurement. The default tail reserve scales with the step factor; the stop overhead keeps its fit. Reservations of reviewers admitted one at a time no longer pile up: check --returned settles named dispatches, --finish settles the pass's dispatches of that step, and a step change settles consumed dispatches. DRIFT and BUDGET_DRIFT lines print projected against actual. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-review-log | 10 +- bin/gstack-ship-budget | 4 +- docs/ship-budget.md | 31 ++- lib/ship-budget-cli.ts | 60 ++++-- lib/ship-budget.ts | 76 ++++++- ship/sections/budget-handoff.md | 3 +- ship/sections/budget-handoff.md.tmpl | 3 +- test/ship-budget-projection.test.ts | 199 ++++++++++++++++++ test/ship-budget.test.ts | 12 +- .../ship/gst-101-ship-budget-calibration.json | 4 + 10 files changed, 357 insertions(+), 45 deletions(-) create mode 100644 test/ship-budget-projection.test.ts create mode 100644 test/size-ledger/ship/gst-101-ship-budget-calibration.json diff --git a/bin/gstack-review-log b/bin/gstack-review-log index 0a7037120..ae60e73a7 100755 --- a/bin/gstack-review-log +++ b/bin/gstack-review-log @@ -6,7 +6,8 @@ # Under a /ship token cap (docs/ship-budget.md): --start SKILL --reservation ID # with the RESERVATION id gstack-ship-budget check printed for this dispatch # batch; refuses (exit 1, BUDGET_CHECK_SKIPPED) without an unspent one, and -# --finish settles it. Without a cap no marker exists and nothing changes. +# --finish settles it with the pass's other dispatch reservations of that +# step. Without a cap no marker exists and nothing changes. # Fix-range repeat (#3339): gstack-review-log --start review --fix-range # prints the token, then FIX_BASE=; refuses (exit 1) unless the latest # pass completed with fixes on unchanged content and its commit is an @@ -151,12 +152,13 @@ try { echo "$STAMPED" >> "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" -# A finished dispatch settles the budget reservation its start consumed (its -# completion receipt); stdout stays unchanged and a failure never undoes the row. +# A finished pass settles the budget reservation its start consumed (its +# completion receipt) and every other dispatch reservation started in the same +# step; stdout stays unchanged and a failure never undoes the row. if [ -n "$TOKEN" ] && [ -f "$GSTACK_SHIP_BUDGET_MARKER" ]; then GSTACK_REVIEW_TOKEN="$TOKEN" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' const { settleReviewFinish } = await import(process.env.GSTACK_SHIP_BUDGET_LIB); - try { const id = settleReviewFinish(process.env.GSTACK_SHIP_BUDGET_MARKER, process.env.GSTACK_REVIEW_TOKEN); if (id) console.error("review-log: settled budget reservation " + id); } + try { const id = settleReviewFinish(process.env.GSTACK_SHIP_BUDGET_MARKER, process.env.GSTACK_REVIEW_TOKEN); if (id) console.error("review-log: settled budget reservations " + id); } catch (e) { console.error("review-log: budget reservation not settled: " + e.message); } ' || true fi diff --git a/bin/gstack-ship-budget b/bin/gstack-ship-budget index ecb3277dc..3f1361c0e 100755 --- a/bin/gstack-ship-budget +++ b/bin/gstack-ship-budget @@ -7,8 +7,8 @@ * * start --record [--token-cap ] [--meter capy|claude|estimate] [--reading ] [--prior-reading ] * [--session |--nonce ] [--reserve ] [--delegate auto|never] [--calibration ] [--ledger ] - * check --run |--record --step --next [,...]> [--reading ] [--prior-reading ] - * settle --run|--record --reservation [--unknown] release --run|--record --reservation + * check --run |--record --step --next [,...]> [--reading ] [--prior-reading ] [--returned [,...]] + * settle --run|--record --reservation [--unknown] [--reading ] release --run|--record --reservation * handoff --run|--record --from --to --base origin/ [--reviewed-tree ] [--trail --trail-run ] * [--sub-cap ] [--reading ] [--child-session ] * launch --in (Claude Code: background headless child with explicit permissions) diff --git a/docs/ship-budget.md b/docs/ship-budget.md index ef7558364..ef2bdd7f0 100644 --- a/docs/ship-budget.md +++ b/docs/ship-budget.md @@ -17,7 +17,9 @@ Exactly at the cap is admitted; one token over is not. A parallel review batch i one sum before any of it starts. Admitted work can still cost more than its projection, and gstack cannot stop a host mid-turn from bash, so a run can finish above the cap by the misprojection of work already admitted. The tail reserve (default 2M, `--reserve `) keeps -room for the receipt steps, and the stop overhead (calibrated) keeps room to stop cleanly. +room for the receipt steps, and the stop overhead (calibrated) keeps room to stop cleanly. Once +the run has measured a step, the tail reserve scales with its measured step cost (a `--reserve` +you set stays as given); the stop overhead never scales. ## Quickstart @@ -48,7 +50,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= verdict=continue|delegate|stop + BUDGET: used= reserved= unknown= admission_cap= tail= unit= meter= epoch= call= next= projected= calibration=step:,dispatch: verdict=continue|delegate|stop | Field | Meaning | |---|---| @@ -56,10 +58,11 @@ With a cap set and no readable meter, start refuses | `reserved` | Open reservations: admitted steps, started dispatches, and each open child's sub-cap counted as max(sub_cap, child_used), never both | | `unknown` | Charges the meter cannot see (an external-CLI outside voice on Claude Code), counted at their reservation | | `admission_cap` | The cap, or `none` | -| `tail` | Tail reserve plus stop overhead, held back from every admission | +| `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 | -| `next`, `projected` | What the check was for and its calibrated projection | +| `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) | Any exit other than 0 means the next step does not start. Exit 2 is a usage or validation @@ -77,7 +80,12 @@ gstack-review-log --start review --reservation ``` Roles are review-depth's: `specialist`, `red-team`, `adversarial-native`, `outside-voice`, -`fix-check`. `--finish ` settles the reservation at the dispatch's completion receipt. +`fix-check`. `--finish ` is the pass's completion receipt: it settles that reservation and +every other dispatch reservation started in the same step, so reviewers admitted one at a time +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. `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. @@ -137,6 +145,19 @@ across three runs; per-dispatch has two observations and stop overhead one. Only meter has observations; the Claude Code and estimate units are seeded from it until a Claude-metered /ship completes. +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 + + DRIFT: id= kind=step|dispatch next= projected= actual= ratio= + +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 +dispatch is measured, so under a tight cap admit one reviewer first and let it calibrate the rest. +`close` prints `BUDGET_DRIFT:` with the run's projected and actual totals per kind. The estimate +meter has no readings, so it always projects at the prior. + ## Token cap and USD spend cap A lane's `--spend-cap` (USD) bounds paid gates and evals through lib/spend-ledger.ts; `--token-cap` diff --git a/lib/ship-budget-cli.ts b/lib/ship-budget-cli.ts index d761c2e74..55f83e357 100644 --- a/lib/ship-budget-cli.ts +++ b/lib/ship-budget-cli.ts @@ -13,9 +13,9 @@ 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, mintRunId, mutateLedger, newEpoch, - parseNext, parseRecordBlock, parseStep, parseTokens, pointMarker, readLedger, reserveIn, restoreRun, reviewPaths, - runDirFor, segmentEstimate, settleIn, stepProjection, projection, tally, unitConstants, unreservedDispatches, usageError, writeLedger, + 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, } from './ship-budget'; import { claudeConfigDir, loadMeterState, newMeterState, nonceSessions, readClaudeUsage, saveMeterState, sessionJournals } from './claude-usage'; @@ -41,6 +41,8 @@ export function parseArgs(argv: string[], values: string[], flags: string[] = [] export const str = (o: Opts, k: string) => (typeof o[k] === 'string' ? (o[k] as string) : undefined); export const tokensOpt = (o: Opts, k: string) => (str(o, k) === undefined ? undefined : parseTokens(str(o, k), `--${k}`)); 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 { @@ -69,13 +71,13 @@ export function readMeter(l: Ledger, o: Opts, requireReading: boolean): number | export function stopLines(l: Ledger, f: { call: number | null; next: string; step: string; projected: number; reason: 'budget_exhausted' | 'context_threshold' }): string[] { l.stopped = { at: new Date().toISOString(), reason: f.reason, step: f.step, next: f.next }; const t = tally(l); - const cal = loadCalibration(l.calibration); + const p = projector(l); const from = f.next.startsWith('dispatch:') ? f.step : f.next; - const tailCost = segmentEstimate(cal, l.meter.contract.unit, from, '22') + l.tail + l.stop_overhead; + const tailCost = p.segment(from, '22') + p.reserve; const lines = [budgetLine(l, { ...f, verdict: 'stop' })]; if (f.reason === 'budget_exhausted') { lines.push(`STOP: reason=budget_exhausted spent=${t.used} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap} next=${f.next} projected=${f.projected}`, - resultLine('gstack-ship-budget', 'BUDGET_CAP_REACHED', `next ${f.next} needs ${f.projected} + reserve ${l.tail + l.stop_overhead}`), + 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'})`, @@ -93,10 +95,10 @@ export function writeReturnFile(file: string, lines: string[]): void { /** Admission and the delegate rule for one boundary; reserves the admitted work under a cap. */ export function decide(l: Ledger, step: string, next: Next, call: number | null, startThreshold = false): Outcome { - const cal = loadCalibration(l.calibration); + const p = projector(l); const nextId = next.kind === 'step' ? next.step : `dispatch:${next.roles.join(',')}`; - const projected = projection(cal, l.meter.contract.unit, next); - if (!admits(tally(l), projected, l.tail + l.stop_overhead, l.cap)) { + 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'); @@ -110,7 +112,7 @@ export function decide(l: Ledger, step: string, next: Next, call: number | null, } const lines = [budgetLine(l, { call, next: nextId, projected, verdict: 'continue' })]; if (l.cap !== null) { - const r = reserveIn(l, { kind: next.kind, step, next: nextId, tokens: projected, ...(next.kind === 'dispatch' ? { roles: next.roles } : {}) }); + const r = reserveIn(l, { kind: next.kind, step, next: nextId, tokens: projected, prior, ...(next.kind === 'dispatch' ? { roles: next.roles } : {}) }); lines.push(`RESERVATION: id=${r.id} next=${nextId} tokens=${projected}`); } return { stdout: lines, exit: 0 }; @@ -166,7 +168,8 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome { const sessions = meter === 'claude' ? bindClaude(o, ctx.env) : undefined; const l: Ledger = { schema_version: 1, kind: 'gstack-ship-budget', run, dir, file: str(o, 'ledger') ?? path.join(dir, 'budget.json'), record: path.resolve(record), - created_at: new Date().toISOString(), cap, tail: tokensOpt(o, 'reserve') ?? DEFAULT_TAIL, stop_overhead: unitConstants(cal, contract.unit).stop_overhead, + created_at: new Date().toISOString(), cap, tail: tokensOpt(o, 'reserve') ?? DEFAULT_TAIL, ...(tokensOpt(o, 'reserve') !== undefined ? { tail_explicit: true } : {}), + stop_overhead: unitConstants(cal, contract.unit).stop_overhead, 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')!) } : {}), @@ -181,40 +184,59 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome { return { stdout: [`BUDGET_RUN: id=${run} ledger=${l.file}`, ...out.stdout], exit: out.exit }; } +/** + * check: read the meter, settle what is done (open step reservations, the + * dispatches named by --returned, and on a step change every dispatch a + * reviewer consumed), measure them, then admit the next step or dispatch. + */ export function cmdCheck(argv: string[], ctx: Ctx): Outcome { - const o = parseArgs(argv, ['run', 'record', 'step', 'next', 'reading', 'prior-reading']); + const o = parseArgs(argv, ['run', 'record', 'step', 'next', 'reading', 'prior-reading', 'returned']); const step = parseStep(str(o, 'step')); const next = parseNext(str(o, 'next')); + const returned = (str(o, 'returned') ?? '').split(',').filter(Boolean); return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => { if (l.closed_at) throw usageError(`run ${l.run} is closed`); if (l.stopped) return { stdout: [budgetLine(l, { call: null, next: l.stopped.next, projected: 0, verdict: 'stop' }), `STOP: reason=${l.stopped.reason} at Step ${l.stopped.step}; resume first: gstack-ship-budget resume --record ${l.record}`, `GSTACK_RESULT: skill=ship status=incomplete run=${l.dir}`], exit: 3 }; - for (const r of l.reservations) if (r.kind === 'step' && r.state === 'open') settleIn(l, r); + 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); - return decide(l, step, next, call); + 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); + } + const drift = freshReading(l, o) ? measureSettled(l) : []; + const out = decide(l, step, next, call); + return { stdout: [...drift, ...out.stdout], exit: out.exit }; }); } export function cmdSettle(argv: string[], ctx: Ctx, release = false): Outcome { - const o = parseArgs(argv, ['run', 'record', 'reservation'], release ? [] : ['unknown']); + const o = parseArgs(argv, ['run', 'record', 'reservation', ...(release ? [] : ['reading'])], release ? [] : ['unknown']); const id = str(o, 'reservation'); if (!id) throw usageError(`${release ? 'release' : 'settle'} needs --reservation `); return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => { const r = l.reservations.find(x => x.id === id); if (!r) throw usageError(`reservation ${id} is not in run ${l.run}`); - if (release) { if (r.state === 'open') r.state = 'released'; } - else settleIn(l, r, { unknown: o.unknown === true, cal: loadCalibration(l.calibration) }); - return { stdout: [`${release ? 'RELEASED' : 'SETTLED'}: id=${id} state=${r.state}`], exit: 0 }; + 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 }; }); } export function cmdClose(argv: string[], ctx: Ctx): Outcome { const o = parseArgs(argv, ['run', 'record', 'reading', 'approved-by', 'words']); return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => { - for (const r of l.reservations) if (r.kind === 'step' && r.state === 'open') settleIn(l, r); readMeter(l, o, false); + for (const r of l.reservations) if (r.kind === 'step' && r.state === 'open') settleIn(l, r); + if (freshReading(l, o)) measureSettled(l); const t = tally(l); const open = l.reservations.filter(r => r.kind === 'dispatch' && r.state === 'consumed').length; const lines = [`BUDGET_FINAL: used=${t.used} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap ?? 'none'} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} open_dispatches=${open}`]; + const sum = (kind: string, k: 'projected' | 'actual') => (l.samples ?? []).filter(s => s.kind === kind).reduce((n, s) => n + s[k], 0); + const count = (kind: string) => (l.samples ?? []).filter(s => s.kind === kind).length; + 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')) { diff --git a/lib/ship-budget.ts b/lib/ship-budget.ts index a3c3d18e1..6768a01ad 100644 --- a/lib/ship-budget.ts +++ b/lib/ship-budget.ts @@ -51,7 +51,9 @@ 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; } +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; @@ -65,7 +67,7 @@ export interface Ledger { carried_used: number; estimate_used: number; unknown: number; next_id: number; 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; + marker?: string; review_log?: string; calibration?: string; samples?: Sample[]; tail_explicit?: boolean; stopped?: { at: string; reason: string; step: string; next: string }; closed_at?: string; } @@ -134,7 +136,61 @@ export function projection(cal: Calibration, unit: string, next: Next): number { return next.kind === 'dispatch' ? next.roles.length * unitConstants(cal, unit).per_dispatch : stepProjection(cal, unit, next.step); } -/** Steps from..to inclusive plus their usual reviewer batches: a child's segment estimate and the RECOVER tail. */ +/** How many recent measurements of a kind the run's factor looks at (GST-101). */ +export const SAMPLE_WINDOW = 3; +const roundUp = (n: number, to: number) => Math.ceil(n / to) * to; + +/** + * The run's own factor for steps or dispatches: the largest actual/prior ratio + * among its last SAMPLE_WINDOW measurements times the calibration headroom, or + * null before the first measurement (the prior applies until then). + */ +export function measuredFactor(l: Ledger, cal: Calibration, kind: Sample['kind']): number | null { + const rows = (l.samples ?? []).filter(s => s.kind === kind && s.epoch === l.meter.epoch && s.actual > 0 && s.prior > 0).slice(-SAMPLE_WINDOW); + return rows.length ? Math.max(...rows.map(s => s.actual / s.prior)) * cal.headroom : null; +} + +/** + * 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. + */ +export function projector(l: Ledger, cal: Calibration = loadCalibration(l.calibration)) { + const unit = l.meter.contract.unit; + const step = measuredFactor(l, cal, 'step'), dispatch = measuredFactor(l, cal, 'dispatch'); + const scale = (n: number, f: number | null) => (f === null ? n : roundUp(n * f, cal.round_to)); + const project = (next: Next) => { + const prior = projection(cal, unit, next); + return { prior, tokens: scale(prior, next.kind === 'dispatch' ? dispatch : step) }; + }; + 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)}` }; +} + +/** + * 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. Returns one DRIFT line per measurement. + */ +export function measureSettled(l: Ledger): 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 + && 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!; + 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)}`; + }); +} + +/** Steps from..to inclusive plus their usual reviewer batches at the prior: a child's segment estimate. */ export function segmentEstimate(cal: Calibration, unit: string, from: string, to: string): number { const i = SHIP_STEPS.indexOf(from as any), j = SHIP_STEPS.indexOf(to as any); const k = unitConstants(cal, unit); @@ -206,11 +262,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); - return `BUDGET: used=${t.used} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap ?? 'none'} tail=${l.tail + l.stop_overhead} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} call=${f.call ?? 'unknown'} next=${f.next} projected=${f.projected} verdict=${f.verdict}`; + 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}`; } 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' }; + 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); return row; } @@ -333,15 +390,20 @@ export function admitReviewStart(marker: string, reservation: string | undefined }); } -/** gstack-review-log --finish TOKEN: settle the reservation that start consumed (its completion receipt). */ +/** + * gstack-review-log --finish TOKEN: the pass is over, so settle the reservation + * that start consumed and every other dispatch reservation started in the same + * step (a pass logs one row even when its reviewers were admitted one by one). + */ export function settleReviewFinish(marker: string, token: string, env: NodeJS.ProcessEnv = process.env): string | undefined { const active = activeLedger(marker); if (!active || !active.reservations.some(r => r.token === token && r.state === 'consumed')) return undefined; return mutateLedger(active.file, env, l => { const r = l.reservations.find(x => x.token === token && x.state === 'consumed'); if (!r) return undefined; - settleIn(l, r); - return r.id; + const pass = l.reservations.filter(x => x.kind === 'dispatch' && x.state === 'consumed' && x.step === r.step); + for (const x of pass) settleIn(l, x); + return pass.map(x => x.id).join(','); }); } diff --git a/ship/sections/budget-handoff.md b/ship/sections/budget-handoff.md index 1b036c551..5529caca6 100644 --- a/ship/sections/budget-handoff.md +++ b/ship/sections/budget-handoff.md @@ -25,7 +25,8 @@ Before each dispatch batch, reserve the batch as one sum, then pass the printed ~/.claude/skills/gstack/bin/gstack-review-log --start review --reservation "$RESERVATION_ID" ``` -`--finish` settles it. A dispatch with no `review-log --start` settles with +`--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. diff --git a/ship/sections/budget-handoff.md.tmpl b/ship/sections/budget-handoff.md.tmpl index fa41ceb04..30fff488e 100644 --- a/ship/sections/budget-handoff.md.tmpl +++ b/ship/sections/budget-handoff.md.tmpl @@ -23,7 +23,8 @@ Before each dispatch batch, reserve the batch as one sum, then pass the printed ~/.claude/skills/gstack/bin/gstack-review-log --start review --reservation "$RESERVATION_ID" ``` -`--finish` settles it. A dispatch with no `review-log --start` settles with +`--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. diff --git a/test/ship-budget-projection.test.ts b/test/ship-budget-projection.test.ts new file mode 100644 index 000000000..940ae169e --- /dev/null +++ b/test/ship-budget-projection.test.ts @@ -0,0 +1,199 @@ +/** + * GST-101: the /ship budget projection is calibrated from the run's own meter + * (the prior only until the first measurement), the tail reserve scales with + * it, a dispatch's reservation settles when its reviewer returns, and each + * measured reservation prints its projected and actual cost. The replays use + * the numbers measured on GSTA-2 (gstack-evals #35/#36, 1.91.319.0) and on + * GSTA-152 PR B (#3596, run ship-20261029T083816-a4cc20; handoffs/token-efficiency/ + * ship-budget-projection-GSTA-152-prb.md in the GStack Minder drive). + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { loadCalibration, SHIP_STEPS, 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(); }); + +function started(h: Harness, cap: string) { + const r = h.sb(['start', '--record', h.record(), '--token-cap', cap, '--reading', '0']); + expect(r.code).toBe(0); + return { run: h.field(r.out, 'BUDGET_RUN', 'id'), ledger: h.field(r.out, 'BUDGET_RUN', 'ledger') }; +} +const finishRow = () => JSON.stringify({ skill: 'review', timestamp: new Date().toISOString(), status: 'clean', issues_found: 0, critical: 0, informational: 0 }); + +/** GSTA-2: about 0.27M per step on Steps 1-5 (1.35M), 165-200k per call; the prior projected 2.25M per step plus a 4.48M reserve. */ +const GSTA2_STEP = 270_000; +const GSTA2_STOP = 4 * 200_000; +/** GSTA-152 PR B pass 2, one reviewer at a time on a 127-line fix range (counter delta per dispatch). */ +const PASS2: Array<[string, number]> = [ + ['specialist', 373_254], ['specialist', 511_454], ['specialist', 364_113], ['specialist', 362_494], + ['specialist', 366_058], ['specialist', 234_661], ['red-team', 1_016_168], +]; +const PASS3: Array<[string, number]> = [['red-team', 1_049_900], ['fix-check', 368_801]]; +/** One parent call late in GSTA-152 (376-384k), and #26's measured stop overhead. */ +const PARENT_CALL = 380_000; +const LEAN_DISPATCH = 500_000; +const STOP_26 = 1_650_000; + +describe('projection calibrated from the run\'s own meter (GST-101)', () => { + test('GSTA-2 replay: an 8M cap admits Steps 6 and 6.5 after Steps 1-5 cost 0.27M each, and stops with room to stop', () => { + const h = fresh(); + const cap = 8_000_000; + const s = started(h, '8M'); + const steps = SHIP_STEPS.slice(SHIP_STEPS.indexOf('1')); + let reading = 0; + const admitted: string[] = []; + let stop = ''; + for (let i = 0; i + 1 < steps.length; i++) { + const calls = cal.step_calls[steps[i]!] ?? cal.step_calls.default!; + reading += Math.round((GSTA2_STEP / 3) * calls * (1 + 0.05 * i)) + (cal.step_dispatches[steps[i]!] ?? 0) * LEAN_DISPATCH; + expect(reading, `spend after Step ${steps[i]}`).toBeLessThanOrEqual(cap); + const c = h.sb(['check', '--run', s.run, '--step', steps[i]!, '--next', steps[i + 1]!, '--reading', String(reading)]); + if (c.code === 3) { stop = c.out; break; } + expect(c.code).toBe(0); + admitted.push(steps[i + 1]!); + if (steps[i + 1] === '6') expect(Number(h.field(c.out, 'BUDGET', 'projected'))).toBeLessThan(1_000_000); + } + expect(admitted).toEqual(['2', '3', '4', '5', '6', '6.5']); + expect(stop).toContain('STOP: reason=budget_exhausted'); + expect(reading + GSTA2_STOP).toBeLessThanOrEqual(cap); + }); + + test('GSTA-152 PR B replay: per-dispatch admission never holds returned reviewers, the 29.28M refusal is admitted, and the run stops before the cap', () => { + const h = fresh(); + const cap = 29_280_000; + const s = started(h, '29.28M'); + let reading = 0; + const steps = SHIP_STEPS.slice(SHIP_STEPS.indexOf('1'), SHIP_STEPS.indexOf('9') + 1); + for (let i = 0; i + 1 < steps.length; i++) { + reading += 750_000; + expect(h.sb(['check', '--run', s.run, '--step', steps[i]!, '--next', steps[i + 1]!, '--reading', String(reading)]).code).toBe(0); + } + let previous = ''; + const dispatch = (role: string) => { + reading += PARENT_CALL; + const c = h.sb(['check', '--run', s.run, '--step', '9', '--next', `dispatch:${role}`, '--reading', String(reading), ...(previous ? ['--returned', previous] : [])]); + return c; + }; + for (const [role, cost] of [...PASS2, ...PASS3]) { + const c = dispatch(role); + expect(c.code, c.out + c.err).toBe(0); + previous = h.field(c.out, 'RESERVATION', 'id'); + expect(h.reviewLog(['--start', 'review', '--reservation', previous]).code).toBe(0); + reading += cost; + } + const lean = h.ledger(s.ledger).reservations.filter((r: any) => r.kind === 'dispatch' && r.actual !== undefined); + expect(lean.length).toBe(PASS2.length + PASS3.length - 1); + const settled = h.sb(['settle', '--run', s.run, '--reservation', previous, '--reading', String(reading)]); + expect(settled.out).toMatch(/^DRIFT: id=\S+ kind=dispatch next=dispatch:fix-check projected=\d+ actual=368801 ratio=\d+\.\d\d$/m); + reading = 17_790_000; + previous = ''; + const fixCheck = dispatch('fix-check'); + expect(fixCheck.code, fixCheck.out).toBe(0); + const projected = Number(h.field(fixCheck.out, 'BUDGET', 'projected')); + expect(projected).toBeLessThan(K.per_dispatch / 3); + expect(projected).toBeGreaterThanOrEqual(1_049_900); + previous = h.field(fixCheck.out, 'RESERVATION', 'id'); + expect(h.reviewLog(['--start', 'review-fix-check', '--reservation', previous]).code).toBe(0); + reading += 368_801; + let parent = PARENT_CALL; + let stop = ''; + for (let round = 0; round < 40 && !stop; round++) { + parent += 50_000; + reading += parent; + const c = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:fix-check', '--reading', String(reading), '--returned', previous]); + if (c.code === 3) { stop = c.out; break; } + previous = h.field(c.out, 'RESERVATION', 'id'); + expect(h.reviewLog(['--start', 'review-fix-check', '--reservation', previous]).code).toBe(0); + reading += 368_801; + expect(reading).toBeLessThanOrEqual(cap); + } + expect(stop).toContain('STOP: reason=budget_exhausted'); + expect(reading + STOP_26).toBeLessThanOrEqual(cap); + }); + + test('the prior holds until the first measurement; the tail reserve scales with the measured step cost', () => { + const h = fresh(); + const s = started(h, '100M'); + const first = h.sb(['check', '--run', s.run, '--step', '1', '--next', '2', '--reading', '0']); + expect(Number(h.field(first.out, 'BUDGET', 'projected'))).toBe(3 * K.per_call); + expect(Number(h.field(first.out, 'BUDGET', 'tail'))).toBe(2_000_000 + K.stop_overhead); + expect(h.field(first.out, 'BUDGET', 'calibration')).toBe('step:prior,dispatch:prior'); + const second = h.sb(['check', '--run', s.run, '--step', '2', '--next', '3', '--reading', '450000']); + const factor = (450_000 / (3 * K.per_call)) * cal.headroom; + expect(Number(h.field(second.out, 'BUDGET', 'projected'))).toBe(Math.ceil((3 * K.per_call * factor) / cal.round_to) * cal.round_to); + expect(Number(h.field(second.out, 'BUDGET', 'tail'))).toBe(Math.ceil((2_000_000 * factor) / cal.round_to) * cal.round_to + K.stop_overhead); + expect(second.out).toMatch(/^DRIFT: id=\S+ kind=step next=2 projected=2250000 actual=450000 ratio=0\.20$/m); + const d = h.sb(['check', '--run', s.run, '--step', '3', '--next', 'dispatch:specialist,red-team', '--reading', '900000']); + expect(Number(h.field(d.out, 'BUDGET', 'projected'))).toBe(2 * K.per_dispatch); + }); + + test('an explicit --reserve is kept as given, and the stop overhead keeps its calibrated fit', () => { + const h = fresh(); + const r = h.sb(['start', '--record', h.record(), '--token-cap', '100M', '--reading', '0', '--reserve', '3M']); + const run = h.field(r.out, 'BUDGET_RUN', 'id'); + h.sb(['check', '--run', run, '--step', '1', '--next', '2', '--reading', '450000']); + const c = h.sb(['check', '--run', run, '--step', '2', '--next', '3', '--reading', '900000']); + expect(h.field(c.out, 'BUDGET', 'calibration')).not.toStartWith('step:prior'); + expect(Number(h.field(c.out, 'BUDGET', 'tail'))).toBe(3_000_000 + K.stop_overhead); + }); +}); + +describe('dispatch reservations settle when the reviewer returns (GST-101)', () => { + test('check --returned settles a consumed reservation, so the next per-dispatch admission is not blocked by it', () => { + const h = fresh(); + const s = started(h, '20M'); + const a = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '0']); + const first = h.field(a.out, 'RESERVATION', 'id'); + expect(h.reviewLog(['--start', 'review', '--reservation', first]).code).toBe(0); + const b = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '400000', '--returned', first]); + expect(b.code, b.out + b.err).toBe(0); + expect(h.field(b.out, 'BUDGET', 'reserved')).toBe('0'); + expect(h.ledger(s.ledger).reservations.find((r: any) => r.id === first)).toMatchObject({ state: 'settled', actual: 400_000 }); + const bad = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '400000', '--returned', 'nope']); + expect(bad.code).toBe(2); + expect(bad.err).toContain('--returned'); + }); + + test('--finish settles every consumed dispatch reservation of the pass, and the next reading measures them as one group', () => { + const h = fresh(); + const s = started(h, '80M'); + const tokens: string[] = []; + let reading = 0; + for (const role of ['specialist', 'specialist', 'red-team']) { + const id = h.field(h.sb(['check', '--run', s.run, '--step', '9', '--next', `dispatch:${role}`, '--reading', String(reading)]).out, 'RESERVATION', 'id'); + const st = h.reviewLog(['--start', 'review', '--reservation', id]); + expect(st.code).toBe(0); + tokens.push(st.out.trim()); + reading += 500_000; + } + expect(h.reviewLog([finishRow(), '--finish', tokens[0]!]).code).toBe(0); + const dispatches = () => h.ledger(s.ledger).reservations.filter((r: any) => r.kind === 'dispatch'); + 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]); + }); + + test('moving to the next step settles dispatch reservations a reviewer consumed in the step before', () => { + const h = fresh(); + const s = started(h, '80M'); + const id = h.field(h.sb(['check', '--run', s.run, '--step', '11', '--next', 'dispatch:outside-voice', '--reading', '0']).out, 'RESERVATION', 'id'); + 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 }); + }); + + test('close prints projected against actual for the run', () => { + const h = fresh(); + const s = started(h, '80M'); + h.sb(['check', '--run', s.run, '--step', '1', '--next', '2', '--reading', '300000']); + const close = h.sb(['close', '--run', s.run, '--reading', '700000']); + expect(close.code).toBe(0); + expect(close.out).toMatch(/^BUDGET_DRIFT: steps=2 step_projected=\d+ step_actual=700000 dispatches=0 dispatch_projected=0 dispatch_actual=0$/m); + }); +}); diff --git a/test/ship-budget.test.ts b/test/ship-budget.test.ts index fcbd635b6..a138a66b1 100644 --- a/test/ship-budget.test.ts +++ b/test/ship-budget.test.ts @@ -11,7 +11,6 @@ import { BUDGET_BIN, budgetHarness, type Harness } from './helpers/ship-budget-h const cal = loadCalibration(); const K = unitConstants(cal, 'capy-tokens'); -const STEP2 = stepProjection(cal, 'capy-tokens', '2'); const harnesses: Harness[] = []; const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; }; afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); }); @@ -42,12 +41,11 @@ describe('parsing and the admission rule', () => { test('CLI boundary: a check exactly at the cap continues, one token over stops (exit 3)', () => { const h = fresh(); - const cap = 40_000_000; for (const [extra, code, verdict] of [[0, 0, 'continue'], [1, 3, 'stop']] as const) { - const s = started(h, ['--token-cap', String(cap), '--reading', '0', '--reserve', '0']); + const cap = K.per_dispatch + K.stop_overhead - extra; + const s = started(h, ['--token-cap', String(cap), '--reading', '3000000', '--reserve', '0']); expect(s.r.code).toBe(0); - const reading = cap - STEP2 - K.stop_overhead + extra; - const c = h.sb(['check', '--run', s.run, '--step', '1', '--next', '2', '--reading', String(reading)]); + const c = h.sb(['check', '--run', s.run, '--step', '1', '--next', 'dispatch:specialist', '--reading', '3000000']); expect(c.code).toBe(code); expect(h.field(c.out, 'BUDGET', 'verdict')).toBe(verdict); expect(h.field(c.out, 'BUDGET', 'admission_cap')).toBe(String(cap)); @@ -98,7 +96,9 @@ describe('reservations', () => { const spent = 2 * K.per_dispatch; 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); - expect(next.code).toBe(spent + K.per_dispatch + K.stop_overhead <= cap ? 0 : 3); + const projected = Number(h.field(next.out, 'BUDGET', 'projected')); + expect(projected).toBe(Math.ceil((K.per_dispatch * 2 * cal.headroom) / cal.round_to) * cal.round_to); + expect(next.code).toBe(spent + projected + K.stop_overhead <= cap ? 0 : 3); const later = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:red-team', '--reading', String(cap - K.stop_overhead - K.per_dispatch + 1)]); expect(later.code).toBe(3); }); diff --git a/test/size-ledger/ship/gst-101-ship-budget-calibration.json b/test/size-ledger/ship/gst-101-ship-budget-calibration.json new file mode 100644 index 000000000..b07b26cdb --- /dev/null +++ b/test/size-ledger/ship/gst-101-ship-budget-calibration.json @@ -0,0 +1,4 @@ +{ + "bytes": 146, + "why": "GST-101: budget-handoff names check --returned for reviewers admitted one at a time" +} From 00aba4e13cb6bb8f98c1475469fab356c9960a43 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 16:42:40 +0000 Subject: [PATCH 02/11] fix: pre-landing review fixes (GST-101) Unseen charges (settle --unknown, a Claude-metered outside voice) are booked at their reservation and never measured, so they cannot shrink the dispatch factor. The handoff holds back the same calibrated reserve as check, so a delegate verdict is one the handoff admits. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/ship-budget.ts | 12 ++++---- lib/ship-handoff.ts | 4 +-- test/ship-budget-projection.test.ts | 44 ++++++++++++++++++++++++++++- 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/lib/ship-budget.ts b/lib/ship-budget.ts index 6768a01ad..ad93c3291 100644 --- a/lib/ship-budget.ts +++ b/lib/ship-budget.ts @@ -51,7 +51,7 @@ 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; + prior?: number; reading_at?: number | null; epoch?: number; actual?: number; unseen?: boolean; } export interface Sample { id: string; kind: 'step' | 'dispatch'; epoch: number; prior: number; projected: number; actual: number } export interface Handoff { @@ -172,12 +172,14 @@ export function projector(l: Ledger, cal: Calibration = loadCalibration(l.calibr /** * 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. Returns one DRIFT line per measurement. + * 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. */ export function measureSettled(l: Ledger): 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 + const pending = l.reservations.filter(r => r.state === 'settled' && r.kind !== 'child' && r.actual === undefined && !r.unseen && 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!)); @@ -277,10 +279,10 @@ export function settleIn(l: Ledger, r: Reservation, opts: { unknown?: boolean; c if (r.state === 'settled' || r.state === 'released') return; r.state = 'settled'; r.settled_at = new Date().toISOString(); - if (opts.unknown) { l.unknown += r.tokens; return; } + 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; - if (unseen && r.roles?.length) l.unknown += Math.round((r.tokens * unseen) / r.roles.length); + if (unseen && r.roles?.length) { l.unknown += Math.round((r.tokens * unseen) / r.roles.length); r.unseen = true; } } /** Validate and store one meter reading against the run's contract. */ diff --git a/lib/ship-handoff.ts b/lib/ship-handoff.ts index 48e2ac7a5..3ff8dd4cc 100644 --- a/lib/ship-handoff.ts +++ b/lib/ship-handoff.ts @@ -20,7 +20,7 @@ import { wtreeFingerprint } from './script-invocation'; import { resultLine } from './result-codes'; import { SHIP_STEPS, budgetError, budgetLine, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker, - readLedger, reserveIn, runDirFor, segmentEstimate, tally, usageError, writeLedger, type Handoff, type Ledger, + projector, readLedger, reserveIn, runDirFor, segmentEstimate, tally, usageError, writeLedger, type Handoff, type Ledger, } from './ship-budget'; import { loadMeterState, saveMeterState } from './claude-usage'; import { parseArgs, readMeter, stopLines, str, tokensOpt, writeReturnFile, type Ctx, type Outcome } from './ship-budget-cli'; @@ -92,7 +92,7 @@ export function cmdHandoff(argv: string[], ctx: Ctx): Outcome { 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 - l.tail - l.stop_overhead; + 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)); 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); diff --git a/test/ship-budget-projection.test.ts b/test/ship-budget-projection.test.ts index 940ae169e..21df72684 100644 --- a/test/ship-budget-projection.test.ts +++ b/test/ship-budget-projection.test.ts @@ -8,7 +8,7 @@ * ship-budget-projection-GSTA-152-prb.md in the GStack Minder drive). */ import { afterEach, describe, expect, test } from 'bun:test'; -import { loadCalibration, SHIP_STEPS, unitConstants } from '../lib/ship-budget'; +import { loadCalibration, measureSettled, settleIn, SHIP_STEPS, unitConstants } from '../lib/ship-budget'; import { budgetHarness, type Harness } from './helpers/ship-budget-harness'; const cal = loadCalibration(); @@ -197,3 +197,45 @@ describe('dispatch reservations settle when the reviewer returns (GST-101)', () expect(close.out).toMatch(/^BUDGET_DRIFT: steps=2 step_projected=\d+ step_actual=700000 dispatches=0 dispatch_projected=0 dispatch_actual=0$/m); }); }); + +describe('the gate fixes from /ship review (GST-101)', () => { + test('a dispatch settled --unknown is never measured, so the dispatch factor stays at the prior', () => { + const h = fresh(); + const s = started(h, '40M'); + const id = h.field(h.sb(['check', '--run', s.run, '--step', '11', '--next', 'dispatch:outside-voice', '--reading', '0']).out, 'RESERVATION', 'id'); + expect(h.reviewLog(['--start', 'adversarial-review', '--reservation', id]).code).toBe(0); + const settled = h.sb(['settle', '--run', s.run, '--reservation', id, '--unknown', '--reading', '100000']); + expect(settled.code, settled.out + settled.err).toBe(0); + expect(settled.out).not.toContain('DRIFT:'); + const next = h.sb(['check', '--run', s.run, '--step', '11', '--next', 'dispatch:outside-voice', '--reading', '200000']); + expect(h.field(next.out, 'BUDGET', 'calibration')).toEndWith('dispatch:prior'); + expect(Number(h.field(next.out, 'BUDGET', 'projected'))).toBe(K.per_dispatch); + expect(Number(h.field(next.out, 'BUDGET', 'unknown'))).toBe(K.per_dispatch); + }); + + test('a Claude-metered outside voice is booked unknown and never measured', () => { + const r = { id: 'r1', kind: 'dispatch', step: '11', next: 'dispatch:outside-voice', roles: ['outside-voice'], tokens: K.per_dispatch, at: '', state: 'consumed', reading_at: 0, epoch: 1 }; + const l: any = { meter: { contract: { meter: 'claude' }, epoch: 1, last: 100_000 }, reservations: [r], unknown: 0, samples: [] }; + settleIn(l, r as any); + expect(l.unknown).toBe(K.per_dispatch); + expect(measureSettled(l)).toEqual([]); + expect(l.samples).toEqual([]); + }); + + test('a calibrated delegate verdict is one the handoff admits: both hold back the same reserve', () => { + const h = fresh(); + const cap = 12_000_000; + const s = started(h, '12M'); + const sb = (args: string[]) => { const c = h.sb(['check', '--run', s.run, ...args]); expect(c.code, c.out + c.err).toBe(0); return c; }; + for (const [i, step] of ['1', '2', '3'].entries()) sb(['--step', step, '--next', String(i + 2), '--reading', String(300_000 * (i + 1)), '--prior-reading', String(300_000 * i + 200_000)]); + const d = h.field(sb(['--step', '4', '--next', 'dispatch:specialist', '--reading', '1200000', '--prior-reading', '1100000']).out, 'RESERVATION', 'id'); + expect(h.reviewLog(['--start', 'review', '--reservation', d]).code).toBe(0); + const c = sb(['--step', '4', '--next', '12', '--reading', '8000000', '--returned', d]); + expect(h.field(c.out, 'BUDGET', 'verdict')).toBe('delegate'); + const tail = Number(h.field(c.out, 'BUDGET', 'tail')); + expect(cap - 8_000_000).toBeLessThan(2_000_000 + K.stop_overhead); + const ho = h.sb(['handoff', '--run', s.run, '--from', '12', '--to', '22', '--base', 'origin/main', '--reading', '8000000']); + expect(ho.code, ho.out + ho.err).toBe(0); + expect(Number(ho.out.match(/^HANDOFF: \S+ sha256=\S+ sub_cap=(\d+)$/m)![1])).toBe(cap - 8_000_000 - tail); + }); +}); From 9499a6344c042ee9466a71fd87d72aee6a63b11d Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 16:51:23 +0000 Subject: [PATCH 03/11] docs(ship-budget): handoff sub-cap subtracts unknown charges and the calibrated reserve Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/ship-budget.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/ship-budget.md b/docs/ship-budget.md index ef2bdd7f0..d48b3dcdd 100644 --- a/docs/ship-budget.md +++ b/docs/ship-budget.md @@ -99,7 +99,8 @@ Step 12, 150k or more (or an unknown charge) delegates the next segment (7–11. 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 − tail, the segment estimate), or `--sub-cap`. + the child's sub-cap: min(cap − used − reserved − unknown − the calibrated tail reserve and stop + overhead, the segment estimate), or `--sub-cap`. 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. From adb3b10e51ca1bf423f3f77e96f1ed0416579b59 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 16:51:23 +0000 Subject: [PATCH 04/11] chore: changelog (Unreleased) for GST-101 ship-budget calibration Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6e40735e..efdb5a546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [Unreleased] + +**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. + +### Fixed +- **`gstack-ship-budget check`** projects steps and dispatches with the run's own factor once it has measured one: the largest actual/prior ratio of its last three measurements, times 1.5. Before the first measurement the calibrated prior applies, as before. The default tail reserve scales with the step factor; the stop overhead and an explicit `--reserve` do not. +- **Reviewers admitted one at a time no longer stay reserved.** `check --returned ` settles the reviewers that came back, `gstack-review-log --finish` settles every dispatch reservation of its step, and a check that moves to the next step settles the dispatches of the step before. `settle` takes `--reading`. +- Charges the meter cannot see (`settle --unknown`, a Claude-metered outside voice) are booked at their reservation and never measured, so they cannot shrink the dispatch factor. +- `gstack-ship-budget handoff` holds back the same calibrated reserve as `check`, so a `verdict=delegate` is always one the handoff admits. + +### Changed +- The `BUDGET:` line adds `calibration=step:,dispatch:`. Each measured reservation prints `DRIFT: … projected= actual= ratio=`, and `close` prints `BUDGET_DRIFT:` totals. docs/ship-budget.md has a Calibration section. + +### Tests +- `test/ship-budget-projection.test.ts` replays GSTA-2 (an 8M cap now reaches Step 6.5 and still stops with room to stop) and GSTA-152 PR B (the 29.28M refusal is admitted and the run stops before the cap), and covers the settle points, unseen charges and the delegate/handoff boundary. The first 8 tests failed at ad8e12d; the 3 review-fix tests failed at f30c33f. + ## [1.91.319.0] - 2026-10-29 **`gstack-pregate sealed` no longer prints a line that reads like a push confirmation.** On gstack-evals#29 the lane read `pushed aeb2413` in the `SEALED:` line as the push landing, while the remote stayed at 9036ab1 (#3565). `sealed` never pushes: it checks the commits a push would publish. `pushed ` was its label for the commit whose `sealed` policy it read beside the base branch's. From ac8cd2abe16f9b139e192b844bf4b8a955dfed22 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 17:18:03 +0000 Subject: [PATCH 05/11] test(ship-budget): give the GSTA-152 PR B replay an explicit 120s timeout It spawns about 100 CLI processes; on windows-latest it ran past the shard runner's 30s per-test timeout (windows-free-shard 5, run 40917695070), and on 2 cores it sat at bare bun test's 5s default. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/ship-budget-projection.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ship-budget-projection.test.ts b/test/ship-budget-projection.test.ts index 21df72684..a0fd46ad8 100644 --- a/test/ship-budget-projection.test.ts +++ b/test/ship-budget-projection.test.ts @@ -113,7 +113,7 @@ describe('projection calibrated from the run\'s own meter (GST-101)', () => { } expect(stop).toContain('STOP: reason=budget_exhausted'); expect(reading + STOP_26).toBeLessThanOrEqual(cap); - }); + }, 120_000); test('the prior holds until the first measurement; the tail reserve scales with the measured step cost', () => { const h = fresh(); From d33461825a147d64de2acd643719877bfe866605 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 19:04:15 +0000 Subject: [PATCH 06/11] fix(ship-budget): thresholds above the host call floor; a child hands off instead of stopping; parallel batches admit a prefix (GSTA-167) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/ship-budget.md | 46 +++- lib/ship-budget-calibration.json | 17 +- lib/ship-budget-cli.ts | 93 +++++-- lib/ship-budget.ts | 36 ++- lib/ship-handoff.ts | 61 +++-- ship/sections/budget-handoff.md | 11 +- ship/sections/budget-handoff.md.tmpl | 11 +- test/ship-budget-batch.test.ts | 93 +++++++ test/ship-budget-calibration.test.ts | 9 + test/ship-budget-chain.test.ts | 238 ++++++++++++++++++ test/ship-budget.test.ts | 21 +- .../ship/gst-101-ship-budget-calibration.json | 4 +- 12 files changed, 557 insertions(+), 83 deletions(-) create mode 100644 test/ship-budget-batch.test.ts create mode 100644 test/ship-budget-chain.test.ts diff --git a/docs/ship-budget.md b/docs/ship-budget.md index d48b3dcdd..1ff56f9e6 100644 --- a/docs/ship-budget.md +++ b/docs/ship-budget.md @@ -50,7 +50,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 | |---|---| @@ -61,6 +61,7 @@ With a cap set and no readable meter, start refuses | `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_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 +80,11 @@ gstack-ship-budget check --run --step 9 --next dispatch:specialist,red-team gstack-review-log --start review --reservation ``` +Each reader is priced at max(its projection, the run's measured mean for that role over settled +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`. `--finish ` is the pass's completion receipt: it settles that reservation and every other dispatch reservation started in the same step, so reviewers admitted one at a time @@ -92,27 +98,47 @@ BUDGET_CHECK_SKIPPED line and the run ends status=incomplete for the owner to ac ## 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). 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 @@ -144,7 +170,9 @@ 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 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..e8bb1bd3f 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, @@ -68,7 +68,7 @@ export function readMeter(l: Ledger, o: Opts, requireReading: boolean): number | } /** 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 +80,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 +93,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 +190,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 +221,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')!; @@ -206,6 +255,7 @@ export function cmdCheck(argv: string[], ctx: Ctx): Outcome { if (done) settleIn(l, r); } const drift = freshReading(l, o) ? measureSettled(l) : []; + 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 }; }); @@ -239,13 +289,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..0a8655b6d 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,12 @@ 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[]; } 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 +73,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 +112,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 +159,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,7 +175,17 @@ 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 }; } /** @@ -265,9 +284,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); diff --git a/lib/ship-handoff.ts b/lib/ship-handoff.ts index 3ff8dd4cc..f98c5f294 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'; @@ -54,14 +56,14 @@ function brief(l: Ledger, h: Omit, pins: Record 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.', '', '## 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)']), '', @@ -77,29 +79,29 @@ 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'); + if (child) joinMeter(file, child, ctx.env); + return mutateLedger(file, ctx.env, l => { + 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); @@ -139,7 +141,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 } : {}) }, @@ -229,17 +232,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 5529caca6..b828deada 100644 --- a/ship/sections/budget-handoff.md +++ b/ship/sections/budget-handoff.md @@ -25,7 +25,8 @@ Before each dispatch batch, reserve the batch as one sum, then pass the printed ~/.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 +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. 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. @@ -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/ship/sections/budget-handoff.md.tmpl b/ship/sections/budget-handoff.md.tmpl index 30fff488e..9dfcee283 100644 --- a/ship/sections/budget-handoff.md.tmpl +++ b/ship/sections/budget-handoff.md.tmpl @@ -23,7 +23,8 @@ Before each dispatch batch, reserve the batch as one sum, then pass the printed ~/.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 +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. 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. @@ -43,9 +44,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..e04013d18 --- /dev/null +++ b/test/ship-budget-chain.test.ts @@ -0,0 +1,238 @@ +/** + * 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 } 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 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]); + }); +}); diff --git a/test/ship-budget.test.ts b/test/ship-budget.test.ts index a138a66b1..1f4aeb023 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', () => { @@ -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/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" } From 109be198b4dca8aeef3307abd12675dbf21decf1 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 19:22:25 +0000 Subject: [PATCH 07/11] fix(ship-budget): a child returns incomplete while a grandchild is unreconciled or an ADMIT remainder was never re-checked; join a child's meter only after its handoff succeeds (GSTA-167) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/ship-budget.md | 5 +++- lib/ship-handoff.ts | 15 +++++++--- test/ship-budget-chain.test.ts | 50 +++++++++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/docs/ship-budget.md b/docs/ship-budget.md index 1ff56f9e6..2180eb82b 100644 --- a/docs/ship-budget.md +++ b/docs/ship-budget.md @@ -129,7 +129,10 @@ line prints `call`, `call_floor` and `above_floor`, so each verdict can be check `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). + 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. Up a chain each token is charged once: the Capy diff --git a/lib/ship-handoff.ts b/lib/ship-handoff.ts index f98c5f294..fba024659 100644 --- a/lib/ship-handoff.ts +++ b/lib/ship-handoff.ts @@ -81,8 +81,7 @@ export function cmdHandoff(argv: string[], ctx: Ctx): Outcome { if (!base) throw usageError('handoff needs --base origin/ (the merge base is pinned against it)'); const file = ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env); const child = str(o, 'child-session'); - if (child) joinMeter(file, child, ctx.env); - return mutateLedger(file, ctx.env, l => { + 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); @@ -108,6 +107,8 @@ export function cmdHandoff(argv: string[], ctx: Ctx): Outcome { 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 { @@ -166,9 +167,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 }; diff --git a/test/ship-budget-chain.test.ts b/test/ship-budget-chain.test.ts index e04013d18..c6a4286a1 100644 --- a/test/ship-budget-chain.test.ts +++ b/test/ship-budget-chain.test.ts @@ -11,11 +11,12 @@ 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 } from '../lib/ship-budget'; +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; }; @@ -236,3 +237,50 @@ describe('a chained return is charged once', () => { 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); + }); +}); From 30ce6a69428e5b83544f00188f32a15d8f099a83 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 19:33:49 +0000 Subject: [PATCH 08/11] docs(troubleshooting): BUDGET_CAP_REACHED admits a fitting prefix of a reviewer batch (GSTA-167) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/troubleshooting.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b06d26507..e02765e12 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. From d2a6e60df21b4ce90d8b0dca9a0e3c230865e2e6 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 19:33:50 +0000 Subject: [PATCH 09/11] chore: changelog (Unreleased) for GSTA-167 handoff floor Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index efdb5a546..77dc5fea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## [Unreleased] +**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. + +### 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. + **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. ### Fixed From ae0b348bcf2e9337d082316eae8fdbb4c4c9fec4 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 21:39:18 +0000 Subject: [PATCH 10/11] chore: move GSTA-167 changelog entry into [Unreleased] Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26577c48b..df35fbf13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [1.91.324.0] - 2026-10-29 +## [Unreleased] **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. @@ -15,6 +15,8 @@ ### 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. +## [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. ### Fixed From 0b49d5619fbd619557d1f3780f89dc356d92194a Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 21:59:20 +0000 Subject: [PATCH 11/11] fix(ship-budget): per-reader drift measured at each reader's return; stale prior readings ignored; segment-only handoff brief (GSTA-167) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 5 +- docs/ship-budget.md | 34 +++- lib/ship-budget-cli.ts | 32 +++- lib/ship-budget.ts | 54 ++++-- lib/ship-handoff.ts | 13 ++ ship/sections/budget-handoff.md | 16 +- ship/sections/budget-handoff.md.tmpl | 16 +- test/ship-budget-projection.test.ts | 8 +- test/ship-budget-reader-drift.test.ts | 173 ++++++++++++++++++ test/ship-budget.test.ts | 2 +- test/ship-handoff-brief.test.ts | 54 ++++++ .../ship/gsta-167-handoff-floor.json | 4 + 12 files changed, 356 insertions(+), 55 deletions(-) create mode 100644 test/ship-budget-reader-drift.test.ts create mode 100644 test/ship-handoff-brief.test.ts create mode 100644 test/size-ledger/ship/gsta-167-handoff-floor.json diff --git a/CHANGELOG.md b/CHANGELOG.md index df35fbf13..12dcfe8ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,15 @@ - **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-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 diff --git a/docs/ship-budget.md b/docs/ship-budget.md index 2180eb82b..b496c1a13 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). @@ -60,7 +65,7 @@ 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 | @@ -80,8 +85,9 @@ gstack-ship-budget check --run --step 9 --next dispatch:specialist,red-team gstack-review-log --start review --reservation ``` -Each reader is priced at max(its projection, the run's measured mean for that role over settled -dispatches). When the whole batch does not fit, `check` admits the largest prefix that does and +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). @@ -91,7 +97,10 @@ 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. @@ -177,12 +186,17 @@ Claude-metered /ship completes. Each unit's `call_floor` is calibrated separatel 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/lib/ship-budget-cli.ts b/lib/ship-budget-cli.ts index e8bb1bd3f..a499d27d0 100644 --- a/lib/ship-budget-cli.ts +++ b/lib/ship-budget-cli.ts @@ -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,8 +68,14 @@ 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. */ @@ -249,15 +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 }; }); } @@ -270,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 }; }); } diff --git a/lib/ship-budget.ts b/lib/ship-budget.ts index 0a8655b6d..a5bd9eb85 100644 --- a/lib/ship-budget.ts +++ b/lib/ship-budget.ts @@ -57,6 +57,7 @@ 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; 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 { @@ -189,26 +190,48 @@ export function projector(l: Ledger, cal: Calibration = loadCalibration(l.calibr } /** - * 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. */ @@ -297,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 fba024659..6fa465a64 100644 --- a/lib/ship-handoff.ts +++ b/lib/ship-handoff.ts @@ -51,6 +51,17 @@ 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) ?? [])]; @@ -65,6 +76,8 @@ function brief(l: Ledger, h: Omit, pins: Record 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=\`. 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}.`, '', diff --git a/ship/sections/budget-handoff.md b/ship/sections/budget-handoff.md index b828deada..fd7a3d2e2 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): ```bash @@ -26,10 +26,10 @@ Before each dispatch batch, reserve the batch as one sum, then pass the printed ``` 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. 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. +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 diff --git a/ship/sections/budget-handoff.md.tmpl b/ship/sections/budget-handoff.md.tmpl index 9dfcee283..eba10e5d5 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): ```bash @@ -24,10 +24,10 @@ Before each dispatch batch, reserve the batch as one sum, then pass the printed ``` 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. 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. +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 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 1f4aeb023..c26066937 100644 --- a/test/ship-budget.test.ts +++ b/test/ship-budget.test.ts @@ -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')); 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/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" +}