diff --git a/src/resources/extensions/gsd/dispatch-guard.ts b/src/resources/extensions/gsd/dispatch-guard.ts index c687f1b30..568f872d0 100644 --- a/src/resources/extensions/gsd/dispatch-guard.ts +++ b/src/resources/extensions/gsd/dispatch-guard.ts @@ -107,10 +107,27 @@ export function getPriorSliceCompletionBlocker( // it may be a cross-milestone reference handled elsewhere. } } else { + // Positional fallback is only a heuristic for legacy slices with no + // declared dependencies. Skip any earlier slice that depends on the + // target, directly or transitively, or we can deadlock a valid zero-dep + // slice behind its own downstream dependents (#3720). + const reverseDependents = new Set(); + let changed = true; + while (changed) { + changed = false; + for (const slice of slices) { + if (reverseDependents.has(slice.id)) continue; + if (slice.depends.some((depId) => depId === targetSid || reverseDependents.has(depId))) { + reverseDependents.add(slice.id); + changed = true; + } + } + } + const targetIndex = slices.findIndex((slice) => slice.id === targetSid); const incomplete = slices .slice(0, targetIndex) - .find((slice) => !slice.done); + .find((slice) => !slice.done && !reverseDependents.has(slice.id)); if (incomplete) { return `Cannot dispatch ${unitType} ${unitId}: earlier slice ${targetMid}/${incomplete.id} is not complete.`; } diff --git a/src/resources/extensions/gsd/tests/dispatch-guard.test.ts b/src/resources/extensions/gsd/tests/dispatch-guard.test.ts index 1989a0195..04c167b08 100644 --- a/src/resources/extensions/gsd/tests/dispatch-guard.test.ts +++ b/src/resources/extensions/gsd/tests/dispatch-guard.test.ts @@ -145,6 +145,33 @@ test("dispatch guard falls back to positional ordering when no dependencies decl ); }); +test("dispatch guard ignores positionally-earlier reverse dependents for zero-dependency slices (#3720)", (t) => { + const repo = setupRepo(); + t.after(() => teardownRepo(repo)); + + mkdirSync(join(repo, ".gsd", "milestones", "M015"), { recursive: true }); + + insertMilestone({ id: "M015", title: "Reverse dependency fallback" }); + insertSlice({ id: "S03", milestoneId: "M015", title: "Complete prerequisite", status: "complete", depends: [], sequence: 0 }); + insertSlice({ id: "S04", milestoneId: "M015", title: "Depends on S04A", status: "pending", depends: ["S03", "S04A"], sequence: 0 }); + insertSlice({ id: "S04A", milestoneId: "M015", title: "No explicit deps", status: "pending", depends: [], sequence: 0 }); + + writeFileSync(join(repo, ".gsd", "milestones", "M015", "M015-ROADMAP.md"), "# M015\n"); + + // S04A has no declared dependencies and should not be blocked by S04, because + // S04 itself depends on S04A. With sequence=0, DB ordering falls back to id. + assert.equal( + getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M015/S04A/T02"), + null, + ); + + // The reverse direction is still blocked normally. + assert.equal( + getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M015/S04/T01"), + "Cannot dispatch execute-task M015/S04/T01: dependency slice M015/S04A is not complete.", + ); +}); + test("dispatch guard allows slice with all declared dependencies complete", (t) => { const repo = setupRepo(); t.after(() => teardownRepo(repo));