fix(engine): a Ready card's retained worktree transfers on release instead of blocking it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-31 16:40:40 -07:00
committed by gsxdsm
parent 8e6b0ad67e
commit 3f95c6d53e
2 changed files with 21 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Planned tasks release at full concurrency again — a card's retained planning worktree no longer blocks its own release.
category: fix
dev: Follow-up to the widened maxWorktrees ledger: a Ready card reuses its planning worktree on release, so its held slot transfers instead of double-counting. Observed live as only 2 of 4 slots releasing after unpause.

View File

@@ -2254,6 +2254,16 @@ export class Scheduler {
&& !isTerminalColumnTask(task)
&& typeof task.worktree === "string" && task.worktree.length > 0)
.map((task) => task.id);
/*
FNXC:WorkflowScheduling 2026-08-01-01:05 (self-deadlock in the widened ledger, observed live):
A planned Ready card RETAINS its planning worktree for execution reuse, so counting it as a
holder must not block ITS OWN release — on release the slot TRANSFERS (the card executes in
the same worktree), it does not add. Without this exclusion the first unpause released only
2 of 4 slots' worth of work: the two remaining Ready cards were gated out by the very
worktrees they would reuse (2 wip + 3 idle-held = 5/4). Candidates in this set subtract
their own slot from the gate and skip the dispatch increment.
*/
const nonWipWorktreeHolderIdSet = new Set(nonWipWorktreeHolderIds);
let reservedWorktreeSlots = wipTaskIds.length + nonWipWorktreeHolderIds.length;
let reservedConcurrentSlots = wipTaskIds.length;
const inProgressTaskIds = wipTaskIds;
@@ -2865,10 +2875,11 @@ export class Scheduler {
store: this.store,
tasks,
});
const candidateHoldsWorktree = nonWipWorktreeHolderIdSet.has(task.id);
const concurrencyDiagnostic = computeConcurrencyGateDiagnostic({
agentSlots: reservedConcurrentSlots,
maxConcurrent,
activeWorktrees: reservedWorktreeSlots,
activeWorktrees: reservedWorktreeSlots - (candidateHoldsWorktree ? 1 : 0),
maxWorktrees,
worktreeHolderTaskIds: [...inProgressTaskIds, ...nonWipWorktreeHolderIds],
semaphore: this.options.semaphore,
@@ -2953,7 +2964,8 @@ export class Scheduler {
task: freshTask,
});
reservedWorktreeSlots += 1;
// Transfer, not addition, for a candidate that already holds its worktree.
if (!candidateHoldsWorktree) reservedWorktreeSlots += 1;
reservedConcurrentSlots += 1;
let released = false;
return {