fix: add defensive guards against undefined .filter() in auto-mode dispatch/recovery (#1180)

Auto-mode crashed with 'Cannot read properties of undefined (reading
filter)' during partial execute-task recovery when derived state was
structurally incomplete.

Added ?? [] fallback guards on all .filter()/.find()/.map() calls
that access state.registry, roadmap.slices, or similar derived arrays
in the dispatch and recovery paths:

- auto.ts: 3 state.registry.filter() calls
- auto-recovery.ts: 1 roadmap.slices.find() call
- auto-start.ts: 1 state.registry.filter() call

These are belt-and-suspenders guards — the parsers always return arrays,
but crash recovery can encounter partially written or corrupt state files
where the parsers return unexpected shapes.

Fixes #1176
This commit is contained in:
Tom Boucher 2026-03-18 12:07:22 -04:00 committed by GitHub
parent 8281a2ea75
commit 8d04ec19fd
3 changed files with 5 additions and 5 deletions

View file

@ -227,7 +227,7 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
try {
const roadmapContent = readFileSync(roadmapFile, "utf-8");
const roadmap = parseRoadmap(roadmapContent);
const slice = roadmap.slices.find(s => s.id === sid);
const slice = (roadmap.slices ?? []).find(s => s.id === sid);
if (slice && !slice.done) return false;
} catch {
// Corrupt/unparseable roadmap — fail verification so the unit

View file

@ -415,7 +415,7 @@ export async function bootstrapAutoSession(
ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto");
ctx.ui.setFooter(hideFooter);
const modeLabel = s.stepMode ? "Step-mode" : "Auto-mode";
const pendingCount = state.registry.filter(m => m.status !== 'complete' && m.status !== 'parked').length;
const pendingCount = (state.registry ?? []).filter(m => m.status !== 'complete' && m.status !== 'parked').length;
const scopeMsg = pendingCount > 1
? `Will loop through ${pendingCount} milestones.`
: "Will loop until milestone complete.";

View file

@ -877,7 +877,7 @@ async function showStepWizard(
: "previous unit";
if (!mid || state.phase === "complete") {
const incomplete = state.registry.filter(m => m.status !== "complete" && m.status !== "parked");
const incomplete = (state.registry ?? []).filter(m => m.status !== "complete" && m.status !== "parked");
if (incomplete.length > 0 && state.phase !== "complete" && state.phase !== "blocked" && state.phase !== "pre-planning") {
const ids = incomplete.map(m => m.id).join(", ");
const diag = `basePath=${s.basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
@ -1171,7 +1171,7 @@ async function dispatchNextUnit(
}
}
const pendingIds = state.registry
const pendingIds = (state.registry ?? [])
.filter(m => m.status !== "complete")
.map(m => m.id);
pruneQueueOrder(s.basePath, pendingIds);
@ -1186,7 +1186,7 @@ async function dispatchNextUnit(
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
}
const incomplete = state.registry.filter(m => m.status !== "complete" && m.status !== "parked");
const incomplete = (state.registry ?? []).filter(m => m.status !== "complete" && m.status !== "parked");
if (incomplete.length === 0) {
// Genuinely all complete (parked milestones excluded) — merge milestone branch to main before stopping (#962)
if (s.currentMilestoneId && isInAutoWorktree(s.basePath) && s.originalBasePath) {