Skip to content

review: enforce the run's spend ceiling in code, in real dollars - #323

Draft
jwbron wants to merge 5 commits into
jwies/review-pi-harness-seamfrom
jwies/review-spend-ceiling
Draft

review: enforce the run's spend ceiling in code, in real dollars#323
jwbron wants to merge 5 commits into
jwies/review-pi-harness-seamfrom
jwies/review-spend-ceiling

Conversation

@jwbron

@jwbron jwbron commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Plan item B, stacked on #305 (base is that branch, so this diff is only the ceiling).

What

Dispatch carries a budget ledger, denominated in real dollars. Work may start only while the run's spend is under CEILING_USD - LANDING_RESERVE_USD; crossing it refuses further dispatches, aborts the agents already in flight, and discloses every shed as a note line rather than a silently missing dimension.

Why in code rather than at the proxy

The only ceiling today is gh-aw's api-proxy credit cap (maxAiCredits: 2500): denominated in list-price credits (2x what Khan pays), unable to shed gracefully (it fails the run), and gone with the proxy when the migration completes. This supersedes #314's cap-denomination motivation; the proxy cap remains a coarse list-price backstop, and #314's remaining purpose is proxy-side model pricing (which #294 needs).

The numbers, derived rather than chosen

value derivation
ceiling $12.50 the lock allows 2500 credits ($12.50 real) for the agent job; the detection pass ($0.27) is outside this ledger
landing reserve $1.50 the measured validator tail (~$0.30, it reads every claim) plus the reconciler

A test pins both, so changing live spend behavior is a review conversation rather than a diff nobody reads. The ceiling must sit under the proxy's real-terms cap or the shed behavior is unobservable (the proxy would kill the run first). The reserve is the piece that is easy to omit and expensive to miss: a ceiling with nothing held back turns an over-budget run into a wasted one, unable to validate or post what it already paid to find.

Enforcement shape

  • Per-turn probe. The runner asks the ledger at each turn boundary, so one heavy reviewer cannot outspend a wave; stopping at a boundary (not by throwing) keeps what the agent already found.
  • One writer. The dispatcher records once per completed attempt (retries and refusal fallbacks each pay); the probe is read-only. A forgotten hook therefore cannot look like a ceiling that never fired, which an earlier two-writer draft allowed.
  • Two abort paths. The probe stops the agent doing the spending; the ledger's AbortSignal, consumed by the runner, stops the in-flight siblings when someone else's completed dispatch crosses the line.
  • Overshoot bounded and measured. A wave can overshoot by at most the unsettled spend of the agents running at once; overshootUsd measures it instead of assuming it away.

Disclosure, rollback, telemetry

Refused and aborted agents become skippedDimensions with cause budget (note lines distinct from planned tier sheds, so "we ran out" reads differently from "we chose not to") and perAgent entries with failed: "budget"; the disclosure is read back from the ledger, so record and notes cannot disagree. REVIEW_SPEND_ENFORCEMENT=proxy-only measures without enforcing (still reports crossed and the sheds), and the record names which enforcement was in force. DispatchResult.spend (ceiling, reserve, spend, overshoot, sheds, enforcement) stages in dispatch-result.json and the out/ artifact copy.

Known trade

Unlike the proxy cap (a container image no Khan repo can edit), this ceiling lives in a repo whose PRs this reviewer reviews. Mitigation: CODEOWNERS on the ledger plus required review; recorded in the module itself.

Tests

1691 pass: 13 on the ledger's arithmetic and abort wiring, 4 integration (one dispatch paid and the rest refused under a tight ceiling; the whole roster under the rollback; the record present in both staged files; the enforcement named).

jwbron added 3 commits August 3, 2026 19:48
…ledger with one rule

First slice of the spend ceiling (plan item B), on its own branch stacked on
#305 rather than added to it. This commit is the ledger and its arithmetic; the
runner and dispatch wiring follow.

The rule is one pure function of three numbers, so every interesting property
is directly testable: work may start while spend is under the DISPATCH BUDGET,
which is the ceiling minus a landing reserve. The reserve is the part that is
easy to leave out and expensive to miss: a ceiling with nothing held back turns
an over-budget run into a wasted one, because the run cannot validate or post
what it already paid to find.

