v1.91.325.0 fix(ship-budget): thresholds above the host call floor; children hand off instead of stopping; batches admit a prefix (GSTA-167) (#3620)

* fix(ship-budget): calibrate projections from the run's own meter, settle dispatch reservations when reviewers return (GST-101)

The /ship budget projected every step and dispatch from fixed constants
(7.43M per dispatch, 2.25M per default step) plus a fixed 4.48M reserve, so
real caps refused work that cost 3-20x less (GSTA-152 PR B, GSTA-2). Each
settled reservation is now measured on the next reading, and the run projects
with its own factor (max actual/prior of its last three measurements x 1.5),
the prior only before the first measurement. The default tail reserve scales
with the step factor; the stop overhead keeps its fit.

Reservations of reviewers admitted one at a time no longer pile up: check
--returned settles named dispatches, --finish settles the pass's dispatches of
that step, and a step change settles consumed dispatches. DRIFT and
BUDGET_DRIFT lines print projected against actual.

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

* fix: pre-landing review fixes (GST-101)

Unseen charges (settle --unknown, a Claude-metered outside voice) are booked
at their reservation and never measured, so they cannot shrink the dispatch
factor. The handoff holds back the same calibrated reserve as check, so a
delegate verdict is one the handoff admits.

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

* docs(ship-budget): handoff sub-cap subtracts unknown charges and the calibrated reserve

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

* chore: changelog (Unreleased) for GST-101 ship-budget calibration

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

* test(ship-budget): give the GSTA-152 PR B replay an explicit 120s timeout

It spawns about 100 CLI processes; on windows-latest it ran past the
shard runner's 30s per-test timeout (windows-free-shard 5, run
40917695070), and on 2 cores it sat at bare bun test's 5s default.

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

* fix(ship-budget): thresholds above the host call floor; a child hands off instead of stopping; parallel batches admit a prefix (GSTA-167)

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

* fix(ship-budget): a child returns incomplete while a grandchild is unreconciled or an ADMIT remainder was never re-checked; join a child's meter only after its handoff succeeds (GSTA-167)

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

* docs(troubleshooting): BUDGET_CAP_REACHED admits a fitting prefix of a reviewer batch (GSTA-167)

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

* chore: changelog (Unreleased) for GSTA-167 handoff floor

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

* chore: move GSTA-167 changelog entry into [Unreleased]

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

* v1.91.325.0 stamp

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

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-30 01:01:20 +00:00
1 parent 53db038c6b
commit dcd62c389d
17 files changed
+639 -91

No files matched your search

