mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
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:
1 parent
53db038c6b
commit
dcd62c389d
17 files changed
+639
-91
No files matched your search
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
Reference in new issue
Block a user