Enforcement is per turn, not per wave. Cost arrives one assistant turn at a
time and one heavy reviewer can outspend a whole wave of light ones, so a
between-waves check notices the overshoot only after paying for it. Crossing
aborts in-flight agents through the ledger's AbortSignal and records the shed
with the dollar it happened at, because a review that quietly drops a dimension
for lack of money is indistinguishable from one that found nothing there.

Numbers, derived rather than picked: $12.50 ceiling, because the compiled lock
allows maxAiCredits: 2500 for the agent job, the detection pass spends ~$0.27
of that and this ledger does not govern it, and a measured full case's
sub-agents ran about $0.50. The ceiling has to sit under the proxy's real-terms
equivalent or the shed behaviour is unobservable, since the proxy would kill the
run first. $1.50 reserve, sized from the validator dispatch (~$0.30 measured,
the expensive tail) plus the reconciler. A test pins both, so changing live
spend behaviour is a review conversation and not a diff nobody reads.

REVIEW_SPEND_ENFORCEMENT=proxy-only is the rollback, and it measures without
enforcing: the report still says crossed, still lists the sheds, and the notice
is loud. A rolled-back run is not a blind run. The report also names which
enforcement was in force, per the plan's rule that where granularity changes
over time the artifact says which one produced it. That report is the substrate
telemetry record v0.

One thing this ceiling does NOT do, recorded in the module rather than implied:
it does not replace the LOCATION property of the proxy cap. awf's cap is
un-weakenable because it lives in a container image no Khan repo can edit;
this one lives in a repo whose PRs the reviewer reviews. The provider-side
workspace limit that would have restored that property is unavailable, so
CODEOWNERS plus required review is the whole mitigation.
… and say when it did

The runner's seam for the ceiling: `onTurnCost` reports each turn's cost delta
as it lands and asks whether to continue. The delta, not the total, because the
ledger is run-wide and this runner only ever knows about one agent.

Stopping happens at a turn boundary through `shouldStopAfterTurn`, not by
throwing. An agent stopped for budget has usually already found something, and
killing it mid-turn would discard that along with the turn's cost that was
already paid.

`AgentResult.stoppedForBudget` carries the fact outward on every success path,
including the free-text final. The dispatcher needs it to disclose the
dimension as cut short rather than completed: an agent that ran out of money
mid-investigation and one that finished and found nothing produce the same empty
shape, and only this flag tells them apart.
…tch, and disclose what it cuts

Wires the ledger into the run and closes the gap that would have made it
decorative.

The gap: as first written, the ledger only learned about cost if a caller
remembered to hook the runner, and a forgotten hook is a ceiling that silently
never fires. It now has exactly one writer, the dispatcher, called once per
completed attempt so a retry and a refusal fallback each pay for themselves. The
per-turn check became a read-only probe against that accounting, which is what
makes double counting impossible without needing per-attempt identity the runner
does not have.

Two abort paths, because one does not cover the other. The probe stops the agent
that is doing the spending, at a turn boundary. The ledger's AbortSignal, now
actually consumed by the runner, stops the siblings already in flight when
someone else's completed dispatch crosses the line; before this it was an unused
field, which would have made "crossing aborts in-flight agents" true only of the
agent that crossed.

Disclosure reuses the machinery already there: refused and aborted agents become
`skippedDimensions` with cause `budget`, get note lines distinct from the
tier-budget shed (planned before the run) so a reader can tell "we chose not to"
from "we ran out", and appear in `perAgent` with `failed: "budget"` rather than
as agents that ran and found nothing. The sheds are read back OUT of the ledger
when the result is assembled, so the record and the disclosure cannot disagree.

`DispatchResult.spend` rides both staged copies of the run artifact, so the gate
and the post-hoc read the same document. Integration tests cover the wiring
rather than the arithmetic: one dispatch paid for and the rest refused under a
tight ceiling, the whole roster running under the rollback, and the record
present in both staged files. CODEOWNERS names @jwbron and @jeresig on the
ledger, since it is now the only limit on what a run can spend.
@changeset-bot

