ship-budget resume opens a new meter epoch for a successor thread (GST-1655) (#4088)

* ship-budget resume opens a new meter epoch for a successor thread's counter (GST-1655)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: ship-budget successor cases exercise lib/ship-budget directly (test-of-test ratchet, GST-1655)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Review-Finding: p1/red-team#1 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: refuse a successor epoch while a handoff child is open (pre-landing review)

Review-Finding: p1/outside-voice#1 reader=outside-voice
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: successor refusal points at reconcile --return; a reconcile without it writes the child off (GST-1655 fix-check#1)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: refuse a successor epoch while a dispatch reservation still has readers on the previous counter (pre-landing review)

Review-Finding: p1/outside-voice#1 reader=outside-voice
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: changelog fragment names the successor refusals (GST-1655)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-06 18:14:51 +00:00
1 parent be173d52bf
commit a19130d66a
8 files changed
+291 -24

No files matched your search

+2 -2
View File
@@ -7,7 +7,7 @@
* note at each program's end, and the cap is one hard stop. Every --reading
* takes --credits <cost.credits> beside it.
*
* start --record <path> [--token-cap <n|none>] [--mode admit|observe] [--meter capy|claude|estimate] [--reading <n>] [--credits <c>]
* start --record <path> [--token-cap <n|none>] [--mode admit|observe] [--meter capy|claude|estimate] [--reading <n>] [--credits <c>] [--meter-source <jam id>]
* [--prior-reading <n>] [--session <id>|--nonce <v>] [--reserve <n>] [--delegate auto|never] [--calibration <file>] [--ledger <path>]
* note --run|--record --step <id> [--label <text>] --reading <n> [--credits <c>] [--dispatched-at <n> [--dispatched-credits <c>] --readers <k>]
* check --run <id>|--record <path> --step <id> --next <id|dispatch:<role>[,<role>...]> [--reading <n>] [--credits <c>] [--prior-reading <n>]
@@ -20,7 +20,7 @@
* verify-handoff --in <brief> [--reading <n>] [--session <id>]
* return --run <child> --to <step> [--changed <sections>] [--status complete|refused|incomplete] [--reading <n>]
* reconcile --run|--record (--return <file>|--handoff <sha256>) [--reading <n>]
* resume --record <path>|--run <id> [--reading <n>] [--meter <m>] [--session <id>] [--token-cap <n> --approved-by <who> --words <text>]
* resume --record <path>|--run <id> [--reading <n>] [--credits <c>] [--meter <m>] [--meter-source <jam id>] [--session <id>] [--token-cap <n> --approved-by <who> --words <text>]
* [--rebuild-from-record]
* close --run|--record [--reading <n>] [--approved-by <who> --words <text>]
* calls --events <file> [--since <iso>] [--until <iso>] (the host's transcript items: one CALLS: line, lib/ship-calls.ts)
@@ -0,0 +1 @@
- **A /ship run handed to a successor thread keeps one budget.** `gstack-ship-budget resume --meter-source <jam id> --reading <n> --credits <c>` (and `resume --rebuild-from-record` with the same flags) opens a new meter epoch at the successor's own counter when the named source differs from the run's, instead of refusing its readings with `BUDGET_READING_REJECTED`. Tokens and credits already spent carry, so `used`, `credits`, admission and the observe hard stop count every epoch; a reading below the last one of the same epoch is still refused. A successor resume is refused while a handoff child or a reader dispatch still spends on the previous thread's counter, so that spend is settled first instead of dropping out of the budget. `start --meter-source` records the run's source. See docs/ship-budget.md#successor-handoff (GST-1655).
+35
View File
@@ -72,6 +72,8 @@ costs, including the full-context re-bill after a gap over five minutes (about 4
handoffs/GSTA-167/cache-probe.md). The first credits reading is the baseline; the `BUDGET:`,
`BUDGET_FINAL:` and record-block lines print `credits=<used>` once one exists, and a reading below
the last is refused like a token reading ([BUDGET_READING_REJECTED](troubleshooting.md#budget-reading-rejected)).
A successor thread's readings open a new epoch for credits as well as tokens
([Successor handoff](#successor-handoff)).
## Quickstart
@@ -262,6 +264,39 @@ change `resume --run <id>` restores both. A corrupt ledger
([BUDGET_LEDGER_INVALID](troubleshooting.md#budget-ledger-invalid)) is rebuilt from the
record's block with `--rebuild-from-record`; open reservations are then charged as unknown.
### Successor handoff
When another thread takes over a /ship run (a successor handoff, routine under the 75%-of-hard-stop
rule), its `cost.tokens` and `cost.credits` are a different counter: they start near zero, or at an
unrelated cumulative value, so the run's last readings mean nothing to them. The successor resumes
the same run and names the thread its readings come from:
```bash
gstack-ship-budget resume --record <record> --meter-source <successor's jam id> --reading <its cost.tokens> --credits <its cost.credits>
```
A `--meter-source` that differs from the run's (`start --meter-source <jam id>` records one; a run
started without it has none, so any named source counts as a successor) closes the current epoch and
opens the next at the successor's readings:
EPOCH: <n> meter=capy unit=capy-tokens carried_used=<tokens> carried_credits=<credits> source=<new> previous_source=<old|unrecorded>
Spend already metered carries, so `used`, `credits`, the observe hard stop and admission all count
every epoch; `epochs[]` in the ledger keeps each closed epoch's baseline, last reading, usage and
source. Spend between the previous thread's last reading and the handoff, and the successor's spend
before its first reading, is not metered. Both readings are required (`BUDGET_READING_MISSING`
otherwise). A handoff child still open is refused (`BUDGET_READING_REJECTED`): its spend lands on
the previous thread's counter, which the new epoch cannot see. Wait for the child's `RETURN:`, then
`reconcile --return <its return file>` with a reading of that counter (`tools.thread.get` on the
previous jam id), then resume. A reconcile without `--return` writes the child off as interrupted:
its later spend is never charged. A dispatch reservation still open or consumed is refused the same way: its readers
spend on the previous counter, so settle it with `settle --reservation <id> --dispatched-at <n> --reading <n>`
on that counter once they finish, then resume. `resume --rebuild-from-record` takes the same flags: with the ledger intact it opens the
epoch as above, and a rebuilt ledger starts at the successor's readings with the record's `used` and
`credits` carried. Within one epoch nothing changes: a reading below the last is still refused
([BUDGET_READING_REJECTED](troubleshooting.md#budget-reading-rejected)), and the same thread resumes
without `--meter-source` (or with the source the run recorded).
## Calibration
`lib/ship-budget-calibration.json` holds per-meter constants (per call, per dispatch, stop
+5 -2
View File
@@ -2114,7 +2114,9 @@ of the same epoch, `--prior-reading` or `--dispatched-at` is above `--reading`,
reads its own source (Claude Code journals, the estimate). Exit 2; nothing changed.
**Fix.** Pass the counter the run started with (the BUDGET line names meter, unit and epoch).
A different meter opens a new epoch that keeps prior usage:
A successor thread's counter opens a new epoch that keeps prior usage:
`gstack-ship-budget resume --run <id> --meter-source <its jam id> --reading <n> --credits <c>`
([Successor handoff](ship-budget.md#successor-handoff)). A different meter opens one too:
`gstack-ship-budget resume --run <id> --meter <m>`.
<a id="budget-meter-unavailable"></a>
@@ -2137,7 +2139,8 @@ the path, the schema version and the parse location. Exit 2.
**Fix.** After a machine change, `gstack-ship-budget resume --run <id>` restores the ledger and
record from the Drive checkpoint. Otherwise rebuild:
`resume --record <path> --rebuild-from-record --reading <n>`; open reservations are charged as unknown.
`resume --record <path> --rebuild-from-record --reading <n>`, plus `--credits <c>` when the run
metered credits (every `ship/DRIVER.md` run does); open reservations are charged as unknown.
<a id="budget-check-skipped"></a>
### `gstack-review-log: ... (BUDGET_CHECK_SKIPPED)` / `gstack-ship-budget: ... role=<r> step=<s> started=<t> spend=unknown (BUDGET_CHECK_SKIPPED)`
+2 -2
View File
@@ -27,7 +27,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, 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>',
fix: 'pass the counter the run started with (the BUDGET line names meter, unit and epoch); a successor thread\'s counter starts a new epoch through gstack-ship-budget resume --meter-source <its jam id> --reading <n> --credits <c>, a different meter through resume --meter <m>',
},
BUDGET_METER_UNAVAILABLE: {
anchor: 'budget-meter-unavailable',
@@ -37,7 +37,7 @@ export const BUDGET_RESULT_CODES = {
BUDGET_LEDGER_INVALID: {
anchor: 'budget-ledger-invalid',
summary: 'the budget ledger or the record\'s gstack-budget block is missing, unparseable, or from another schema version',
fix: 'restore it from the Drive checkpoint (gstack-ship-budget resume --run <id>) or rebuild it from the record with resume --record <path> --rebuild-from-record --reading <n>; open reservations are then charged as unknown',
fix: 'restore it from the Drive checkpoint (gstack-ship-budget resume --run <id>) or rebuild it from the record with resume --record <path> --rebuild-from-record --reading <n> (plus --credits <c> when the run metered credits); open reservations are then charged as unknown',
},
BUDGET_CHECK_SKIPPED: {
anchor: 'budget-check-skipped',
+21 -7
View File
@@ -238,7 +238,7 @@ function pickMeter(o: Opts): MeterKind | undefined {
}
export function cmdStart(argv: string[], ctx: Ctx): Outcome {
const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'credits', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger', 'call-floor', 'mode', 'meter-scope', 'reading-from'], ['subagent']);
const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'credits', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger', 'call-floor', 'mode', 'meter-scope', 'reading-from', 'meter-source'], ['subagent']);
const mode = str(o, 'mode') ?? 'admit';
if (mode !== 'admit' && mode !== 'observe') throw usageError(`--mode '${mode}' is not admit or observe`);
const scope = str(o, 'meter-scope');
@@ -277,7 +277,7 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome {
schema_version: 1, kind: 'gstack-ship-budget', run, dir, file: str(o, 'ledger') ?? path.join(dir, 'budget.json'), record: path.resolve(record),
created_at: new Date().toISOString(), cap, tail: tokensOpt(o, 'reserve') ?? DEFAULT_TAIL, ...(tokensOpt(o, 'reserve') !== undefined ? { tail_explicit: true } : {}),
stop_overhead: unitConstants(cal, contract.unit).stop_overhead,
delegate, role: 'parent', meter: { contract, epoch: 1, baseline: 0, last: null, ...(sessions ? { sessions, config_dir: claudeConfigDir(ctx.env) } : {}) },
delegate, role: 'parent', meter: { contract, epoch: 1, baseline: 0, last: null, ...(sessions ? { sessions, config_dir: claudeConfigDir(ctx.env) } : {}), ...(str(o, 'meter-source') ? { source: str(o, 'meter-source') } : {}) },
epochs: [], carried_used: 0, estimate_used: 0, unknown: 0, next_id: 1, reservations: [], handoffs: [], cap_raises: [],
marker: paths.marker, review_log: paths.reviewLog, ...(str(o, 'calibration') ? { calibration: path.resolve(str(o, 'calibration')!) } : {}),
...(callFloor !== undefined ? { call_floor: callFloor } : {}), ...(mode === 'observe' ? { mode } : {}),
@@ -435,7 +435,8 @@ export function cmdCalls(argv: string[], ctx: Ctx): Outcome {
/** resume: re-attach after a stop or a machine change; rebuild or restore a lost ledger; journal a cap raise. */
export function cmdResume(argv: string[], ctx: Ctx): Outcome {
const o = parseArgs(argv, ['record', 'run', 'reading', 'credits', 'prior-reading', 'meter', 'session', 'nonce', 'token-cap', 'approved-by', 'words'], ['rebuild-from-record']);
const o = parseArgs(argv, ['record', 'run', 'reading', 'credits', 'prior-reading', 'meter', 'session', 'nonce', 'token-cap', 'approved-by', 'words', 'meter-source'], ['rebuild-from-record']);
const source = str(o, 'meter-source');
const cap = str(o, 'token-cap');
if (cap !== undefined && (!str(o, 'approved-by') || !str(o, 'words'))) throw usageError('resume --token-cap needs --approved-by <who> --words <the owner\'s words>; raising the cap is the owner\'s call');
const lines: string[] = [];
@@ -453,14 +454,24 @@ export function cmdResume(argv: string[], ctx: Ctx): Outcome {
}
const out = mutateLedger(file, ctx.env, l => {
const meter = pickMeter(o) ?? l.meter.contract.meter;
if (meter !== l.meter.contract.meter) {
const successor = source !== undefined && source !== l.meter.source;
if (successor && meter === 'capy' && str(o, 'reading') === undefined) throw budgetError('BUDGET_READING_MISSING', `resume --meter-source ${source}: a successor's counter needs --reading <its cost.tokens>, the new epoch's baseline`);
if (successor && l.credits && str(o, 'credits') === undefined) throw budgetError('BUDGET_READING_MISSING', `resume --meter-source ${source}: run ${l.run} meters credits; pass --credits <its cost.credits>, the new epoch's baseline`);
const open = successor ? l.handoffs.filter(h => !h.reconciled_at) : [];
if (open.length) throw budgetError('BUDGET_READING_REJECTED', `resume --meter-source ${source}: handoff ${open.map(h => `${h.sha} (steps ${h.from}-${h.to})`).join(', ')} still runs on ${l.meter.source ?? 'the previous'} counter, which the new epoch cannot see; wait for its RETURN, reconcile it with a reading of that counter (${open.map(h => `gstack-ship-budget reconcile --run ${l.run} --return ${h.return_file} --reading <its cost.tokens>`).join('; ')}), then resume. A reconcile without --return writes the child off as interrupted, and its later spend is never charged`);
const inFlight = successor ? l.reservations.filter(r => r.kind === 'dispatch' && (r.state === 'open' || r.state === 'consumed')) : [];
if (inFlight.length) throw budgetError('BUDGET_READING_REJECTED', `resume --meter-source ${source}: dispatch reservation ${inFlight.map(r => `${r.id} (step ${r.step}, ${r.state})`).join(', ')} still has readers on ${l.meter.source ?? 'the previous'} counter, which the new epoch cannot see; wait for them to settle, settle each with a reading of that counter (${inFlight.map(r => `gstack-ship-budget settle --run ${l.run} --reservation ${r.id} --dispatched-at <its reading when the readers started> --reading <its cost.tokens>`).join('; ')}), then resume`);
if (meter !== l.meter.contract.meter || successor) {
const previous = l.meter.source ?? 'unrecorded';
const sessions = meter === 'claude' ? bindClaude(o, ctx.env) : undefined;
newEpoch(l, METER_CONTRACTS[meter], meter === 'capy' ? tokensOpt(o, 'reading') ?? 0 : 0);
newEpoch(l, METER_CONTRACTS[meter], meter === 'capy' ? tokensOpt(o, 'reading') ?? 0 : 0,
successor ? { source, ...(str(o, 'credits') === undefined ? {} : { credits: parseCredits(str(o, 'credits'), '--credits') }) } : undefined);
if (sessions) { l.meter.sessions = sessions; l.meter.config_dir = claudeConfigDir(ctx.env); try { fs.unlinkSync(path.join(l.dir, 'claude-meter.json')); } catch {} }
const call = readMeter(l, { ...o, ...(meter === 'capy' ? {} : { reading: undefined as any }) }, false);
if (meter === 'claude') { l.meter.baseline = l.meter.last ?? 0; }
void call;
lines.push(`EPOCH: ${l.meter.epoch} meter=${meter} unit=${l.meter.contract.unit} carried_used=${l.carried_used}`);
lines.push(`EPOCH: ${l.meter.epoch} meter=${meter} unit=${l.meter.contract.unit} carried_used=${l.carried_used}`
+ (successor ? `${l.credits?.carried === undefined ? '' : ` carried_credits=${l.credits.carried}`} source=${source} previous_source=${previous}` : ''));
} else readMeter(l, o, false);
if (cap !== undefined) {
const to = cap === 'none' ? null : parseTokens(cap, '--token-cap');
@@ -482,12 +493,15 @@ function rebuildLedger(file: string, record: string | undefined, ctx: Ctx, o: Op
const meter = b.meter as MeterKind;
const reading = tokensOpt(o, 'reading');
if (meter === 'capy' && reading === undefined) throw budgetError('BUDGET_READING_MISSING', 'resume --rebuild-from-record on meter=capy');
const credits = str(o, 'credits') === undefined ? undefined : parseCredits(str(o, 'credits'), '--credits');
if (b.credits !== undefined && credits === undefined) throw budgetError('BUDGET_READING_MISSING', `resume --rebuild-from-record: run ${b.run} metered credits (credits=${b.credits}); pass --credits <cost.credits>, the new epoch's baseline`);
const num = (k: string) => Number(b[k] ?? 0) || 0;
const l: Ledger = {
schema_version: 1, kind: 'gstack-ship-budget', run: b.run, dir: path.dirname(file), file, record: path.resolve(record!),
created_at: new Date().toISOString(), cap: b.cap === 'none' ? null : num('cap'), tail: num('tail') || DEFAULT_TAIL,
stop_overhead: unitConstants(loadCalibration(), METER_CONTRACTS[meter].unit).stop_overhead, delegate: 'auto', role: b.role === 'child' ? 'child' : 'parent',
meter: { contract: METER_CONTRACTS[meter], epoch: num('epoch') + 1, baseline: meter === 'capy' ? reading! : 0, last: meter === 'capy' ? reading! : null },
meter: { contract: METER_CONTRACTS[meter], epoch: num('epoch') + 1, baseline: meter === 'capy' ? reading! : 0, last: meter === 'capy' ? reading! : null, ...(str(o, 'meter-source') ? { source: str(o, 'meter-source') } : {}) },
...(credits === undefined ? {} : { credits: { baseline: credits, last: credits, ...(b.credits === undefined ? {} : { carried: num('credits') }) } }),
epochs: [], carried_used: num('used'), estimate_used: 0, unknown: num('unknown') + num('reserved'), next_id: 1, reservations: [], handoffs: [], cap_raises: [],
...(() => { try { return { marker: reviewPaths(ctx.cwd, ctx.env).marker, review_log: reviewPaths(ctx.cwd, ctx.env).reviewLog }; } catch { return {}; } })(),
...(b.mode === 'observe' ? { mode: 'observe' as const } : {}), ...(b.meter_scope === 'tree' ? { meter_scope: 'tree' as const } : {}),
+21 -11
View File
@@ -11,7 +11,8 @@
* lib/spend-ledger.ts's withFileLock, so lib/autoplan-budget.ts's USD ledger is
* untouched. Meters are versioned contracts (unit, host field, scope,
* descendants, epoch); a reading lower than the last one of its epoch is
* rejected and a meter change opens a new epoch that keeps prior usage. Under a
* rejected; a meter change, or a successor thread's counter (resume --meter-source,
* GST-1655), opens a new epoch that keeps prior usage. Under a
* cap, start writes a per-branch marker next to the review log so
* gstack-review-log --start refuses a dispatch without an unspent reservation;
* without a cap no marker exists and review-log behaves as before. Commands:
@@ -81,8 +82,9 @@ export interface Ledger {
schema_version: 1; kind: 'gstack-ship-budget'; run: string; dir: string; file: string; record?: string; created_at: string;
cap: number | null; tail: number; stop_overhead: number; delegate: 'auto' | 'never'; role: 'parent' | 'child';
parent?: { run: string; ledger: string; handoff_sha: string; from: string; to: string; return_file: string };
meter: { contract: MeterContract; epoch: number; baseline: number; last: number | null; last_at?: string; sessions?: string[]; config_dir?: string };
epochs: Array<{ epoch: number; contract: MeterContract; baseline: number; last: number | null; used: number }>;
/** source: the thread whose counter the readings come from (--meter-source); a different one on resume is a successor. */
meter: { contract: MeterContract; epoch: number; baseline: number; last: number | null; last_at?: string; sessions?: string[]; config_dir?: string; source?: string };
epochs: Array<{ epoch: number; contract: MeterContract; baseline: number; last: number | null; used: number; source?: string; credits?: number }>;
carried_used: number; estimate_used: number; unknown: number; next_id: number;
reservations: Reservation[]; handoffs: Handoff[];
cap_raises: Array<{ from: number | null; to: number | null; by: string; words: string; at: string }>;
@@ -94,8 +96,8 @@ export interface Ledger {
parent_metered?: boolean;
/** observe (ship/DRIVER.md): readings are recorded by note and the cap is one hard stop; no reservation, admission or delegation. */
mode?: 'admit' | 'observe';
/** Capy cost.credits readings beside the token meter: baseline is the first reading, last the newest. */
credits?: { baseline: number; last: number };
/** Capy cost.credits readings beside the token meter: baseline is the first reading of its epoch, last the newest, carried what earlier sources spent. */
credits?: { baseline: number; last: number; carried?: number };
notes?: Note[];
/** tree: the reading is the root thread's counter (a subagent cannot read its own jam id), so every cost line carries TREE_METER. */
meter_scope?: 'tree';
@@ -415,10 +417,10 @@ export function parseCredits(raw: string | undefined, flag: string): number {
export function applyCredits(l: Ledger, value: number | undefined): void {
if (value === undefined) return;
if (l.credits && value < l.credits.last) throw budgetError('BUDGET_READING_REJECTED', `--credits ${value} is below the last credits reading ${l.credits.last}`);
l.credits = { baseline: l.credits?.baseline ?? value, last: value };
l.credits = { ...l.credits, baseline: l.credits?.baseline ?? value, last: value };
}
export const creditsUsed = (l: Ledger): number | null => (l.credits ? Math.round((l.credits.last - l.credits.baseline) * 100) / 100 : null);
export const creditsUsed = (l: Ledger): number | null => (l.credits ? Math.round(((l.credits.carried ?? 0) + l.credits.last - l.credits.baseline) * 100) / 100 : null);
export function budgetLine(l: Ledger, f: { call: number | null; next: string; projected: number; verdict: string }): string {
const t = tally(l);
@@ -469,13 +471,21 @@ export function applyReading(l: Ledger, value: number | null, source: 'flag' | '
l.meter.last_at = new Date().toISOString();
}
/** A meter change: close the epoch (its usage carries) and start the next at the new baseline. */
export function newEpoch(l: Ledger, contract: MeterContract, baseline: number): void {
/**
* A meter change: close the epoch (its usage carries) and start the next at the new baseline.
* A successor (another thread's counter) also closes the credits epoch: what they used carries
* and its credits reading is the new baseline.
*/
export function newEpoch(l: Ledger, contract: MeterContract, baseline: number, successor?: { source: string; credits?: number }): void {
const metered = l.meter.contract.meter === 'estimate' ? l.estimate_used : Math.max(0, (l.meter.last ?? l.meter.baseline) - l.meter.baseline);
l.epochs.push({ epoch: l.meter.epoch, contract: l.meter.contract, baseline: l.meter.baseline, last: l.meter.last, used: metered });
const credits = successor ? creditsUsed(l) : null;
l.epochs.push({ epoch: l.meter.epoch, contract: l.meter.contract, baseline: l.meter.baseline, last: l.meter.last, used: metered,
...(l.meter.source ? { source: l.meter.source } : {}), ...(credits === null ? {} : { credits }) });
l.carried_used += metered;
l.estimate_used = 0;
l.meter = { contract, epoch: l.meter.epoch + 1, baseline, last: baseline };
const source = successor?.source ?? l.meter.source;
l.meter = { contract, epoch: l.meter.epoch + 1, baseline, last: baseline, ...(source ? { source } : {}) };
if (credits !== null && successor?.credits !== undefined) l.credits = { baseline: successor.credits, last: successor.credits, carried: credits };
}
const BLOCK = /```gstack-budget\n[\s\S]*?\n```\n?/;
+204
View File
@@ -0,0 +1,204 @@
/**
* GST-1655: a /ship run handed to a successor thread. The successor's counter is
* another thread's (it starts near zero or at an unrelated cumulative value), so
* resume --meter-source <its jam id> opens a new meter epoch at its readings while
* spend already metered carries; a reading that goes backwards within one epoch
* is still refused.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import { BUDGET_RESULT_CODES } from '../lib/result-codes-budget';
import { applyCredits, creditsUsed, METER_CONTRACTS, newEpoch, tally, type Ledger } from '../lib/ship-budget';
import { budgetHarness, type Harness } from './helpers/ship-budget-harness';
const harnesses: Harness[] = [];
const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; };
afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); });
/** Thread A's run: started at 40M tokens and 20000 credits, last noted at 80M and 45000. */
function threadA(h: Harness, cap = '200M', source: string[] = ['--meter-source', 'jam_A']) {
const record = h.record();
const s = h.sb(['start', '--record', record, '--mode', 'observe', '--token-cap', cap, '--reading', '40M', '--credits', '20000', ...source]);
expect(s.code).toBe(0);
const n = h.sb(['note', '--record', record, '--step', '9', '--reading', '80M', '--credits', '45000']);
expect(n.code).toBe(0);
return { record, ledger: h.field(s.out, 'BUDGET_RUN', 'ledger') };
}
describe('successor thread resume (GST-1655)', () => {
const ledgerA = () => ({
meter: { contract: METER_CONTRACTS.capy, epoch: 1, baseline: 40_000_000, last: 80_000_000, source: 'jam_A' },
epochs: [], carried_used: 0, estimate_used: 0, unknown: 0, reservations: [], credits: { baseline: 20000, last: 45000 },
} as unknown as Ledger);
test('newEpoch for a successor carries tokens and credits; its credits epoch refuses a backward reading', () => {
const l = ledgerA();
newEpoch(l, METER_CONTRACTS.capy, 3_000_000, { source: 'jam_B', credits: 1500 });
expect(l.meter).toMatchObject({ epoch: 2, baseline: 3_000_000, last: 3_000_000, source: 'jam_B' });
expect(l.epochs).toEqual([expect.objectContaining({ epoch: 1, used: 40_000_000, credits: 25000, source: 'jam_A' })]);
expect(l.credits).toEqual({ baseline: 1500, last: 1500, carried: 25000 });
expect(tally(l).used).toBe(40_000_000);
applyCredits(l, 6500);
expect(creditsUsed(l)).toBe(30000);
expect(() => applyCredits(l, 6400)).toThrow('below the last credits reading 6500');
expect(BUDGET_RESULT_CODES.BUDGET_READING_REJECTED.fix).toContain('resume --meter-source <its jam id>');
});
test('newEpoch for a meter change keeps the source and leaves the credits epoch alone', () => {
const l = ledgerA();
newEpoch(l, METER_CONTRACTS.estimate, 0);
expect(l.meter).toMatchObject({ epoch: 2, source: 'jam_A' });
expect(l.epochs[0]).not.toHaveProperty('credits');
expect(l.credits).toEqual({ baseline: 20000, last: 45000 });
expect(creditsUsed(l)).toBe(25000);
});
test('the same thread: a reading below the last of its epoch is still refused, and nothing changes', () => {
const h = fresh();
const a = threadA(h);
const before = fs.readFileSync(a.ledger, 'utf8');
for (const args of [['--reading', '3M', '--credits', '50000'], ['--reading', '90M', '--credits', '1500']]) {
const r = h.sb(['resume', '--record', a.record, '--meter-source', 'jam_A', ...args]);
expect(r.code).toBe(2);
expect(r.err).toContain('(BUDGET_READING_REJECTED)');
expect(r.err).toContain('--meter-source');
}
const bare = h.sb(['resume', '--record', a.record, '--reading', '3M', '--credits', '1500']);
expect(bare.code).toBe(2);
expect(bare.err).toContain('(BUDGET_READING_REJECTED)');
expect(fs.readFileSync(a.ledger, 'utf8')).toBe(before);
});
test('a successor thread\'s readings open a new epoch at its own baseline; spend carries in tokens and credits', () => {
const h = fresh();
const a = threadA(h);
const r = h.sb(['resume', '--record', a.record, '--meter-source', 'jam_B', '--reading', '3M', '--credits', '1500']);
expect(r.code).toBe(0);
expect(r.out).toContain('EPOCH: 2 meter=capy unit=capy-tokens carried_used=40000000 carried_credits=25000 source=jam_B previous_source=jam_A');
expect(h.field(r.out, 'BUDGET', 'epoch')).toBe('2');
expect(h.field(r.out, 'BUDGET', 'used')).toBe('40000000');
expect(h.field(r.out, 'BUDGET', 'credits')).toBe('25000');
const n = h.sb(['note', '--record', a.record, '--step', '12', '--reading', '10M', '--credits', '6500']);
expect(n.code).toBe(0);
expect(h.field(n.out, 'BUDGET', 'used')).toBe('47000000');
expect(h.field(n.out, 'BUDGET', 'credits')).toBe('30000');
const back = h.sb(['note', '--record', a.record, '--step', '12', '--reading', '9M', '--credits', '6600']);
expect(back.code).toBe(2);
expect(back.err).toContain('below the last reading 10000000 of epoch 2');
const l = h.ledger(a.ledger);
expect(l.meter).toMatchObject({ epoch: 2, baseline: 3_000_000, last: 10_000_000, source: 'jam_B' });
expect(l.epochs).toEqual([expect.objectContaining({ epoch: 1, baseline: 40_000_000, last: 80_000_000, used: 40_000_000, credits: 25000, source: 'jam_A' })]);
expect(fs.readFileSync(a.record, 'utf8')).toMatch(/epoch=2 baseline=3000000 used=47000000 credits=30000/);
});
test('a successor whose counter is above the last reading still gets its own epoch instead of a mixed counter', () => {
const h = fresh();
const a = threadA(h);
const r = h.sb(['resume', '--record', a.record, '--meter-source', 'jam_B', '--reading', '120M', '--credits', '60000']);
expect(r.code).toBe(0);
expect(h.field(r.out, 'BUDGET', 'used')).toBe('40000000');
expect(h.field(r.out, 'BUDGET', 'credits')).toBe('25000');
});
test('a ledger that recorded no source treats a named one as a successor; a successor needs both readings', () => {
const h = fresh();
const a = threadA(h, '200M', []);
expect(h.sb(['resume', '--record', a.record, '--meter-source', 'jam_B', '--credits', '1500']).err).toContain('(BUDGET_READING_MISSING)');
expect(h.sb(['resume', '--record', a.record, '--meter-source', 'jam_B', '--reading', '3M']).err).toContain('(BUDGET_READING_MISSING)');
const r = h.sb(['resume', '--record', a.record, '--meter-source', 'jam_B', '--reading', '3M', '--credits', '1500']);
expect(r.code).toBe(0);
expect(r.out).toContain('source=jam_B previous_source=unrecorded');
});
test('a successor cannot open an epoch while a handoff child still runs on the predecessor\'s counter', () => {
const h = fresh();
const record = h.record();
const s = h.sb(['start', '--record', record, '--token-cap', '200M', '--reading', '40M', '--meter-source', 'jam_A']);
expect(s.code).toBe(0);
const run = h.field(s.out, 'BUDGET_RUN', 'id');
const ho = h.sb(['handoff', '--run', run, '--from', '9', '--to', '22', '--base', 'origin/main', '--reading', '41M']);
expect(ho.code, ho.out + ho.err).toBe(0);
const sha = /^HANDOFF: \S+ sha256=(\S+)/m.exec(ho.out)![1]!;
const ledger = h.field(s.out, 'BUDGET_RUN', 'ledger');
const before = fs.readFileSync(ledger, 'utf8');
const r = h.sb(['resume', '--record', record, '--meter-source', 'jam_B', '--reading', '3M']);
expect(r.code).toBe(2);
const returnFile = /^RETURN_FILE: (\S+)$/m.exec(ho.out)![1]!;
expect(r.err).toContain(sha);
expect(r.err).toContain(`reconcile --run ${run} --return ${returnFile} --reading`);
expect(r.err).toContain('writes the child off');
expect(fs.readFileSync(ledger, 'utf8')).toBe(before);
const v = h.sb(['verify-handoff', '--in', /^HANDOFF: (\S+)/m.exec(ho.out)![1]!, '--reading', '200000']);
expect(v.code, v.out + v.err).toBe(0);
expect(h.sb(['return', '--run', h.field(v.out, 'BUDGET_RUN', 'id'), '--to', '22', '--changed', 'Reviews', '--reading', '5M']).code).toBe(0);
const rec = h.sb(['reconcile', '--run', run, '--return', returnFile, '--reading', '46M']);
expect(rec.code, rec.out + rec.err).toBe(0);
expect(rec.out).toContain('child=complete child_used=4800000');
const after = h.sb(['resume', '--record', record, '--meter-source', 'jam_B', '--reading', '3M']);
expect(after.code, after.out + after.err).toBe(0);
expect(h.field(after.out, 'BUDGET', 'used')).toBe('6000000');
expect(h.field(after.out, 'BUDGET', 'epoch')).toBe('2');
const later = h.sb(['check', '--run', run, '--step', '22', '--next', '22', '--reading', '4M']);
expect(h.field(later.out, 'BUDGET', 'used')).toBe('7000000');
});
test('a successor cannot open an epoch while a dispatch reservation still has readers on the predecessor\'s counter', () => {
const h = fresh();
const record = h.record();
const s = h.sb(['start', '--record', record, '--token-cap', '200M', '--reading', '40M', '--meter-source', 'jam_A']);
expect(s.code).toBe(0);
const run = h.field(s.out, 'BUDGET_RUN', 'id');
const c = h.sb(['check', '--run', run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '41M']);
expect(c.code, c.out + c.err).toBe(0);
const id = h.field(c.out, 'RESERVATION', 'id');
const ledger = h.field(s.out, 'BUDGET_RUN', 'ledger');
const before = fs.readFileSync(ledger, 'utf8');
const r = h.sb(['resume', '--record', record, '--meter-source', 'jam_B', '--reading', '3M']);
expect(r.code).toBe(2);
expect(r.err).toContain('(BUDGET_READING_REJECTED)');
expect(r.err).toContain(`settle --run ${run} --reservation ${id} --dispatched-at <its reading when the readers started> --reading`);
expect(fs.readFileSync(ledger, 'utf8')).toBe(before);
expect(h.sb(['settle', '--run', run, '--reservation', id, '--dispatched-at', '41M', '--reading', '46M']).code).toBe(0);
const after = h.sb(['resume', '--record', record, '--meter-source', 'jam_B', '--reading', '3M']);
expect(after.code, after.out + after.err).toBe(0);
expect(h.field(after.out, 'BUDGET', 'used')).toBe('6000000');
expect(h.field(after.out, 'BUDGET', 'epoch')).toBe('2');
});
test('the total across epochs trips the hard stop', () => {
const h = fresh();
const a = threadA(h, '50M');
expect(h.sb(['resume', '--record', a.record, '--meter-source', 'jam_B', '--reading', '3M', '--credits', '1500']).code).toBe(0);
const under = h.sb(['note', '--record', a.record, '--step', '12', '--reading', '13M', '--credits', '2000']);
expect(under.code).toBe(0);
expect(h.field(under.out, 'BUDGET', 'used')).toBe('50000000');
const over = h.sb(['note', '--record', a.record, '--step', '14', '--reading', '13000001', '--credits', '2001']);
expect(over.code).toBe(3);
expect(over.out).toMatch(/^STOP: reason=budget_exhausted spent=50000001 /m);
expect(over.out).toContain('GSTACK_RESULT: skill=ship status=incomplete');
});
test('resume --rebuild-from-record takes the same path, with the ledger intact or rebuilt from the record', () => {
const h = fresh();
const a = threadA(h);
const intact = h.sb(['resume', '--record', a.record, '--rebuild-from-record', '--meter-source', 'jam_B', '--reading', '3M', '--credits', '1500']);
expect(intact.code).toBe(0);
expect(intact.out).toContain('EPOCH: 2 ');
expect(h.field(intact.out, 'BUDGET', 'used')).toBe('40000000');
const b = threadA(h, '50M');
fs.writeFileSync(b.ledger, '{"kind": "gstack-ship-budget", oops');
expect(h.sb(['resume', '--record', b.record, '--rebuild-from-record', '--meter-source', 'jam_B', '--reading', '3M']).err).toContain('(BUDGET_READING_MISSING)');
const rebuilt = h.sb(['resume', '--record', b.record, '--rebuild-from-record', '--meter-source', 'jam_B', '--reading', '3M', '--credits', '1500']);
expect(rebuilt.code).toBe(0);
expect(rebuilt.out).toContain('REBUILT:');
expect(h.field(rebuilt.out, 'BUDGET', 'epoch')).toBe('2');
expect(h.field(rebuilt.out, 'BUDGET', 'used')).toBe('40000000');
expect(h.field(rebuilt.out, 'BUDGET', 'credits')).toBe('25000');
expect(h.ledger(b.ledger).meter.source).toBe('jam_B');
expect(h.sb(['note', '--record', b.record, '--step', '12', '--reading', '2M', '--credits', '1600']).code).toBe(2);
const over = h.sb(['note', '--record', b.record, '--step', '12', '--reading', '14M', '--credits', '1600']);
expect(over.code).toBe(3);
expect(over.out).toMatch(/^STOP: reason=budget_exhausted spent=51000000 /m);
});
});