From ba42df715ae9877d8ed519a4e3fb0e8ea4c064fe Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 05:53:30 +0000 Subject: [PATCH] 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> --- CHANGELOG.md | 2 +- lib/result-codes-budget.ts | 2 +- lib/ship-budget.ts | 8 +++++--- lib/ship-handoff.ts | 2 +- test/ship-budget-review-log.test.ts | 15 +++++++++++++++ 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b658d0d2a..aa2fd7281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `/ship-runs//` (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 `/ship-runs//` (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 `**: 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 `, `--continue `) 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. diff --git a/lib/result-codes-budget.ts b/lib/result-codes-budget.ts index 63852ee3c..28ef0abc2 100644 --- a/lib/result-codes-budget.ts +++ b/lib/result-codes-budget.ts @@ -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 ', }, BUDGET_METER_UNAVAILABLE: { diff --git a/lib/ship-budget.ts b/lib/ship-budget.ts index 9fdb792fe..4a1bfb7ef 100644 --- a/lib/ship-budget.ts +++ b/lib/ship-budget.ts @@ -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 --next dispatch: and pass --reservation `; 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); diff --git a/lib/ship-handoff.ts b/lib/ship-handoff.ts index e37e448b5..48e2ac7a5 100644 --- a/lib/ship-handoff.ts +++ b/lib/ship-handoff.ts @@ -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); diff --git a/test/ship-budget-review-log.test.ts b/test/ship-budget-review-log.test.ts index 7723400d8..dcd9e5763 100644 --- a/test/ship-budget-review-log.test.ts +++ b/test/ship-budget-review-log.test.ts @@ -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); + }); });