From 109be198b4dca8aeef3307abd12675dbf21decf1 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 19:22:25 +0000 Subject: [PATCH] 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); + }); +});