diff --git a/.changeset/spawn-worktree-gate.md b/.changeset/spawn-worktree-gate.md new file mode 100644 index 0000000000..912eeccc69 --- /dev/null +++ b/.changeset/spawn-worktree-gate.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Spawned child agents now count against the worktree cap, not only the agent cap. +category: fix +dev: fn_spawn_agent's own note said a child consumes both dimensions but gated only agents — a fan-out could exceed maxWorktrees to the agent limit. The worktree check runs after the synchronous slot reservation (preserving the anti-TOCTOU ordering, proven by the racing-spawns test) and unwinds it on refusal. diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index f7f03b060b..469c71d448 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -21898,6 +21898,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB details: { agentId: "", state: "error" }, }; } + /* FNXC:CapacityModel 2026-07-29-19:20 (PR #2579 review — greptile P1, TOCTOU): RESERVE THE SLOT SYNCHRONOUSLY, before the first await. @@ -21921,6 +21922,37 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB this.totalSpawnedCount = Math.max(0, this.totalSpawnedCount - 1); }; + /* + FNXC:CapacityModel 2026-08-01-02:40 (same class as the planning-admission gap, 374956ef23): + The FNXC above says a child "consumes both configured dimensions" — and then gated only ONE. + A child's worktree is not a task row, so the task-ledger gates never see it; count live + children against the worktree budget here at the acquisition source, like planning admission + now does. Runs AFTER the synchronous agent-slot reservation (its own TOCTOU rule: the awaits + in this check must not reopen the two-racing-spawns hole — the reservation is already held, + and a worktree refusal unwinds it). Absent/null maxWorktrees (worktrees off) falls through + to the agent gate alone, matching every other lane. + */ + { + const spawnMaxWorktrees = (settings as { maxWorktrees?: number | null }).maxWorktrees ?? 4; + if (typeof spawnMaxWorktrees === "number" && Number.isFinite(spawnMaxWorktrees)) { + const spawnTasks = await this.store.listTasks({ slim: true, includeArchived: false }); + const heldWorktrees = spawnTasks.filter((t) => + t.column !== "done" && t.column !== "archived" + && typeof t.worktree === "string" && t.worktree.length > 0).length; + // totalSpawnedCount already includes THIS reservation; heldWorktrees covers task lanes. + if (heldWorktrees + this.totalSpawnedCount > spawnMaxWorktrees) { + releaseSpawnReservation(); + return { + content: [{ + type: "text" as const, + text: `Worktree capacity reached (${heldWorktrees + this.totalSpawnedCount - 1}/${spawnMaxWorktrees} held, including spawned child agent(s)). Wait for work to finish, or raise Max Worktrees.`, + }], + details: { agentId: "", state: "error" }, + }; + } + } + } + try { // Create agent in AgentStore with reportsTo = parent task ID const agent = await this.options.agentStore.createAgent({