From 839262efd72040f8e0af1323595936c0db1ea02e Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot <269191875+qwen-code-dev-bot@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:53:30 +0800 Subject: [PATCH] feat(cli): add a shared cost budget across delegated subagents, failing closed when exhausted (#238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spawn-count cap (#126) bounds how many subagents a session may spawn and the per-run spend budget (#58) bounds a single run, but nothing bounded the aggregate cost that delegated subagents collectively consume — a parent delegating many children could exhaust resources even within the count limit. Add an opt-in shared cost budget to SubagentManager (maxTotalCost): cumulative cost is tracked via addCost, reported via costUsage, and once the budget is exhausted further delegation is refused fail-closed with a deterministic, content-free SubagentBudgetError — independent of and complementary to the spawn-count cap. --- src/subagents.ts | 56 ++++++++++++++++++++ tests/integration/subagents.test.ts | 41 +++++++++++++++ tests/unit/subagents.test.ts | 79 +++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+) diff --git a/src/subagents.ts b/src/subagents.ts index d744099..bbd5728 100644 --- a/src/subagents.ts +++ b/src/subagents.ts @@ -60,6 +60,13 @@ export interface SubagentManagerOptions { * this ceiling, so a churn of short-lived children cannot evade it. */ maxTotalSpawns?: number; + /** + * Shared budget cap on the cumulative cost (e.g. USD) that all delegated + * children may collectively consume. When the cumulative reported cost reaches + * this cap, further spawns are refused fail-closed (#238). Independent of and + * complementary to maxTotalSpawns. Undefined ⇒ no shared cost budget. + */ + maxTotalCost?: number; /** Injectable clock for deterministic tests. Defaults to Date.now. */ clock?: () => number; /** @@ -153,14 +160,31 @@ export class SubagentSpawnCapError extends Error { } } +/** + * Thrown by the subagent launcher once the shared cost budget (#238) is + * exhausted. The message is a static, deterministic bound notice — it never + * carries secrets, host paths, or untrusted content. + */ +export class SubagentBudgetError extends Error { + readonly reason = "shared_budget" as const; + + constructor(message: string) { + super(message); + this.name = "SubagentBudgetError"; + } +} + export class SubagentManager { private readonly entries = new Map(); private readonly maxConcurrent: number; private readonly maxTotalSpawns: number; + private readonly maxTotalCost?: number; private readonly clock: () => number; private readonly parentWorkspace?: string; private readonly parentIdentity?: WorkspaceIdentity; private seq = 0; + // Cumulative cost reported across all delegated children (#238 shared budget). + private cumulativeCost = 0; constructor(opts: SubagentManagerOptions = {}) { const max = opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT; @@ -173,6 +197,12 @@ export class SubagentManager { throw new Error("maxTotalSpawns must be a positive integer"); } this.maxTotalSpawns = total; + if (opts.maxTotalCost !== undefined) { + if (!Number.isFinite(opts.maxTotalCost) || opts.maxTotalCost <= 0) { + throw new Error("maxTotalCost must be a positive finite number"); + } + this.maxTotalCost = opts.maxTotalCost; + } this.clock = opts.clock ?? (() => Date.now()); if (opts.parentWorkspace !== undefined) { this.parentWorkspace = opts.parentWorkspace; @@ -203,6 +233,11 @@ export class SubagentManager { `Refusing to spawn a delegated agent: session subagent cap of ${this.maxTotalSpawns} reached`, ); } + if (this.maxTotalCost !== undefined && this.cumulativeCost >= this.maxTotalCost) { + throw new SubagentBudgetError( + `Refusing to spawn a delegated agent: shared cost budget of ${this.maxTotalCost} exhausted`, + ); + } const mode: WorkspaceMode = opts.mode ?? "mutating"; if (this.parentIdentity) { const decision = evaluateWorkspaceGuard({ @@ -267,6 +302,27 @@ export class SubagentManager { return countByState(this.list()); } + /** + * Report cost consumed by a delegated child, adding it to the cumulative shared + * budget total (#238). Non-finite or negative amounts are ignored so a bad + * report cannot corrupt the budget or open it back up. + */ + addCost(amount: number): void { + if (Number.isFinite(amount) && amount > 0) { + this.cumulativeCost += amount; + } + } + + /** + * The shared cost budget state (#238): cumulative reported cost, the cap (null + * when no budget is configured), and the remaining budget (null when uncapped). + */ + costUsage(): { cumulativeCost: number; maxTotalCost: number | null; remaining: number | null } { + const maxTotalCost = this.maxTotalCost ?? null; + const remaining = maxTotalCost === null ? null : Math.max(0, maxTotalCost - this.cumulativeCost); + return { cumulativeCost: this.cumulativeCost, maxTotalCost, remaining }; + } + private runningCount(): number { let n = 0; for (const entry of this.entries.values()) { diff --git a/tests/integration/subagents.test.ts b/tests/integration/subagents.test.ts index 9720a0a..fdf1aa7 100644 --- a/tests/integration/subagents.test.ts +++ b/tests/integration/subagents.test.ts @@ -3,6 +3,7 @@ import { SubagentManager, formatSubagentView, SubagentSpawnCapError, + SubagentBudgetError, } from "../../src/subagents.js"; // End-to-end lifecycle scenarios that exercise the manager the way a parent @@ -206,4 +207,44 @@ describe("Integration: subagent spawn cap dogfood", () => { expect(view).not.toContain("SECRET"); expect(view).toMatch(/2 total/); }); + + it("enforces a shared cost budget across delegated children, failing closed once exhausted", async () => { + // A parent delegates research children that each report a cost. The shared + // budget bounds their aggregate cost; once exhausted, further delegation is + // refused fail-closed even though the spawn-count ceiling is nowhere near. + const mgr = new SubagentManager({ maxConcurrent: 4, maxTotalSpawns: 100, maxTotalCost: 1.0 }); + + const a = deferred(); + const b = deferred(); + mgr.spawn(() => a.promise, { label: "researcher-1", mode: "read-only" }); + mgr.spawn(() => b.promise, { label: "researcher-2", mode: "read-only" }); + + // Both children complete and report their cost (0.6 + 0.4 = 1.0). + a.resolve("found X"); + b.resolve("found Y"); + await flush(); + mgr.addCost(0.6); + mgr.addCost(0.4); + expect(mgr.costUsage()).toEqual({ cumulativeCost: 1.0, maxTotalCost: 1.0, remaining: 0 }); + + // The next delegation is refused fail-closed by the shared budget — even + // though only 2 of 100 spawns are used. + let err: unknown; + try { + mgr.spawn(() => deferred().promise, { label: "researcher-3", mode: "read-only" }); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(SubagentBudgetError); + expect((err as SubagentBudgetError).reason).toBe("shared_budget"); + const reason = (err as Error).message; + expect(reason).toContain("shared cost budget of 1 exhausted"); + expect(reason).not.toContain("researcher-3"); + + // The parent and its completed children are unaffected; the refused spawn + // registered nothing. + expect(mgr.list()).toHaveLength(2); + expect(mgr.get("sub-001")?.state).toBe("completed"); + expect(mgr.get("sub-002")?.state).toBe("completed"); + }); }); diff --git a/tests/unit/subagents.test.ts b/tests/unit/subagents.test.ts index d8e62fd..03be0c6 100644 --- a/tests/unit/subagents.test.ts +++ b/tests/unit/subagents.test.ts @@ -9,6 +9,7 @@ import { TERMINAL_SUBAGENT_STATES, SharedWorkspaceLaunchError, SubagentSpawnCapError, + SubagentBudgetError, } from "../../src/subagents.js"; import type { SubagentRecord } from "../../src/subagents.js"; @@ -492,3 +493,81 @@ describe("SubagentManager: spawn cap", () => { expect(msg).not.toContain("/home/user"); }); }); + +describe("SubagentManager: shared cost budget", () => { + it("spawns while under budget and refuses once the budget is exhausted", () => { + const mgr = new SubagentManager({ maxTotalCost: 1.0 }); + mgr.spawn(() => deferred().promise); + mgr.addCost(0.6); + // Still under budget (0.6 < 1.0): allowed. + mgr.spawn(() => deferred().promise); + mgr.addCost(0.4); + // Now at the cap (1.0 >= 1.0): refused. + expect(() => mgr.spawn(() => deferred().promise)).toThrow(SubagentBudgetError); + }); + + it("is independent of the spawn-count cap (budget refuses even under the count ceiling)", () => { + const mgr = new SubagentManager({ maxTotalSpawns: 100, maxTotalCost: 0.5 }); + mgr.spawn(() => deferred().promise); + mgr.addCost(0.5); + // Well under the spawn cap (1 of 100) but at the cost budget: refused by budget. + let err: unknown; + try { + mgr.spawn(() => deferred().promise); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(SubagentBudgetError); + expect((err as SubagentBudgetError).reason).toBe("shared_budget"); + }); + + it("imposes no budget when maxTotalCost is not configured", () => { + const mgr = new SubagentManager({ maxTotalSpawns: 100 }); + mgr.addCost(1000); + // No budget configured: cost is tracked but never refuses. + expect(() => mgr.spawn(() => deferred().promise)).not.toThrow(); + expect(mgr.costUsage()).toEqual({ cumulativeCost: 1000, maxTotalCost: null, remaining: null }); + }); + + it("reports cumulative cost, cap, and remaining via costUsage", () => { + const mgr = new SubagentManager({ maxTotalCost: 2.0 }); + expect(mgr.costUsage()).toEqual({ cumulativeCost: 0, maxTotalCost: 2.0, remaining: 2.0 }); + mgr.addCost(0.5); + expect(mgr.costUsage()).toEqual({ cumulativeCost: 0.5, maxTotalCost: 2.0, remaining: 1.5 }); + mgr.addCost(3.0); + // Remaining floors at 0 once over budget. + expect(mgr.costUsage()).toEqual({ cumulativeCost: 3.5, maxTotalCost: 2.0, remaining: 0 }); + }); + + it("ignores non-finite or negative cost reports", () => { + const mgr = new SubagentManager({ maxTotalCost: 1.0 }); + mgr.addCost(Number.NaN); + mgr.addCost(-5); + mgr.addCost(Number.POSITIVE_INFINITY); + expect(mgr.costUsage().cumulativeCost).toBe(0); + }); + + it("throws on a non-positive or non-finite maxTotalCost", () => { + expect(() => new SubagentManager({ maxTotalCost: 0 })).toThrow(/positive finite number/); + expect(() => new SubagentManager({ maxTotalCost: -1 })).toThrow(/positive finite number/); + expect(() => new SubagentManager({ maxTotalCost: Number.NaN })).toThrow(/positive finite number/); + }); + + it("refuses with a deterministic, content-free reason (no secret/host/label leak)", () => { + const mgr = new SubagentManager({ maxTotalCost: 0.1 }); + mgr.spawn(() => deferred().promise, { label: "SECRET=abc123 /home/user/keys" }); + mgr.addCost(0.1); + let err: unknown; + try { + mgr.spawn(() => deferred().promise, { label: "leak this label" }); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(SubagentBudgetError); + const msg = (err as Error).message; + expect(msg).toContain("shared cost budget of 0.1 exhausted"); + expect(msg).not.toContain("leak this label"); + expect(msg).not.toContain("SECRET"); + expect(msg).not.toContain("/home/user"); + }); +});