+15
View File
@@ -1,5 +1,20 @@
# Changelog
## [1.91.325.0] - 2026-10-30
**A capped /ship on Capy no longer delegates or stops just because the host's own prompt is big, and a delegated child hands off instead of stopping.** A fresh Capy call costs 150-230k before any /ship work, so the 100k start and 150k segment thresholds fired on every capped Capy start and stopped two gstack-evals #36 children at their first boundary. The thresholds now measure context above a calibrated per-host call floor.
### Fixed
- **Thresholds sit above the host call floor.** `lib/ship-budget-calibration.json` gives each meter a `call_floor` (Capy 150k, Claude Code 40k, estimate 0); `start --call-floor <n>` overrides it and children inherit it. `BUDGET:` and `STOP:` lines print `call_floor=` and `above_floor=`.
- **A child never stops on the context rule.** After a completed step it hands its remaining steps to a grandchild through the same handoff, verify, return and reconcile commands (chain depth 3 at most). Before its first completed step, at the maximum depth, or past its segment it continues with one `CONTEXT_HIGH:` line. The admission cap still stops it.
- **A delegated child cannot return complete with work left over.** `return` reports `status=incomplete` with `UNRECONCILED:` or `DROPPED:` lines while one of its own grandchildren is unreconciled or a batch remainder was never re-checked.
### Changed
- **Parallel reviewer batches near the cap admit the largest prefix that fits** (`ADMIT: k of n (remaining=<roles>)`) instead of refusing the whole batch; each reader is priced at the larger of its projection and the run's measured mean for that role. `close` flags remaining roles nobody re-checked (`DROPPED:`, `status=incomplete` until the owner accepts).
### Tests
- `test/ship-budget-chain.test.ts` replays #36 (a child at its first boundary continues) and covers the Capy floor, grandchild handoff at depth 2, the depth-3 cap, charging a chained return once on all three meters, and the incomplete return. `test/ship-budget-batch.test.ts` replays #35's 7-reader batch near the cap.
## [1.91.324.0] - 2026-10-29
**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.
+1 -1
View File
@@ -1 +1 @@
1.91.324.0
1.91.325.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.324.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.325.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+40 -9
View File
@@ -50,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> calibration=step:<f|prior>,dispatch:<f|prior> 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> call_floor=<n> above_floor=<n|unknown> next=<id> projected=<n> calibration=step:<f|prior>,dispatch:<f|prior> verdict=continue|delegate|stop
| Field | Meaning |
|---|---|
@@ -61,6 +61,7 @@ With a cap set and no readable meter, start refuses
| `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 |
| `call_floor`, `above_floor` | The host's per-call floor (see Delegation) and the charge above it, max(0, call − floor); the delegate rule compares `above_floor` |
| `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) |
@@ -79,6 +80,11 @@ gstack-ship-budget check --run <id> --step 9 --next dispatch:specialist,red-team
gstack-review-log --start review --reservation <id>
```
Each reader is priced at max(its projection, the run's measured mean for that role over settled
dispatches). When the whole batch does not fit, `check` admits the largest prefix that does and
prints `ADMIT: k of n (remaining=<roles>)`: dispatch those k, then re-check the remaining roles
once they settle. Only a batch whose first reader does not fit stops, and `close` flags remaining
roles no later check requested (`DROPPED:`, status=incomplete until the owner accepts).
Roles are review-depth's: `specialist`, `red-team`, `adversarial-native`, `outside-voice`,
`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
@@ -92,27 +98,50 @@ BUDGET_CHECK_SKIPPED line and the run ends status=incomplete for the owner to ac
## Delegation
At start, a per-call charge of 100k or more delegates the whole /ship. Before Step 7 and before
Step 12, 150k or more (or an unknown charge) delegates the next segment (7–11.5, 12–22):
The delegate rule measures context above the host's per-call floor: what one call costs in a
fresh session with the ship skill loaded, before any /ship work. The floor is `call_floor` per
unit in `lib/ship-budget-calibration.json` (150k on Capy, where a fresh thread's calls cost
150–230k; 40k on Claude Code; 0 for the estimate meter, whose charge is always unknown), or
`start --call-floor <n>`, which the ledger records and every child inherits. At start, 100k or
more above the floor delegates the whole /ship. Before Step 7 and before Step 12, 150k or more
above the floor (or an unknown charge) delegates the next segment (7–11.5, 12–22). The BUDGET
line prints `call`, `call_floor` and `above_floor`, so each verdict can be checked:
1. `gstack-ship-budget handoff --run <id> --from 12 --to 22 --base origin/<base> --reading <n>` writes a
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 − unknown − the calibrated tail reserve and stop
overhead, the segment estimate), or `--sub-cap`.
overhead, the segment estimate), or `--sub-cap`. A child handing off reserves its whole
remaining sub-cap instead (cap − used − reserved − unknown − its stop overhead).
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.
3. The child runs `verify-handoff` first; a moved pin refuses
([HANDOFF_PIN_MOVED](troubleshooting.md#handoff-pin-moved)). It admits against its sub-cap
with child_used = reading − reading at handoff, is the only writer of the record, and never
delegates again: a delegate rule stops it. It ends with `return` (at most 40 lines).
with child_used = reading − reading at handoff and is the only writer of the record. The
context rule never stops a child. When it fires at one of the child's boundaries:
- once the child has completed a step since `verify-handoff`, `verdict=delegate` hands its
remaining steps (the next step through the end of its segment) to a fresh grandchild
through these same four steps. The chain depth (1 for a child, 2 for a grandchild, at most
3) is recorded in the brief, the handoff and the new ledger;
- before its first completed step, at depth 3, or when the next step is past its segment,
`verdict=continue` with one `CONTEXT_HIGH:` line (call, floor, threshold, depth, reason).
The admission cap still stops a child (`reason=budget_exhausted`). It reconciles any
grandchild, then ends with `return` (at most 40 lines). `return` reports `status=incomplete`
while one of the child's own handoffs is unreconciled (`UNRECONCILED:`) or an ADMIT remainder
was never re-checked (`DROPPED:`), so a delegated segment cannot drop readers or leave a
grandchild writing after the parent moves on.
4. `gstack-ship-budget reconcile --run <id> --return <file> --reading <n>`; idempotent per handoff hash,
so a duplicate return charges nothing twice. A refused, incomplete or interrupted child
exits 3: regenerate the handoff or stop.
exits 3: regenerate the handoff or stop. Up a chain each token is charged once: the Capy
counter already includes the whole subtree, a session `launch` starts joins the Claude Code
meter of every ancestor, and under `meter=estimate` a child's returned `used` carries its
grandchild's.
`--delegate never` turns delegate into stop for hosts without children.
`--delegate never` turns delegate into stop for a parent on a host without children; a child
continues with a `CONTEXT_HIGH:` note instead.
## Stop and resume
@@ -144,7 +173,9 @@ largest observation times 1.5, rounded up to 10,000 tokens. The leave-one-out te
(test/ship-budget-calibration.test.ts) covers the per-call constant only, the one observed
across three runs; per-dispatch has two observations and stop overhead one. Only the Capy
meter has observations; the Claude Code and estimate units are seeded from it until a
Claude-metered /ship completes.
Claude-metered /ship completes. Each unit's `call_floor` is calibrated separately, with its
evidence in `call_floor_why`; a floor is the largest 10,000 multiple at or below its smallest
observed fresh call.
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
+4 -3
View File
@@ -1832,9 +1832,10 @@ GSTACK_RESULT lines.
<a id="budget-cap-reached"></a>
### `gstack-ship-budget: ... (BUDGET_CAP_REACHED)`
**Meaning.** The next step or dispatch batch would push used + open reservations + unknown
charges + its projection + the tail and stop reserve past the admission cap, so it did not
start. Exit 3. `gstack-review-log --start` prints the same code (exit 1) for a dispatch
**Meaning.** The next step, or the first reader of a dispatch batch, would push used + open
reservations + unknown charges + its projection + the tail and stop reserve past the admission
cap, so it did not start. Exit 3. A batch that partly fits is not refused: `check` admits the
largest prefix that fits and prints `ADMIT: k of n (remaining=<roles>)`. `gstack-review-log --start` prints the same code (exit 1) for a dispatch
after the run stopped. The cap admits work; it cannot stop a turn already running, so the
overshoot is bounded by the misprojection of work admitted before the stop.
+13 -4
View File
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"note": "GSTA-152 plan item 8. Per-meter constants for /ship budget projections, fitted conservatively from metered runs: each constant is the largest observation times `headroom`, rounded up to `round_to` (lib/ship-budget.ts fitConstant). test/ship-budget-calibration.test.ts checks the constants equal the fit and that a leave-one-out fit on two runs is at or above the meter on the third. Savings claims count completed ships only.",
"note": "GSTA-152 plan item 8. Per-meter constants for /ship budget projections, fitted conservatively from metered runs: each constant is the largest observation times `headroom`, rounded up to `round_to` (lib/ship-budget.ts fitConstant). test/ship-budget-calibration.test.ts checks the constants equal the fit and that a leave-one-out fit on two runs is at or above the meter on the third. Savings claims count completed ships only. Each unit's call_floor (GSTA-167) is the host's per-call floor: what one call costs in a fresh session with the ship skill loaded, before any /ship work. The delegate thresholds compare the per-call charge minus this floor, so a host's own system prompt and tools never trigger delegation. A floor is the largest round_to multiple at or below its smallest observed fresh call, so it never hides real context growth; start --call-floor <n> overrides it for one run.",
"headroom": 1.5,
"round_to": 10000,
"observations": {
@@ -63,15 +63,24 @@
},
"units": {
"capy-tokens": {
"fit": "observations above (Capy root counter, children included)"
"fit": "observations above (Capy root counter, children included)",
"call_floor": 150000,
"call_floor_observed": 150000,
"call_floor_why": "GSTA-2 (gstack 1.91.320.0, gstack-evals #35/#36): a fresh Capy thread's first counter reading is about 98k (host system prompt and tools) and every call with the ship skill loaded costs 150-230k before any /ship work; 150k is the smallest observed call. Below it, DELEGATE_START_CALL (100k) fired on every capped Capy start and DELEGATE_SEGMENT_CALL (150k) stopped two #36 children at their first boundary."
},
"claude-usage-tokens": {
"seeded_from": "capy-tokens",
"why": "no Claude Code-metered /ship yet; refit once one completes"
"why": "no Claude Code-metered /ship yet; refit once one completes",
"call_floor": 40000,
"call_floor_observed": 46379,
"call_floor_why": "GSTA-167 measurement, 2026-10-29: Claude Code 2.1.312 on claude-opus-5-5 with gstack's 64 skills listed, metered as this meter does (input + cache_creation + cache_read + output per message): the first call of a fresh -p session cost 19,225 and the call after reading ship/SKILL.md in full cost 46,379. One observation, so the floor rounds down to 40k; refit once a Claude-metered /ship completes."
},
"estimate-tokens": {
"seeded_from": "capy-tokens",
"why": "the estimate meter projects with the Capy fit, which is at or above the meter on the metered runs"
"why": "the estimate meter projects with the Capy fit, which is at or above the meter on the metered runs",
"call_floor": 0,
"call_floor_observed": null,
"call_floor_why": "The estimate meter takes no readings, so its per-call charge is always unknown and the floor has nothing to subtract: an unknown charge keeps the pre-floor rule (delegate at a segment boundary, never at start)."
}
}
}
+73 -20
View File
@@ -12,8 +12,8 @@ import * as fs from 'node:fs';
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, measureSettled, mintRunId, mutateLedger, newEpoch,
BudgetError, DEFAULT_TAIL, DELEGATE_SEGMENT_CALL, DELEGATE_START_CALL, MAX_CHAIN_DEPTH, METER_CONTRACTS, SEGMENTS, SHIP_STEPS,
aboveFloor, 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,
@@ -68,7 +68,7 @@ export function readMeter(l: Ledger, o: Opts, requireReading: boolean): number |
}
/** The stop block: STOP, the resumable RECOVER line and GSTACK_RESULT incomplete; a child also writes its return. */
export function stopLines(l: Ledger, f: { call: number | null; next: string; step: string; projected: number; reason: 'budget_exhausted' | 'context_threshold' }): string[] {
export function stopLines(l: Ledger, f: { call: number | null; next: string; step: string; projected: number; reason: 'budget_exhausted' | 'context_threshold'; threshold?: number }): string[] {
l.stopped = { at: new Date().toISOString(), reason: f.reason, step: f.step, next: f.next };
const t = tally(l);
const p = projector(l);
@@ -80,7 +80,7 @@ export function stopLines(l: Ledger, f: { call: number | null; next: string; ste
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'})`,
lines.push(`STOP: reason=context_threshold call=${f.call ?? 'unknown'} call_floor=${p.floor} above_floor=${aboveFloor(f.call, p.floor) ?? 'unknown'} threshold=${f.threshold ?? DELEGATE_SEGMENT_CALL} next=${f.next} (--delegate never)`,
`RECOVER: /ship --resume ${l.record ?? '<record>'} in a fresh session`);
}
lines.push(`GSTACK_RESULT: skill=ship status=incomplete run=${l.dir}`);
@@ -93,31 +93,78 @@ export function writeReturnFile(file: string, lines: string[]): void {
fs.writeFileSync(file, lines.join('\n') + '\n', { mode: 0o600 });
}
/** Admission and the delegate rule for one boundary; reserves the admitted work under a cap. */
/**
* Admission and the delegate rule for one boundary; reserves the admitted work
* under a cap (a dispatch batch goes to admitBatch). The rule compares the call's context above the host floor (an
* unknown charge delegates at a segment boundary, never at start). A child
* never stops on it: after a completed step it hands its remaining steps to a
* grandchild, and before one, at the maximum depth or past its segment it
* continues with a CONTEXT_HIGH note. Admission still stops anyone.
*/
export function decide(l: Ledger, step: string, next: Next, call: number | null, startThreshold = false): Outcome {
if (next.kind === 'dispatch') return admitBatch(l, step, next.roles, call);
const p = projector(l);
const nextId = next.kind === 'step' ? next.step : `dispatch:${next.roles.join(',')}`;
const nextId = next.step;
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');
const wants = boundary && (startThreshold ? call !== null && call >= DELEGATE_START_CALL : call === null || call >= DELEGATE_SEGMENT_CALL);
if (wants && (l.role === 'child' || l.delegate === 'never')) {
return { stdout: stopLines(l, { call, next: nextId, step, projected, reason: 'context_threshold' }), exit: 3 };
const threshold = startThreshold ? DELEGATE_START_CALL : DELEGATE_SEGMENT_CALL;
const above = aboveFloor(call, p.floor);
const boundary = (startThreshold ? next.step === '1' : next.step === '7' || next.step === '12');
const wants = boundary && (above === null ? !startThreshold : above >= threshold);
const depth = l.depth ?? (l.parent ? 1 : 0);
const hold = !wants || !l.parent ? undefined
: !l.steps_done?.length ? 'no step completed since verify-handoff'
: depth >= MAX_CHAIN_DEPTH ? `chain depth ${depth} is the maximum`
: l.delegate === 'never' ? '--delegate never'
: SHIP_STEPS.indexOf(nextId as any) > SHIP_STEPS.indexOf(l.parent.to as any) ? `Step ${nextId} is past this child's Steps ${l.parent.from}-${l.parent.to}` : undefined;
if (wants && !l.parent && l.delegate === 'never') {
return { stdout: stopLines(l, { call, next: nextId, step, projected, reason: 'context_threshold', threshold }), exit: 3 };
}
if (wants && next.kind === 'step') {
if (wants && !hold) {
const to = l.parent ? l.parent.to : SEGMENTS[next.step];
return { stdout: [budgetLine(l, { call, next: nextId, projected, verdict: 'delegate' }),
`DELEGATE: Steps ${next.step}-${SEGMENTS[next.step]} go to a fresh child: gstack-ship-budget handoff --run ${l.run} --from ${next.step} --to ${SEGMENTS[next.step]} --base origin/<base> (ship/sections/budget-handoff.md)`], exit: 0 };
`DELEGATE: Steps ${next.step}-${to} go to a fresh ${l.parent ? `grandchild (chain depth ${depth + 1})` : 'child'}: gstack-ship-budget handoff --run ${l.run} --from ${next.step} --to ${to} --base origin/<base> (ship/sections/budget-handoff.md)`], exit: 0 };
}
const lines = [budgetLine(l, { call, next: nextId, projected, verdict: 'continue' })];
if (hold) lines.push(`CONTEXT_HIGH: call=${call ?? 'unknown'} call_floor=${p.floor} above_floor=${above ?? 'unknown'} threshold=${threshold} next=${nextId} depth=${depth}; continuing: ${hold}`);
if (l.cap !== null) {
const r = reserveIn(l, { kind: next.kind, step, next: nextId, tokens: projected, prior, ...(next.kind === 'dispatch' ? { roles: next.roles } : {}) });
const r = reserveIn(l, { kind: 'step', step, next: nextId, tokens: projected, prior });
lines.push(`RESERVATION: id=${r.id} next=${nextId} tokens=${projected}`);
}
return { stdout: lines, exit: 0 };
}
/**
* A dispatch batch: each reader is priced at max(its projection, the run's
* measured mean for its role) and the batch is reserved as one sum. When it
* does not fit, the largest prefix that does is admitted and ADMIT names the
* rest; the reservation keeps them as `remaining` until a later check of the
* same step requests them, and close flags any still left. Only a batch whose
* first reader does not fit stops.
*/
function admitBatch(l: Ledger, step: string, roles: string[], call: number | null): Outcome {
const p = projector(l);
const prices = roles.map(p.reader);
const cost = (k: number) => prices.slice(0, k).reduce((a, b) => a + b, 0);
const t = tally(l);
let k = roles.length;
while (k > 0 && !admits(t, cost(k), p.reserve, l.cap)) k--;
if (k === 0) return { stdout: stopLines(l, { call, next: `dispatch:${roles.join(',')}`, step, projected: cost(roles.length), reason: 'budget_exhausted' }), exit: 3 };
const admitted = roles.slice(0, k), remaining = roles.slice(k), nextId = `dispatch:${admitted.join(',')}`;
const lines = [budgetLine(l, { call, next: nextId, projected: cost(k), verdict: 'continue' })];
if (remaining.length) lines.push(`ADMIT: ${k} of ${roles.length} (remaining=${remaining.join(',')}); dispatch these ${k} now and re-check --next dispatch:${remaining.join(',')} after they settle`);
if (l.cap === null) return { stdout: lines, exit: 0 };
for (const role of roles) {
const older = l.reservations.find(r => r.step === step && r.remaining?.includes(role));
older?.remaining!.splice(older.remaining!.indexOf(role), 1);
}
const r = reserveIn(l, { kind: 'dispatch', step, next: nextId, tokens: cost(k), prior: p.project({ kind: 'dispatch', roles: admitted }).prior, roles: admitted, ...(remaining.length ? { remaining } : {}) });
lines.push(`RESERVATION: id=${r.id} next=${nextId} tokens=${cost(k)}`);
return { stdout: lines, exit: 0 };
}
/** Bind the Claude Code session: --session <id>, or a nonce echoed once; zero or several matches refuse. */
function bindClaude(o: Opts, env: NodeJS.ProcessEnv): string[] {
const dir = claudeConfigDir(env);
@@ -143,11 +190,12 @@ function pickMeter(o: Opts): MeterKind | undefined {
}
export function cmdStart(argv: string[], ctx: Ctx): Outcome {
const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger']);
const o = parseArgs(argv, ['token-cap', 'record', 'meter', 'reading', 'prior-reading', 'session', 'nonce', 'reserve', 'delegate', 'calibration', 'ledger', 'call-floor']);
const record = str(o, 'record');
if (!record) throw usageError('start needs --record <invocation record path>');
const rawCap = str(o, 'token-cap') ?? ctx.env.GSTACK_SHIP_TOKEN_CAP;
const cap = rawCap === undefined || rawCap === 'none' ? null : parseTokens(rawCap, '--token-cap');
const callFloor = tokensOpt(o, 'call-floor');
const delegate = str(o, 'delegate') ?? 'auto';
if (delegate !== 'auto' && delegate !== 'never') throw usageError(`--delegate '${delegate}' is not auto or never`);
const existing = parseRecordBlock(record);
@@ -173,6 +221,7 @@ export function cmdStart(argv: string[], ctx: Ctx): Outcome {
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')!) } : {}),
...(callFloor !== undefined ? { call_floor: callFloor } : {}),
};
if (meter === 'capy' && tokensOpt(o, 'reading') === undefined) throw budgetError('BUDGET_READING_MISSING', 'start --meter capy');
if (meter === 'capy') l.meter.baseline = tokensOpt(o, 'reading')!;
@@ -206,6 +255,7 @@ export function cmdCheck(argv: string[], ctx: Ctx): Outcome {
if (done) settleIn(l, r);
}
const drift = freshReading(l, o) ? measureSettled(l) : [];
if (l.parent && next.kind === 'step' && SHIP_STEPS.indexOf(step as any) >= SHIP_STEPS.indexOf(l.parent.from as any)) l.steps_done = [...new Set([...(l.steps_done ?? []), step])];
const out = decide(l, step, next, call);
return { stdout: [...drift, ...out.stdout], exit: out.exit };
});
@@ -239,13 +289,16 @@ export function cmdClose(argv: string[], ctx: Ctx): Outcome {
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')) {
(l as any).accepted_skips = [...accepted, ...skipped.map(s => s.started)];
lines.push(`ACCEPTED: ${skipped.length} unreserved dispatches by ${str(o, 'approved-by')}: "${str(o, 'words')}"`);
} else if (skipped.length) {
const dropped = l.reservations.filter(r => r.remaining?.length && !accepted.has(r.id));
if ((skipped.length || dropped.length) && str(o, 'approved-by') && str(o, 'words')) {
(l as any).accepted_skips = [...accepted, ...skipped.map(s => s.started), ...dropped.map(r => r.id)];
lines.push(`ACCEPTED: ${skipped.length} unreserved dispatches${dropped.length ? ` and ${dropped.length} unfinished batches` : ''} by ${str(o, 'approved-by')}: "${str(o, 'words')}"`);
} else if (skipped.length || dropped.length) {
for (const s of skipped) lines.push(resultLine('gstack-ship-budget', 'BUDGET_CHECK_SKIPPED', `role=${s.skill} step=${s.step} started=${s.started} spend=unknown`));
l.stopped = { at: new Date().toISOString(), reason: 'budget_check_skipped', step: '22', next: '22' };
lines.push(`STOP: reason=budget_check_skipped dispatches=${skipped.length}; owner decision: accept the unreserved spend (close --record ${l.record} --approved-by <who> --words <their words>) or rerun them under reservations`,
for (const r of dropped) lines.push(`DROPPED: step=${r.step} reservation=${r.id} roles=${r.remaining!.join(',')} (ADMIT named them; no later check requested them)`);
const reason = skipped.length ? 'budget_check_skipped' : 'batch_incomplete';
l.stopped = { at: new Date().toISOString(), reason, step: '22', next: '22' };
lines.push(`STOP: reason=${reason} dispatches=${skipped.length}${dropped.length ? ` unfinished_batches=${dropped.length}` : ''}; owner decision: accept ${skipped.length ? 'the unreserved spend' : 'the readers that never ran'} (close --record ${l.record} --approved-by <who> --words <their words>) or rerun them under reservations`,
`GSTACK_RESULT: skill=ship status=incomplete run=${l.dir}`);
return { stdout: lines, exit: 3 };
}
+29 -7
View File
@@ -32,9 +32,14 @@ export const DISPATCH_ROLES = ['specialist', 'red-team', 'adversarial-native', '
type MissingRole = Exclude<Role, typeof DISPATCH_ROLES[number]>;
const ROLES_COMPLETE: [MissingRole] extends [never] ? true : never = true;
void ROLES_COMPLETE;
/** D3: per-call charge that runs the whole /ship in a fresh child, and the one that delegates a heavy segment. */
/**
* D3: per-call context above the host's per-call floor (GSTA-167) that runs the
* whole /ship in a fresh child, and the one that delegates a heavy segment.
*/
export const DELEGATE_START_CALL = 100_000;
export const DELEGATE_SEGMENT_CALL = 150_000;
/** Handoffs between the root run and the deepest child; a run at this depth continues instead of handing off. */
export const MAX_CHAIN_DEPTH = 3;
/** Segment boundaries (before Step 7, before Step 12) and the last step each segment runs. */
export const SEGMENTS: Record<string, string> = { '1': '22', '7': '11.5', '12': '22' };
export const DEFAULT_TAIL = 2_000_000;
@@ -51,12 +56,12 @@ 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;
prior?: number; reading_at?: number | null; epoch?: number; actual?: number; unseen?: boolean; remaining?: string[];
}
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;
epoch: number; return_file: string; reconciled_at?: string; status?: string; child_used?: number; depth?: number;
}
export interface Ledger {
schema_version: 1; kind: 'gstack-ship-budget'; run: string; dir: string; file: string; record?: string; created_at: string;
@@ -68,6 +73,7 @@ export interface Ledger {
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; samples?: Sample[]; tail_explicit?: boolean;
call_floor?: number; depth?: number; steps_done?: string[];
stopped?: { at: string; reason: string; step: string; next: string }; closed_at?: string;
}
@@ -106,7 +112,7 @@ export interface Calibration {
observations: Record<string, Array<{ run: string; value: number; source: string }>>;
constants: Record<'per_call' | 'per_dispatch' | 'stop_overhead', number>;
step_calls: Record<string, number>; step_dispatches: Record<string, number>; segment_dispatches: Record<string, number>;
units: Record<string, { fit?: string; seeded_from?: string; why?: string }>;
units: Record<string, { fit?: string; seeded_from?: string; why?: string; call_floor?: number; call_floor_observed?: number | null; call_floor_why?: string }>;
}
export const CALIBRATION_FILE = path.join(import.meta.dir, 'ship-budget-calibration.json');
@@ -153,7 +159,10 @@ export function measuredFactor(l: Ledger, cal: Calibration, kind: Sample['kind']
/**
* 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.
* calibrated fit so a run always has room to stop. `floor` is the host's
* per-call floor: start --call-floor, else the unit's calibrated value.
* `reader` prices one dispatch reader at max(its projection, the run's mean
* measured cost for that role over settled dispatch reservations).
*/
export function projector(l: Ledger, cal: Calibration = loadCalibration(l.calibration)) {
const unit = l.meter.contract.unit;
@@ -166,7 +175,17 @@ export function projector(l: Ledger, cal: Calibration = loadCalibration(l.calibr
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)}` };
const measured = new Map<string, number[]>();
for (const r of l.reservations) {
if (r.kind !== 'dispatch' || r.actual === undefined || r.unseen || r.epoch !== l.meter.epoch) continue;
for (const role of r.roles ?? []) measured.set(role, [...(measured.get(role) ?? []), r.actual / r.roles!.length]);
}
const reader = (role: string) => {
const seen = measured.get(role) ?? [];
return Math.max(project({ kind: 'dispatch', roles: [role] }).tokens, seen.length ? Math.ceil(seen.reduce((a, b) => a + b, 0) / seen.length) : 0);
};
return { project, segment, reader, reserve: (l.tail_explicit ? l.tail : scale(l.tail, step)) + l.stop_overhead, label: `step:${label(step)},dispatch:${label(dispatch)}`,
floor: l.call_floor ?? cal.units[unit]?.call_floor ?? 0 };
}
/**
@@ -265,9 +284,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);
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}`;
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'} call_floor=${p.floor} above_floor=${aboveFloor(f.call, p.floor) ?? 'unknown'} next=${f.next} projected=${f.projected} calibration=${p.label} verdict=${f.verdict}`;
}
/** The per-call context above the host floor that the delegate thresholds compare; null when the charge is unknown. */
export const aboveFloor = (call: number | null, floor: number) => (call === null ? null : Math.max(0, call - floor));
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', reading_at: l.meter.last, epoch: l.meter.epoch };
l.reservations.push(row);
+46 -26
View File
@@ -6,7 +6,9 @@
* trail, the budget and the return contract; its sha256 goes in the parent's
* ledger with a reservation for the child's whole sub-cap. The child verifies
* the brief before anything else and admits against its sub-cap with
* child_used = reading - reading_at_handoff; it never delegates again. It
* child_used = reading - reading_at_handoff. The context rule never stops a
* child (GSTA-167): once it has completed a step it hands its remaining steps
* to a grandchild the same way, up to MAX_CHAIN_DEPTH handoffs from the root. It
* returns at most 40 lines through a return file; the parent reconciles once
* per handoff hash (a duplicate return charges nothing twice) and reads the
* record, never the child's transcript.
@@ -19,7 +21,7 @@ import { resolveClaudeCommand } from './claude-bin';
import { wtreeFingerprint } from './script-invocation';
import { resultLine } from './result-codes';
import {
SHIP_STEPS, budgetError, budgetLine, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker,
MAX_CHAIN_DEPTH, SHIP_STEPS, budgetError, budgetLine, ledgerFileFor, loadCalibration, mintRunId, mutateLedger, parseStep, pointMarker,
projector, readLedger, reserveIn, runDirFor, segmentEstimate, tally, usageError, writeLedger, type Handoff, type Ledger,
} from './ship-budget';
import { loadMeterState, saveMeterState } from './claude-usage';
@@ -54,14 +56,14 @@ function brief(l: Ledger, h: Omit<Handoff, 'sha'>, pins: Record<string, unknown>
const tokens = [...new Set(reviews.join('\n').match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g) ?? [])];
const block = { version: 1, parent_run: l.run, parent_ledger: l.file, from: h.from, to: h.to, sub_cap: h.sub_cap, parent_tail: l.tail,
unit: l.meter.contract.unit, meter: l.meter.contract.meter, epoch: h.epoch, reading_at: h.reading_at, ...pins, record: l.record,
return_file: h.return_file, trail: o.trail ?? null, trail_run: o.trailRun ?? null, launch_file: o.launch ?? null, start_tokens: tokens };
depth: h.depth, return_file: h.return_file, trail: o.trail ?? null, trail_run: o.trailRun ?? null, launch_file: o.launch ?? null, start_tokens: tokens };
return [
`# /ship handoff ${h.n}: Steps ${h.from}-${h.to}`, '',
'```gstack-handoff', JSON.stringify(block, null, 2), '```', '',
'## Start', '',
`1. Run \`gstack-ship-budget verify-handoff --in <this file>\` (on Capy add \`--reading <n>\`) before anything else. On a refusal, return its lines and stop.`,
block.trail ? `2. Run \`gstack-decision-trail start ${block.trail} <your run id>\` before any trail write.` : '2. No decision trail was named.',
`3. Run /ship --continue <this file> for Steps ${h.from}-${h.to} only, under the printed child run: \`check\` at every boundary and before every dispatch batch, obeying \`verdict=\`. You never delegate; a delegate rule stops you.`,
`3. Run /ship --continue <this file> for Steps ${h.from}-${h.to} only, under the printed child run: \`check\` at every boundary and before every dispatch batch, obeying \`verdict=\`. The context rule never stops you: \`verdict=delegate\` hands your remaining steps to a fresh child the same way (ship/sections/budget-handoff.md), and a \`CONTEXT_HIGH:\` note means continue. This run is at chain depth ${h.depth} of ${MAX_CHAIN_DEPTH}.`,
block.launch_file ? `4. Source the launch file \`${block.launch_file}\` in every shell that runs a gstack command (its path only; never print its values).` : '4. No GSTACK_LAUNCH_FILE was named.', '',
'## Record', '', `You are the only writer of \`${l.record}\` during this segment. It keeps its six headings (${RECORD_HEADINGS.join(', ')}). Original start tokens: ${tokens.join(', ') || 'none'}.`, '',
'## Decisions and approvals in scope (verbatim from the record)', '', ...(recordSection(recordText, 'Decisions') ?? ['(none recorded)']), '',
@@ -77,35 +79,36 @@ export function cmdHandoff(argv: string[], ctx: Ctx): Outcome {
if (SHIP_STEPS.indexOf(from as any) > SHIP_STEPS.indexOf(to as any)) throw usageError(`--from ${from} is after --to ${to}`);
const base = str(o, 'base');
if (!base) throw usageError('handoff needs --base origin/<base> (the merge base is pinned against it)');
return mutateLedger(ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env), ctx.env, l => {
if (l.role === 'child') throw usageError('a child never delegates again; stop instead');
const file = ledgerFileFor({ run: str(o, 'run'), record: str(o, 'record') }, ctx.env);
const child = str(o, 'child-session');
const out = mutateLedger(file, ctx.env, (l): Outcome => {
const depth = l.depth ?? (l.parent ? 1 : 0);
if (depth >= MAX_CHAIN_DEPTH) throw usageError(`run ${l.run} is at chain depth ${depth}, the maximum (${MAX_CHAIN_DEPTH}); continue instead`);
const at = (s: string) => SHIP_STEPS.indexOf(s as any);
if (l.parent && (at(from) < at(l.parent.from) || at(to) > at(l.parent.to))) throw usageError(`--from ${from} --to ${to} is outside this child's Steps ${l.parent.from}-${l.parent.to}`);
if (!l.record || !fs.existsSync(l.record)) throw usageError(`the run has no invocation record at ${l.record}`);
const recordText = fs.readFileSync(l.record, 'utf8');
const missing = RECORD_HEADINGS.filter(h => !recordSection(recordText, h));
if (missing.length) throw usageError(`record ${l.record} lacks headings: ${missing.join(', ')}; fix: keep the six headings the ship skill names`);
const child = str(o, 'child-session');
if (child && l.meter.contract.meter === 'claude') {
l.meter.sessions = [...new Set([...(l.meter.sessions ?? []), child])];
const state = loadMeterState(path.join(l.dir, 'claude-meter.json'));
if (state) { state.sessions = l.meter.sessions; saveMeterState(path.join(l.dir, 'claude-meter.json'), state); }
}
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 - projector(l, cal).reserve;
const subCap = tokensOpt(o, 'sub-cap') ?? Math.min(room, segmentEstimate(cal, l.meter.contract.unit, from, to));
const subCap = tokensOpt(o, 'sub-cap') ?? (l.parent && l.cap !== null ? room : 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);
const reviewed = str(o, 'reviewed-tree') ?? null;
const r = reserveIn(l, { kind: 'child', step: from, next: `child:${from}-${to}`, tokens: subCap });
const n = l.handoffs.length + 1;
const draft = { n, file: path.join(l.dir, `handoff-${n}.md`), from, to, sub_cap: subCap, reservation: r.id, reading_at: l.meter.contract.meter === 'estimate' ? null : l.meter.last, epoch: l.meter.epoch, return_file: path.join(l.dir, `return-${n}.txt`) };
const draft = { n, file: path.join(l.dir, `handoff-${n}.md`), from, to, sub_cap: subCap, reservation: r.id, depth: depth + 1, reading_at: l.meter.contract.meter === 'estimate' ? null : l.meter.last, epoch: l.meter.epoch, return_file: path.join(l.dir, `return-${n}.txt`) };
const text = brief(l, draft, { ...pins, base_ref: base, reviewed_tree: reviewed }, recordText, { trail: str(o, 'trail'), trailRun: str(o, 'trail-run'), launch: ctx.env.GSTACK_LAUNCH_FILE });
fs.writeFileSync(draft.file, text, { mode: 0o600 });
const sha = sha256(text);
l.handoffs.push({ ...draft, sha });
return { stdout: [`HANDOFF: ${draft.file} sha256=${sha} sub_cap=${subCap}`, `RETURN_FILE: ${draft.return_file}`, budgetLine(l, { call, next: from, projected: subCap, verdict: 'delegate' })], exit: 0 };
});
if (child && out.exit === 0) joinMeter(file, child, ctx.env);
return out;
}
function handoffBlock(text: string): any {
@@ -139,7 +142,8 @@ export function cmdVerifyHandoff(argv: string[], ctx: Ctx): Outcome {
const dir = runDirFor(ctx.env, run);
const child: Ledger = {
schema_version: 1, kind: 'gstack-ship-budget', run, dir, file: path.join(dir, 'budget.json'), record: block.record, created_at: new Date().toISOString(),
cap: parent.cap === null ? null : block.sub_cap, tail: 0, stop_overhead: parent.stop_overhead, delegate: 'never', role: 'child',
cap: parent.cap === null ? null : block.sub_cap, tail: 0, stop_overhead: parent.stop_overhead, delegate: parent.delegate, role: 'child',
depth: h!.depth ?? (parent.depth ?? 0) + 1, ...(parent.call_floor !== undefined ? { call_floor: parent.call_floor } : {}),
parent: { run: parent.run, ledger: parent.file, handoff_sha: sha, from: block.from, to: block.to, return_file: block.return_file },
meter: { contract: parent.meter.contract, epoch: 1, baseline: parent.meter.contract.meter === 'capy' ? block.reading_at ?? 0 : 0, last: null,
...(parent.meter.contract.meter === 'claude' ? { sessions: [str(o, 'session') ?? ''], config_dir: parent.meter.config_dir } : {}) },
@@ -163,9 +167,15 @@ export function cmdReturn(argv: string[], ctx: Ctx): Outcome {
readMeter(l, o, false);
const head = git(ctx.cwd, ['rev-parse', 'HEAD']);
const tree = pinsNow(ctx.cwd, 'HEAD', ctx.env).wtree;
const lines = [`RETURN: handoff=${l.parent.handoff_sha} status=${status} steps=${l.parent.from}-${to} head=${head} tree=${tree}`,
const accepted = new Set((l as any).accepted_skips as string[] | undefined ?? []);
const unreconciled = l.handoffs.filter(h => !h.reconciled_at);
const dropped = l.reservations.filter(r => r.remaining?.length && !accepted.has(r.id));
const result = status === 'complete' && (unreconciled.length || dropped.length) ? 'incomplete' : status;
const lines = [`RETURN: handoff=${l.parent.handoff_sha} status=${result} steps=${l.parent.from}-${to} head=${head} tree=${tree}`,
...unreconciled.map(h => `UNRECONCILED: handoff=${h.sha} steps=${h.from}-${h.to} (reconcile --run ${l.run} --return ${h.return_file} before returning)`),
...dropped.map(r => `DROPPED: step=${r.step} reservation=${r.id} roles=${r.remaining!.join(',')} (ADMIT named them; no later check requested them)`),
`RECORD_CHANGED: ${str(o, 'changed') ?? 'none'}`, budgetLine(l, { call: null, next: '-', projected: 0, verdict: 'continue' }),
`GSTACK_RESULT: skill=ship status=${status} run=${l.dir}`];
`GSTACK_RESULT: skill=ship status=${result} run=${l.dir}`];
writeReturnFile(l.parent.return_file, lines);
l.closed_at = new Date().toISOString();
return { stdout: lines, exit: 0 };
@@ -229,17 +239,27 @@ export function cmdLaunch(argv: string[], ctx: Ctx): Outcome {
const session = crypto.randomUUID();
const prompt = `Load gstack. Your Claude Code session id is ${session}. Run /ship --continue ${path.resolve(file)}: first gstack-ship-budget verify-handoff --in ${path.resolve(file)} --session ${session}, then only the steps the brief names, then gstack-ship-budget return as its return contract says.`;
const log = path.join(path.dirname(block.return_file), `child-${session}.log`);
return mutateLedger(block.parent_ledger, ctx.env, l => {
if (l.meter.contract.meter === 'claude') {
joinMeter(block.parent_ledger, session, ctx.env);
const out = fs.openSync(log, 'a', 0o600);
const child = spawn(claude.command, [...claude.argsPrefix, '-p', '--session-id', session, ...CHILD_PERMISSION_ARGS, prompt], { cwd: ctx.cwd, env: ctx.env, detached: true, stdio: ['ignore', out, out] });
child.unref();
fs.closeSync(out);
return { stdout: [`CHILD: session=${session} pid=${child.pid} log=${log} return=${block.return_file}`], exit: 0 };
}
/**
* Claude Code: a child's session joins the meter of the run that launched it and
* of every ancestor up the chain, so each run meters its whole subtree once.
*/
function joinMeter(file: string, session: string, env: NodeJS.ProcessEnv): void {
for (let next: string | undefined = file; next;) {
next = mutateLedger(next, env, l => {
if (l.meter.contract.meter !== 'claude') return undefined;
l.meter.sessions = [...new Set([...(l.meter.sessions ?? []), session])];
const meter = path.join(l.dir, 'claude-meter.json');
const state = loadMeterState(meter);
if (state) { state.sessions = l.meter.sessions; saveMeterState(meter, state); }
}
const out = fs.openSync(log, 'a', 0o600);
const child = spawn(claude.command, [...claude.argsPrefix, '-p', '--session-id', session, ...CHILD_PERMISSION_ARGS, prompt], { cwd: ctx.cwd, env: ctx.env, detached: true, stdio: ['ignore', out, out] });
child.unref();
fs.closeSync(out);
return { stdout: [`CHILD: session=${session} pid=${child.pid} log=${log} return=${block.return_file}`], exit: 0 };
});
return l.parent?.ledger;
});
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.324",
"version": "1.91.325",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+7 -4
View File
@@ -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 and the pass's other dispatch reservations. Admitting reviewers one at
On `ADMIT: k of n (remaining=<roles>)`, dispatch only those k and re-check the remaining roles
after they settle; `close` flags roles never re-checked. `--finish` settles the reservation 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.
@@ -45,9 +46,11 @@ fresh child; you wait and advance only after reconciling it:
GSTACK_LAUNCH_FILE, run /ship --continue <HANDOFF file>"; on Claude Code
`gstack-ship-budget launch --in <HANDOFF file>` (background, explicit permissions).
3. The child runs `verify-handoff --in <file>` first (`--reading <n>` on Capy); a
`HANDOFF_PIN_MOVED` refusal returns status=refused. It admits against its sub-cap,
never delegates, and ends with `gstack-ship-budget return --run <child> --to <step>
--changed <sections>` (at most 40 lines).
`HANDOFF_PIN_MOVED` refusal returns status=refused. It admits against its sub-cap and
never stops on the context rule: after a completed step, `verdict=delegate` hands its
remaining steps to a grandchild through these four steps (depth 3 at most); a
`CONTEXT_HIGH:` note means continue. It reconciles any grandchild, then ends with
`gstack-ship-budget return --run <child> --to <step> --changed <sections>` (at most 40 lines).
4. `gstack-ship-budget reconcile --run <id> --return <RETURN_FILE> --reading <n>`. Read the
record, never the child's transcript. Exit 3 (refused, incomplete or interrupted):
regenerate the handoff or stop.
+7 -4
View File
@@ -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 and the pass's other dispatch reservations. Admitting reviewers one at
On `ADMIT: k of n (remaining=<roles>)`, dispatch only those k and re-check the remaining roles
after they settle; `close` flags roles never re-checked. `--finish` settles the reservation 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.
@@ -43,9 +44,11 @@ fresh child; you wait and advance only after reconciling it:
GSTACK_LAUNCH_FILE, run /ship --continue <HANDOFF file>"; on Claude Code
`gstack-ship-budget launch --in <HANDOFF file>` (background, explicit permissions).
3. The child runs `verify-handoff --in <file>` first (`--reading <n>` on Capy); a
`HANDOFF_PIN_MOVED` refusal returns status=refused. It admits against its sub-cap,
never delegates, and ends with `gstack-ship-budget return --run <child> --to <step>
--changed <sections>` (at most 40 lines).
`HANDOFF_PIN_MOVED` refusal returns status=refused. It admits against its sub-cap and
never stops on the context rule: after a completed step, `verdict=delegate` hands its
remaining steps to a grandchild through these four steps (depth 3 at most); a
`CONTEXT_HIGH:` note means continue. It reconciles any grandchild, then ends with
`gstack-ship-budget return --run <child> --to <step> --changed <sections>` (at most 40 lines).
4. `gstack-ship-budget reconcile --run <id> --return <RETURN_FILE> --reading <n>`. Read the
record, never the child's transcript. Exit 3 (refused, incomplete or interrupted):
regenerate the handoff or stop.
+93
View File
@@ -0,0 +1,93 @@
/**
* GSTA-167 (gstack-evals #35, gate class): full-depth Step 9 launched 7 Opus
* readers in parallel at about 0.76M each, and the shared counter went 13.2M ->
* 18.5M before the parent's poll saw it. A dispatch batch now prices each reader
* at max(its projection, the run's measured mean for that role), admits the
* largest prefix that fits near the cap (ADMIT: k of n), and keeps the rest as
* remaining until a later check requests them; close flags readers never re-checked.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { loadCalibration, 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(); });
const READERS = ['specialist', 'specialist', 'specialist', 'specialist', 'specialist', 'red-team', 'adversarial-native'];
const AT_35 = 13_200_000;
/** Room for exactly three readers at the prior above the #35 reading, with 1M to spare. */
const CAP_35 = AT_35 + 3 * K.per_dispatch + K.stop_overhead + 1_000_000;
function start(h: Harness, cap: number) {
const r = h.sb(['start', '--record', h.record(), '--token-cap', String(cap), '--reading', '0', '--reserve', '0']);
expect(r.code, r.out + r.err).toBe(0);
return { run: h.field(r.out, 'BUDGET_RUN', 'id'), ledger: h.field(r.out, 'BUDGET_RUN', 'ledger') };
}
const batch = (h: Harness, run: string, roles: string[], reading: number, extra: string[] = []) =>
h.sb(['check', '--run', run, '--step', '9', '--next', 'dispatch:' + roles.join(','), '--reading', String(reading), ...extra]);
describe('parallel dispatch admission near the cap', () => {
test('#35 replay: a 7-reader batch near the cap admits the largest prefix and names the rest', () => {
const h = fresh();
const s = start(h, CAP_35);
const c = batch(h, s.run, READERS, AT_35);
expect(c.code, c.out + c.err).toBe(0);
expect(c.out).toMatch(/^ADMIT: 3 of 7 \(remaining=specialist,specialist,red-team,adversarial-native\)/m);
expect(h.field(c.out, 'BUDGET', 'verdict')).toBe('continue');
expect(h.field(c.out, 'RESERVATION', 'next')).toBe('dispatch:specialist,specialist,specialist');
expect(Number(h.field(c.out, 'RESERVATION', 'tokens'))).toBe(3 * K.per_dispatch);
const r = h.ledger(s.ledger).reservations.find((x: any) => x.kind === 'dispatch');
expect(r).toMatchObject({ roles: ['specialist', 'specialist', 'specialist'], remaining: ['specialist', 'specialist', 'red-team', 'adversarial-native'] });
});
test('a role\'s measured mean overrides a lower projection', () => {
const h = fresh();
const s = start(h, 200_000_000);
let id = h.field(batch(h, s.run, ['red-team'], 0).out, 'RESERVATION', 'id');
let reading = 3_000_000;
for (let i = 0; i < 4; i++, reading += 500_000) id = h.field(batch(h, s.run, ['specialist'], reading, ['--returned', id]).out, 'RESERVATION', 'id');
const c = batch(h, s.run, ['red-team', 'specialist'], reading, ['--returned', id]);
expect(c.code, c.out + c.err).toBe(0);
const f = (500_000 / K.per_dispatch) * cal.headroom;
const specialist = Math.ceil((K.per_dispatch * f) / cal.round_to) * cal.round_to;
expect(specialist).toBeLessThan(3_000_000);
expect(Number(h.field(c.out, 'BUDGET', 'projected'))).toBe(3_000_000 + specialist);
});
test('when not even the first reader fits, the batch stops as before', () => {
const h = fresh();
const s = start(h, CAP_35);
const c = batch(h, s.run, READERS, CAP_35 - K.stop_overhead - K.per_dispatch + 1);
expect(c.code).toBe(3);
expect(c.out).toContain('STOP: reason=budget_exhausted');
expect(c.out).not.toContain('ADMIT:');
expect(Number(h.field(c.out, 'BUDGET', 'projected'))).toBe(READERS.length * K.per_dispatch);
expect(h.ledger(s.ledger).reservations.filter((x: any) => x.kind === 'dispatch')).toHaveLength(0);
});
test('the remainder re-check admits after the first readers settle; close flags readers never re-checked', () => {
const h = fresh();
const s = start(h, CAP_35);
const first = h.field(batch(h, s.run, READERS, AT_35).out, 'RESERVATION', 'id');
expect(h.sb(['settle', '--run', s.run, '--reservation', first, '--reading', String(AT_35 + 3 * 760_000)]).code).toBe(0);
const rest = batch(h, s.run, READERS.slice(3), AT_35 + 3 * 760_000);
expect(rest.code, rest.out + rest.err).toBe(0);
expect(rest.out).not.toContain('ADMIT:');
expect(h.field(rest.out, 'RESERVATION', 'next')).toBe('dispatch:' + READERS.slice(3).join(','));
expect(h.ledger(s.ledger).reservations.find((x: any) => x.id === first).remaining).toEqual([]);
expect(h.sb(['close', '--run', s.run, '--reading', String(AT_35 + 4_000_000)]).code).toBe(0);
const d = start(h, CAP_35);
expect(batch(h, d.run, READERS, AT_35).out).toContain('ADMIT: 3 of 7');
const close = h.sb(['close', '--run', d.run, '--reading', String(AT_35 + 3_000_000)]);
expect(close.code).toBe(3);
expect(close.out).toMatch(/^DROPPED: step=9 reservation=\S+ roles=specialist,specialist,red-team,adversarial-native/m);
expect(close.out.trim().split('\n').at(-1)).toMatch(/^GSTACK_RESULT: skill=ship status=incomplete run=/);
const accepted = h.sb(['close', '--run', d.run, '--reading', String(AT_35 + 3_000_000), '--approved-by', 'Garry', '--words', 'three readers are enough']);
expect(accepted.code).toBe(0);
expect(accepted.out).toContain('ACCEPTED: 0 unreserved dispatches and 1 unfinished batches by Garry');
});
});
+9
View File
@@ -29,6 +29,15 @@ describe('ship-budget calibration', () => {
}
});
test('every meter unit has its own per-call floor: the largest round_to multiple at or below its observed fresh call, justified', () => {
for (const unit of ['capy-tokens', 'claude-usage-tokens', 'estimate-tokens']) {
const u = cal.units[unit]!;
expect(u.call_floor_why?.length ?? 0, unit).toBeGreaterThan(80);
const observed = u.call_floor_observed ?? 0;
expect(u.call_floor, unit).toBe(Math.floor(observed / cal.round_to) * cal.round_to);
}
});
test('every meter unit resolves to constants (calibration is per meter)', () => {
for (const unit of ['capy-tokens', 'claude-usage-tokens', 'estimate-tokens']) {
expect(unitConstants(cal, unit).per_dispatch).toBeGreaterThan(0);
+286
View File
@@ -0,0 +1,286 @@
/**
* GSTA-167: per-call thresholds measure context above the host's per-call
* floor (a fresh Capy thread's first call costs 150-230k before any work,
* GSTA-2 on gstack 1.91.320.0), and a child never stops on the context rule.
* Before its first completed step, or at the maximum chain depth, it continues
* with a CONTEXT_HIGH note; after one it hands its remaining steps to a
* grandchild through the same handoff, verify, return and reconcile commands.
* The token admission cap still stops a child. Replays gstack-evals #36, where
* two handoff children stopped at the Step 7/12 boundary before doing any work.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { loadCalibration, MAX_CHAIN_DEPTH, stepProjection, unitConstants } from '../lib/ship-budget';
import { budgetHarness, type Harness } from './helpers/ship-budget-harness';
const cal = loadCalibration();
const FLOOR = cal.units['capy-tokens']!.call_floor!;
const K = unitConstants(cal, 'capy-tokens');
const FIXTURE = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures', 'claude-usage-2.1.292.json'), 'utf8'));
const harnesses: Harness[] = [];
const fresh = () => { const h = budgetHarness(); harnesses.push(h); return h; };
afterEach(() => { while (harnesses.length) harnesses.pop()!.cleanup(); });
function start(h: Harness, args: string[]) {
const r = h.sb(['start', '--record', h.record(), ...args]);
expect(r.code, r.out + r.err).toBe(0);
return { r, run: h.field(r.out, 'BUDGET_RUN', 'id'), ledger: h.field(r.out, 'BUDGET_RUN', 'ledger') };
}
const readingArgs = (reading: number | null) => (reading === null ? [] : ['--reading', String(reading)]);
/** Hand Steps from-to off from a run and verify the brief as the next run in the chain. */
function handOff(h: Harness, run: string, from: string, to: string, reading: number | null, verifyArgs: string[] = readingArgs(reading)) {
const ho = h.sb(['handoff', '--run', run, '--from', from, '--to', to, '--base', 'origin/main', ...readingArgs(reading)]);
expect(ho.code, ho.out + ho.err).toBe(0);
const m = ho.out.match(/^HANDOFF: (\S+) sha256=\S+ sub_cap=(\d+)$/m)!;
const v = h.sb(['verify-handoff', '--in', m[1]!, ...verifyArgs]);
expect(v.code, v.out + v.err).toBe(0);
return { ho, v, brief: m[1]!, subCap: Number(m[2]), returnFile: ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!, run: h.field(v.out, 'BUDGET_RUN', 'id'), ledger: h.field(v.out, 'BUDGET_RUN', 'ledger') };
}
function check(h: Harness, run: string, step: string, next: string, extra: string[] = []) {
return h.sb(['check', '--run', run, '--step', step, '--next', next, ...extra]);
}
describe('per-call thresholds above the host floor', () => {
test('Capy floor: a fresh thread\'s first call of 150-230k does not delegate at start; 260k does', () => {
expect(FLOOR).toBe(150_000);
const h = fresh();
for (const [call, verdict] of [[150_000, 'continue'], [230_000, 'continue'], [260_000, 'delegate']] as const) {
const s = start(h, ['--token-cap', '60M', '--reading', String(98_000 + call), '--prior-reading', '98000']);
expect(h.field(s.r.out, 'BUDGET', 'call')).toBe(String(call));
expect(h.field(s.r.out, 'BUDGET', 'call_floor')).toBe(String(FLOOR));
expect(h.field(s.r.out, 'BUDGET', 'above_floor')).toBe(String(call - FLOOR));
expect(h.field(s.r.out, 'BUDGET', 'verdict')).toBe(verdict);
expect(s.r.out.includes('--from 1 --to 22')).toBe(verdict === 'delegate');
}
});
test('at a segment boundary, 150k above the floor delegates and less continues; an unknown charge still delegates', () => {
const h = fresh();
const s = start(h, ['--token-cap', '80M', '--reading', '1000000']);
const low = check(h, s.run, '6', '7', ['--reading', '1230000', '--prior-reading', '1000000']);
expect(low.code, low.out + low.err).toBe(0);
expect(h.field(low.out, 'BUDGET', 'above_floor')).toBe('80000');
expect(h.field(low.out, 'BUDGET', 'verdict')).toBe('continue');
const high = check(h, s.run, '11.5', '12', ['--reading', '2000000', '--prior-reading', '1700000']);
expect(h.field(high.out, 'BUDGET', 'above_floor')).toBe('150000');
expect(h.field(high.out, 'BUDGET', 'verdict')).toBe('delegate');
const unknown = check(h, s.run, '11.5', '12', ['--reading', '2100000']);
expect(h.field(unknown.out, 'BUDGET', 'above_floor')).toBe('unknown');
expect(h.field(unknown.out, 'BUDGET', 'verdict')).toBe('delegate');
});
test('--call-floor overrides the calibrated floor and is recorded in the ledger', () => {
const h = fresh();
const s = start(h, ['--token-cap', '60M', '--reading', '218000', '--prior-reading', '98000', '--call-floor', '0']);
expect(h.field(s.r.out, 'BUDGET', 'call_floor')).toBe('0');
expect(h.field(s.r.out, 'BUDGET', 'verdict')).toBe('delegate');
expect(h.ledger(s.ledger).call_floor).toBe(0);
const bad = h.sb(['start', '--record', h.record(), '--token-cap', '60M', '--reading', '0', '--call-floor', 'lots']);
expect(bad.code).toBe(2);
expect(bad.err).toContain("--call-floor 'lots' is not a token count");
});
});
describe('a child never stops on the context rule', () => {
test('#36 replay: a child at its first boundary with zero completed steps continues with one CONTEXT_HIGH note', () => {
const h = fresh();
const s = start(h, ['--token-cap', '400M', '--reading', '98000']);
for (const [from, to, step, at] of [['7', '11.5', '6', 1_000_000], ['12', '22', '11.5', 2_000_000]] as const) {
const c = handOff(h, s.run, from, to, at);
const r = check(h, c.run, step, from, ['--reading', String(at + 200_000)]);
expect(r.code, r.out + r.err).toBe(0);
expect(h.field(r.out, 'BUDGET', 'verdict')).toBe('continue');
const notes = r.out.split('\n').filter(l => l.startsWith('CONTEXT_HIGH:'));
expect(notes).toHaveLength(1);
expect(notes[0]).toContain('no step completed since verify-handoff');
expect(r.out).toContain(`RESERVATION: id=${c.run}-r1 next=${from}`);
expect(r.out).not.toContain('STOP:');
expect(r.out).not.toContain('GSTACK_RESULT');
expect(fs.existsSync(c.returnFile)).toBe(false);
}
});
test('a child with a completed step hands its remaining steps to a grandchild at depth 2, sub-cap = its remaining sub-cap', () => {
const h = fresh();
const s = start(h, ['--token-cap', '200M', '--reading', '98000']);
const child = handOff(h, s.run, '1', '22', 200_000);
expect(h.ledger(child.ledger).depth).toBe(1);
expect(check(h, child.run, '1', '2', ['--reading', '600000']).code).toBe(0);
const d = check(h, child.run, '6', '7', ['--reading', '1500000']);
expect(d.code, d.out + d.err).toBe(0);
expect(h.field(d.out, 'BUDGET', 'verdict')).toBe('delegate');
expect(d.out).toContain(`gstack-ship-budget handoff --run ${child.run} --from 7 --to 22`);
expect(d.out).not.toContain('RESERVATION');
const room = ['admission_cap', 'used', 'reserved', 'unknown', 'tail'].map(k => Number(h.field(d.out, 'BUDGET', k)));
const grand = handOff(h, child.run, '7', '22', 1_500_000);
expect(grand.subCap).toBe(room[0]! - room[1]! - room[2]! - room[3]! - room[4]!);
expect(h.field(grand.v.out, 'BUDGET', 'admission_cap')).toBe(String(grand.subCap));
const g = h.ledger(grand.ledger);
expect(g).toMatchObject({ role: 'child', depth: 2, parent: { run: child.run, ledger: child.ledger, from: '7', to: '22' } });
expect(h.ledger(child.ledger).handoffs[0]).toMatchObject({ depth: 2, from: '7', to: '22' });
const text = fs.readFileSync(grand.brief, 'utf8');
expect(text).toContain('"depth": 2');
expect(text).not.toContain('a delegate rule stops you');
});
test(`at the maximum chain depth (${MAX_CHAIN_DEPTH}) a child continues and its handoff refuses`, () => {
expect(MAX_CHAIN_DEPTH).toBe(3);
const h = fresh();
const s = start(h, ['--token-cap', '900M', '--reading', '0']);
let run = s.run;
let reading = 100_000;
const chain: string[] = [];
for (const [from, done] of [['1', '1'], ['2', '2'], ['3', '3']] as const) {
const c = handOff(h, run, from, '22', (reading += 100_000));
expect(check(h, c.run, done, String(Number(done) + 1), ['--reading', String((reading += 100_000))]).code).toBe(0);
run = c.run;
chain.push(c.ledger);
}
expect(chain.map(f => h.ledger(f).depth)).toEqual([1, 2, 3]);
const r = check(h, run, '6', '7', ['--reading', String((reading += 100_000))]);
expect(r.code, r.out + r.err).toBe(0);
expect(h.field(r.out, 'BUDGET', 'verdict')).toBe('continue');
expect(r.out).toMatch(/^CONTEXT_HIGH: .*chain depth 3 is the maximum/m);
const refused = h.sb(['handoff', '--run', run, '--from', '7', '--to', '22', '--base', 'origin/main', '--reading', String(reading)]);
expect(refused.code).toBe(2);
expect(refused.err).toContain('chain depth 3');
});
test('the token admission cap still stops a child that would otherwise hand off', () => {
const h = fresh();
const s = start(h, ['--token-cap', '80M', '--reading', '0']);
const ho = h.sb(['handoff', '--run', s.run, '--from', '1', '--to', '22', '--base', 'origin/main', '--reading', '100000', '--sub-cap', '5M']);
const brief = ho.out.match(/^HANDOFF: (\S+)/m)![1]!;
const returnFile = ho.out.match(/^RETURN_FILE: (\S+)$/m)![1]!;
const child = h.field(h.sb(['verify-handoff', '--in', brief, '--reading', '110000']).out, 'BUDGET_RUN', 'id');
expect(check(h, child, '1', '2', ['--reading', '120000']).code).toBe(0);
const stop = check(h, child, '6', '7', ['--reading', '2120000']);
expect(stop.code).toBe(3);
expect(stop.out).toContain('STOP: reason=budget_exhausted');
expect(stop.out).toContain('(BUDGET_CAP_REACHED)');
expect(stop.out).not.toContain('context_threshold');
expect(fs.readFileSync(returnFile, 'utf8')).toMatch(/^RETURN: handoff=[0-9a-f]{64} status=incomplete steps=1-22 next=7/);
});
});
describe('a chained return is charged once', () => {
test('Capy meter: root, child and grandchild reconcile, each token counted once and a duplicate reconcile charges nothing', () => {
const h = fresh();
const s = start(h, ['--token-cap', '200M', '--reading', '1000000']);
const child = handOff(h, s.run, '1', '22', 1_100_000, ['--reading', '1200000']);
expect(check(h, child.run, '1', '2', ['--reading', '1500000']).code).toBe(0);
expect(h.field(check(h, child.run, '6', '7', ['--reading', '2000000']).out, 'BUDGET', 'verdict')).toBe('delegate');
const grand = handOff(h, child.run, '7', '22', 2_100_000, ['--reading', '2200000']);
expect(check(h, grand.run, '7', '8', ['--reading', '3000000']).code).toBe(0);
expect(h.sb(['return', '--run', grand.run, '--to', '22', '--changed', 'Reviews', '--reading', '6000000']).code).toBe(0);
const mid = h.sb(['reconcile', '--run', child.run, '--return', grand.returnFile, '--reading', '6100000']);
expect(mid.code, mid.out + mid.err).toBe(0);
expect(mid.out).toContain('child=complete child_used=4000000');
expect(h.field(mid.out, 'BUDGET', 'used')).toBe('5000000');
expect(h.field(mid.out, 'BUDGET', 'reserved')).toBe('0');
const midAgain = h.sb(['reconcile', '--run', child.run, '--return', grand.returnFile, '--reading', '6100000']);
expect(midAgain.out).toContain('nothing charged twice');
expect(h.field(midAgain.out, 'BUDGET', 'used')).toBe('5000000');
expect(h.sb(['return', '--run', child.run, '--to', '22', '--changed', 'Reviews', '--reading', '6200000']).code).toBe(0);
const top = h.sb(['reconcile', '--run', s.run, '--return', child.returnFile, '--reading', '6300000']);
expect(top.code, top.out + top.err).toBe(0);
expect(top.out).toContain('child=complete child_used=5200000');
expect(h.field(top.out, 'BUDGET', 'reserved')).toBe(String(stepProjection(cal, 'capy-tokens', '1')));
expect(h.sb(['reconcile', '--run', s.run, '--return', child.returnFile, '--reading', '6300000']).out).toContain('nothing charged twice');
const close = h.sb(['close', '--run', s.run, '--reading', '6300000']);
expect(close.out).toContain('BUDGET_FINAL: used=5300000 reserved=0');
});
test("estimate meter: the grandchild's settled spend reaches the root once, through the child's return", () => {
const h = fresh();
const s = start(h, ['--token-cap', '400M', '--meter', 'estimate']);
const child = handOff(h, s.run, '1', '22', null);
expect(check(h, child.run, '1', '2').code).toBe(0);
expect(h.field(check(h, child.run, '6', '7').out, 'BUDGET', 'verdict')).toBe('delegate');
const grand = handOff(h, child.run, '7', '22', null);
expect(check(h, grand.run, '7', '8').code).toBe(0);
expect(check(h, grand.run, '8', '9').code).toBe(0);
const gret = h.sb(['return', '--run', grand.run, '--to', '22', '--changed', 'Reviews']);
const grandUsed = Number(h.field(gret.out, 'BUDGET', 'used'));
expect(grandUsed).toBe(stepProjection(cal, 'estimate-tokens', '8'));
const childOwn = stepProjection(cal, 'estimate-tokens', '2');
for (let i = 0; i < 2; i++) expect(h.sb(['reconcile', '--run', child.run, '--return', grand.returnFile]).code).toBe(0);
const cret = h.sb(['return', '--run', child.run, '--to', '22', '--changed', 'Reviews']);
expect(Number(h.field(cret.out, 'BUDGET', 'used'))).toBe(childOwn + grandUsed);
const top = h.sb(['reconcile', '--run', s.run, '--return', child.returnFile]);
expect(Number(h.field(top.out, 'BUDGET', 'used'))).toBe(childOwn + grandUsed);
const again = h.sb(['reconcile', '--run', s.run, '--return', child.returnFile]);
expect(Number(h.field(again.out, 'BUDGET', 'used'))).toBe(childOwn + grandUsed);
});
test("Claude Code meter: a grandchild launched by a child joins every ancestor's meter, so the root sees its usage", () => {
const h = fresh();
const project = path.join(h.root, 'claude', 'projects', '-tmp-repo');
fs.mkdirSync(project, { recursive: true });
fs.writeFileSync(path.join(project, `${FIXTURE.sessions.parent}.jsonl`), FIXTURE.journals.parent.map((l: string) => l + '\n').join(''));
const s = start(h, ['--token-cap', '400M', '--session', FIXTURE.sessions.parent]);
const child = handOff(h, s.run, '1', '22', null, ['--session', 'child-session-1']);
expect(check(h, child.run, '1', '2').code).toBe(0);
expect(h.field(check(h, child.run, '6', '7').out, 'BUDGET', 'verdict')).toBe('delegate');
const ho = h.sb(['handoff', '--run', child.run, '--from', '7', '--to', '22', '--base', 'origin/main']);
const brief = ho.out.match(/^HANDOFF: (\S+)/m)![1]!;
const fake = path.join(h.root, 'fake-claude');
fs.writeFileSync(fake, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
const launched = h.sb(['launch', '--in', brief], { GSTACK_CLAUDE_BIN: fake });
expect(launched.code, launched.out + launched.err).toBe(0);
const session = h.field(launched.out, 'CHILD', 'session');
expect(h.ledger(child.ledger).meter.sessions).toContain(session);
expect(h.ledger(s.ledger).meter.sessions).toEqual([FIXTURE.sessions.parent, session]);
});
});
describe('a child returns complete only when its own work is finished', () => {
test('a child that returns before reconciling its grandchild returns incomplete and names the handoff', () => {
const h = fresh();
const s = start(h, ['--token-cap', '200M', '--reading', '98000']);
const child = handOff(h, s.run, '1', '22', 200_000);
expect(check(h, child.run, '1', '2', ['--reading', '600000']).code).toBe(0);
expect(h.field(check(h, child.run, '6', '7', ['--reading', '1500000']).out, 'BUDGET', 'verdict')).toBe('delegate');
const grand = handOff(h, child.run, '7', '22', 1_500_000);
const ret = h.sb(['return', '--run', child.run, '--to', '22', '--changed', 'Reviews', '--reading', '1600000']);
expect(ret.code, ret.out + ret.err).toBe(0);
expect(ret.out).toMatch(/^RETURN: handoff=[0-9a-f]{64} status=incomplete steps=1-22/);
expect(ret.out).toContain(`UNRECONCILED: handoff=${h.ledger(child.ledger).handoffs[0].sha} steps=7-22`);
expect(ret.out).toContain(grand.returnFile);
const top = h.sb(['reconcile', '--run', s.run, '--return', child.returnFile, '--reading', '1700000']);
expect(top.code).toBe(3);
expect(top.out).toContain('child=incomplete');
});
test('a child with an ADMIT remainder it never re-checked returns incomplete with a DROPPED line', () => {
const h = fresh();
const s = start(h, ['--token-cap', '900M', '--reading', '0', '--reserve', '0']);
const subCap = K.stop_overhead + Math.floor(1.5 * K.per_dispatch);
const ho = h.sb(['handoff', '--run', s.run, '--from', '7', '--to', '11.5', '--base', 'origin/main', '--reading', '100000', '--sub-cap', String(subCap)]);
expect(ho.code, ho.out + ho.err).toBe(0);
const child = h.field(h.sb(['verify-handoff', '--in', ho.out.match(/^HANDOFF: (\S+)/m)![1]!, '--reading', '100000']).out, 'BUDGET_RUN', 'id');
const b = check(h, child, '9', 'dispatch:specialist,specialist,red-team', ['--reading', '100000']);
expect(b.code, b.out + b.err).toBe(0);
expect(b.out).toMatch(/^ADMIT: 1 of 3 \(remaining=specialist,red-team\)/m);
const ret = h.sb(['return', '--run', child, '--to', '11.5', '--reading', '100000']);
expect(ret.out).toMatch(/^RETURN: handoff=[0-9a-f]{64} status=incomplete steps=7-11.5/);
expect(ret.out).toMatch(/^DROPPED: step=9 reservation=\S+ roles=specialist,red-team/m);
expect(ret.out.trim().split('\n').at(-1)).toMatch(/^GSTACK_RESULT: skill=ship status=incomplete run=/);
});
test("a grandchild handoff outside the child's own Steps refuses and reserves nothing", () => {
const h = fresh();
const s = start(h, ['--token-cap', '200M', '--reading', '98000']);
const child = handOff(h, s.run, '7', '11.5', 200_000);
for (const [from, to] of [['7', '22'], ['6', '11.5']]) {
const r = h.sb(['handoff', '--run', child.run, '--from', from!, '--to', to!, '--base', 'origin/main', '--reading', '300000']);
expect(r.code).toBe(2);
expect(r.err).toContain(`--from ${from} --to ${to} is outside this child's Steps 7-11.5`);
}
expect(h.ledger(child.ledger).reservations.filter((r: any) => r.kind === 'child')).toHaveLength(0);
});
});
+12 -9
View File
@@ -54,14 +54,14 @@ describe('parsing and the admission rule', () => {
});
describe('reservations', () => {
test('a parallel batch is reserved as one sum and refused whole when it would overshoot', () => {
test('a parallel batch is reserved as one sum; when it would overshoot only the prefix that fits is admitted', () => {
const h = fresh();
const cap = 2 * K.per_dispatch + K.stop_overhead + 1_000_000;
const s = started(h, ['--token-cap', String(cap), '--reading', '0', '--reserve', '0']);
const over = h.sb(['check', '--run', s.run, '--step', '9', '--next', 'dispatch:specialist,red-team,fix-check', '--reading', '0']);
expect(over.code).toBe(3);
expect(over.out).toContain('(BUDGET_CAP_REACHED)');
expect(h.ledger(s.ledger).reservations.filter((r: any) => r.kind === 'dispatch')).toHaveLength(0);
expect(over.code).toBe(0);
expect(over.out).toContain('ADMIT: 2 of 3 (remaining=fix-check)');
expect(h.ledger(s.ledger).reservations.filter((r: any) => r.kind === 'dispatch').map((r: any) => [r.tokens, r.roles])).toEqual([[2 * K.per_dispatch, ['specialist', 'red-team']]]);
});
test('#31 replay: a 25M cap stops before the dispatch that would cross it', () => {
@@ -176,10 +176,11 @@ describe('meters and exit codes', () => {
});
describe('delegation', () => {
test('start from a long-lived context (call >= 100k) delegates the whole /ship', () => {
test('start from a long-lived context (100k or more above the host floor) delegates the whole /ship', () => {
const h = fresh();
const s = started(h, ['--token-cap', '60M', '--reading', '9000000', '--prior-reading', '8880000']);
expect(h.field(s.r.out, 'BUDGET', 'call')).toBe('120000');
const s = started(h, ['--token-cap', '60M', '--reading', '9000000', '--prior-reading', '8740000']);
expect(h.field(s.r.out, 'BUDGET', 'call')).toBe('260000');
expect(h.field(s.r.out, 'BUDGET', 'above_floor')).toBe('110000');
expect(h.field(s.r.out, 'BUDGET', 'verdict')).toBe('delegate');
expect(s.r.out).toContain('--from 1 --to 22');
});
@@ -202,7 +203,7 @@ describe('delegation', () => {
test('full chain: delegate, handoff, verify, child checks, 40-line return, idempotent reconcile, close', () => {
const h = fresh();
const s = started(h, ['--token-cap', '80M', '--reading', '1000000']);
const d = h.sb(['check', '--run', s.run, '--step', '6', '--next', '7', '--reading', '1200000', '--prior-reading', '1000000']);
const d = h.sb(['check', '--run', s.run, '--step', '6', '--next', '7', '--reading', '1200000', '--prior-reading', '800000']);
expect(h.field(d.out, 'BUDGET', 'verdict')).toBe('delegate');
const ho = h.sb(['handoff', '--run', s.run, '--from', '7', '--to', '11.5', '--base', 'origin/main', '--reading', '1300000']);
expect(ho.code).toBe(0);
@@ -221,7 +222,9 @@ describe('delegation', () => {
expect(h.field(v.out, 'BUDGET', 'used')).toBe('100000');
const cc = h.sb(['check', '--run', child, '--step', '8', '--next', '9', '--reading', '5000000']);
expect(cc.code).toBe(0);
expect(h.sb(['check', '--run', child, '--step', '11', '--next', '12', '--reading', '6000000']).out).toContain('a child never delegates');
const past = h.sb(['check', '--run', child, '--step', '11', '--next', '12', '--reading', '6000000']);
expect(past.code).toBe(0);
expect(past.out).toMatch(/^CONTEXT_HIGH: .*Step 12 is past this child's Steps 7-11\.5/m);
const ret = h.sb(['return', '--run', child, '--to', '11.5', '--changed', 'Reviews,Checks', '--reading', '9000000']);
expect(ret.code).toBe(0);
expect(ret.out.trim().split('\n').length).toBeLessThanOrEqual(40);
@@ -1,4 +1,4 @@
{
"bytes": 146,
"why": "GST-101: budget-handoff names check --returned for reviewers admitted one at a time"
"bytes": 535,
"why": "GST-101: budget-handoff names check --returned for reviewers admitted one at a time; GSTA-167: a child never stops on the context rule and hands off after a completed step, and an ADMIT k of n batch re-checks its remaining readers"
}