- Replace fileURLToPath(import.meta.url) with import.meta.dirname across
scripts and extensions
- Rename parsers-legacy.ts → parsers.ts
- Remove deleted plan/spec docs (cicd-pipeline)
- Update package.json engines and deps across workspace packages
- Update web/package-lock.json
💘 Generated with Crush
Assisted-by: GLM-5.1 via Crush <crush@charm.land>
172 lines
5.9 KiB
TypeScript
172 lines
5.9 KiB
TypeScript
// SF Dispatch Guard — prevents out-of-order slice dispatch
|
|
|
|
import { readFileSync } from "node:fs";
|
|
import { findMilestoneIds } from "./guided-flow.js";
|
|
import { parseRoadmap } from "./parsers.js";
|
|
import { resolveMilestoneFile } from "./paths.js";
|
|
import { getMilestoneSlices, isDbAvailable } from "./sf-db.js";
|
|
import { isClosedStatus } from "./status-guards.js";
|
|
import { parseUnitId } from "./unit-id.js";
|
|
|
|
const SLICE_DISPATCH_TYPES = new Set([
|
|
"research-slice",
|
|
"plan-slice",
|
|
"replan-slice",
|
|
"execute-task",
|
|
"complete-slice",
|
|
]);
|
|
|
|
/**
|
|
* Check if a slice/task dispatch should be blocked by incomplete prior slices.
|
|
* Returns error message if blocked, null if dispatch is safe.
|
|
* Respects milestone locking (SF_MILESTONE_LOCK) for parallel worker isolation.
|
|
*/
|
|
export function getPriorSliceCompletionBlocker(
|
|
base: string,
|
|
_mainBranch: string,
|
|
unitType: string,
|
|
unitId: string,
|
|
): string | null {
|
|
if (!SLICE_DISPATCH_TYPES.has(unitType)) return null;
|
|
|
|
const { milestone: targetMid, slice: targetSid } = parseUnitId(unitId);
|
|
if (!targetMid || !targetSid) return null;
|
|
|
|
// Parallel worker isolation: when SF_MILESTONE_LOCK is set, this worker
|
|
// is scoped to a single milestone. Skip the cross-milestone dependency
|
|
// check — other milestones are being handled by their own workers.
|
|
// Without this, the dispatch guard sees incomplete slices in M010/M011
|
|
// (cloned into the worktree DB) and blocks M012 from ever starting. #2797
|
|
const milestoneLock = process.env.SF_MILESTONE_LOCK;
|
|
|
|
// Use findMilestoneIds to respect custom queue order.
|
|
// Only check milestones that come BEFORE the target in queue order.
|
|
// When locked to a specific milestone, only check that milestone's
|
|
// intra-slice dependencies — skip all cross-milestone checks.
|
|
const allIds =
|
|
milestoneLock && targetMid === milestoneLock
|
|
? [targetMid]
|
|
: findMilestoneIds(base);
|
|
const targetIdx = allIds.indexOf(targetMid);
|
|
if (targetIdx < 0) return null;
|
|
const milestoneIds = allIds.slice(0, targetIdx + 1);
|
|
|
|
for (const mid of milestoneIds) {
|
|
if (resolveMilestoneFile(base, mid, "PARKED")) continue;
|
|
if (resolveMilestoneFile(base, mid, "SUMMARY")) continue;
|
|
|
|
// Normalised slice list from DB or file fallback
|
|
type NormSlice = { id: string; done: boolean; depends: string[] };
|
|
let slices: NormSlice[] | null = null;
|
|
|
|
if (isDbAvailable()) {
|
|
const rows = getMilestoneSlices(mid);
|
|
if (rows.length > 0) {
|
|
slices = rows.map((r) => ({
|
|
id: r.id,
|
|
done: isClosedStatus(r.status),
|
|
depends: r.depends ?? [],
|
|
}));
|
|
}
|
|
}
|
|
if (!slices) {
|
|
// File-based fallback: parse roadmap checkboxes
|
|
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
|
|
if (!roadmapPath) continue;
|
|
let roadmapContent: string;
|
|
try {
|
|
roadmapContent = readFileSync(roadmapPath, "utf-8");
|
|
} catch {
|
|
continue;
|
|
}
|
|
const parsed = parseRoadmap(roadmapContent);
|
|
if (parsed.slices.length === 0) continue;
|
|
slices = parsed.slices.map((s) => ({
|
|
id: s.id,
|
|
done: s.done,
|
|
depends: s.depends ?? [],
|
|
}));
|
|
}
|
|
|
|
if (mid !== targetMid) {
|
|
const incomplete = slices.find((slice) => !slice.done);
|
|
if (incomplete) {
|
|
return `Cannot dispatch ${unitType} ${unitId}: earlier slice ${mid}/${incomplete.id} is not complete.`;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const targetSlice = slices.find((slice) => slice.id === targetSid);
|
|
if (!targetSlice) return null;
|
|
|
|
// Dependency-aware ordering: if the target slice declares dependencies,
|
|
// only require those specific slices to be complete — not all positionally
|
|
// earlier slices. This prevents deadlocks when a positionally-earlier
|
|
// slice depends on a positionally-later one (e.g. S05 depends_on S06).
|
|
//
|
|
// When the target has NO declared dependencies, fall back to the original
|
|
// positional ordering for backward compatibility.
|
|
if (targetSlice.depends.length > 0) {
|
|
const sliceMap = new Map(slices.map((s) => [s.id, s]));
|
|
for (const depId of targetSlice.depends) {
|
|
const dep = sliceMap.get(depId);
|
|
if (dep && !dep.done) {
|
|
return `Cannot dispatch ${unitType} ${unitId}: dependency slice ${targetMid}/${depId} is not complete.`;
|
|
}
|
|
// If dep is not found in this milestone's slices, ignore it —
|
|
// 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).
|
|
//
|
|
// Also skip incomplete earlier slices that have unsatisfied dependencies
|
|
// of their own — those slices are legitimately stuck and should not
|
|
// block a zero-dep slice that is ready to run. This scopes the
|
|
// positional check to the target slice only, rather than applying the
|
|
// global milestone-has-explicit-deps short-circuit that was here
|
|
// previously (#3998).
|
|
const sliceMap = new Map(slices.map((s) => [s.id, s]));
|
|
|
|
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 hasUnsatisfiedDeps = (slice: { depends: string[] }): boolean =>
|
|
slice.depends.some((depId) => {
|
|
const dep = sliceMap.get(depId);
|
|
return dep !== undefined && !dep.done;
|
|
});
|
|
|
|
const targetIndex = slices.findIndex((slice) => slice.id === targetSid);
|
|
const incomplete = slices
|
|
.slice(0, targetIndex)
|
|
.find(
|
|
(slice) =>
|
|
!slice.done &&
|
|
!reverseDependents.has(slice.id) &&
|
|
!hasUnsatisfiedDeps(slice),
|
|
);
|
|
if (incomplete) {
|
|
return `Cannot dispatch ${unitType} ${unitId}: earlier slice ${targetMid}/${incomplete.id} is not complete.`;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|