Merge pull request #3735 from mastertyko/fix/3720-dispatch-guard-reverse-dependency-fallback-fresh

fix(gsd): skip reverse dependents in dispatch fallback
This commit is contained in:
Jeremy McSpadden 2026-04-11 22:57:10 -05:00 committed by GitHub
commit 12ed853dc3
2 changed files with 45 additions and 1 deletions

View file

@ -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<string>();
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.`;
}

View file

@ -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));