changeset-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1b2ead2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
review Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested — see inline comments.
Note: correctness-reviewer not assessed this run (correctness-reviewer output unavailable).
Note: skill-auditor not assessed this run (skill-auditor output unavailable).

Comment thread workflows/review/lib/spend-ledger.ts Outdated
recordSpend: (agent, usd) => {
spentUsd += Math.max(0, usd);
if (decideSpend(spentUsd, ceilingUsd, landingReserveUsd).crossed) {
cross(agent, "aborted");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): The agent that completes-and-crosses is mislabeled 'aborted' and disclosed as a shed, so the record contradicts itself. recordSpend is only ever called after a dispatch completes (dispatch-calls.ts:306/334), so the agent passed to cross(agent,'aborted') ran to completion — it was not aborted or cut short. dispatch.ts:672-683 then lists it in skippedDimensions and dispatch.ts:718 emits a 'cut short mid-investigation' note, even though the same agent appears in perAgent/dispatched with its findings kept. spend-ledger.test.ts:97 pins this mislabel. Ironically the actual in-flight siblings killed by the AbortSignal are NOT in ledger.sheds, so they surface as generic 'unavailable', not budget — the disclosure names the wrong agents.

Also flagged by completeness.

// Cumulative for this agent, because the probe is a
// question about the run's total and the caller adds this
// agent's in-flight spend to what it has already settled.
if (options.onTurnCost?.(usd) === "abort") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo (blocking): Per-turn budget probe (onTurnCost → budgetExhausted → stoppedForBudget) is untested. grep confirms onTurnCost/budgetExhausted/stoppedForBudget appear only in source, never in any test; dispatch-runner-pi.test.ts never passes onTurnCost, and dispatch-spend.test.ts's fake chargingRunner never invokes it. The runner test harness already emits turn_end events with costs, so this is cheap to pin.

A sketch, not a committable replacement:

it("stops at the turn boundary and flags stoppedForBudget when onTurnCost aborts", async () => {
    loop = async ({emit}) => {
        emit(turnEnd("first turn", 5));
        emit(turnEnd("second turn", 5));
        return [];
    };
    const runner = await createPiRunner({
        onTurnCost: (spent) => (spent >= 5 ? "abort" : "continue"),
    });
    const result = await runner(request());
    expect(result.stoppedForBudget).toBe(true);
    expect(result.turns).toBe(1); // stopped after the first turn crossed
});

if (runAbort?.aborted === true) {
abort.abort(runAbort.reason);
} else {
runAbort?.addEventListener(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo (blocking): Run-wide abortSignal linking into the runner's internal AbortController is untested. The ledger unit test asserts signal.aborted flips, but nothing verifies the runner consumes that signal to stop an in-flight request. The fake chargingRunner ignores the signal entirely, so both the pre-aborted and later-abort branches are dead to the suite.

A sketch, not a committable replacement:

it("aborts an in-flight request when the run-wide signal fires", async () => {
    const controller = new AbortController();
    loop = ({signal}) =>
        new Promise((_r, reject) =>
            signal?.addEventListener("abort", () => reject(new Error("aborted"))),
        );
    const runner = await createPiRunner({abortSignal: controller.signal});
    const pending = runner(request());
    controller.abort(new Error("spend ceiling"));
    const result = await pending;
    expect(result.stopReason).not.toBe(undefined); // stopped, not run to completion
});

maxTurns,
timeoutMs,
validatorFor,
mayDispatch: (name) => ledger.mayDispatch(name),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): Landing reserve cannot fund the validation it exists for — the validator is gated by the dispatch budget, not the ceiling. mayDispatch is wired uniformly (no exemption for the claim-validator or reconciler) and decideSpend compares against ceiling−reserve. The reserve's stated purpose is 'held back so the run can still validate and post what it already found', but the very dispatch it reserves money for is refused by the same gate the moment the run crosses, leaving the reserve as dead headroom and silently disabling validation exactly on expensive runs. To make the reserve work, landing-phase dispatches (validator/reconciler) must be allowed to spend into it, i.e. checked against the ceiling rather than the dispatch budget.

Comment thread workflows/review/lib/spend-ledger.ts Outdated
return true;
}
cross(agent, "refused");
return enforcement !== "in-code";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): Rollback mode emits false 'not assessed' sheds for agents that actually ran. Same root cause as the aborted-mislabel: disclosure is derived unconditionally from ledger.sheds regardless of whether the agent ran. In proxy-only this is worse — every post-cross agent runs and is simultaneously reported as shed, which defeats the stated goal that 'a rolled-back run is not a blind run' by making it a lying run. Sheds should reflect work that was actually refused/aborted, not measured hypotheticals.

