From 290295fd51f5aecb24b6afbd669ec5c9690d8107 Mon Sep 17 00:00:00 2001 From: Hasit Bhatt <7106317-hasitpbhatt@users.noreply.replit.com> Date: Sun, 11 Oct 2026 09:20:44 +0000 Subject: [PATCH 1/2] fix: child budget split could hand out more than the parent had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit allocateChildBudgets clamped each share at Math.max(1, …), so four children on a two-token remainder each drew 1 and the SUM exceeded the pool while parentBudget still reported 2 — false exactly when the budget was tight enough for the split to matter. The clamps are gone; the fix is their deletion. The remainder stays in the coordination reserve, a 0 allocation is dropped and NAMED in the tool output rather than re-floored into a run (the child runner no longer re-floors a 0 either, which is how the floor used to get back in), and delegate's pool-exhausted case is a refusal with the numbers instead of a 1-token child. src/budget.test.ts is new — the module had no test file while it owned the arithmetic that was wrong — and pins the invariant over a table of (parent, count) pairs, not the examples that follow it. Two more in the same seam: delegate_many's "[agent] started:" lines sat inside the `budget !== undefined` branch, so a fan-out on a run with no token budget went quiet between the last parent event and the first report (the exact silence the line exists to prevent); and a single child carried no costCheck at all, so delegate under --max-budget-usd spent its whole 85% share before the parent re-read the ceiling at its next turn. The ceiling is forwarded to a single child only — a fan-out holding the same closure would have four holders each seeing only their own buckets, each believing it alone owns the cap. Signed-off-by: Hasit Bhatt <7106317-hasitpbhatt@users.noreply.replit.com> --- src/budget.test.ts | 69 ++++++++++++++++++++++++++++++++++++++ src/budget.ts | 29 ++++++++++++++-- src/loop.ts | 10 ++++++ src/subagents.ts | 18 +++++++++- src/tools/child-run.ts | 4 +++ src/tools/delegate.ts | 13 +++++-- src/tools/delegate_many.ts | 48 +++++++++++++++++++------- src/tools/types.ts | 9 +++++ 8 files changed, 182 insertions(+), 18 deletions(-) create mode 100644 src/budget.test.ts diff --git a/src/budget.test.ts b/src/budget.test.ts new file mode 100644 index 0000000..e57e38b --- /dev/null +++ b/src/budget.test.ts @@ -0,0 +1,69 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { allocateChildBudgets } from "./budget.js"; + +/** + * The child budget split (`src/budget.ts`). + * + * This module had no test file at all while the arithmetic it owned was + * wrong: `Math.max(1, …)` clamps made each child look owed at least one token, + * so four children on a two-token remainder each drew 1 and the SUM exceeded + * the pool it was split from — while `parentBudget` still reported 2. The + * invariant that matters is the sum, so it is pinned over a table rather than + * by the examples that follow. + */ + +test("the sum of allocations never exceeds the parent's remaining budget", () => { + for (const parent of [0, 1, 2, 3, 4, 5, 9, 10, 99, 100, 250_000]) { + for (const count of [1, 2, 3, 4]) { + const r = allocateChildBudgets(parent, count); + const sum = r.children.reduce((s, c) => s + c.allocated, 0); + assert.ok( + sum + r.coordinationBudget <= parent, + `parent=${parent} count=${count}: children ${sum} + coord ${r.coordinationBudget} > ${parent}` + ); + assert.equal(r.parentBudget, parent, "the reported pool is the real pool, not a rounded one"); + assert.equal(r.children.length, count, "every child is still reported, funded or not"); + } + } +}); + +test("a tight pool zeroes the children it cannot fund instead of flooring them", () => { + const r = allocateChildBudgets(2, 4); + assert.deepEqual(r.children.map((c) => c.allocated), [0, 0, 0, 0]); + assert.equal(r.coordinationBudget, 1, "the remainder stays with the coordination reserve"); + // The old clamp produced [1,1,1,1] here — 4 tokens out of a 2-token pool. + assert.equal(r.children.reduce((s, c) => s + c.allocated, 0), 0); +}); + +test("the default 85% child share / 15% coordination reserve", () => { + const r = allocateChildBudgets(100, 1); + assert.equal(r.children[0]?.allocated, 85); + assert.equal(r.coordinationBudget, 15); + const two = allocateChildBudgets(100, 2); + assert.equal(two.children[0]?.allocated, 42); + assert.equal(two.children[1]?.allocated, 42); + assert.equal(two.coordinationBudget, 15, "the reserve is held once, not per child"); +}); + +test("a negative or zero pool allocates nothing and reports no NaN", () => { + for (const parent of [0, -5, -1000]) { + const r = allocateChildBudgets(parent, 4); + assert.equal(r.children.reduce((s, c) => s + c.allocated, 0), 0); + assert.equal(r.coordinationBudget, 0); + for (const c of r.children) assert.equal(Number.isFinite(c.allocated), true); + } +}); + +test("childrenCount 0 allocates an empty list, not an infinity", () => { + const r = allocateChildBudgets(1000, 0); + assert.deepEqual(r.children, []); + assert.equal(r.coordinationBudget, 150, "the reserve is the pool's own remainder"); +}); + +test("an explicit childShare overrides the default and still honours the sum", () => { + const r = allocateChildBudgets(100, 2, { childShare: 0.5 }); + assert.equal(r.children[0]?.allocated, 25); + assert.equal(r.coordinationBudget, 50); + assert.ok(r.children.reduce((s, c) => s + c.allocated, 0) + r.coordinationBudget <= 100); +}); diff --git a/src/budget.ts b/src/budget.ts index 9341edd..2d9662b 100644 --- a/src/budget.ts +++ b/src/budget.ts @@ -1,5 +1,21 @@ import { sha256Hex } from "./hash.js"; +/** + * A ledger masquerading as an allocation — narrowed to its two real lines. + * + * This module answers exactly one question: given what the parent has left, + * how much does each child get, and how much stays with the parent? The + * `used`/`status` fields are carried by no caller and the hashed child ids are + * a constant string over the index, so they exist to be read, not to be + * written; the invariant that matters is `Σ allocated ≤ parentRemaining`, which + * is pinned by `src/budget.test.ts` over a table of (parent, count) pairs + * rather than by the examples in it. + * + * Surfaces must read `allocated === 0` as "not enough pool for this child" and + * say so: the child runner deliberately will not re-floor a 0 into 1 (see + * `runChildAgent`), because a floor is how the split stops being a split. + */ + export type ChildAllocation = { id: string; allocated: number; @@ -28,10 +44,17 @@ export function allocateChildBudgets( ): BudgetAllocationResult { const childShare = options.childShare ?? DEFAULT_CHILD_SHARE; - const budgetForChildren = Math.max(1, Math.floor(parentRemaining * childShare)); - const coordBudget = Math.max(0, parentRemaining - budgetForChildren); + // No `Math.max(1, …)` clamp anywhere. A clamp makes each child look owed at + // least one token, so four children on a 2-token remainder each get 1 and + // the SUM exceeds the pool it was split from — the over-allocation that + // made "children are capped by the parent's budget" false exactly when the + // budget was tight enough to matter. The remainder belongs to the + // coordination reserve and is reported as unallocated, not distributed. + const pool = Math.max(0, Math.floor(parentRemaining)); + const budgetForChildren = Math.floor(pool * childShare); + const coordBudget = pool - budgetForChildren; - const perChild = Math.max(1, Math.floor(budgetForChildren / childrenCount)); + const perChild = childrenCount <= 0 ? 0 : Math.floor(budgetForChildren / childrenCount); const children: ChildAllocation[] = []; for (let i = 0; i < childrenCount; i++) { diff --git a/src/loop.ts b/src/loop.ts index bb94bd1..ca39ee6 100644 --- a/src/loop.ts +++ b/src/loop.ts @@ -952,6 +952,11 @@ export async function agentLoop(args: LoopArgs): Promise { onChildEvent: (text) => emit("tool", text), remainingBudget: args.tokenBudget === undefined ? undefined : Math.max(0, args.tokenBudget - (promptTokens + completionTokens)), rotationModels: args.models, + // The run's dollar ceiling, forwarded to a SINGLE child (see + // ToolContext.costCheck: a fan-out holding the same closure + // would each see only its own buckets and report a ceiling it + // spends MAX_FANOUT times). + ...(args.costCheck === undefined ? {} : { costCheck: args.costCheck }), retryWait: args.retryWait, compactTokens: args.compactTokens, disallowedTools: args.disallowedTools, @@ -1436,6 +1441,11 @@ export async function agentLoop(args: LoopArgs): Promise { remainingBudget: args.tokenBudget === undefined ? undefined : Math.max(0, args.tokenBudget - (promptTokens + completionTokens)), rotationModels: args.models, + // The run's dollar ceiling, forwarded to a SINGLE child (see + // ToolContext.costCheck: a fan-out holding the same closure + // would each see only its own buckets and report a ceiling it + // spends MAX_FANOUT times). + ...(args.costCheck === undefined ? {} : { costCheck: args.costCheck }), retryWait: args.retryWait, compactTokens: args.compactTokens, disallowedTools: args.disallowedTools, diff --git a/src/subagents.ts b/src/subagents.ts index ba24146..88e415f 100644 --- a/src/subagents.ts +++ b/src/subagents.ts @@ -562,6 +562,14 @@ export type ChildRunOptions = { parentRunId?: string; /** Parent's `--debug` sink — a child's decisions join the same log. */ debug?: Debug; + /** + * The run's dollar ceiling, forwarded to a SINGLE child only. A fan-out + * deliberately does not get it: four concurrent children each holding the + * same closure each see only their own buckets, so each would believe it + * alone owns the ceiling and the fan-out would spend four times what it + * reports. Its ceiling stays at the parent's turn boundary. + */ + costCheck?: LoopArgs["costCheck"]; /** Progress lines, already prefixed with the agent name. */ onEvent?: (text: string) => void; /** Per-child wall clock — the child's signal aborts when it fires. */ @@ -603,9 +611,17 @@ export async function runChildAgent(opts: ChildRunOptions): Promise 0 ? { models: opts.models } : {}), diff --git a/src/tools/child-run.ts b/src/tools/child-run.ts index d340044..e69be4c 100644 --- a/src/tools/child-run.ts +++ b/src/tools/child-run.ts @@ -47,6 +47,10 @@ export function runChild( disallowedTools: ctx.disallowedTools, parentRunId: ctx.parentRunId, debug: ctx.debug, + // The parent's dollar ceiling rides a single child only (delegate_many's + // four concurrent holders would each see only their own buckets, so the + // fan-out keeps its ceiling at the parent's turn boundary). + ...(ctx.costCheck === undefined ? {} : { costCheck: ctx.costCheck }), deadlineMs: child.deadlineMs, ...(ctx.onChildEvent === undefined ? {} : { onEvent: ctx.onChildEvent }), }); diff --git a/src/tools/delegate.ts b/src/tools/delegate.ts index 5fbca8d..e605cea 100644 --- a/src/tools/delegate.ts +++ b/src/tools/delegate.ts @@ -63,18 +63,27 @@ export async function runDelegate(ctx: ToolContext, args: { agent: string; task: const budget = ctx.remainingBudget === undefined ? undefined : allocateChildBudgets(ctx.remainingBudget, 1); + const share = budget?.children[0]?.allocated; + ctx.onChildEvent?.(`[${agent.name}] started: ${args.task.slice(0, 120)}`); + if (share === 0) { + // The pool cannot fund this child. Spawning it would over-allocate the + // parent's own ceiling, so it is refused with the numbers, not run. + return { + ok: false, + output: `delegate: the parent's remaining token pool (${ctx.remainingBudget}) is too small to fund ${agent.name} (allocated 0) — spend fewer steps, raise --token-budget, or do the work in this run`, + }; + } if (budget !== undefined) { ctx.onChildEvent?.( `[budget] parent=${budget.parentBudget} coord=${budget.coordinationBudget}` + ` child=${budget.children[0]?.allocated ?? 0} (85% share, 15% reserve)` ); } - ctx.onChildEvent?.(`[${agent.name}] started: ${args.task.slice(0, 120)}`); const r = await runChild(ctx, { agent, task: args.task, signal, - tokenBudget: budget?.children[0]?.allocated, + tokenBudget: share, deadlineMs: DELEGATE_TIMEOUT_MS, }); ctx.onChildUsage?.(r.usageByModel); diff --git a/src/tools/delegate_many.ts b/src/tools/delegate_many.ts index 0ae5751..5cbc385 100644 --- a/src/tools/delegate_many.ts +++ b/src/tools/delegate_many.ts @@ -91,22 +91,45 @@ export async function runDelegateMany(ctx: ToolContext, args: { entries: Entry[] const budget = ctx.remainingBudget === undefined ? undefined : allocateChildBudgets(ctx.remainingBudget, agents.length); + // The visible start is unconditional. It used to sit inside the + // `budget !== undefined` branch, so a fan-out on a run without a token + // budget started several children with nothing between the last event and + // the first report — the exact "silent silence" the line exists to prevent. + for (const { name, task } of agents) { + ctx.onChildEvent?.(`[${name}] started: ${task.slice(0, 120)}`); + } + // `allocated === 0` is the split saying "the pool cannot fund this child". + // Running it anyway is how four children once drew four tokens out of a + // two-token pool, so a zero is dropped — and named, because a silently + // dropped perspective is the unaccountable outcome this codebase refuses. + const dropped = agents.filter((_, i) => budget?.children[i]?.allocated === 0); + if (dropped.length > 0) { + ctx.onChildEvent?.( + `[budget] dropped ${dropped.length} child(ren): ${dropped.map((d) => d.name).join(", ")} — each allocated 0 of the parent's remaining pool (${ctx.remainingBudget})` + ); + } + const runnable = agents + .map((a, i) => ({ entry: a, index: i, tokens: budget?.children[i]?.allocated })) + .filter((r) => r.tokens === undefined || r.tokens > 0); + if (runnable.length === 0) { + return { + ok: false, + output: `delegate_many: the parent's remaining token pool (${ctx.remainingBudget}) cannot fund any child (each allocated 0) — spend fewer steps, raise --token-budget, or do the work in this run`, + }; + } if (budget !== undefined) { - for (const { name, task } of agents) { - ctx.onChildEvent?.(`[${name}] started: ${task.slice(0, 120)}`); - } ctx.onChildEvent?.( `[budget] parent=${budget.parentBudget} coord=${budget.coordinationBudget}` + - ` children=${budget.children.reduce((s, c) => s + c.allocated, 0)} (${agents.length} children, 85% share, 15% reserve)` + ` children=${runnable.reduce((s, r) => s + (r.tokens ?? 0), 0)} (${runnable.length} of ${agents.length} children, 85% share, 15% reserve)` ); } const runs = await Promise.all( - agents.map((agentEntry, i) => + runnable.map((r) => runChild(ctx, { - agent: agentEntry.def, - task: agentEntry.task, + agent: r.entry.def, + task: r.entry.task, signal, - tokenBudget: budget?.children[i]?.allocated, + tokenBudget: r.tokens, deadlineMs: DELEGATE_TIMEOUT_MS, }) ) @@ -114,15 +137,16 @@ export async function runDelegateMany(ctx: ToolContext, args: { entries: Entry[] const lines: string[] = []; const runIds: string[] = []; let failed = 0; - runs.forEach((r, i) => { + runs.forEach((r, k) => { ctx.onChildUsage?.(r.usageByModel); - const name = agents[i]?.name ?? `agent${i + 1}`; + const name = runnable[k]?.entry.name ?? `agent${k + 1}`; + const slot = runnable[k]?.index ?? k; if (r.ok) { runIds.push(r.runId); - lines.push(`#${i + 1} [${name}]\n${r.text}`); + lines.push(`#${slot + 1} [${name}]\n${r.text}`); } else { failed += 1; - lines.push(`#${i + 1} [${name}] FAILED: ${r.error}`); + lines.push(`#${slot + 1} [${name}] FAILED: ${r.error}`); } }); const tail = runIds.length > 0 ? `\n[subagent runIds: ${runIds.join(", ")}]` : ""; diff --git a/src/tools/types.ts b/src/tools/types.ts index eff2ef7..c11d794 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -2,6 +2,7 @@ import type { ChatPort } from "../provider-port.js"; import type { UsageBucket } from "../outcomes.js"; import type { Debug } from "../debug.js"; import type { ToolFilter } from "../tool-filter.js"; +import type { StopReason } from "../loop.js"; import type { AgentDef } from "../subagents.js"; /** @@ -96,6 +97,14 @@ export type ToolContext = { /** Same-provider rotation candidates + 429 retry-wait, forwarded so a * rate-limited child rotates instead of dying (parallel fan-out friendly). */ rotationModels?: string[]; + /** + * The run's dollar ceiling as the parent evaluates it. Forwarded to a SINGLE + * child (not a fan-out: four concurrent children each holding the same + * closure each see only their own buckets, so the fan-out would report a + * ceiling it spends four times). Same shape as `LoopArgs.costCheck`, and + * injected by the surface — core never imports a price table. + */ + costCheck?: (buckets: UsageBucket[]) => { stopReason: StopReason; message: string } | null; retryWait?: boolean; /** Parent's compaction ceiling (children share the transcript-size policy). */ compactTokens?: number; From 2934494f72a360a8478984c6f80c6e194cabcbc0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:30:45 +0000 Subject: [PATCH 2/2] build(deps): bump softprops/action-gh-release from 2 to 3 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af16f45..3f50ece 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,7 +79,7 @@ jobs: cp package.json README.md LICENSE release/ tar -czf codewhip-${{ inputs.version }}.tar.gz -C release . - name: Create Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: files: codewhip-${{ inputs.version }}.tar.gz tag_name: ${{ inputs.version }}