fix(engine): spawned children gate on maxWorktrees too — the spawn note promised both dimensions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-31 18:43:37 -07:00
parent 374956ef23
commit 6c7467a78d
2 changed files with 39 additions and 0 deletions

View File

@@ -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.

View File

@@ -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({