mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
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:
1 parent
d33461825a
commit
109be198b4
3 files changed
+64
-6
No files matched your search
+4
-1
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user