diff --git a/.changeset/planning-admission-worktree-gate.md b/.changeset/planning-admission-worktree-gate.md new file mode 100644 index 0000000000..c972d12cf4 --- /dev/null +++ b/.changeset/planning-admission-worktree-gate.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Planning admission now respects the worktree cap — no more 8 planners on a 4-worktree board. +category: fix +dev: Triage admission gated only on the agent count; every planner acquires a real worktree, and the merge-drain freeze (00769fad7c) had been accidentally masking the gap. Admission now budgets min(agent room, worktree room) with the transfer rule (a replan candidate holding its worktree spends no fresh slot); the FN-8600 throttle event names "worktree cap" when it binds. Approximation note: per-candidate budget pairing assumes admitOldest's age order matches the eligible list; small transient skews self-correct on the next 15s poll. diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index f32a306afd..9f0b6e6bfe 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -2127,7 +2127,30 @@ export class TriageProcessor { two admission-reservation hand-offs. Only its use as an ADMISSION LIMIT is gone. */ const projectRoom = Math.max(0, maxConcurrent - claimed); - const maxToStart = projectRoom; + /* + FNXC:CapacityModel 2026-08-01-02:15 (live breach — 8 planners on a maxWorktrees=4 board): + Planning admission gated ONLY on the agent count. Every planner acquires a REAL worktree + (plan-in-place), so admission must also respect the worktree budget — the same two-number + model the scheduler's dispatch gate enforces. This gap was invisible for days because the + merge-inside-admission-drain bug (00769fad7c) froze admission during every merge and + accidentally throttled planners; unfreezing it exposed 8 concurrent planning sessions and 11 + worktrees on a 4-slot board within one restart. + + The ledger mirrors the scheduler's: every non-terminal task HOLDING a worktree occupies a + slot, and a candidate that already holds one (replan re-entry) transfers rather than adds — + only worktree-less candidates consume remaining slots, so a held tree never blocks its own + resume. maxWorktrees is absent/null when worktrees are off; admission then falls back to the + agent gate alone. + */ + const maxWorktrees = (settings as { maxWorktrees?: number | null }).maxWorktrees ?? 4; + let worktreeRoom = Number.POSITIVE_INFINITY; + if (typeof maxWorktrees === "number" && Number.isFinite(maxWorktrees)) { + const heldWorktrees = allTasks.filter((t) => + t.column !== "done" && t.column !== "archived" + && typeof t.worktree === "string" && t.worktree.length > 0).length; + worktreeRoom = Math.max(0, maxWorktrees - heldWorktrees); + } + const maxToStart = Math.min(projectRoom, worktreeRoom); if (maxToStart <= 0 && triageTasks.length > 0) { const processingIds = [...this.processing].slice(0, 5); @@ -2141,7 +2164,7 @@ export class TriageProcessor { queued?", and a named reason answers it even when there is only one — while a payload with no reason field at all would read as "unknown". */ - const blockedBy = "running-agent cap"; + const blockedBy = worktreeRoom <= 0 && projectRoom > 0 ? "worktree cap" : "running-agent cap"; planLog.log( `Plan throttled by ${blockedBy}: eligible=${triageTasks.length} [${eligibleIds.join(", ")}], ` + `maxConcurrent=${maxConcurrent}, claimed=${claimed}, processing=${this.processing.size}` + @@ -2237,7 +2260,20 @@ export class TriageProcessor { // Keep handoff reservations visible even when a test/runtime wrapper delays // the planner's synchronous processing claim until after this poll returns. const admittedThisPoll = new Set(); - for (let i = 0; i < Math.min(triageTasks.length, maxToStart); i++) { + /* + FNXC:CapacityModel 2026-08-01-02:20: the transfer rule from the scheduler ledger, applied at + admission — a candidate that already HOLDS a worktree (replan re-entry) reuses it, so it + spends only agent room, never a fresh worktree slot. Budgets decrement per admission below. + */ + let agentBudget = projectRoom; + let worktreeBudget = worktreeRoom; + for (let i = 0; i < triageTasks.length; i++) { + if (agentBudget <= 0) break; + const candidate = triageTasks[i]; + const candidateHoldsWorktree = typeof candidate.worktree === "string" && candidate.worktree.length > 0; + if (!candidateHoldsWorktree && worktreeBudget <= 0) continue; + agentBudget -= 1; + if (!candidateHoldsWorktree) worktreeBudget -= 1; await projectAdmissionCoordinator.admitOldest({ // rootDir is the stable per-project identity held by this processor. projectId: this.rootDir,