From 6ffbfb8ef1d7d64113f7768b9819b1bd48148e17 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 12:45:39 -0800 Subject: [PATCH] v1.92.40.0 ship-budget: a subagent's /ship cost says it is the root counter, an upper bound (GST-467) (#3696) * gstack-ship-budget start --meter-scope self|tree: label tree-scoped cost lines, refuse METER_SCOPE_PARENT (GST-467) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship-budget: a ledger rebuilt from its record keeps meter_scope=tree; document the cap on a tree run Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship-budget: a re-start asking for tree upgrades an open run; tree labels reach the return file, drift and reconcile lines; reading-missing fix names the subagent route Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs/ship-budget.md: GSTACK_SHIP_SUBAGENT=1 stands in for --subagent only Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 14 +++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-doctor-components.sh | 1 + capy/skill/gstack/SKILL.md | 2 +- docs/ship-budget.md | 11 +- docs/troubleshooting.md | 12 ++ lib/result-codes-budget.ts | 7 +- lib/ship-budget-cli.ts | 21 +++- lib/ship-budget.ts | 18 ++- lib/ship-handoff.ts | 12 +- package.json | 2 +- ship/DRIVER.md | 4 +- ship/DRIVER.md.tmpl | 4 +- test/ship-budget-meter-scope.test.ts | 161 +++++++++++++++++++++++++++ 15 files changed, 248 insertions(+), 25 deletions(-) create mode 100644 test/ship-budget-meter-scope.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 758e468fa..7f5163280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.92.40.0] - 2026-11-01 + +**A subagent's /ship cost now says it is an upper bound.** A Capy subagent cannot read its own jam counter, so its /ship read `"this"`, the root thread, whose counter also holds every sibling lane. BUDGET_FINAL and the notes printed that total as if it were the run's own cost. + +### Added +- **`gstack-ship-budget start --meter-scope self|tree`.** A run started with `tree`, or with `--subagent` (or `GSTACK_SHIP_SUBAGENT=1`) and `--reading-from this`, is tree-scoped: every BUDGET, NOTE, BUDGET_FINAL and BUDGET_TIMELINE line it prints is followed by `METER: tree (root counter, upper bound)`. +- **`METER_SCOPE_PARENT`.** A subagent reading `"this"` that asks for `--meter-scope self` is refused with exit 2 and the fix `--meter-scope tree` (docs/troubleshooting.md#meter-scope-parent). + +### Changed +- The Capy skill and docs/ship-budget.md tell a subagent to start with `--subagent --reading-from this --meter-scope tree`, and say exact per-run cost comes from a root replay thread. The driver's Step 22 carries the `METER: tree` line into the report. + +### Tests +- `test/ship-budget-meter-scope.test.ts`: tree runs label every cost line, self runs print none, the refusal and its troubleshooting anchor, and the docs pointers. + ## [1.92.39.0] - 2026-11-01 **A push to main no longer runs Free Tests and Windows Free Tests again on a tree its pull request already passed.** 22 of 22 merges in 30 hours (Oct 27–29) reran both suites on the identical tree, about 66 Linux shard-minutes and 44 Windows minutes each. Now a `reuse` job, on main pushes only, finds the merged pull request, requires its head's tree to equal the pushed tree, and verifies that head's newest green `pull_request` or `workflow_dispatch` run with the same checks `gstack-pregate clear --run` uses. When it verifies, every suite job is skipped and the required check links the reused run. Anything else runs every job as before. The same verifier backs a new `--from-run` flag for the pregate tests stage. diff --git a/VERSION b/VERSION index 0e6e6f87b..15ecb1fd0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.39.0 +1.92.40.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index ab2e7139f..08a0a099b 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.39.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.40.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index e592bae08..33f77842f 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -361,6 +361,7 @@ gstack_result_anchor() { UPGRADE_SKIPPED) printf '%s' 'upgrade-skipped' ;; BUDGET_CAP_REACHED) printf '%s' 'budget-cap-reached' ;; BUDGET_READING_MISSING) printf '%s' 'budget-reading-missing' ;; + METER_SCOPE_PARENT) printf '%s' 'meter-scope-parent' ;; BUDGET_READING_REJECTED) printf '%s' 'budget-reading-rejected' ;; BUDGET_METER_UNAVAILABLE) printf '%s' 'budget-meter-unavailable' ;; BUDGET_LEDGER_INVALID) printf '%s' 'budget-ledger-invalid' ;; diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 12e9d01a2..7c87a2786 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -44,7 +44,7 @@ Run directories and scratch on Capy live under `~/.capy/work/` (`--out ~/. Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation presented as a run. Relay `gstack-gate render /gate.json --all` with `rec` (every recommendation, plan changes included) as the one-word default; answer with `--by agent: --owner-reply ""`. Set `GSTACK_AUTOPLAN_CHECKPOINT_DIR` to a private project-user Drive folder; after machine replacement, `gstack-autoplan resume --out --from ` restores the newest valid checkpoint. -Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "" })` (its own jam, subagents included; `"this"` names the root thread, so only a root run may pass it) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading --credits `, never in a call of its own. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`). +Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "" })` (its own jam; a subagent reading root `"this"` starts `--subagent --reading-from this --meter-scope tree`) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading --credits `, never alone. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`). /ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT:` (`poll`: rerun) or `STOP:`. `models` maps roles to models (`outside-voice`: another family); Step 11 adds `stage: "voices"`; a full repeat pass, `diffBase: ""`. Steps 7, 8, 9.2.1 and 14.5 (`step: "14.5"`) use `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your jam's transcript since /ship began. A small waiting child saves nothing. diff --git a/docs/ship-budget.md b/docs/ship-budget.md index c00dab63a..34380ab94 100644 --- a/docs/ship-budget.md +++ b/docs/ship-budget.md @@ -76,8 +76,14 @@ one program measure nothing: Capy books a call after its program ends. A jam's own counter includes its own subagents, outside voices too (measured 2026-10-30: +10.92M across a six-reader dispatch with two ~0.35M parent calls between). `"this"` is the root thread, -whose counter also holds every sibling tree, so a subagent running /ship passes its own jam id; -a root thread's own jam id is its `"this"`. A Capy handoff child cannot read its own jam id, so it +whose counter also holds every sibling tree. A subagent running /ship cannot read its own jam id, so it +reads `"this"` and starts with `--subagent --reading-from this --meter-scope tree` (`GSTACK_SHIP_SUBAGENT=1` in its +brief stands in for `--subagent` only; `--reading-from this` is still required): the run is tree-scoped, and every BUDGET, NOTE, BUDGET_FINAL +and BUDGET_TIMELINE line it prints is followed by `METER: tree (root counter, upper bound)`. A +subagent that asks for `--meter-scope self` while reading `"this"` is refused +(`METER_SCOPE_PARENT`). A `--token-cap` on a tree-scoped run stops on that upper bound, so sibling +lanes count against it. Exact per-run cost comes from a root replay thread: a root thread that runs +only that /ship, whose `"this"` is its own counter. A root thread's own jam id is its `"this"`. A Capy handoff child cannot read its own jam id, so it verifies with no reading and prints `METER: parent`; the parent's handoff and reconcile readings, taken on its own counter (which holds the child), measure it. @@ -286,6 +292,7 @@ stop rules) before claiming a saving from the cap or from delegation. [BUDGET_CAP_REACHED](troubleshooting.md#budget-cap-reached) · [BUDGET_READING_MISSING](troubleshooting.md#budget-reading-missing) · +[METER_SCOPE_PARENT](troubleshooting.md#meter-scope-parent) · [BUDGET_READING_REJECTED](troubleshooting.md#budget-reading-rejected) · [BUDGET_METER_UNAVAILABLE](troubleshooting.md#budget-meter-unavailable) · [BUDGET_LEDGER_INVALID](troubleshooting.md#budget-ledger-invalid) · diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 575871a04..80496ac85 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1942,6 +1942,18 @@ owner's call: run the printed `RECOVER:` line; its resume needs **Fix.** In the execute program that runs the command, read `(await tools.thread.get({ thread_id: "" })).cost.tokens` and pass it as `--reading `. + +### `gstack-ship-budget: ... (METER_SCOPE_PARENT)` + +**Meaning.** `start` was told the run is a subagent (`--subagent` or `GSTACK_SHIP_SUBAGENT=1`) reading +`"this"` (`--reading-from this`), the root thread's counter, which also holds every sibling lane, and +asked for `--meter-scope self`. A self scope would print that counter as the run's own cost. Exit 2; +no run started. + +**Fix.** Start with `--meter-scope tree`. Every cost line then prints +`METER: tree (root counter, upper bound)`. Exact per-run cost comes from a root replay thread +(`docs/ship-budget.md`). + ### `gstack-ship-budget: ... (BUDGET_READING_REJECTED)` diff --git a/lib/result-codes-budget.ts b/lib/result-codes-budget.ts index 7ddcf5bd2..5fcb1b63b 100644 --- a/lib/result-codes-budget.ts +++ b/lib/result-codes-budget.ts @@ -17,7 +17,12 @@ export const BUDGET_RESULT_CODES = { BUDGET_READING_MISSING: { anchor: 'budget-reading-missing', summary: 'the Capy meter needs a fresh counter reading and none was passed', - fix: 'read cost.tokens from tools.thread.get({ thread_id: "" }) (your own jam, its subagents included, never the root thread) inside the execute program that runs the command and pass it with --reading (ship/sections/budget-handoff.md has the recipe)', + fix: 'read cost.tokens from tools.thread.get({ thread_id: "" }) (your own jam, its subagents included; a subagent that can read only the root thread starts with --subagent --reading-from this --meter-scope tree) inside the execute program that runs the command and pass it with --reading (ship/sections/budget-handoff.md has the recipe)', + }, + METER_SCOPE_PARENT: { + anchor: 'meter-scope-parent', + summary: 'a subagent asked for a self-scoped run while its reading comes from "this", the root thread\'s counter, which also holds every sibling lane', + fix: 'start with --meter-scope tree (every cost line then prints METER: tree (root counter, upper bound)); exact per-run cost comes from a root replay thread', }, BUDGET_READING_REJECTED: { anchor: 'budget-reading-rejected', diff --git a/lib/ship-budget-cli.ts b/lib/ship-budget-cli.ts index 99c10d167..1f88cc6b6 100644 --- a/lib/ship-budget-cli.ts +++ b/lib/ship-budget-cli.ts @@ -14,7 +14,7 @@ import * as path from 'node:path'; import { resultLine } from './result-codes'; import { BudgetError, DEFAULT_TAIL, DELEGATE_SEGMENT_CALL, DELEGATE_START_CALL, MAX_CHAIN_DEPTH, METER_CONTRACTS, SEGMENTS, SHIP_STEPS, - aboveFloor, admits, applyCredits, applyReading, budgetError, creditsUsed, parseCredits, budgetLine, keepsSmallRun, ledgerFileFor, loadCalibration, measureSettled, mintRunId, mutateLedger, newEpoch, + aboveFloor, admits, applyCredits, applyReading, budgetError, creditsUsed, parseCredits, budgetLine, keepsSmallRun, labelScope, 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, type Note, @@ -237,9 +237,15 @@ function pickMeter(o: Opts): MeterKind | undefined { } export function cmdStart(argv: string[], ctx: Ctx): Outcome { - const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'credits', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger', 'call-floor', 'mode']); + const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'credits', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger', 'call-floor', 'mode', 'meter-scope', 'reading-from'], ['subagent']); const mode = str(o, 'mode') ?? 'admit'; if (mode !== 'admit' && mode !== 'observe') throw usageError(`--mode '${mode}' is not admit or observe`); + const scope = str(o, 'meter-scope'); + if (scope !== undefined && scope !== 'self' && scope !== 'tree') throw usageError(`--meter-scope '${scope}' is not self or tree`); + const from = str(o, 'reading-from'); + if (from !== undefined && from !== 'this' && from !== 'own') throw usageError(`--reading-from '${from}' is not this or own`); + const parentCounter = (o.subagent === true || ctx.env.GSTACK_SHIP_SUBAGENT === '1') && from === 'this'; + if (parentCounter && scope === 'self') throw budgetError('METER_SCOPE_PARENT', 'start --subagent --reading-from this --meter-scope self'); const record = str(o, 'record'); if (!record) throw usageError('start needs --record '); const rawCap = str(o, 'token-cap') ?? ctx.env.GSTACK_SHIP_TOKEN_CAP; @@ -249,8 +255,10 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome { if (delegate !== 'auto' && delegate !== 'never') throw usageError(`--delegate '${delegate}' is not auto or never`); const existing = parseRecordBlock(record); if (existing?.ledger && fs.existsSync(existing.ledger) && !readLedger(existing.ledger).closed_at) { - const l = readLedger(existing.ledger); - return { stdout: [`BUDGET_RUN: id=${l.run} ledger=${l.file}`, budgetLine(l, { call: null, next: '1', projected: 0, verdict: l.stopped ? 'stop' : 'continue' })], exit: l.stopped ? 3 : 0 }; + return mutateLedger(existing.ledger, ctx.env, l => { + if (scope === 'tree' || parentCounter) l.meter_scope = 'tree'; + return { stdout: [`BUDGET_RUN: id=${l.run} ledger=${l.file}`, budgetLine(l, { call: null, next: '1', projected: 0, verdict: l.stopped ? 'stop' : 'continue' })], exit: l.stopped ? 3 : 0 }; + }); } const meter = pickMeter(o); if (!meter) { @@ -271,6 +279,7 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome { 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 } : {}), ...(mode === 'observe' ? { mode } : {}), + ...(scope === 'tree' || parentCounter ? { meter_scope: 'tree' as const } : {}), }; if (meter === 'capy' && tokensOpt(o, 'reading') === undefined) throw budgetError('BUDGET_READING_MISSING', 'start --meter capy'); if (meter === 'capy') l.meter.baseline = tokensOpt(o, 'reading')!; @@ -279,7 +288,7 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome { const out = decide(l, '0.9', { kind: 'step', step: '1' }, call, true); writeLedger(l, ctx.env); pointMarker(l.marker, l); - return { stdout: [`BUDGET_RUN: id=${run} ledger=${l.file}`, ...out.stdout], exit: out.exit }; + return labelScope(l, { stdout: [`BUDGET_RUN: id=${run} ledger=${l.file}`, ...out.stdout], exit: out.exit }); } /** @@ -472,7 +481,7 @@ function rebuildLedger(file: string, record: string | undefined, ctx: Ctx, o: Op meter: { contract: METER_CONTRACTS[meter], epoch: num('epoch') + 1, baseline: meter === 'capy' ? reading! : 0, last: meter === 'capy' ? reading! : null }, epochs: [], carried_used: num('used'), estimate_used: 0, unknown: num('unknown') + num('reserved'), next_id: 1, reservations: [], handoffs: [], cap_raises: [], ...(() => { try { return { marker: reviewPaths(ctx.cwd, ctx.env).marker, review_log: reviewPaths(ctx.cwd, ctx.env).reviewLog }; } catch { return {}; } })(), - ...(b.mode === 'observe' ? { mode: 'observe' as const } : {}), + ...(b.mode === 'observe' ? { mode: 'observe' as const } : {}), ...(b.meter_scope === 'tree' ? { meter_scope: 'tree' as const } : {}), }; if (meter === 'estimate') l.estimate_used = 0; fs.mkdirSync(l.dir, { recursive: true, mode: 0o700 }); diff --git a/lib/ship-budget.ts b/lib/ship-budget.ts index 57083b236..ae354cf40 100644 --- a/lib/ship-budget.ts +++ b/lib/ship-budget.ts @@ -91,6 +91,8 @@ export interface Ledger { /** Capy cost.credits readings beside the token meter: baseline is the first reading, last the newest. */ credits?: { baseline: number; last: number }; notes?: Note[]; + /** tree: the reading is the root thread's counter (a subagent cannot read its own jam id), so every cost line carries TREE_METER. */ + meter_scope?: 'tree'; stopped?: { at: string; reason: string; step: string; next: string }; closed_at?: string; } @@ -359,10 +361,22 @@ export function mutateLedger(file: string, env: NodeJS.ProcessEnv, fn: (l: Le const ledger = readLedger(file); const out = fn(ledger); writeLedger(ledger, env); - return out; + return labelScope(ledger, out); }); } +export const TREE_METER = 'METER: tree (root counter, upper bound)'; + +/** A tree-scoped run never prints a cost line without TREE_METER on the line after it. */ +export function labelScope(l: Ledger, out: T): T { + if (l.meter_scope !== 'tree') return out; + const label = (lines: string[]) => lines.flatMap((line, i) => /^(BUDGET|BUDGET_FINAL|BUDGET_TIMELINE|BUDGET_DRIFT|NOTE|RECONCILED):/.test(line) && lines[i + 1] !== TREE_METER ? [line, TREE_METER] : [line]); + if (Array.isArray(out)) return label(out) as T; + const o = out as { stdout?: unknown }; + if (Array.isArray(o?.stdout)) o.stdout = label(o.stdout); + return out; +} + function childUsed(l: Ledger, r: Reservation): number { const h = l.handoffs.find(x => x.reservation === r.id); if (!h || h.reading_at === null || h.epoch !== l.meter.epoch || l.meter.last === null) return 0; @@ -463,7 +477,7 @@ export function recordBlock(l: Ledger): string { const t = tally(l); const open = l.handoffs.filter(h => !h.reconciled_at).map(h => h.sha).join(',') || 'none'; const credits = creditsUsed(l); - return `run=${l.run} ledger=${l.file} role=${l.role} cap=${l.cap ?? 'none'} tail=${l.tail} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} baseline=${l.meter.baseline} used=${t.used}${credits === null ? '' : ` credits=${credits}`} reserved=${t.reserved} unknown=${t.unknown} stop=${l.stopped?.reason ?? 'none'} handoff=${open}${l.mode === 'observe' ? ' mode=observe' : ''}`; + return `run=${l.run} ledger=${l.file} role=${l.role} cap=${l.cap ?? 'none'} tail=${l.tail} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} baseline=${l.meter.baseline} used=${t.used}${credits === null ? '' : ` credits=${credits}`} reserved=${t.reserved} unknown=${t.unknown} stop=${l.stopped?.reason ?? 'none'} handoff=${open}${l.mode === 'observe' ? ' mode=observe' : ''}${l.meter_scope === 'tree' ? ' meter_scope=tree' : ''}`; } /** The record's fenced gstack-budget block: written by the bin, never by the agent. */ diff --git a/lib/ship-handoff.ts b/lib/ship-handoff.ts index e50af160b..e19cdb190 100644 --- a/lib/ship-handoff.ts +++ b/lib/ship-handoff.ts @@ -25,7 +25,7 @@ import { resolveClaudeCommand } from './claude-bin'; import { wtreeFingerprint } from './script-invocation'; import { resultLine } from './result-codes'; import { - MAX_CHAIN_DEPTH, SHIP_STEPS, budgetError, budgetLine, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker, + MAX_CHAIN_DEPTH, SHIP_STEPS, budgetError, budgetLine, labelScope, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker, projector, readLedger, reserveIn, runDirFor, segmentEstimate, settleIn, tally, usageError, writeLedger, type Handoff, type Ledger, } from './ship-budget'; import { loadMeterState, saveMeterState } from './claude-usage'; @@ -195,7 +195,7 @@ export function cmdVerifyHandoff(argv: string[], ctx: Ctx): Outcome { ...(parent.meter.contract.meter === 'claude' ? { sessions: [str(o, 'session') ?? ''], config_dir: parent.meter.config_dir } : {}) }, epochs: [], carried_used: 0, estimate_used: 0, unknown: 0, next_id: 1, reservations: [], handoffs: [], cap_raises: [], marker: parent.marker, review_log: parent.review_log, calibration: parent.calibration, - ...(parentMetered ? { parent_metered: true } : {}), + ...(parentMetered ? { parent_metered: true } : {}), ...(parent.meter_scope ? { meter_scope: parent.meter_scope } : {}), }; if (parent.meter.contract.meter === 'claude' && !str(o, 'session')) throw usageError('verify-handoff on meter=claude needs --session '); readMeter(child, o, !parentMetered); @@ -205,9 +205,9 @@ export function cmdVerifyHandoff(argv: string[], ctx: Ctx): Outcome { }); writeLedger(child, ctx.env); pointMarker(child.marker, child); - return { stdout: [`HANDOFF_OK: steps=${block.from}-${block.to} sha256=${sha}`, `BUDGET_RUN: id=${run} ledger=${child.file}`, + return labelScope(child, { stdout: [`HANDOFF_OK: steps=${block.from}-${block.to} sha256=${sha}`, `BUDGET_RUN: id=${run} ledger=${child.file}`, ...(parentMetered ? [`${PARENT_METERED} (no counter of its own: the parent's handoff and reconcile readings measure Steps ${block.from}-${block.to})`] : []), - budgetLine(child, { call: null, next: block.from, projected: 0, verdict: 'continue' })], exit: 0 }; + budgetLine(child, { call: null, next: block.from, projected: 0, verdict: 'continue' })], exit: 0 }); } export function cmdReturn(argv: string[], ctx: Ctx): Outcome { @@ -229,11 +229,11 @@ export function cmdReturn(argv: string[], ctx: Ctx): Outcome { 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}`, + const lines = labelScope(l, [`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'}`, ...(l.parent_metered ? [PARENT_METERED] : []), budgetLine(l, { call: null, next: '-', projected: 0, verdict: 'continue' }), - `GSTACK_RESULT: skill=ship status=${result} 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/package.json b/package.json index c1604360f..888907da6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.39", + "version": "1.92.40", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/ship/DRIVER.md b/ship/DRIVER.md index f5f6e3cce..3dec39134 100644 --- a/ship/DRIVER.md +++ b/ship/DRIVER.md @@ -120,8 +120,8 @@ STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` an - Step 18: the open-PR lookup; a title file starting `v `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H. - Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr --base --gate @ --pregate --issues @ --review-loop @ --docs @ --body `; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report. -- Step 20: the ship metrics row via `gstack-review-log`. -- Step 22: `gstack-ship-budget close --reading R --credits C` (`BUDGET_FINAL:` and `BUDGET_TIMELINE:` carry tokens and credits; report both), then `gstack-ship-receipt result --pr `: `GSTACK_RESULT: skill=ship status=complete run=`. Under a merge queue, `gstack-ship-queue ready --pr [--screen|--no-screen]` at the pushed head; paste its output. +- Step 20: the metrics row via `gstack-review-log`. +- Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr `: `GSTACK_RESULT: skill=ship status=complete run=`. Under a merge queue, `gstack-ship-queue ready --pr [--screen|--no-screen]` at the pushed head; paste its output. ## Where each STOP sends you (paths under `/ship/`) diff --git a/ship/DRIVER.md.tmpl b/ship/DRIVER.md.tmpl index f78226988..d4a874ae6 100644 --- a/ship/DRIVER.md.tmpl +++ b/ship/DRIVER.md.tmpl @@ -118,8 +118,8 @@ STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` an - Step 18: the open-PR lookup; a title file starting `v `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H. - Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" && gstack-ship-receipt write --pr --base --gate @ --pregate --issues @ --review-loop @ --docs @ --body `; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report. -- Step 20: the ship metrics row via `gstack-review-log`. -- Step 22: `gstack-ship-budget close --reading R --credits C` (`BUDGET_FINAL:` and `BUDGET_TIMELINE:` carry tokens and credits; report both), then `gstack-ship-receipt result --pr `: `GSTACK_RESULT: skill=ship status=complete run=`. Under a merge queue, `gstack-ship-queue ready --pr [--screen|--no-screen]` at the pushed head; paste its output. +- Step 20: the metrics row via `gstack-review-log`. +- Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr `: `GSTACK_RESULT: skill=ship status=complete run=`. Under a merge queue, `gstack-ship-queue ready --pr [--screen|--no-screen]` at the pushed head; paste its output. ## Where each STOP sends you (paths under `/ship/`) diff --git a/test/ship-budget-meter-scope.test.ts b/test/ship-budget-meter-scope.test.ts new file mode 100644 index 000000000..acd8b4608 --- /dev/null +++ b/test/ship-budget-meter-scope.test.ts @@ -0,0 +1,161 @@ +/** + * GST-467: a subagent running /ship cannot read its own jam id, so its Capy reading is the root + * thread's counter, which also holds every sibling lane. Such a run is tree-scoped and every cost + * line it prints carries METER: tree (root counter, upper bound); a subagent that asks for a self + * scope while metering from "this" is refused (METER_SCOPE_PARENT). + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { RESULT_CODES } from '../lib/result-codes'; +import { labelScope, type Ledger } from '../lib/ship-budget'; +import { budgetHarness, type Harness } from './helpers/ship-budget-harness'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const TREE = 'METER: tree (root counter, upper bound)'; +const COST = /^(BUDGET|BUDGET_FINAL|BUDGET_TIMELINE|BUDGET_DRIFT|NOTE|RECONCILED):/; +const harnesses: Harness[] = []; +const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; }; +afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); }); + +function costLinesLabeled(out: string): boolean[] { + const lines = out.split('\n'); + return lines.flatMap((l, i) => COST.test(l) ? [lines[i + 1] === TREE] : []); +} + +function observedRun(h: Harness, start: string[], env: NodeJS.ProcessEnv = {}) { + const s = h.sb(['start', '--record', h.record(), '--mode', 'observe', '--reading', '1000000', '--credits', '100', ...start], env); + if (s.code !== 0) return { start: s, note: s, close: s }; + const run = h.field(s.out, 'BUDGET_RUN', 'id'); + const note = h.sb(['note', '--run', run, '--step', '6', '--reading', '1500000', '--credits', '150']); + const close = h.sb(['close', '--run', run, '--reading', '2000000', '--credits', '200']); + return { start: s, note, close }; +} + +describe('meter scope (GST-467)', () => { + // Value: protects=a run metered off the root counter never prints a cost without saying it is the root counter, an upper bound; fails_when=a tree-scoped run prints a BUDGET, NOTE, BUDGET_FINAL or BUDGET_TIMELINE line with no METER: tree line under it, or a self run is labeled tree; + // why_new=#3686's ledger baseline 5,769,355,282 was the root thread's counter, sibling lanes included (GST-467); seam=none; no_claim=the exact per-run cost, which only a root replay thread measures + test('every cost line of a tree-scoped run is labeled, whether asked for or implied by a subagent reading "this"; a self run is not', () => { + for (const [start, env] of [[['--meter-scope', 'tree'], {}], [['--subagent', '--reading-from', 'this'], {}], [['--reading-from', 'this'], { GSTACK_SHIP_SUBAGENT: '1' }]] as const) { + const r = observedRun(fresh(), [...start], env); + for (const out of [r.start, r.note, r.close]) { + expect(out.code, out.err).toBe(0); + const labels = costLinesLabeled(out.out); + expect(labels.length, out.out).toBeGreaterThan(0); + expect(labels.every(Boolean), out.out).toBe(true); + } + expect(r.close.out).toMatch(/^BUDGET_FINAL: [^\n]*\nMETER: tree \(root counter, upper bound\)$/m); + expect(r.close.out).toMatch(/^BUDGET_TIMELINE: [^\n]*\nMETER: tree \(root counter, upper bound\)$/m); + } + for (const start of [['--meter-scope', 'self'], ['--reading-from', 'this', '--meter-scope', 'self'], []]) { + const r = observedRun(fresh(), start); + for (const out of [r.start, r.note, r.close]) { + expect(out.code, out.err).toBe(0); + expect(out.out).not.toContain('METER: tree'); + } + } + }); + + // Value: protects=resume and a handoff child of a tree-scoped run keep the label; fails_when=resume or verify-handoff prints a BUDGET line with no METER: tree line under it; + // why_new=the test above drives start, note and close only; seam=none; no_claim=a child's exact cost + test('resume and a handoff child of a tree-scoped run label their cost lines too', () => { + const h = fresh(); + const s = h.sb(['start', '--record', h.record(), '--token-cap', '200M', '--reading', '1000000', '--meter-scope', 'tree']); + const run = h.field(s.out, 'BUDGET_RUN', 'id'); + const resumed = h.sb(['resume', '--run', run, '--reading', '1200000']); + const ho = h.sb(['handoff', '--run', run, '--from', '9', '--to', '11.5', '--base', 'origin/main', '--reading', '1300000']); + expect(ho.code, ho.out + ho.err).toBe(0); + const child = h.sb(['verify-handoff', '--in', ho.out.match(/^HANDOFF: (\S+) /m)![1]!]); + for (const out of [resumed, ho, child]) { + expect(out.code, out.out + out.err).toBe(0); + const labels = costLinesLabeled(out.out); + expect(labels.length, out.out).toBeGreaterThan(0); + expect(labels.every(Boolean), out.out).toBe(true); + } + }); + + // Value: protects=a tree run rebuilt from its record after a lost ledger keeps the label; fails_when=the record block drops meter_scope or rebuildLedger does not restore it (red-team, ship gst467-p1); + // why_new=the cases above never lose the ledger; seam=none; no_claim=checkpoint restore, which copies the whole ledger + test('a tree run rebuilt from its record still labels its cost lines', () => { + const h = fresh(); + const record = h.record(); + const s = h.sb(['start', '--record', record, '--mode', 'observe', '--reading', '1000000', '--meter-scope', 'tree']); + const ledger = h.field(s.out, 'BUDGET_RUN', 'ledger'); + fs.writeFileSync(ledger, '{"kind": "gstack-ship-budget", oops'); + const rebuilt = h.sb(['resume', '--record', record, '--rebuild-from-record', '--reading', '1500000']); + expect(rebuilt.out, rebuilt.out + rebuilt.err).toContain('REBUILT:'); + const close = h.sb(['close', '--record', record, '--reading', '2000000']); + for (const out of [rebuilt, close]) { + const labels = costLinesLabeled(out.out); + expect(labels.length, out.out).toBeGreaterThan(0); + expect(labels.every(Boolean), out.out).toBe(true); + } + }); + + // Value: protects=re-running start with tree flags on an open self run upgrades it, and a tree child's saved return file keeps the label the parent reads; fails_when=the re-start returns before applying the scope, or the return file is written before labelScope (outside voice, ship gst467-p1); + // why_new=start's resume branch and the return file were never driven with tree scope; seam=none; no_claim=a downgrade, which tree never allows + test('a re-start asking for tree upgrades an open run, and a tree child return file carries the label once', () => { + const h = fresh(); + const record = h.record(); + const s = h.sb(['start', '--record', record, '--mode', 'observe', '--reading', '1000000']); + expect(s.out).not.toContain(TREE); + const again = h.sb(['start', '--record', record, '--mode', 'observe', '--reading', '1100000', '--subagent', '--reading-from', 'this', '--meter-scope', 'tree']); + const close = h.sb(['close', '--record', record, '--reading', '2000000']); + for (const out of [again, close]) { + expect(out.code, out.out + out.err).toBe(0); + const labels = costLinesLabeled(out.out); + expect(labels.length, out.out).toBeGreaterThan(0); + expect(labels.every(Boolean), out.out).toBe(true); + } + const p = h.sb(['start', '--record', h.record(), '--token-cap', '200M', '--reading', '1000000', '--meter-scope', 'tree']); + const run = h.field(p.out, 'BUDGET_RUN', 'id'); + const ho = h.sb(['handoff', '--run', run, '--from', '9', '--to', '11.5', '--base', 'origin/main', '--reading', '1300000']); + const returnFile = ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!; + const child = h.sb(['verify-handoff', '--in', ho.out.match(/^HANDOFF: (\S+) /m)![1]!]); + const ret = h.sb(['return', '--run', h.field(child.out, 'BUDGET_RUN', 'id'), '--to', '11.5']); + expect(ret.code, ret.out + ret.err).toBe(0); + for (const text of [ret.out, fs.readFileSync(returnFile, 'utf8')]) { + const labels = costLinesLabeled(text); + expect(labels.length, text).toBeGreaterThan(0); + expect(labels.every(Boolean), text).toBe(true); + expect(text.split(TREE).length - 1, text).toBe(labels.length); + } + }); + + // Value: protects=every root-counter line a tree run prints is labeled once, drift and reconcile lines included; fails_when=labelScope misses BUDGET_DRIFT or RECONCILED, or labels an already-labeled line twice (adversarial, ship gst467-p1); + // why_new=no CLI case above prints drift or reconcile lines; seam=labelScope; no_claim=which commands print them + test('labelScope labels drift and reconcile lines too, and never twice', () => { + const tree = { meter_scope: 'tree' } as Ledger; + const lines = ['RECONCILED: handoff=x child=complete child_used=5', 'BUDGET: used=1', TREE, 'BUDGET_DRIFT: steps=1 step_actual=2', 'GSTACK_RESULT: skill=ship']; + expect(labelScope(tree, [...lines])).toEqual(['RECONCILED: handoff=x child=complete child_used=5', TREE, 'BUDGET: used=1', TREE, 'BUDGET_DRIFT: steps=1 step_actual=2', TREE, 'GSTACK_RESULT: skill=ship']); + expect(labelScope({} as Ledger, [...lines])).toEqual(lines); + }); + + // Value: protects=a subagent cannot claim its own cost while reading the root counter; fails_when=start accepts --meter-scope self from a subagent metering "this", or the refusal lacks its code and fix; + // why_new=GST-467; seam=none; no_claim=whether the brief sets the subagent flag + test('a subagent metering "this" that asks for a self scope is refused with METER_SCOPE_PARENT and a fix', () => { + const h = fresh(); + for (const [args, env] of [[['--subagent'], {}], [[], { GSTACK_SHIP_SUBAGENT: '1' }]] as const) { + const r = h.sb(['start', '--record', h.record(), '--mode', 'observe', '--reading', '1000000', '--reading-from', 'this', '--meter-scope', 'self', ...args], env); + expect(r.code).toBe(2); + expect(r.err).toContain('(METER_SCOPE_PARENT)'); + expect(r.err).toContain('--meter-scope tree'); + } + const bad = h.sb(['start', '--record', h.record(), '--reading', '1', '--meter-scope', 'mine']); + expect(bad.code).toBe(2); + expect(bad.err).toContain('--meter-scope'); + expect(RESULT_CODES.METER_SCOPE_PARENT.fix).toContain('--meter-scope tree'); + expect(fs.readFileSync(path.join(ROOT, 'docs/troubleshooting.md'), 'utf8')).toContain(``); + }); + + // Value: protects=the /ship brief tells a subagent to meter tree-scoped and the docs say where exact cost comes from; fails_when=the Capy skill or docs/ship-budget.md drops --meter-scope tree or the root-replay pointer; + // why_new=GST-467; seam=none; no_claim=agent compliance + test('the Capy skill and docs/ship-budget.md tell a subagent to pass --meter-scope tree; exact per-run cost comes from root replay threads', () => { + const skill = fs.readFileSync(path.join(ROOT, 'capy/skill/gstack/SKILL.md'), 'utf8').replace(/\s+/g, ' '); + expect(skill).toContain('--subagent --reading-from this --meter-scope tree'); + const doc = fs.readFileSync(path.join(ROOT, 'docs/ship-budget.md'), 'utf8').replace(/\s+/g, ' '); + expect(doc).toContain('--meter-scope tree'); + expect(doc).toContain(TREE); + expect(doc).toContain('root replay thread'); + }); +});