fix: pre-landing review fixes (stopped or lost capped run no longer blocks uncapped review starts; short child return reconciles incomplete)

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 05:53:30 +00:00
1 parent 8915643197
commit ba42df715a
5 files changed
+23 -6

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@
**/ship takes a token cap and stops cleanly at it, and long runs hand their heavy segments to fresh-context children.** On gstack-evals#31 a /ship spent 54M tokens against a 25M cap because the cap was only a number in a brief; #26, whose agent read the counter after every step, stopped at 39.40M of 40M. `/ship --token-cap 25M` (or `GSTACK_SHIP_TOKEN_CAP`) now admits each step and dispatch batch only when it fits, and a run started from a long-lived context, or one whose per-call charge passes 150k at Step 7 or Step 12, runs that segment in a fresh child.
### Added
- **`gstack-ship-budget`** (`lib/ship-budget.ts`, `lib/ship-budget-cli.ts`, `lib/ship-handoff.ts`): an admission cap on a per-run token ledger under `<state root>/ship-runs/<run>/` (its own schema; only `withFileLock` is shared with `lib/spend-ledger.ts`). `start`, `check` (the `BUDGET:` line, `verdict=continue|delegate|stop`, a `RESERVATION:` id per admitted step or batch), `settle`/`release`, `handoff`/`launch`/`verify-handoff`/`return`/`reconcile`, `resume` (cap raises need `--approved-by` and the owner's words; `--rebuild-from-record`; restore from a private Drive checkpoint) and `close`. Exit 0 continue/delegate, 3 stop, 2 usage. Meters are versioned contracts: the Capy root counter (`--reading`), Claude Code journal usage (`lib/claude-usage.ts`, bound by `--session` or an echoed nonce) or `--meter estimate`.
- **`gstack-ship-budget`** (`lib/ship-budget.ts`, `lib/ship-budget-cli.ts`, `lib/ship-handoff.ts`): an admission cap on a per-run token ledger under `<state root>/ship-runs/<run>/` (its own schema; only `withFileLock` is shared with `lib/spend-ledger.ts`). `start`, `check` (the `BUDGET:` line, `verdict=continue|delegate|stop`, a `RESERVATION:` id per admitted step or batch), `settle`/`release`, `handoff`/`launch`/`verify-handoff`/`return`/`reconcile`, `resume` (cap raises need `--approved-by` and the owner's words; `--rebuild-from-record`; restore from a private Drive checkpoint) and `close`. Exit 0 continue/delegate, 3 stop, 2 usage. Each meter is a versioned contract, and there are three: the Capy root counter (`--reading`), Claude Code journal usage (`lib/claude-usage.ts`, bound by `--session` or an echoed nonce) and `--meter estimate`.
- **`gstack-review-log --start ... --reservation <id>`**: under a cap, a dispatch without an unspent, one-shot reservation refuses before launch (`BUDGET_CHECK_SKIPPED`), and `--finish` settles it. Without a cap nothing changes.
- **/ship**: the skeleton's Budget paragraph (`--token-cap`, `--resume <record>`, `--continue <handoff>`) and a new `budget-handoff` section with the meter recipes, reservations, stop and resume, and the delegate flow per host.
- **Result codes** `BUDGET_CAP_REACHED`, `BUDGET_READING_MISSING`, `BUDGET_READING_REJECTED`, `BUDGET_METER_UNAVAILABLE`, `BUDGET_LEDGER_INVALID`, `BUDGET_CHECK_SKIPPED`, `HANDOFF_PIN_MOVED` with docs/troubleshooting.md anchors; `lib/ship-budget-calibration.json` seeded from #19, #26 and #31.
+1 -1
View File
@@ -21,7 +21,7 @@ export const BUDGET_RESULT_CODES = {
},
BUDGET_READING_REJECTED: {
anchor: 'budget-reading-rejected',
summary: 'the reading does not fit the run\'s meter contract: it is lower than the last reading of the same epoch, or it was passed to a meter that reads its own source',
summary: 'the reading does not fit the run\'s meter contract: it is lower than the last reading of the same epoch, it was passed to a meter that reads its own source, or --prior-reading is above --reading',
fix: 'pass the counter the run started with (the BUDGET line names meter, unit and epoch); a different meter starts a new epoch through gstack-ship-budget resume --meter <m>',
},
BUDGET_METER_UNAVAILABLE: {
+5 -3
View File
@@ -303,18 +303,20 @@ export function pointMarker(marker: string | undefined, l: Ledger): void {
function activeLedger(marker: string): Ledger | undefined {
let pointer: { ledger?: string };
try { pointer = JSON.parse(fs.readFileSync(marker, 'utf8')); } catch (e: any) { if (e.code === 'ENOENT') return undefined; throw e; }
const l = pointer.ledger ? readLedger(pointer.ledger) : undefined;
const l = pointer.ledger && fs.existsSync(pointer.ledger) ? readLedger(pointer.ledger) : undefined;
return l && l.cap !== null && !l.closed_at ? l : undefined;
}
/**
* gstack-review-log --start under a cap: the dispatch needs an unspent, one-shot
* dispatch reservation of the active run, consumed atomically with the start
* capture. No marker (no cap) runs capture() exactly as before.
* capture. No marker (no cap), a marker whose ledger is gone, or a stopped run
* started without --reservation (a later uncapped review on the branch) runs
* capture() exactly as before.
*/
export function admitReviewStart(marker: string, reservation: string | undefined, capture: () => string, env: NodeJS.ProcessEnv = process.env): string {
const active = activeLedger(marker);
if (!active) return capture();
if (!active || (active.stopped && !reservation)) return capture();
return mutateLedger(active.file, env, l => {
const fix = `run gstack-ship-budget check --run ${l.run} --step <s> --next dispatch:<roles> and pass --reservation <id>`;
if (l.stopped) throw new BudgetError('BUDGET_CAP_REACHED', resultLine('gstack-review-log', 'BUDGET_CAP_REACHED', `run ${l.run} stopped at Step ${l.stopped.step}: ${l.stopped.reason}`), 1);
+1 -1
View File
@@ -203,7 +203,7 @@ export function cmdReconcile(argv: string[], ctx: Ctx): Outcome {
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;
h.status = ret?.status ?? 'interrupted';
h.status = ret && ret.to !== h.to && ret.status === 'complete' ? 'incomplete' : ret?.status ?? 'interrupted';
h.child_used = childUsed;
h.reconciled_at = new Date().toISOString();
pointMarker(l.marker, l);
+15
View File
@@ -116,4 +116,19 @@ describe('gstack-review-log --start under a token cap', () => {
expect(r.code).toBe(1);
expect(r.err).toContain('(BUDGET_CAP_REACHED)');
});
test('a stopped run or a lost ledger does not block a later uncapped review on the branch', () => {
const h = fresh();
const run = capped(h, '20M');
expect(h.sb(['check', '--run', run.run, '--step', '9', '--next', 'dispatch:specialist,red-team', '--reading', '9000000']).code).toBe(3);
const afterStop = h.reviewLog(['--start', 'review']);
expect(afterStop.code).toBe(0);
expect(afterStop.out.trim()).toMatch(UUID);
const h2 = fresh();
const lost = capped(h2);
fs.unlinkSync(lost.ledger);
const afterLoss = h2.reviewLog(['--start', 'review']);
expect(afterLoss.code).toBe(0);
expect(afterLoss.out.trim()).toMatch(UUID);
});
});