Comment thread CODEOWNERS
# Pin bumps and policy edits under these paths are behaviour changes: reviewed
# as such, never batch-merged.

/workflows/review/lib/spend-ledger.ts @jwbron @jeresig

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (non-blocking): Does CODEOWNERS on spend-ledger.ts alone cover the bypass surfaces the mitigation claims to close? The owned file holds the constants, but the enforcement is wired elsewhere: dispatch.ts (unowned) constructs the ledger and accepts an arbitrary ledger override via DispatchOptions, and the rollback flag is read from process.env, settable from the workflow lock/frontmatter — none of which are under the new entries. Grep confirms REVIEW_SPEND_ENFORCEMENT flows through dispatch.ts and the runner, not just the owned module; the mitigation as written protects the number, not the decision to use it. (Also worth confirming branch protection actually requires code-owner review in this repo — a CODEOWNERS file with no such rule enforces nothing.)

A sketch, not a committable replacement:

Extend the entries to the wiring that selects ceiling and enforcement (dispatch.ts's ledger construction and the review workflow files that set env), or record in the CODEOWNERS comment that the caller-override and env-flag surfaces are knowingly outside the mitigation.

* changes. Each consumer compiles its own lock, so a consumer with a different
* credit allowance wants a different number here.
*/
export const CEILING_USD = 12.5;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought (non-blocking): The pinned CEILING_USD duplicates a cap the repo already discovers at runtime, and its 'sits under the proxy' invariant is unenforced. credit-cap.ts already resolves the live per-run credit cap from the environment (resolveCreditCap, 1 credit = $0.01) and already holds back a landing reserve (LANDING_TARGET_RATIO = 0.75), so the run now carries two independently-sized budget systems — the router's clamped runBudget.maxUsd and the ledger's CEILING_USD/LANDING_RESERVE_USD — that nothing keeps consistent; the PR's own premise that 'a consumer with a different allowance wants a different ceiling' is precisely the problem resolveCreditCap already solves dynamically. The pinned constant makes sense only after the migration deletes the proxy, but until then the load-bearing invariant (ceiling < proxy's real-terms equivalent, which also bakes in an unstated credits-to-real-dollars discount rate) is asserted in a comment rather than checked anywhere.

A sketch, not a committable replacement:

While the proxy exists, derive ceilingUsd from resolveCreditCap (e.g. min(CEILING_USD, discoveredCapUsd × realTermsRatio)) or at least assert at ledger construction that the ceiling sits under the discovered cap, warning loudly when it does not; and say in one place how this ledger's reserve relates to LANDING_TARGET_RATIO so the two reserves cannot silently diverge.

Comment thread workflows/review/lib/spend-ledger.ts Outdated
* flag is read ONCE at construction: an enforcement posture that could
* change mid-run is not a posture.
*/
env?: {[key: string]: string | undefined};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (non-blocking): Injected env typed with an index-signature literal instead of the repo's Record<string, string | undefined> idiom. Every sibling module that injects the environment types it the same way — credit-cap.ts env: Record<string, string | undefined>, and likewise in router.ts, dispatch-runner-pi.ts, and stage-pr.ts — but spend-ledger.ts line 138 writes env?: {[key: string]: string | undefined};. The two are semantically identical; the literal form just departs from the established spelling.

Suggested change
env?: {[key: string]: string | undefined};
env?: Record<string, string | undefined>;

jwbron added 2 commits August 13, 2026 17:20
…nd let the reserve fund landing

Review feedback on #323, all four blocking findings plus two
non-blocking:

