mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 17:28:23 +01:00
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:
1 parent
be173d52bf
commit
a19130d66a
8 files changed
+291
-24
No files matched your search
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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
@@ -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
@@ -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?/;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user