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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-29 19:22:25 +00:00
1 parent d33461825a
commit 109be198b4
3 files changed
+64 -6

No files matched your search

+4 -1
View File
@@ -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 <id> --return <file> --reading <n>`; 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
+11 -4
View File
@@ -81,8 +81,7 @@ export function cmdHandoff(argv: string[], ctx: Ctx): Outcome {
if (!base) throw usageError('handoff needs --base origin/<base> (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 };
+49 -1
View File
@@ -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);
});
});