From 43b35a75c900de65d9bc8913e33e056f6a736db9 Mon Sep 17 00:00:00 2001 From: garrytan Date: Fri, 30 Oct 2026 02:48:47 +0000 Subject: [PATCH] fix(ship-budget): the Capy meter is the agent's own jam counter; a child reads its own and reconcile charges its return thread_id "this" is the root thread, so a lane subagent's budget counted every sibling tree (#3633: root +37.3M while the lane's own counter moved +22.9M). A jam's own counter includes its own subagents (measured 2026-10-30 on jam_01M658JBRMTMQ1WQWYT6WFD22W: +10.92M across a six-reader dispatch). The capy contract now reads the agent's own jam id (scope thread, descendants), the handoff child baselines at its own verify-handoff reading, and reconcile charges the child's returned own-counter used=. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- capy/skill/gstack/SKILL.md | 2 +- docs/ship-budget.md | 15 +++-- docs/troubleshooting.md | 6 +- lib/result-codes-budget.ts | 2 +- lib/ship-budget.ts | 4 +- lib/ship-handoff.ts | 12 ++-- ship/sections/budget-handoff.md | 7 +- ship/sections/budget-handoff.md.tmpl | 7 +- test/ship-budget-chain.test.ts | 9 +-- test/ship-budget-meter-contract.test.ts | 85 +++++++++++++++++++++++++ test/ship-budget.test.ts | 4 +- 11 files changed, 123 insertions(+), 30 deletions(-) create mode 100644 test/ship-budget-meter-contract.test.ts diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 2cede15c5..880b89d8f 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -42,7 +42,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` from `tools.thread.get({ thread_id: "this" })` inside the same execute program as the `gstack-ship-budget` command that takes it as `--reading `, never in a call of its own, and obeys `verdict=` (`docs/ship-budget.md`). +Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` 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 it as `--reading `, never in a call of its own, and obeys `verdict=` (`docs/ship-budget.md`). /ship reviewers on Capy (`docs/review-policy.md#reviewer-packets`): one execute program per review pass, in this order: (1) `gstack-review-packet build` with the pass's plan flags (a later build in the same pass, the Red Team or Step 11's packets, is `build --like `, never hand-copied flags); (2) under a token cap, `gstack-ship-budget check --next "$BUDGET_NEXT"` (build's `BUDGET_NEXT:` value, `role@bytes` per packet, so each reader is priced from its packet), then `gstack-review-log --start --reservation ` once per role on that reservation; (3) the counter reading `BEFORE` (a check in this program takes this same reading); (4) `tools.subagent.create`, one subagent per `PACKET:` line (at most 10 per call), `machine: "shared"`, on the lane's model at reasoning `medium` (red-team and adversarial keep the lane's reasoning; a cross-family outside voice stays as it is), starting at most the free slots of the running-subagent cap and retrying refused starts as slots free; (5) a poll of `tools.subagent.get` with `transcript: "none"` every ~20 s until every reviewer settles; (6) the reading `AFTER`, then `gstack-ship-budget settle --run --reservation --dispatched-at --reading --readers `; (7) `gstack-review-packet collect --dir `, returning only its output. Each brief is the packet brief with that packet's `read=` ranges, plus one more parallel call in turn 1: `install.sh --check --for review-voice` in bash; a reviewer then finishes in about three calls (the parallel reads, at most one turn of extra ranges, one write). The parent makes no model call while the program runs, so only that pair measures the readers; a batch settled from a later program (a rerun poll) omits `--dispatched-at` and stays `UNMEASURED`. Keep one program under ~8 minutes and rerun the poll until collect has run (a lost program loses no reviewer); never read a reviewer's transcript. `gstack-review-log --finish` stays at the step that persists the pass: it records the parent's verdict. diff --git a/docs/ship-budget.md b/docs/ship-budget.md index 2e08c7d01..f1c4d4291 100644 --- a/docs/ship-budget.md +++ b/docs/ship-budget.md @@ -23,11 +23,11 @@ you set stays as given); the stop overhead never scales. ## Quickstart -**Capy.** Read the root counter inside the execute program that runs the command, never as a +**Capy.** Read your own jam's counter inside the execute program that runs the command, never as a call of its own: ```js -const n = (await tools.thread.get({ thread_id: "this" })).cost.tokens; +const n = (await tools.thread.get({ thread_id: "" })).cost.tokens; ``` then pass it to @@ -41,8 +41,10 @@ gstack-ship-budget check --run --step 6 --next 7 --reading [--prior-rea 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). +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 handoff child reads its own counter too. **Claude Code.** `gstack-ship-budget start --record --token-cap 25M --session `. Without the session id, `start --meter claude` prints a nonce: echo it once, then rerun with `--nonce `. @@ -64,7 +66,7 @@ With a cap set and no readable meter, start refuses | `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 (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 | +| `unit`, `meter`, `epoch` | The meter contract: `capy-tokens` (the agent's own jam counter, subagents included), `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 (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 | @@ -141,7 +143,8 @@ line prints `call`, `call_floor` and `above_floor`, so each verdict can be check 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 and is the only writer of the record. The + with child_used = its own counter's reading − its reading at `verify-handoff` and is the only + writer of the record; `reconcile` charges the `BUDGET used=` its return prints. 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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 72f35a00c..c14092400 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1846,11 +1846,11 @@ owner's call: run the printed `RECOVER:` line; its resume needs ### `gstack-ship-budget: ... (BUDGET_READING_MISSING)` -**Meaning.** The run's meter is the Capy root counter, a cap is set, and the command got no +**Meaning.** The run's meter is the agent's own Capy jam counter, a cap is set, and the command got no `--reading`. Exit 2. No gstack binary can call Capy's API, so the agent reads it. -**Fix.** In one execute program return only -`(await tools.thread.get({ thread_id: "this" })).cost.tokens` and pass it as `--reading `. +**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: ... (BUDGET_READING_REJECTED)` diff --git a/lib/result-codes-budget.ts b/lib/result-codes-budget.ts index ce39b0aee..193fb16e1 100644 --- a/lib/result-codes-budget.ts +++ b/lib/result-codes-budget.ts @@ -18,7 +18,7 @@ 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: "this" }) in one execute program 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, 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)', }, BUDGET_READING_REJECTED: { anchor: 'budget-reading-rejected', diff --git a/lib/ship-budget.ts b/lib/ship-budget.ts index 06e2b8eea..462a0c1f2 100644 --- a/lib/ship-budget.ts +++ b/lib/ship-budget.ts @@ -46,9 +46,9 @@ export const DEFAULT_TAIL = 2_000_000; const DIFF_REVIEW_STEP: Record = { review: '9', 'review-fix-check': '9', 'design-review-lite': '9', 'adversarial-review': '11', 'codex-review': '11' }; export type MeterKind = 'capy' | 'claude' | 'estimate'; -export interface MeterContract { version: 1; meter: MeterKind; unit: string; host_field: string; scope: 'root' | 'session' | 'run'; descendants: boolean } +export interface MeterContract { version: 1; meter: MeterKind; unit: string; host_field: string; scope: 'thread' | 'session' | 'run'; descendants: boolean } export const METER_CONTRACTS: Record = { - capy: { version: 1, meter: 'capy', unit: 'capy-tokens', host_field: 'tools.thread.get({ thread_id: "this" }).cost.tokens', scope: 'root', descendants: true }, + capy: { version: 1, meter: 'capy', unit: 'capy-tokens', host_field: 'tools.thread.get({ thread_id: "" }).cost.tokens', scope: 'thread', descendants: true }, claude: { version: 1, meter: 'claude', unit: 'claude-usage-tokens', host_field: 'journal message.usage input+cache_creation+cache_read+output, max per (message.id, requestId)', scope: 'session', descendants: true }, estimate: { version: 1, meter: 'estimate', unit: 'estimate-tokens', host_field: 'lib/ship-budget-calibration.json projections', scope: 'run', descendants: true }, }; diff --git a/lib/ship-handoff.ts b/lib/ship-handoff.ts index 783edf97f..e9d82daaa 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. The context rule never stops a + * child_used = its own counter's reading - its reading at verify-handoff (on + * Capy each run reads its own jam, subagents included, so a child's counter + * starts below its parent's); reconcile charges the BUDGET used= its return prints. 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 @@ -102,7 +104,7 @@ function brief(l: Ledger, h: Omit, pins: Record `# /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.`, + `1. Run \`gstack-ship-budget verify-handoff --in \` (on Capy add \`--reading \`, \`(await tools.thread.get({ thread_id: "" })).cost.tokens\`, never the parent's or the root's) 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=\`. 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.', '', @@ -110,7 +112,7 @@ function brief(l: Ledger, h: Omit, pins: Record `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. Read only the \`READ:\` ranges below (\`path:first-last\`, from the gstack install root), in one parallel call; Step 9.1's selection rules are left out because the review plan's \`DISPATCH:\` lines replace them.`, '', ...readRanges(h.from, h.to), '', '## 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}.`, '', + '## Budget', '', `Sub-cap ${h.sub_cap} ${block.unit} (meter=${block.meter}); the parent keeps a ${l.tail} tail reserve. Ledger: ${l.file}. Every --reading is your own jam counter (your subagents included); the parent charges child_used from the BUDGET used= your return prints.`, '', '## Return contract', '', `Your last step is Step ${h.to}. After it, finish with \`gstack-ship-budget return --run --to ${h.to} --changed \` (a child that stops earlier returns \`--status incomplete --to \`). It writes ${h.return_file} (at most ${RETURN_MAX_LINES} lines: the steps done, new HEAD and tree, the record sections changed, the BUDGET line and GSTACK_RESULT) and prints the same lines. The parent reads the record, never your transcript.`, '', ].join('\n'); @@ -188,7 +190,7 @@ export function cmdVerifyHandoff(argv: string[], ctx: Ctx): Outcome { 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, + meter: { contract: parent.meter.contract, epoch: 1, baseline: parent.meter.contract.meter === 'capy' ? tokensOpt(o, 'reading') ?? 0 : 0, last: null, ...(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, @@ -252,7 +254,7 @@ export function cmdReconcile(argv: string[], ctx: Ctx): Outcome { const ret = parsed ?? (own?.handoff === h.sha ? own : undefined); const call = readMeter(l, o, true); const metered = h.reading_at !== null && h.epoch === l.meter.epoch && l.meter.last !== null; - const childUsed = metered ? Math.max(0, l.meter.last! - h.reading_at!) : ret?.used ?? h.sub_cap; + const childUsed = ret?.used ?? (metered ? Math.max(0, l.meter.last! - h.reading_at!) : h.sub_cap); const r = l.reservations.find(x => x.id === h.reservation); if (r && (r.state === 'open' || r.state === 'consumed')) { r.state = 'settled'; r.settled_at = new Date().toISOString(); } if (l.meter.contract.meter === 'estimate') l.estimate_used += childUsed; diff --git a/ship/sections/budget-handoff.md b/ship/sections/budget-handoff.md index cee86c793..c5bda276f 100644 --- a/ship/sections/budget-handoff.md +++ b/ship/sections/budget-handoff.md @@ -9,7 +9,8 @@ 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: read `(await tools.thread.get({ thread_id: "this" })).cost.tokens` in the same program as the +- Capy: read `(await tools.thread.get({ thread_id: "" })).cost.tokens` (your own jam, its + subagents included; a root thread's is `"this"`) 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 @@ -56,13 +57,13 @@ fresh child; you wait and advance only after reconciling it: 2. Launch: on Capy a subagent on the shared machine whose brief is "Load gstack, source 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 +3. The child runs `verify-handoff --in ` first (`--reading ` on Capy, its own counter); a `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 +4. `gstack-ship-budget reconcile --run --return --reading ` charges the return's own-counter `used=`. Read the record, never the child's transcript. Exit 3 (refused, incomplete or interrupted): regenerate the handoff or stop; every check refuses until a regenerated child reconciles complete or the owner resumes. diff --git a/ship/sections/budget-handoff.md.tmpl b/ship/sections/budget-handoff.md.tmpl index 7a96e00b1..07676954f 100644 --- a/ship/sections/budget-handoff.md.tmpl +++ b/ship/sections/budget-handoff.md.tmpl @@ -7,7 +7,8 @@ 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: read `(await tools.thread.get({ thread_id: "this" })).cost.tokens` in the same program as the +- Capy: read `(await tools.thread.get({ thread_id: "" })).cost.tokens` (your own jam, its + subagents included; a root thread's is `"this"`) 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 @@ -54,13 +55,13 @@ fresh child; you wait and advance only after reconciling it: 2. Launch: on Capy a subagent on the shared machine whose brief is "Load gstack, source 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 +3. The child runs `verify-handoff --in ` first (`--reading ` on Capy, its own counter); a `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 +4. `gstack-ship-budget reconcile --run --return --reading ` charges the return's own-counter `used=`. Read the record, never the child's transcript. Exit 3 (refused, incomplete or interrupted): regenerate the handoff or stop; every check refuses until a regenerated child reconciles complete or the owner resumes. diff --git a/test/ship-budget-chain.test.ts b/test/ship-budget-chain.test.ts index 5dedff682..59dd2f6c9 100644 --- a/test/ship-budget-chain.test.ts +++ b/test/ship-budget-chain.test.ts @@ -179,16 +179,17 @@ describe('a chained return is charged once', () => { 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'); + // Each run reads its own counter from its verify-handoff reading, and reconcile charges the return's used=. + expect(mid.out).toContain('child=complete child_used=3800000'); + expect(h.field(mid.out, 'BUDGET', 'used')).toBe('4900000'); 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.field(midAgain.out, 'BUDGET', 'used')).toBe('4900000'); 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(top.out).toContain('child=complete child_used=5000000'); 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']); diff --git a/test/ship-budget-meter-contract.test.ts b/test/ship-budget-meter-contract.test.ts new file mode 100644 index 000000000..29ce0bb99 --- /dev/null +++ b/test/ship-budget-meter-contract.test.ts @@ -0,0 +1,85 @@ +/** + * GSTA-167 #3633: on Capy the budget meter is the agent's own jam counter. + * `tools.thread.get({ thread_id: "this" })` is the ROOT thread, so a lane + * subagent's budget counted every sibling tree under the root (on #3633 the + * root moved 37.3M while the lane's own counter moved 22.9M). A jam's own + * counter includes its own subagents (jam_01M658JBRMTMQ1WQWYT6WFD22W, + * 2026-10-30: +10.92M across a 6-reader dispatch with two ~0.35M parent calls + * between), so each run reads its own jam and a handoff child reads its own + * counter, starting below the parent's; reconcile charges the child's own delta. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { METER_CONTRACTS } from '../lib/ship-budget'; +import { RESULT_CODES } from '../lib/result-codes'; +import { budgetHarness, type Harness } from './helpers/ship-budget-harness'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8'); +const OWN = 'tools.thread.get({ thread_id: "" }).cost.tokens'; +const harnesses: Harness[] = []; +const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; }; +afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); }); + +describe('the Capy meter is the agent\'s own jam counter', () => { + // Value: protects=a lane subagent's ledger meters its own jam (its subagents included), never the root thread's siblings; fails_when=the capy contract names thread_id "this" or a root scope; + // why_new=no test pinned the contract fields; seam=none; no_claim=that Capy's counter rolls up descendants (measured, not tested) + test('METER_CONTRACTS.capy reads the agent\'s own jam id with descendants, scope thread', () => { + expect(METER_CONTRACTS.capy).toMatchObject({ meter: 'capy', unit: 'capy-tokens', host_field: OWN, scope: 'thread', descendants: true }); + expect(RESULT_CODES.BUDGET_READING_MISSING.fix).toContain("own jam id"); + expect(RESULT_CODES.BUDGET_READING_MISSING.fix).not.toContain('thread_id: "this"'); + }); + + // Value: protects=every place that tells the agent how to read the meter names its own jam, so prose and contract agree; fails_when=a section, the Capy skill, docs/ship-budget.md or the troubleshooting fix still reads thread_id "this"; + // why_new=the contract test above reads only lib; seam=none; no_claim=the agent finds its jam id + test('the budget prose reads the agent\'s own jam counter, never thread_id "this"', () => { + for (const rel of ['ship/sections/budget-handoff.md.tmpl', 'capy/skill/gstack/SKILL.md', 'docs/ship-budget.md', 'docs/troubleshooting.md']) { + const text = read(rel); + expect(text, rel).not.toContain('thread_id: "this"'); + expect(text, rel).toMatch(/thread_id: "<(?:this agent's|your) own jam id>"/); + } + const capy = read('capy/skill/gstack/SKILL.md'); + expect(capy.slice(capy.indexOf('Token caps on /ship'), capy.indexOf('/ship reviewers on Capy'))).toMatch(/own jam id/); + }); + + // Value: protects=a handoff child meters its own counter, which starts far below the parent's, and the parent charges the child's own delta; fails_when=verify-handoff baselines the child at the parent's reading (its own lower reading is rejected) or reconcile charges the parent's counter delta; + // why_new=the chain tests feed child readings above the parent's, so they pass on a shared counter; seam=none; no_claim=the child passes its own counter + test('a child reads its own counter from below the parent\'s reading; reconcile charges its returned own-counter delta', () => { + const h = fresh(); + const s = h.sb(['start', '--record', h.record(), '--token-cap', '200M', '--reading', '1000000']); + const run = h.field(s.out, 'BUDGET_RUN', 'id'); + const ho = h.sb(['handoff', '--run', run, '--from', '7', '--to', '11.5', '--base', 'origin/main', '--reading', '8000000']); + expect(ho.code, ho.out + ho.err).toBe(0); + const brief = ho.out.match(/^HANDOFF: (\S+) /m)![1]!; + const returnFile = ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!; + const v = h.sb(['verify-handoff', '--in', brief, '--reading', '200000']); + expect(v.code, v.out + v.err).toBe(0); + expect(h.field(v.out, 'BUDGET', 'used')).toBe('0'); + const child = h.field(v.out, 'BUDGET_RUN', 'id'); + const c = h.sb(['check', '--run', child, '--step', '8', '--next', '9', '--reading', '1200000']); + expect(c.code, c.out + c.err).toBe(0); + expect(h.field(c.out, 'BUDGET', 'used')).toBe('1000000'); + const ret = h.sb(['return', '--run', child, '--to', '11.5', '--reading', '3200000']); + expect(h.field(ret.out, 'BUDGET', 'used')).toBe('3000000'); + const rec = h.sb(['reconcile', '--run', run, '--return', returnFile, '--reading', '11500000']); + expect(rec.code, rec.out + rec.err).toBe(0); + expect(rec.out).toContain('child=complete child_used=3000000'); + expect(h.field(rec.out, 'BUDGET', 'used')).toBe('10500000'); + }); + + // Value: protects=the child's brief tells it to read its own jam counter and that its return is what the parent charges; fails_when=the brief's verify-handoff or Budget lines omit the own-counter reading; + // why_new=ship-handoff-brief tests read only the Read and Return sections; seam=none; no_claim=the child obeys + test('the handoff brief names the child\'s own jam counter for every reading and the return as the charge', () => { + const h = fresh(); + const s = h.sb(['start', '--record', h.record(), '--token-cap', '200M', '--reading', '1000000']); + const ho = h.sb(['handoff', '--run', h.field(s.out, 'BUDGET_RUN', 'id'), '--from', '12', '--to', '22', '--base', 'origin/main', '--reading', '2000000']); + const text = fs.readFileSync(ho.out.match(/^HANDOFF: (\S+) /m)![1]!, 'utf8'); + const start = text.slice(text.indexOf('## Start'), text.indexOf('## Read')); + expect(start).toContain('(await tools.thread.get({ thread_id: "" })).cost.tokens'); + expect(start).toMatch(/never the parent's or the root's/); + const budget = text.slice(text.indexOf('## Budget'), text.indexOf('## Return contract')); + expect(budget).toMatch(/Every --reading is your own jam counter \(your subagents included\)/); + expect(budget).toMatch(/the parent charges child_used from the BUDGET used= your return prints/); + }); +}); diff --git a/test/ship-budget.test.ts b/test/ship-budget.test.ts index 8f0876f86..600aa7e24 100644 --- a/test/ship-budget.test.ts +++ b/test/ship-budget.test.ts @@ -220,7 +220,7 @@ describe('delegation', () => { expect(v.code).toBe(0); const child = h.field(v.out, 'BUDGET_RUN', 'id'); expect(h.field(v.out, 'BUDGET', 'admission_cap')).toBe(brief[3]); - expect(h.field(v.out, 'BUDGET', 'used')).toBe('100000'); + expect(h.field(v.out, 'BUDGET', 'used')).toBe('0'); const cc = h.sb(['check', '--run', child, '--step', '8', '--next', '9', '--reading', '5000000']); expect(cc.code).toBe(0); const past = h.sb(['check', '--run', child, '--step', '11', '--next', '12', '--reading', '6000000']); @@ -233,7 +233,7 @@ describe('delegation', () => { const returnFile = ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!; const rec = h.sb(['reconcile', '--run', s.run, '--return', returnFile, '--reading', '9100000']); expect(rec.code).toBe(0); - expect(rec.out).toContain('child=complete child_used=7800000'); + expect(rec.out).toContain('child=complete child_used=7600000'); expect(h.field(rec.out, 'BUDGET', 'reserved')).toBe('0'); const again = h.sb(['reconcile', '--run', s.run, '--return', returnFile, '--reading', '9200000']); expect(again.out).toContain('nothing charged twice');