- A completed dispatch that crosses the budget is no longer shed as
  'aborted': the completer ran and its findings are kept. The agents
  the crossing actually cut are disclosed via the new
  ledger.recordAborted, fed by the dispatcher (a budget-stopped
  completion always; a thrown attempt only when the ledger's signal
  killed it), and a budget cut recorded upstream as a generic
  'unavailable' is upgraded to cause budget in the disclosure.
- The landing reserve now funds the landing: ledger.enterLanding()
  (called at Phase 3) moves the gates to the full ceiling and swaps in
  a fresh abort signal, so the validator and anything after it can
  spend the reserve instead of being refused or born aborted by the
  dispatch-phase crossing. The runner accepts an abort-signal thunk
  and resolves it per request.
- The per-turn budget probe (onTurnCost -> budgetExhausted ->
  stoppedForBudget) and the run-wide abort linking (pre-aborted and
  aborted-later) are pinned by tests.
- Rollback mode (proxy-only) no longer records refused sheds for
  dispatches that actually ran; the run instead carries an explicit
  crossed-with-enforcement-off note.
- Injected env typed Record<string, string | undefined> per the
  sibling-module idiom.
…es/review-pi-harness-seam' into jwies/review-spend-ceiling
@jwbron

jwbron commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all feedback in 70fcbb1 (base sync with #305 in 1b2ead2):

  • spend-ledger.ts:235 (blocking, completer mislabeled): recordSpend no longer sheds the completing agent. The agents the crossing actually cuts are disclosed through the new ledger.recordAborted, fed by the dispatcher: a budget-stopped completion always, a thrown attempt only when the ledger's signal killed it. A budget cut recorded upstream as generic 'unavailable' is upgraded to cause budget, closing the wrong-agents disclosure defect the comment named.
  • dispatch.ts:347 (blocking, reserve cannot fund landing): ledger.enterLanding() at Phase 3 moves mayDispatch/wouldCross to the full ceiling and swaps in a fresh abort signal (the runner now accepts a signal thunk, resolved per request), so the validator spends the reserve instead of being refused or born aborted. Tests pin the whole sequence: crossed fan-out, live landing gate, ceiling still binding.
  • dispatch-runner-pi.ts:818 and :684 (blocking, untested seams): both pinned. onTurnCost -> shouldStopAfterTurn -> stoppedForBudget (with probe values and turn count), the continue path, the aborted-later rejection, and the pre-aborted linkage.
  • spend-ledger.ts:258 (rollback false sheds): proxy-only mayDispatch no longer records sheds for dispatches that ran; the run carries an explicit crossed-with-enforcement-off note instead.
  • spend-ledger.ts:138 (env type): now Record<string, string | undefined>.
  • CODEOWNERS:16 (question): accurate as asked: the constants live in the owned file but enforcement is wired in dispatch.ts/dispatch-calls.ts/dispatch-runner-pi.ts, which CODEOWNERS does not cover. Extending the rule to those files is a one-line change; flagging for the PR author to decide scope.
  • spend-ledger.ts:57 (thought, CEILING_USD duplicates the runtime cap): deliberate while both exist; review: replace the Claude Agent SDK harness with Pi, and sandbox tool subprocesses with srt #305 deletes the proxy and its discovered cap, at which point this constant is the only ceiling. Reconciling the two before that lands would couple this PR to credit-cap.ts for one release of overlap.

@jwbron

jwbron commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

CI note, unrelated to this PR's diff: the agent job on 1b2ead2 (run 31745225791) died from the firewall api-proxy's cache-miss guard, not from the branch. The artifact log shows 403 {"type":"max_cache_misses_exceeded","message":"Maximum consecutive cache misses exceeded (8 / 5)"} after a healthy run (all 11 out-files staged, 16 turns, $1.06). The gh-aw claude-harness misclassifies this 403 as an authentication failure ("authentication failed - not retrying"), turning a retryable proxy guard into a hard fail; #294 hit the same signature earlier today and its rerun passed. Two follow-ups worth filing upstream against gh-aw: the 403 misclassification, and whether maxCacheMisses: 5 is too tight when the API is shedding cache under load.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant