diff --git a/docs/ship-budget.md b/docs/ship-budget.md index c24505c8a..0554a89ee 100644 --- a/docs/ship-budget.md +++ b/docs/ship-budget.md @@ -81,7 +81,8 @@ reads `"this"` and starts with `--subagent --reading-from this --meter-scope tre `GSTACK_SHIP_SUBAGENT=1` in its brief): 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`). Exact per-run cost comes from a root replay thread: a root thread that runs +(`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. diff --git a/lib/ship-budget-cli.ts b/lib/ship-budget-cli.ts index 170931c97..140f14be2 100644 --- a/lib/ship-budget-cli.ts +++ b/lib/ship-budget-cli.ts @@ -477,7 +477,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 248a22fe9..bce7667be 100644 --- a/lib/ship-budget.ts +++ b/lib/ship-budget.ts @@ -477,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/test/ship-budget-meter-scope.test.ts b/test/ship-budget-meter-scope.test.ts index 85356e61e..67a262474 100644 --- a/test/ship-budget-meter-scope.test.ts +++ b/test/ship-budget-meter-scope.test.ts @@ -73,6 +73,24 @@ describe('meter scope (GST-467)', () => { } }); + // 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=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', () => {