singularity-forge/src/resources/extensions/sf/dispatch-guard.ts
Mikael Hugo d73a73d7f3 chore: node 24 native APIs, import.meta.dirname, parsers rename, dep updates
- 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>
2026-05-02 06:18:25 +02:00

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;
}