mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 14:34:42 +01:00
train T3: merge #3614 at ac8cd2abe1
This commit is contained in:
commit
d06ab83a02
12 files changed
+423
-50
No files matched your search
@@ -2,6 +2,22 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**A /ship token cap now projects each step and reviewer from what the run has actually spent, so real caps stop refusing work that costs a fraction of the old guess.** On GSTA-152 a 29.28M cap refused its third reviewer because every dispatch was projected at 7.43M while real ones cost 0.23 to 1.02M; on GSTA-2 an 8M cap stopped before Step 6 after Steps 1 to 5 cost about 0.27M each, because every step was projected at 2.25M.
|
||||
|
||||
### Fixed
|
||||
- **`gstack-ship-budget check`** projects steps and dispatches with the run's own factor once it has measured one: the largest actual/prior ratio of its last three measurements, times 1.5. Before the first measurement the calibrated prior applies, as before. The default tail reserve scales with the step factor; the stop overhead and an explicit `--reserve` do not.
|
||||
- **Reviewers admitted one at a time no longer stay reserved.** `check --returned <id>` settles the reviewers that came back, `gstack-review-log --finish` settles every dispatch reservation of its step, and a check that moves to the next step settles the dispatches of the step before. `settle` takes `--reading`.
|
||||
- Charges the meter cannot see (`settle --unknown`, a Claude-metered outside voice) are booked at their reservation and never measured, so they cannot shrink the dispatch factor.
|
||||
- `gstack-ship-budget handoff` holds back the same calibrated reserve as `check`, so a `verdict=delegate` is always one the handoff admits.
|
||||
|
||||
### Changed
|
||||
- The `BUDGET:` line adds `calibration=step:<f|prior>,dispatch:<f|prior>`. Each measured reservation prints `DRIFT: … projected=<n> actual=<n> ratio=<r>`, and `close` prints `BUDGET_DRIFT:` totals. docs/ship-budget.md has a Calibration section.
|
||||
|
||||
### Tests
|
||||
- `test/ship-budget-projection.test.ts` replays GSTA-2 (an 8M cap now reaches Step 6.5 and still stops with room to stop) and GSTA-152 PR B (the 29.28M refusal is admitted and the run stops before the cap), and covers the settle points, unseen charges and the delegate/handoff boundary. The first 8 tests failed at ad8e12d; the 3 review-fix tests failed at f30c33f.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Tests
|
||||
- `test/review-depth.test.ts`: the #19 shape plans fix-check, Red Team and native adversarial; full depth, a fail-closed classification, `--depth-floor full`, a 151-line, missing or unread range and a gate-touching fix keep the full pass; a forced specialist and changed numbers keep their reviewers; 150, 151 and a docs-only range; the scoped pass counts toward the cap and a blocker it leaves stops; `DEPTH_RULES` moves with the constant.
|
||||
- `test/review-plan-cli.test.ts`: `--range-base` plans `NEXT: scoped`; a 151-line range, no range, an unknown base and uncommitted fixes plan full.
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
# Under a /ship token cap (docs/ship-budget.md): --start SKILL --reservation ID
|
||||
# with the RESERVATION id gstack-ship-budget check printed for this dispatch
|
||||
# batch; refuses (exit 1, BUDGET_CHECK_SKIPPED) without an unspent one, and
|
||||
# --finish settles it. Without a cap no marker exists and nothing changes.
|
||||
# --finish settles it with the pass's other dispatch reservations of that
|
||||
# step. Without a cap no marker exists and nothing changes.
|
||||
# Fix-range repeat (#3339): gstack-review-log --start review --fix-range
|
||||
# prints the token, then FIX_BASE=<sha>; refuses (exit 1) unless the latest
|
||||
# pass completed with fixes on unchanged content and its commit is an
|
||||
@@ -151,12 +152,13 @@ try {
|
||||
|
||||
echo "$STAMPED" >> "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl"
|
||||
|
||||
# A finished dispatch settles the budget reservation its start consumed (its
|
||||
# completion receipt); stdout stays unchanged and a failure never undoes the row.
|
||||
# A finished pass settles the budget reservation its start consumed (its
|
||||
# completion receipt) and every other dispatch reservation started in the same
|
||||
# step; stdout stays unchanged and a failure never undoes the row.
|
||||
if [ -n "$TOKEN" ] && [ -f "$GSTACK_SHIP_BUDGET_MARKER" ]; then
|
||||
GSTACK_REVIEW_TOKEN="$TOKEN" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e '
|
||||
const { settleReviewFinish } = await import(process.env.GSTACK_SHIP_BUDGET_LIB);
|
||||
try { const id = settleReviewFinish(process.env.GSTACK_SHIP_BUDGET_MARKER, process.env.GSTACK_REVIEW_TOKEN); if (id) console.error("review-log: settled budget reservation " + id); }
|
||||
try { const id = settleReviewFinish(process.env.GSTACK_SHIP_BUDGET_MARKER, process.env.GSTACK_REVIEW_TOKEN); if (id) console.error("review-log: settled budget reservations " + id); }
|
||||
catch (e) { console.error("review-log: budget reservation not settled: " + e.message); }
|
||||
' || true
|
||||
fi
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
*
|
||||
* start --record <path> [--token-cap <n|none>] [--meter capy|claude|estimate] [--reading <n>] [--prior-reading <n>]
|
||||
* [--session <id>|--nonce <v>] [--reserve <n>] [--delegate auto|never] [--calibration <file>] [--ledger <path>]
|
||||
* check --run <id>|--record <path> --step <id> --next <id|dispatch:<role>[,<role>...]> [--reading <n>] [--prior-reading <n>]
|
||||
* settle --run|--record --reservation <id> [--unknown] release --run|--record --reservation <id>
|
||||
* check --run <id>|--record <path> --step <id> --next <id|dispatch:<role>[,<role>...]> [--reading <n>] [--prior-reading <n>] [--returned <id>[,<id>...]]
|
||||
* settle --run|--record --reservation <id> [--unknown] [--reading <n>] release --run|--record --reservation <id>
|
||||
* handoff --run|--record --from <step> --to <step> --base origin/<base> [--reviewed-tree <t>] [--trail <f> --trail-run <id>]
|
||||
* [--sub-cap <n>] [--reading <n>] [--child-session <id>]
|
||||
* launch --in <brief> (Claude Code: background headless child with explicit permissions)
|
||||
|
||||
+28
-6
@@ -17,7 +17,9 @@ Exactly at the cap is admitted; one token over is not. A parallel review batch i
|
||||
one sum before any of it starts. Admitted work can still cost more than its projection, and
|
||||
gstack cannot stop a host mid-turn from bash, so a run can finish above the cap by the
|
||||
misprojection of work already admitted. The tail reserve (default 2M, `--reserve <n>`) keeps
|
||||
room for the receipt steps, and the stop overhead (calibrated) keeps room to stop cleanly.
|
||||
room for the receipt steps, and the stop overhead (calibrated) keeps room to stop cleanly. Once
|
||||
the run has measured a step, the tail reserve scales with its measured step cost (a `--reserve`
|
||||
you set stays as given); the stop overhead never scales.
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -48,7 +50,7 @@ With a cap set and no readable meter, start refuses
|
||||
|
||||
## The BUDGET line
|
||||
|
||||
BUDGET: used=<n> reserved=<n> unknown=<n> admission_cap=<n|none> tail=<n> unit=<u> meter=<m> epoch=<n> call=<n|unknown> next=<id> projected=<n> verdict=continue|delegate|stop
|
||||
BUDGET: used=<n> reserved=<n> unknown=<n> admission_cap=<n|none> tail=<n> unit=<u> meter=<m> epoch=<n> call=<n|unknown> next=<id> projected=<n> calibration=step:<f|prior>,dispatch:<f|prior> verdict=continue|delegate|stop
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
@@ -56,10 +58,11 @@ With a cap set and no readable meter, start refuses
|
||||
| `reserved` | Open reservations: admitted steps, started dispatches, and each open child's sub-cap counted as max(sub_cap, child_used), never both |
|
||||
| `unknown` | Charges the meter cannot see (an external-CLI outside voice on Claude Code), counted at their reservation |
|
||||
| `admission_cap` | The cap, or `none` |
|
||||
| `tail` | Tail reserve plus stop overhead, held back from every admission |
|
||||
| `tail` | Tail reserve (scaled by the step factor once measured) plus stop overhead, held back from every admission |
|
||||
| `unit`, `meter`, `epoch` | The meter contract: `capy-tokens` (root counter), `claude-usage-tokens` (journal usage), `estimate-tokens`; a meter change opens a new epoch |
|
||||
| `call` | The per-call charge: two Capy readings in consecutive calls, or the last Claude Code call's usage |
|
||||
| `next`, `projected` | What the check was for and its calibrated projection |
|
||||
| `next`, `projected` | What the check was for and its projection: the prior times the run's factor for that kind |
|
||||
| `calibration` | The run's step and dispatch factors (see Calibration), or `prior` before the first measurement |
|
||||
| `verdict` | `continue` (exit 0, prints `RESERVATION:` under a cap), `delegate` (exit 0), `stop` (exit 3) |
|
||||
|
||||
Any exit other than 0 means the next step does not start. Exit 2 is a usage or validation
|
||||
@@ -77,7 +80,12 @@ gstack-review-log --start review --reservation <id>
|
||||
```
|
||||
|
||||
Roles are review-depth's: `specialist`, `red-team`, `adversarial-native`, `outside-voice`,
|
||||
`fix-check`. `--finish <token>` settles the reservation at the dispatch's completion receipt.
|
||||
`fix-check`. `--finish <token>` is the pass's completion receipt: it settles that reservation and
|
||||
every other dispatch reservation started in the same step, so reviewers admitted one at a time
|
||||
do not stay reserved. Name a reviewer that came back in your next check
|
||||
(`check ... --returned <id>[,<id>...]`), or, when other work comes first, `settle --reservation <id>
|
||||
--reading <n>` as it returns so its cost is measured alone. A check that moves to the next step
|
||||
settles every dispatch a reviewer started in the step before.
|
||||
`close` audits after the fact: each review-log row of the run with no reservation gets one
|
||||
BUDGET_CHECK_SKIPPED line and the run ends status=incomplete for the owner to accept
|
||||
(`close --approved-by <who> --words "<their words>"`); a reservation is never created after the fact.
|
||||
@@ -91,7 +99,8 @@ Step 12, 150k or more (or an unknown charge) delegates the next segment (7–11.
|
||||
brief (mode 0600, under the run directory) with the pins (HEAD, merge base, working tree,
|
||||
reviewed tree), the record with its six headings and start tokens, the decisions in scope
|
||||
with the owner's words, the decision trail, the budget and the return contract. It reserves
|
||||
the child's sub-cap: min(cap − used − reserved − tail, the segment estimate), or `--sub-cap`.
|
||||
the child's sub-cap: min(cap − used − reserved − unknown − the calibrated tail reserve and stop
|
||||
overhead, the segment estimate), or `--sub-cap`.
|
||||
2. The child is a Capy subagent on the shared machine, or on Claude Code
|
||||
`gstack-ship-budget launch --in <brief>`: a background `claude -p` (resolved through lib/claude-bin.ts)
|
||||
with explicit permission flags and the return file named.
|
||||
@@ -137,6 +146,19 @@ across three runs; per-dispatch has two observations and stop overhead one. Only
|
||||
meter has observations; the Claude Code and estimate units are seeded from it until a
|
||||
Claude-metered /ship completes.
|
||||
|
||||
These constants are the prior. Each settled step or dispatch is measured on the next fresh
|
||||
reading (a step from its check's reading to the next one; dispatches settled together as one
|
||||
group, split by role), and prints
|
||||
|
||||
DRIFT: id=<reservation> kind=step|dispatch next=<id> projected=<n> actual=<n> ratio=<actual/projected>
|
||||
|
||||
From then on the run projects steps and dispatches with its own factor: the largest
|
||||
actual/prior ratio among its last three measurements of that kind, times 1.5. The step factor
|
||||
also scales the default tail reserve. Dispatches keep the prior until the first
|
||||
dispatch is measured, so under a tight cap admit one reviewer first and let it calibrate the rest.
|
||||
`close` prints `BUDGET_DRIFT:` with the run's projected and actual totals per kind. The estimate
|
||||
meter has no readings, so it always projects at the prior.
|
||||
|
||||
## Token cap and USD spend cap
|
||||
|
||||
A lane's `--spend-cap` (USD) bounds paid gates and evals through lib/spend-ledger.ts; `--token-cap`
|
||||
|
||||
+41
-19
@@ -13,9 +13,9 @@ import * as path from 'node:path';
|
||||
import { resultLine } from './result-codes';
|
||||
import {
|
||||
BudgetError, DEFAULT_TAIL, DELEGATE_SEGMENT_CALL, DELEGATE_START_CALL, METER_CONTRACTS, SEGMENTS, SHIP_STEPS,
|
||||
admits, applyReading, budgetError, budgetLine, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, newEpoch,
|
||||
parseNext, parseRecordBlock, parseStep, parseTokens, pointMarker, readLedger, reserveIn, restoreRun, reviewPaths,
|
||||
runDirFor, segmentEstimate, settleIn, stepProjection, projection, tally, unitConstants, unreservedDispatches, usageError, writeLedger,
|
||||
admits, applyReading, budgetError, budgetLine, ledgerFileFor, loadCalibration, measureSettled, mintRunId, mutateLedger, newEpoch,
|
||||
parseNext, parseRecordBlock, parseStep, parseTokens, pointMarker, projector, readLedger, reserveIn, restoreRun, reviewPaths,
|
||||
runDirFor, settleIn, stepProjection, tally, unitConstants, unreservedDispatches, usageError, writeLedger,
|
||||
type Ledger, type MeterKind, type Next,
|
||||
} from './ship-budget';
|
||||
import { claudeConfigDir, loadMeterState, newMeterState, nonceSessions, readClaudeUsage, saveMeterState, sessionJournals } from './claude-usage';
|
||||
@@ -41,6 +41,8 @@ export function parseArgs(argv: string[], values: string[], flags: string[] = []
|
||||
export const str = (o: Opts, k: string) => (typeof o[k] === 'string' ? (o[k] as string) : undefined);
|
||||
export const tokensOpt = (o: Opts, k: string) => (str(o, k) === undefined ? undefined : parseTokens(str(o, k), `--${k}`));
|
||||
const meterFile = (l: Ledger) => path.join(l.dir, 'claude-meter.json');
|
||||
/** Whether this command took a fresh reading (Claude Code reads its journals on every command). */
|
||||
const freshReading = (l: Ledger, o: Opts) => l.meter.contract.meter === 'claude' || str(o, 'reading') !== undefined;
|
||||
|
||||
/** Take this command's reading under the run's meter contract; returns the per-call charge when known. */
|
||||
export function readMeter(l: Ledger, o: Opts, requireReading: boolean): number | null {
|
||||
@@ -69,13 +71,13 @@ export function readMeter(l: Ledger, o: Opts, requireReading: boolean): number |
|
||||
export function stopLines(l: Ledger, f: { call: number | null; next: string; step: string; projected: number; reason: 'budget_exhausted' | 'context_threshold' }): string[] {
|
||||
l.stopped = { at: new Date().toISOString(), reason: f.reason, step: f.step, next: f.next };
|
||||
const t = tally(l);
|
||||
const cal = loadCalibration(l.calibration);
|
||||
const p = projector(l);
|
||||
const from = f.next.startsWith('dispatch:') ? f.step : f.next;
|
||||
const tailCost = segmentEstimate(cal, l.meter.contract.unit, from, '22') + l.tail + l.stop_overhead;
|
||||
const tailCost = p.segment(from, '22') + p.reserve;
|
||||
const lines = [budgetLine(l, { ...f, verdict: 'stop' })];
|
||||
if (f.reason === 'budget_exhausted') {
|
||||
lines.push(`STOP: reason=budget_exhausted spent=${t.used} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap} next=${f.next} projected=${f.projected}`,
|
||||
resultLine('gstack-ship-budget', 'BUDGET_CAP_REACHED', `next ${f.next} needs ${f.projected} + reserve ${l.tail + l.stop_overhead}`),
|
||||
resultLine('gstack-ship-budget', 'BUDGET_CAP_REACHED', `next ${f.next} needs ${f.projected} + reserve ${p.reserve}`),
|
||||
`RECOVER: /ship --resume ${l.record ?? '<record>'} --token-cap ${t.used + t.reserved + t.unknown + tailCost}`);
|
||||
} else {
|
||||
lines.push(`STOP: reason=context_threshold call=${f.call ?? 'unknown'} threshold=${DELEGATE_SEGMENT_CALL} next=${f.next} (${l.role === 'child' ? 'a child never delegates' : '--delegate never'})`,
|
||||
@@ -93,10 +95,10 @@ export function writeReturnFile(file: string, lines: string[]): void {
|
||||
|
||||
/** Admission and the delegate rule for one boundary; reserves the admitted work under a cap. */
|
||||
export function decide(l: Ledger, step: string, next: Next, call: number | null, startThreshold = false): Outcome {
|
||||
const cal = loadCalibration(l.calibration);
|
||||
const p = projector(l);
|
||||
const nextId = next.kind === 'step' ? next.step : `dispatch:${next.roles.join(',')}`;
|
||||
const projected = projection(cal, l.meter.contract.unit, next);
|
||||
if (!admits(tally(l), projected, l.tail + l.stop_overhead, l.cap)) {
|
||||
const { prior, tokens: projected } = p.project(next);
|
||||
if (!admits(tally(l), projected, p.reserve, l.cap)) {
|
||||
return { stdout: stopLines(l, { call, next: nextId, step, projected, reason: 'budget_exhausted' }), exit: 3 };
|
||||
}
|
||||
const boundary = next.kind === 'step' && (startThreshold ? next.step === '1' : next.step === '7' || next.step === '12');
|
||||
@@ -110,7 +112,7 @@ export function decide(l: Ledger, step: string, next: Next, call: number | null,
|
||||
}
|
||||
const lines = [budgetLine(l, { call, next: nextId, projected, verdict: 'continue' })];
|
||||
if (l.cap !== null) {
|
||||
const r = reserveIn(l, { kind: next.kind, step, next: nextId, tokens: projected, ...(next.kind === 'dispatch' ? { roles: next.roles } : {}) });
|
||||
const r = reserveIn(l, { kind: next.kind, step, next: nextId, tokens: projected, prior, ...(next.kind === 'dispatch' ? { roles: next.roles } : {}) });
|
||||
lines.push(`RESERVATION: id=${r.id} next=${nextId} tokens=${projected}`);
|
||||
}
|
||||
return { stdout: lines, exit: 0 };
|
||||
@@ -166,7 +168,8 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome {
|
||||
const sessions = meter === 'claude' ? bindClaude(o, ctx.env) : undefined;
|
||||
const l: Ledger = {
|
||||
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, stop_overhead: unitConstants(cal, contract.unit).stop_overhead,
|
||||
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) } : {}) },
|
||||
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')!) } : {}),
|
||||
@@ -181,40 +184,59 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome {
|
||||
return { stdout: [`BUDGET_RUN: id=${run} ledger=${l.file}`, ...out.stdout], exit: out.exit };
|
||||
}
|
||||
|
||||
/**
|
||||
* check: read the meter, settle what is done (open step reservations, the
|
||||
* dispatches named by --returned, and on a step change every dispatch a
|
||||
* reviewer consumed), measure them, then admit the next step or dispatch.
|
||||
*/
|
||||
export function cmdCheck(argv: string[], ctx: Ctx): Outcome {
|
||||
const o = parseArgs(argv, ['run', 'record', 'step', 'next', 'reading', 'prior-reading']);
|
||||
const o = parseArgs(argv, ['run', 'record', 'step', 'next', 'reading', 'prior-reading', 'returned']);
|
||||
const step = parseStep(str(o, 'step'));
|
||||
const next = parseNext(str(o, 'next'));
|
||||
const returned = (str(o, 'returned') ?? '').split(',').filter(Boolean);
|
||||
return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => {
|
||||
if (l.closed_at) throw usageError(`run ${l.run} is closed`);
|
||||
if (l.stopped) return { stdout: [budgetLine(l, { call: null, next: l.stopped.next, projected: 0, verdict: 'stop' }), `STOP: reason=${l.stopped.reason} at Step ${l.stopped.step}; resume first: gstack-ship-budget resume --record ${l.record}`, `GSTACK_RESULT: skill=ship status=incomplete run=${l.dir}`], exit: 3 };
|
||||
for (const r of l.reservations) if (r.kind === 'step' && r.state === 'open') settleIn(l, r);
|
||||
const back = returned.map(id => l.reservations.find(r => r.id === id && r.kind === 'dispatch'));
|
||||
const bad = returned.filter((_, i) => !back[i]);
|
||||
if (bad.length) throw usageError(`--returned ${bad.join(',')} is not a dispatch reservation of run ${l.run}; fix: pass the RESERVATION ids check printed for the reviewers that came back`);
|
||||
const call = readMeter(l, o, true);
|
||||
return decide(l, step, next, call);
|
||||
for (const r of l.reservations) {
|
||||
const done = r.kind === 'step' ? r.state === 'open' : r.kind === 'dispatch' && (back.includes(r) || (next.kind === 'step' && r.state === 'consumed'));
|
||||
if (done) settleIn(l, r);
|
||||
}
|
||||
const drift = freshReading(l, o) ? measureSettled(l) : [];
|
||||
const out = decide(l, step, next, call);
|
||||
return { stdout: [...drift, ...out.stdout], exit: out.exit };
|
||||
});
|
||||
}
|
||||
|
||||
export function cmdSettle(argv: string[], ctx: Ctx, release = false): Outcome {
|
||||
const o = parseArgs(argv, ['run', 'record', 'reservation'], release ? [] : ['unknown']);
|
||||
const o = parseArgs(argv, ['run', 'record', 'reservation', ...(release ? [] : ['reading'])], release ? [] : ['unknown']);
|
||||
const id = str(o, 'reservation');
|
||||
if (!id) throw usageError(`${release ? 'release' : 'settle'} needs --reservation <id>`);
|
||||
return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => {
|
||||
const r = l.reservations.find(x => x.id === id);
|
||||
if (!r) throw usageError(`reservation ${id} is not in run ${l.run}`);
|
||||
if (release) { if (r.state === 'open') r.state = 'released'; }
|
||||
else settleIn(l, r, { unknown: o.unknown === true, cal: loadCalibration(l.calibration) });
|
||||
return { stdout: [`${release ? 'RELEASED' : 'SETTLED'}: id=${id} state=${r.state}`], exit: 0 };
|
||||
if (release) { if (r.state === 'open') r.state = 'released'; return { stdout: [`RELEASED: id=${id} state=${r.state}`], exit: 0 }; }
|
||||
readMeter(l, o, false);
|
||||
settleIn(l, r, { unknown: o.unknown === true, cal: loadCalibration(l.calibration) });
|
||||
return { stdout: [`SETTLED: id=${id} state=${r.state}`, ...(freshReading(l, o) ? measureSettled(l) : [])], exit: 0 };
|
||||
});
|
||||
}
|
||||
|
||||
export function cmdClose(argv: string[], ctx: Ctx): Outcome {
|
||||
const o = parseArgs(argv, ['run', 'record', 'reading', 'approved-by', 'words']);
|
||||
return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => {
|
||||
for (const r of l.reservations) if (r.kind === 'step' && r.state === 'open') settleIn(l, r);
|
||||
readMeter(l, o, false);
|
||||
for (const r of l.reservations) if (r.kind === 'step' && r.state === 'open') settleIn(l, r);
|
||||
if (freshReading(l, o)) measureSettled(l);
|
||||
const t = tally(l);
|
||||
const open = l.reservations.filter(r => r.kind === 'dispatch' && r.state === 'consumed').length;
|
||||
const lines = [`BUDGET_FINAL: used=${t.used} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap ?? 'none'} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} open_dispatches=${open}`];
|
||||
const sum = (kind: string, k: 'projected' | 'actual') => (l.samples ?? []).filter(s => s.kind === kind).reduce((n, s) => n + s[k], 0);
|
||||
const count = (kind: string) => (l.samples ?? []).filter(s => s.kind === kind).length;
|
||||
lines.push(`BUDGET_DRIFT: steps=${count('step')} step_projected=${sum('step', 'projected')} step_actual=${sum('step', 'actual')} dispatches=${count('dispatch')} dispatch_projected=${sum('dispatch', 'projected')} dispatch_actual=${sum('dispatch', 'actual')}`);
|
||||
const accepted = new Set((l as any).accepted_skips as string[] | undefined ?? []);
|
||||
const skipped = l.cap === null ? [] : unreservedDispatches(l).filter(s => !accepted.has(s.started));
|
||||
if (skipped.length && str(o, 'approved-by') && str(o, 'words')) {
|
||||
|
||||
+73
-9
@@ -51,7 +51,9 @@ export const METER_CONTRACTS: Record<MeterKind, MeterContract> = {
|
||||
export interface Reservation {
|
||||
id: string; kind: 'step' | 'dispatch' | 'child'; step: string; next: string; roles?: string[]; tokens: number; at: string;
|
||||
state: 'open' | 'consumed' | 'settled' | 'released'; token?: string; started_at?: string; settled_at?: string;
|
||||
prior?: number; reading_at?: number | null; epoch?: number; actual?: number; unseen?: boolean;
|
||||
}
|
||||
export interface Sample { id: string; kind: 'step' | 'dispatch'; epoch: number; prior: number; projected: number; actual: number }
|
||||
export interface Handoff {
|
||||
n: number; sha: string; file: string; from: string; to: string; sub_cap: number; reservation: string; reading_at: number | null;
|
||||
epoch: number; return_file: string; reconciled_at?: string; status?: string; child_used?: number;
|
||||
@@ -65,7 +67,7 @@ export interface Ledger {
|
||||
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 }>;
|
||||
marker?: string; review_log?: string; calibration?: string;
|
||||
marker?: string; review_log?: string; calibration?: string; samples?: Sample[]; tail_explicit?: boolean;
|
||||
stopped?: { at: string; reason: string; step: string; next: string }; closed_at?: string;
|
||||
}
|
||||
|
||||
@@ -134,7 +136,63 @@ export function projection(cal: Calibration, unit: string, next: Next): number {
|
||||
return next.kind === 'dispatch' ? next.roles.length * unitConstants(cal, unit).per_dispatch : stepProjection(cal, unit, next.step);
|
||||
}
|
||||
|
||||
/** Steps from..to inclusive plus their usual reviewer batches: a child's segment estimate and the RECOVER tail. */
|
||||
/** How many recent measurements of a kind the run's factor looks at (GST-101). */
|
||||
export const SAMPLE_WINDOW = 3;
|
||||
const roundUp = (n: number, to: number) => Math.ceil(n / to) * to;
|
||||
|
||||
/**
|
||||
* The run's own factor for steps or dispatches: the largest actual/prior ratio
|
||||
* among its last SAMPLE_WINDOW measurements times the calibration headroom, or
|
||||
* null before the first measurement (the prior applies until then).
|
||||
*/
|
||||
export function measuredFactor(l: Ledger, cal: Calibration, kind: Sample['kind']): number | null {
|
||||
const rows = (l.samples ?? []).filter(s => s.kind === kind && s.epoch === l.meter.epoch && s.actual > 0 && s.prior > 0).slice(-SAMPLE_WINDOW);
|
||||
return rows.length ? Math.max(...rows.map(s => s.actual / s.prior)) * cal.headroom : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Projections calibrated by the run's measured factors. The tail reserve scales
|
||||
* with the step factor unless --reserve set it; the stop overhead keeps its
|
||||
* calibrated fit so a run always has room to stop.
|
||||
*/
|
||||
export function projector(l: Ledger, cal: Calibration = loadCalibration(l.calibration)) {
|
||||
const unit = l.meter.contract.unit;
|
||||
const step = measuredFactor(l, cal, 'step'), dispatch = measuredFactor(l, cal, 'dispatch');
|
||||
const scale = (n: number, f: number | null) => (f === null ? n : roundUp(n * f, cal.round_to));
|
||||
const project = (next: Next) => {
|
||||
const prior = projection(cal, unit, next);
|
||||
return { prior, tokens: scale(prior, next.kind === 'dispatch' ? dispatch : step) };
|
||||
};
|
||||
const segment = (from: string, to: string) => SHIP_STEPS.slice(SHIP_STEPS.indexOf(from as any), SHIP_STEPS.indexOf(to as any) + 1)
|
||||
.reduce((sum, s) => sum + project({ kind: 'step', step: s }).tokens + (cal.segment_dispatches[s] ?? 0) * project({ kind: 'dispatch', roles: ['specialist'] }).tokens, 0);
|
||||
const label = (f: number | null) => (f === null ? 'prior' : f.toFixed(3));
|
||||
return { project, segment, reserve: (l.tail_explicit ? l.tail : scale(l.tail, step)) + l.stop_overhead, label: `step:${label(step)},dispatch:${label(dispatch)}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure settled reservations against the current (fresh) reading: a step
|
||||
* from its reading to now; settled dispatches as one group from the earliest
|
||||
* of their readings, split by role. A charge the meter cannot see (settled
|
||||
* --unknown, or a Claude-metered outside voice) is never measured. Returns one
|
||||
* DRIFT line per measurement.
|
||||
*/
|
||||
export function measureSettled(l: Ledger): string[] {
|
||||
const last = l.meter.last;
|
||||
if (last === null) return [];
|
||||
const pending = l.reservations.filter(r => r.state === 'settled' && r.kind !== 'child' && r.actual === undefined && !r.unseen
|
||||
&& typeof r.reading_at === 'number' && r.epoch === l.meter.epoch && last >= r.reading_at);
|
||||
const dispatches = pending.filter(r => r.kind === 'dispatch');
|
||||
const from = Math.min(...dispatches.map(r => r.reading_at!));
|
||||
const roles = dispatches.reduce((n, r) => n + (r.roles?.length ?? 1), 0);
|
||||
return pending.map(r => {
|
||||
const actual = r.kind === 'dispatch' ? Math.round(((last - from) * (r.roles?.length ?? 1)) / roles) : last - r.reading_at!;
|
||||
r.actual = actual;
|
||||
(l.samples ??= []).push({ id: r.id, kind: r.kind as Sample['kind'], epoch: l.meter.epoch, prior: r.prior ?? r.tokens, projected: r.tokens, actual });
|
||||
return `DRIFT: id=${r.id} kind=${r.kind} next=${r.next} projected=${r.tokens} actual=${actual} ratio=${(actual / Math.max(1, r.tokens)).toFixed(2)}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Steps from..to inclusive plus their usual reviewer batches at the prior: a child's segment estimate. */
|
||||
export function segmentEstimate(cal: Calibration, unit: string, from: string, to: string): number {
|
||||
const i = SHIP_STEPS.indexOf(from as any), j = SHIP_STEPS.indexOf(to as any);
|
||||
const k = unitConstants(cal, unit);
|
||||
@@ -206,11 +264,12 @@ export function admits(t: { used: number; reserved: number; unknown: number }, p
|
||||
|
||||
export function budgetLine(l: Ledger, f: { call: number | null; next: string; projected: number; verdict: string }): string {
|
||||
const t = tally(l);
|
||||
return `BUDGET: used=${t.used} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap ?? 'none'} tail=${l.tail + l.stop_overhead} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} call=${f.call ?? 'unknown'} next=${f.next} projected=${f.projected} verdict=${f.verdict}`;
|
||||
const p = projector(l);
|
||||
return `BUDGET: used=${t.used} reserved=${t.reserved} unknown=${t.unknown} admission_cap=${l.cap ?? 'none'} tail=${p.reserve} unit=${l.meter.contract.unit} meter=${l.meter.contract.meter} epoch=${l.meter.epoch} call=${f.call ?? 'unknown'} next=${f.next} projected=${f.projected} calibration=${p.label} verdict=${f.verdict}`;
|
||||
}
|
||||
|
||||
export function reserveIn(l: Ledger, r: Omit<Reservation, 'id' | 'at' | 'state'>): Reservation {
|
||||
const row: Reservation = { ...r, id: `${l.run}-r${l.next_id++}`, at: new Date().toISOString(), state: 'open' };
|
||||
const row: Reservation = { ...r, id: `${l.run}-r${l.next_id++}`, at: new Date().toISOString(), state: 'open', reading_at: l.meter.last, epoch: l.meter.epoch };
|
||||
l.reservations.push(row);
|
||||
return row;
|
||||
}
|
||||
@@ -220,10 +279,10 @@ export function settleIn(l: Ledger, r: Reservation, opts: { unknown?: boolean; c
|
||||
if (r.state === 'settled' || r.state === 'released') return;
|
||||
r.state = 'settled';
|
||||
r.settled_at = new Date().toISOString();
|
||||
if (opts.unknown) { l.unknown += r.tokens; return; }
|
||||
if (opts.unknown) { l.unknown += r.tokens; r.unseen = true; return; }
|
||||
if (l.meter.contract.meter === 'estimate') { l.estimate_used += r.tokens; return; }
|
||||
const unseen = l.meter.contract.meter === 'claude' ? (r.roles ?? []).filter(x => x === 'outside-voice').length : 0;
|
||||
if (unseen && r.roles?.length) l.unknown += Math.round((r.tokens * unseen) / r.roles.length);
|
||||
if (unseen && r.roles?.length) { l.unknown += Math.round((r.tokens * unseen) / r.roles.length); r.unseen = true; }
|
||||
}
|
||||
|
||||
/** Validate and store one meter reading against the run's contract. */
|
||||
@@ -333,15 +392,20 @@ export function admitReviewStart(marker: string, reservation: string | undefined
|
||||
});
|
||||
}
|
||||
|
||||
/** gstack-review-log --finish TOKEN: settle the reservation that start consumed (its completion receipt). */
|
||||
/**
|
||||
* gstack-review-log --finish TOKEN: the pass is over, so settle the reservation
|
||||
* that start consumed and every other dispatch reservation started in the same
|
||||
* step (a pass logs one row even when its reviewers were admitted one by one).
|
||||
*/
|
||||
export function settleReviewFinish(marker: string, token: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
||||
const active = activeLedger(marker);
|
||||
if (!active || !active.reservations.some(r => r.token === token && r.state === 'consumed')) return undefined;
|
||||
return mutateLedger(active.file, env, l => {
|
||||
const r = l.reservations.find(x => x.token === token && x.state === 'consumed');
|
||||
if (!r) return undefined;
|
||||
settleIn(l, r);
|
||||
return r.id;
|
||||
const pass = l.reservations.filter(x => x.kind === 'dispatch' && x.state === 'consumed' && x.step === r.step);
|
||||
for (const x of pass) settleIn(l, x);
|
||||
return pass.map(x => x.id).join(',');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ import { wtreeFingerprint } from './script-invocation';
|
||||
import { resultLine } from './result-codes';
|
||||
import {
|
||||
SHIP_STEPS, budgetError, budgetLine, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker,
|
||||
readLedger, reserveIn, runDirFor, segmentEstimate, tally, usageError, writeLedger, type Handoff, type Ledger,
|
||||
projector, readLedger, reserveIn, runDirFor, segmentEstimate, tally, usageError, writeLedger, type Handoff, type Ledger,
|
||||
} from './ship-budget';
|
||||
import { loadMeterState, saveMeterState } from './claude-usage';
|
||||
import { parseArgs, readMeter, stopLines, str, tokensOpt, writeReturnFile, type Ctx, type Outcome } from './ship-budget-cli';
|
||||
@@ -92,7 +92,7 @@ export function cmdHandoff(argv: string[], ctx: Ctx): Outcome {
|
||||
const call = readMeter(l, o, true);
|
||||
const cal = loadCalibration(l.calibration);
|
||||
const t = tally(l);
|
||||
const room = l.cap === null ? Infinity : l.cap - t.used - t.reserved - t.unknown - l.tail - l.stop_overhead;
|
||||
const room = l.cap === null ? Infinity : l.cap - t.used - t.reserved - t.unknown - projector(l, cal).reserve;
|
||||
const subCap = tokensOpt(o, 'sub-cap') ?? Math.min(room, segmentEstimate(cal, l.meter.contract.unit, from, to));
|
||||
if (subCap <= 0 || subCap > room) return { stdout: stopLines(l, { call, next: from, step: from, projected: subCap, reason: 'budget_exhausted' }), exit: 3 };
|
||||
const pins = pinsNow(ctx.cwd, base, ctx.env);
|
||||
|
||||
@@ -25,7 +25,8 @@ Before each dispatch batch, reserve the batch as one sum, then pass the printed
|
||||
~/.claude/skills/gstack/bin/gstack-review-log --start review --reservation "$RESERVATION_ID"
|
||||
```
|
||||
|
||||
`--finish` settles it. A dispatch with no `review-log --start` settles with
|
||||
`--finish` settles it and the pass's other dispatch reservations. Admitting reviewers one at
|
||||
a time, name each one that came back in the next check's `--returned <id>`. A dispatch with no `review-log --start` settles with
|
||||
`gstack-ship-budget settle --run <id> --reservation <id>` when it returns
|
||||
(`--unknown` when the meter cannot see it), or `release` if it never ran.
|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ Before each dispatch batch, reserve the batch as one sum, then pass the printed
|
||||
~/.claude/skills/gstack/bin/gstack-review-log --start review --reservation "$RESERVATION_ID"
|
||||
```
|
||||
|
||||
`--finish` settles it. A dispatch with no `review-log --start` settles with
|
||||
`--finish` settles it and the pass's other dispatch reservations. Admitting reviewers one at
|
||||
a time, name each one that came back in the next check's `--returned <id>`. A dispatch with no `review-log --start` settles with
|
||||
`gstack-ship-budget settle --run <id> --reservation <id>` when it returns
|
||||
(`--unknown` when the meter cannot see it), or `release` if it never ran.
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* GST-101: the /ship budget projection is calibrated from the run's own meter
|
||||
* (the prior only until the first measurement), the tail reserve scales with
|
||||
* it, a dispatch's reservation settles when its reviewer returns, and each
|
||||
* measured reservation prints its projected and actual cost. The replays use
|
||||
* the numbers measured on GSTA-2 (gstack-evals #35/#36, 1.91.319.0) and on
|
||||
* GSTA-152 PR B (#3596, run ship-20261029T083816-a4cc20; handoffs/token-efficiency/
|
||||
* ship-budget-projection-GSTA-152-prb.md in the GStack Minder drive).
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { loadCalibration, measureSettled, settleIn, SHIP_STEPS, unitConstants } from '../lib/ship-budget';
|
||||
import { budgetHarness, type Harness } from './helpers/ship-budget-harness';
|
||||
|
||||
const cal = loadCalibration();
|
||||
const K = unitConstants(cal, 'capy-tokens');
|
||||
const harnesses: Harness[] = [];
|
||||
const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; };
|
||||
afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); });
|
||||
|
||||
function started(h: Harness, cap: string) {
|
||||
const r = h.sb(['start', '--record', h.record(), '--token-cap', cap, '--reading', '0']);
|
||||
expect(r.code).toBe(0);
|
||||
return { run: h.field(r.out, 'BUDGET_RUN', 'id'), ledger: h.field(r.out, 'BUDGET_RUN', 'ledger') };
|
||||
}
|
||||
const finishRow = () => JSON.stringify({ skill: 'review', timestamp: new Date().toISOString(), status: 'clean', issues_found: 0, critical: 0, informational: 0 });
|
||||
|
||||
/** GSTA-2: about 0.27M per step on Steps 1-5 (1.35M), 165-200k per call; the prior projected 2.25M per step plus a 4.48M reserve. */
|
||||
const GSTA2_STEP = 270_000;
|
||||
const GSTA2_STOP = 4 * 200_000;
|
||||
/** GSTA-152 PR B pass 2, one reviewer at a time on a 127-line fix range (counter delta per dispatch). */
|
||||
const PASS2: Array<[string, number]> = [
|
||||
['specialist', 373_254], ['specialist', 511_454], ['specialist', 364_113], ['specialist', 362_494],
|
||||
['specialist', 366_058], ['specialist', 234_661], ['red-team', 1_016_168],
|
||||
];
|
||||
const PASS3: Array<[string, number]> = [['red-team', 1_049_900], ['fix-check', 368_801]];
|
||||
/** One parent call late in GSTA-152 (376-384k), and #26's measured stop overhead. */
|
||||
const PARENT_CALL = 380_000;
|
||||
const LEAN_DISPATCH = 500_000;
|
||||
const STOP_26 = 1_650_000;
|
||||
|
||||
describe('projection calibrated from the run\'s own meter (GST-101)', () => {
|
||||
test('GSTA-2 replay: an 8M cap admits Steps 6 and 6.5 after Steps 1-5 cost 0.27M each, and stops with room to stop', () => {
|
||||
const h = fresh();
|
||||
const cap = 8_000_000;
|
||||
const s = started(h, '8M');
|
||||
const steps = SHIP_STEPS.slice(SHIP_STEPS.indexOf('1'));
|
||||
let reading = 0;
|
||||
const admitted: string[] = [];
|
||||
let stop = '';
|
||||
for (let i = 0; i + 1 < steps.length; i++) {
|
||||
const calls = cal.step_calls[steps[i]!] ?? cal.step_calls.default!;
|
||||
reading += Math.round((GSTA2_STEP / 3) * calls * (1 + 0.05 * i)) + (cal.step_dispatches[steps[i]!] ?? 0) * LEAN_DISPATCH;
|
||||
expect(reading, `spend after Step ${steps[i]}`).toBeLessThanOrEqual(cap);
|
||||
const c = h.sb(['check', '--run', s.run, '--step', steps[i]!, '--next', steps[i + 1]!, '--reading', String(reading)]);
|
||||
if (c.code === 3) { stop = c.out; break; }
|
||||
expect(c.code).toBe(0);
|
||||
admitted.push(steps[i + 1]!);
|
||||
if (steps[i + 1] === '6') expect(Number(h.field(c.out, 'BUDGET', 'projected'))).toBeLessThan(1_000_000);
|
||||
}
|
||||
expect(admitted).toEqual(['2', '3', '4', '5', '6', '6.5']);
|
||||
expect(stop).toContain('STOP: reason=budget_exhausted');
|
||||
expect(reading + GSTA2_STOP).toBeLessThanOrEqual(cap);
|
||||
});
|
||||
|
||||
test('GSTA-152 PR B replay: per-dispatch admission never holds returned reviewers, the 29.28M refusal is admitted, and the run stops before the cap', () => {
|
||||
const h = fresh();
|
||||
const cap = 29_280_000;
|
||||
const s = started(h, '29.28M');
|
||||
let reading = 0;
|
||||
const steps = SHIP_STEPS.slice(SHIP_STEPS.indexOf('1'), SHIP_STEPS.indexOf('9') + 1);
|
||||
for (let i = 0; i + 1 < steps.length; i++) {
|
||||
reading += 750_000;
|
||||
expect(h.sb(['check', '--run', s.run, '--step', steps[i]!, '--next', steps[i + 1]!, '--reading', String(reading)]).code).toBe(0);
|
||||
}
|
||||
let previous = '';
|
||||
const dispatch = (role: string) => {
|
||||
reading += PARENT_CALL;
|
||||
const c = h.sb(['check', '--run', s.run, '--step', '9', '--next', `dispatch:${role}`, '--reading', String(reading), ...(previous ? ['--returned', previous] : [])]);
|
||||
return c;
|
||||
};
|
||||
for (const [role, cost] of [...PASS2, ...PASS3]) {
|
||||
const c = dispatch(role);
|
||||
expect(c.code, c.out + c.err).toBe(0);
|
||||
previous = h.field(c.out, 'RESERVATION', 'id');
|
||||
expect(h.reviewLog(['--start', 'review', '--reservation', previous]).code).toBe(0);
|
||||
reading += cost;
|
||||
}
|
||||
const lean = h.ledger(s.ledger).reservations.filter((r: any) => r.kind === 'dispatch' && r.actual !== undefined);
|
||||
expect(lean.length).toBe(PASS2.length + PASS3.length - 1);
|
||||
const settled = h.sb(['settle', '--run', s.run, '--reservation', previous, '--reading', String(reading)]);
|
||||
expect(settled.out).toMatch(/^DRIFT: id=\S+ kind=dispatch next=dispatch:fix-check projected=\d+ actual=368801 ratio=\d+\.\d\d$/m);
|
||||
reading = 17_790_000;
|
||||
previous = '';
|
||||
const fixCheck = dispatch('fix-check');
|
||||
expect(fixCheck.code, fixCheck.out).toBe(0);
|
||||
const projected = Number(h.field(fixCheck.out, 'BUDGET', 'projected'));
|
||||
expect(projected).toBeLessThan(K.per_dispatch / 3);
|
||||
expect(projected).toBeGreaterThanOrEqual(1_049_900);
|
||||
previous = h.field(fixCheck.out, 'RESERVATION', 'id');
|
||||
expect(h.reviewLog(['--start', 'review-fix-check', '--reservation', previous]).code).toBe(0);
|
||||
reading += 368_801;
|
||||
let parent = PARENT_CALL;
|
||||
let stop = '';
|
||||
for (let round = 0; round < 40 && !stop; round++) {
|
||||
parent += 50_000;
|
||||
reading += parent;
|
||||
const c = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:fix-check', '--reading', String(reading), '--returned', previous]);
|
||||
if (c.code === 3) { stop = c.out; break; }
|
||||
previous = h.field(c.out, 'RESERVATION', 'id');
|
||||
expect(h.reviewLog(['--start', 'review-fix-check', '--reservation', previous]).code).toBe(0);
|
||||
reading += 368_801;
|
||||
expect(reading).toBeLessThanOrEqual(cap);
|
||||
}
|
||||
expect(stop).toContain('STOP: reason=budget_exhausted');
|
||||
expect(reading + STOP_26).toBeLessThanOrEqual(cap);
|
||||
}, 120_000);
|
||||
|
||||
test('the prior holds until the first measurement; the tail reserve scales with the measured step cost', () => {
|
||||
const h = fresh();
|
||||
const s = started(h, '100M');
|
||||
const first = h.sb(['check', '--run', s.run, '--step', '1', '--next', '2', '--reading', '0']);
|
||||
expect(Number(h.field(first.out, 'BUDGET', 'projected'))).toBe(3 * K.per_call);
|
||||
expect(Number(h.field(first.out, 'BUDGET', 'tail'))).toBe(2_000_000 + K.stop_overhead);
|
||||
expect(h.field(first.out, 'BUDGET', 'calibration')).toBe('step:prior,dispatch:prior');
|
||||
const second = h.sb(['check', '--run', s.run, '--step', '2', '--next', '3', '--reading', '450000']);
|
||||
const factor = (450_000 / (3 * K.per_call)) * cal.headroom;
|
||||
expect(Number(h.field(second.out, 'BUDGET', 'projected'))).toBe(Math.ceil((3 * K.per_call * factor) / cal.round_to) * cal.round_to);
|
||||
expect(Number(h.field(second.out, 'BUDGET', 'tail'))).toBe(Math.ceil((2_000_000 * factor) / cal.round_to) * cal.round_to + K.stop_overhead);
|
||||
expect(second.out).toMatch(/^DRIFT: id=\S+ kind=step next=2 projected=2250000 actual=450000 ratio=0\.20$/m);
|
||||
const d = h.sb(['check', '--run', s.run, '--step', '3', '--next', 'dispatch:specialist,red-team', '--reading', '900000']);
|
||||
expect(Number(h.field(d.out, 'BUDGET', 'projected'))).toBe(2 * K.per_dispatch);
|
||||
});
|
||||
|
||||
test('an explicit --reserve is kept as given, and the stop overhead keeps its calibrated fit', () => {
|
||||
const h = fresh();
|
||||
const r = h.sb(['start', '--record', h.record(), '--token-cap', '100M', '--reading', '0', '--reserve', '3M']);
|
||||
const run = h.field(r.out, 'BUDGET_RUN', 'id');
|
||||
h.sb(['check', '--run', run, '--step', '1', '--next', '2', '--reading', '450000']);
|
||||
const c = h.sb(['check', '--run', run, '--step', '2', '--next', '3', '--reading', '900000']);
|
||||
expect(h.field(c.out, 'BUDGET', 'calibration')).not.toStartWith('step:prior');
|
||||
expect(Number(h.field(c.out, 'BUDGET', 'tail'))).toBe(3_000_000 + K.stop_overhead);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispatch reservations settle when the reviewer returns (GST-101)', () => {
|
||||
test('check --returned settles a consumed reservation, so the next per-dispatch admission is not blocked by it', () => {
|
||||
const h = fresh();
|
||||
const s = started(h, '20M');
|
||||
const a = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '0']);
|
||||
const first = h.field(a.out, 'RESERVATION', 'id');
|
||||
expect(h.reviewLog(['--start', 'review', '--reservation', first]).code).toBe(0);
|
||||
const b = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '400000', '--returned', first]);
|
||||
expect(b.code, b.out + b.err).toBe(0);
|
||||
expect(h.field(b.out, 'BUDGET', 'reserved')).toBe('0');
|
||||
expect(h.ledger(s.ledger).reservations.find((r: any) => r.id === first)).toMatchObject({ state: 'settled', actual: 400_000 });
|
||||
const bad = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist', '--reading', '400000', '--returned', 'nope']);
|
||||
expect(bad.code).toBe(2);
|
||||
expect(bad.err).toContain('--returned');
|
||||
});
|
||||
|
||||
test('--finish settles every consumed dispatch reservation of the pass, and the next reading measures them as one group', () => {
|
||||
const h = fresh();
|
||||
const s = started(h, '80M');
|
||||
const tokens: string[] = [];
|
||||
let reading = 0;
|
||||
for (const role of ['specialist', 'specialist', 'red-team']) {
|
||||
const id = h.field(h.sb(['check', '--run', s.run, '--step', '9', '--next', `dispatch:${role}`, '--reading', String(reading)]).out, 'RESERVATION', 'id');
|
||||
const st = h.reviewLog(['--start', 'review', '--reservation', id]);
|
||||
expect(st.code).toBe(0);
|
||||
tokens.push(st.out.trim());
|
||||
reading += 500_000;
|
||||
}
|
||||
expect(h.reviewLog([finishRow(), '--finish', tokens[0]!]).code).toBe(0);
|
||||
const dispatches = () => h.ledger(s.ledger).reservations.filter((r: any) => r.kind === 'dispatch');
|
||||
expect(dispatches().map((r: any) => r.state)).toEqual(['settled', 'settled', 'settled']);
|
||||
const next = h.sb(['check', '--run', s.run, '--step', '9', '--next', '10', '--reading', String(reading)]);
|
||||
expect(h.field(next.out, 'BUDGET', 'reserved')).toBe('0');
|
||||
expect(dispatches().map((r: any) => r.actual)).toEqual([500_000, 500_000, 500_000]);
|
||||
});
|
||||
|
||||
test('moving to the next step settles dispatch reservations a reviewer consumed in the step before', () => {
|
||||
const h = fresh();
|
||||
const s = started(h, '80M');
|
||||
const id = h.field(h.sb(['check', '--run', s.run, '--step', '11', '--next', 'dispatch:outside-voice', '--reading', '0']).out, 'RESERVATION', 'id');
|
||||
expect(h.reviewLog(['--start', 'adversarial-review', '--reservation', id]).code).toBe(0);
|
||||
const c = h.sb(['check', '--run', s.run, '--step', '11', '--next', '11.5', '--reading', '600000']);
|
||||
expect(c.code).toBe(0);
|
||||
expect(h.ledger(s.ledger).reservations.find((r: any) => r.id === id)).toMatchObject({ state: 'settled', actual: 600_000 });
|
||||
});
|
||||
|
||||
test('close prints projected against actual for the run', () => {
|
||||
const h = fresh();
|
||||
const s = started(h, '80M');
|
||||
h.sb(['check', '--run', s.run, '--step', '1', '--next', '2', '--reading', '300000']);
|
||||
const close = h.sb(['close', '--run', s.run, '--reading', '700000']);
|
||||
expect(close.code).toBe(0);
|
||||
expect(close.out).toMatch(/^BUDGET_DRIFT: steps=2 step_projected=\d+ step_actual=700000 dispatches=0 dispatch_projected=0 dispatch_actual=0$/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the gate fixes from /ship review (GST-101)', () => {
|
||||
test('a dispatch settled --unknown is never measured, so the dispatch factor stays at the prior', () => {
|
||||
const h = fresh();
|
||||
const s = started(h, '40M');
|
||||
const id = h.field(h.sb(['check', '--run', s.run, '--step', '11', '--next', 'dispatch:outside-voice', '--reading', '0']).out, 'RESERVATION', 'id');
|
||||
expect(h.reviewLog(['--start', 'adversarial-review', '--reservation', id]).code).toBe(0);
|
||||
const settled = h.sb(['settle', '--run', s.run, '--reservation', id, '--unknown', '--reading', '100000']);
|
||||
expect(settled.code, settled.out + settled.err).toBe(0);
|
||||
expect(settled.out).not.toContain('DRIFT:');
|
||||
const next = h.sb(['check', '--run', s.run, '--step', '11', '--next', 'dispatch:outside-voice', '--reading', '200000']);
|
||||
expect(h.field(next.out, 'BUDGET', 'calibration')).toEndWith('dispatch:prior');
|
||||
expect(Number(h.field(next.out, 'BUDGET', 'projected'))).toBe(K.per_dispatch);
|
||||
expect(Number(h.field(next.out, 'BUDGET', 'unknown'))).toBe(K.per_dispatch);
|
||||
});
|
||||
|
||||
test('a Claude-metered outside voice is booked unknown and never measured', () => {
|
||||
const r = { id: 'r1', kind: 'dispatch', step: '11', next: 'dispatch:outside-voice', roles: ['outside-voice'], tokens: K.per_dispatch, at: '', state: 'consumed', reading_at: 0, epoch: 1 };
|
||||
const l: any = { meter: { contract: { meter: 'claude' }, epoch: 1, last: 100_000 }, reservations: [r], unknown: 0, samples: [] };
|
||||
settleIn(l, r as any);
|
||||
expect(l.unknown).toBe(K.per_dispatch);
|
||||
expect(measureSettled(l)).toEqual([]);
|
||||
expect(l.samples).toEqual([]);
|
||||
});
|
||||
|
||||
test('a calibrated delegate verdict is one the handoff admits: both hold back the same reserve', () => {
|
||||
const h = fresh();
|
||||
const cap = 12_000_000;
|
||||
const s = started(h, '12M');
|
||||
const sb = (args: string[]) => { const c = h.sb(['check', '--run', s.run, ...args]); expect(c.code, c.out + c.err).toBe(0); return c; };
|
||||
for (const [i, step] of ['1', '2', '3'].entries()) sb(['--step', step, '--next', String(i + 2), '--reading', String(300_000 * (i + 1)), '--prior-reading', String(300_000 * i + 200_000)]);
|
||||
const d = h.field(sb(['--step', '4', '--next', 'dispatch:specialist', '--reading', '1200000', '--prior-reading', '1100000']).out, 'RESERVATION', 'id');
|
||||
expect(h.reviewLog(['--start', 'review', '--reservation', d]).code).toBe(0);
|
||||
const c = sb(['--step', '4', '--next', '12', '--reading', '8000000', '--returned', d]);
|
||||
expect(h.field(c.out, 'BUDGET', 'verdict')).toBe('delegate');
|
||||
const tail = Number(h.field(c.out, 'BUDGET', 'tail'));
|
||||
expect(cap - 8_000_000).toBeLessThan(2_000_000 + K.stop_overhead);
|
||||
const ho = h.sb(['handoff', '--run', s.run, '--from', '12', '--to', '22', '--base', 'origin/main', '--reading', '8000000']);
|
||||
expect(ho.code, ho.out + ho.err).toBe(0);
|
||||
expect(Number(ho.out.match(/^HANDOFF: \S+ sha256=\S+ sub_cap=(\d+)$/m)![1])).toBe(cap - 8_000_000 - tail);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,6 @@ import { BUDGET_BIN, budgetHarness, type Harness } from './helpers/ship-budget-h
|
||||
|
||||
const cal = loadCalibration();
|
||||
const K = unitConstants(cal, 'capy-tokens');
|
||||
const STEP2 = stepProjection(cal, 'capy-tokens', '2');
|
||||
const harnesses: Harness[] = [];
|
||||
const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; };
|
||||
afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); });
|
||||
@@ -42,12 +41,11 @@ describe('parsing and the admission rule', () => {
|
||||
|
||||
test('CLI boundary: a check exactly at the cap continues, one token over stops (exit 3)', () => {
|
||||
const h = fresh();
|
||||
const cap = 40_000_000;
|
||||
for (const [extra, code, verdict] of [[0, 0, 'continue'], [1, 3, 'stop']] as const) {
|
||||
const s = started(h, ['--token-cap', String(cap), '--reading', '0', '--reserve', '0']);
|
||||
const cap = K.per_dispatch + K.stop_overhead - extra;
|
||||
const s = started(h, ['--token-cap', String(cap), '--reading', '3000000', '--reserve', '0']);
|
||||
expect(s.r.code).toBe(0);
|
||||
const reading = cap - STEP2 - K.stop_overhead + extra;
|
||||
const c = h.sb(['check', '--run', s.run, '--step', '1', '--next', '2', '--reading', String(reading)]);
|
||||
const c = h.sb(['check', '--run', s.run, '--step', '1', '--next', 'dispatch:specialist', '--reading', '3000000']);
|
||||
expect(c.code).toBe(code);
|
||||
expect(h.field(c.out, 'BUDGET', 'verdict')).toBe(verdict);
|
||||
expect(h.field(c.out, 'BUDGET', 'admission_cap')).toBe(String(cap));
|
||||
@@ -98,7 +96,9 @@ describe('reservations', () => {
|
||||
const spent = 2 * K.per_dispatch;
|
||||
const next = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:red-team', '--reading', String(spent)]);
|
||||
expect(Number(h.field(next.out, 'BUDGET', 'used'))).toBe(spent);
|
||||
expect(next.code).toBe(spent + K.per_dispatch + K.stop_overhead <= cap ? 0 : 3);
|
||||
const projected = Number(h.field(next.out, 'BUDGET', 'projected'));
|
||||
expect(projected).toBe(Math.ceil((K.per_dispatch * 2 * cal.headroom) / cal.round_to) * cal.round_to);
|
||||
expect(next.code).toBe(spent + projected + K.stop_overhead <= cap ? 0 : 3);
|
||||
const later = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:red-team', '--reading', String(cap - K.stop_overhead - K.per_dispatch + 1)]);
|
||||
expect(later.code).toBe(3);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": 146,
|
||||
"why": "GST-101: budget-handoff names check --returned for reviewers admitted one at a time"
|
||||
}
|
||||
Reference in new issue
Block a user