diff --git a/src/resources/extensions/gsd/tests/wave1-critical-regressions.test.ts b/src/resources/extensions/gsd/tests/wave1-critical-regressions.test.ts new file mode 100644 index 000000000..4ec804895 --- /dev/null +++ b/src/resources/extensions/gsd/tests/wave1-critical-regressions.test.ts @@ -0,0 +1,49 @@ +// GSD State Machine — Wave 1 Critical Regression Tests +// Validates fixes for event log format mismatch, skipped milestone status, +// dead code removal, and replan disk-file fallback. + +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { extractEntityKey } from "../workflow-reconcile.js"; +import { isClosedStatus } from "../status-guards.js"; +import type { WorkflowEvent } from "../workflow-events.js"; + +// ── Fix 1: Event log cmd format — hyphens and underscores both accepted ── + +describe("extractEntityKey normalizes cmd format", () => { + const baseEvent = { params: {}, ts: "", hash: "", actor: "agent" as const, session_id: "" }; + + test("accepts hyphenated complete-task", () => { + const event: WorkflowEvent = { ...baseEvent, cmd: "complete-task", params: { taskId: "T01" } }; + const key = extractEntityKey(event); + assert.deepStrictEqual(key, { type: "task", id: "T01" }); + }); + + test("accepts underscored complete_task (legacy)", () => { + const event: WorkflowEvent = { ...baseEvent, cmd: "complete_task", params: { taskId: "T01" } }; + const key = extractEntityKey(event); + assert.deepStrictEqual(key, { type: "task", id: "T01" }); + }); + + test("accepts hyphenated complete-slice", () => { + const event: WorkflowEvent = { ...baseEvent, cmd: "complete-slice", params: { sliceId: "S01" } }; + const key = extractEntityKey(event); + assert.deepStrictEqual(key, { type: "slice", id: "S01" }); + }); + + test("accepts hyphenated complete-milestone", () => { + const event: WorkflowEvent = { ...baseEvent, cmd: "complete-milestone", params: { milestoneId: "M001" } }; + const key = extractEntityKey(event); + assert.deepStrictEqual(key, { type: "milestone", id: "M001" }); + }); +}); + +// ── Fix 3: getActiveMilestoneId must skip "skipped" milestones ── + +describe("isClosedStatus includes skipped", () => { + test("complete is closed", () => assert.ok(isClosedStatus("complete"))); + test("done is closed", () => assert.ok(isClosedStatus("done"))); + test("skipped is closed", () => assert.ok(isClosedStatus("skipped"))); + test("pending is not closed", () => assert.ok(!isClosedStatus("pending"))); + test("active is not closed", () => assert.ok(!isClosedStatus("active"))); +}); diff --git a/src/resources/extensions/gsd/workflow-reconcile.ts b/src/resources/extensions/gsd/workflow-reconcile.ts index 2a27eb108..719df7a21 100644 --- a/src/resources/extensions/gsd/workflow-reconcile.ts +++ b/src/resources/extensions/gsd/workflow-reconcile.ts @@ -11,6 +11,7 @@ import { getSliceTasks, insertMilestone, _getAdapter, + getMilestoneSlices, insertVerificationEvidence, upsertDecision, openDatabase, @@ -82,6 +83,7 @@ function replayEvents(events: WorkflowEvent[]): void { const p = event.params; // Normalize cmd format: completion tools write hyphens ("complete-task"), // legacy logs use underscores ("complete_task"). Accept both formats. + // Type guard: malformed event lines with non-string cmd are skipped. if (typeof event.cmd !== "string") { logWarning("reconcile", `Event with non-string cmd skipped: ${JSON.stringify(event.cmd)}`); continue; @@ -134,17 +136,24 @@ function replayEvents(events: WorkflowEvent[]): void { } case "complete_milestone": { const milestoneId = p["milestoneId"] as string; - // Milestone completion via worktree replay — update status to complete - if (milestoneId) { + if (!milestoneId) break; + // Invariant check: only mark complete if all slices are closed. + // Without this guard, a reordered/partial event stream could close + // a milestone while work is still incomplete. + const mSlices = getMilestoneSlices(milestoneId); + const allClosed = mSlices.length === 0 || mSlices.every(s => isClosedStatus(s.status)); + if (allClosed) { updateMilestoneStatus(milestoneId, "complete", event.ts); + } else { + logWarning("reconcile", `Skipping complete_milestone replay for ${milestoneId}: not all slices are closed`); } break; } case "plan_milestone": { // Replay milestone creation — uses INSERT OR IGNORE (gsd-db's insertMilestone is safe) - const milestoneId = p["milestoneId"] as string; - if (milestoneId) { - insertMilestone({ id: milestoneId, title: (p["title"] as string) ?? milestoneId }); + const mId = p["milestoneId"] as string; + if (mId) { + insertMilestone({ id: mId, title: (p["title"] as string) ?? mId }); } break; } @@ -240,6 +249,11 @@ export function extractEntityKey( ? { type: "slice", id: p["sliceId"] } : null; + case "complete_milestone": + return typeof p["milestoneId"] === "string" + ? { type: "milestone", id: p["milestoneId"] } + : null; + case "plan_slice": return typeof p["sliceId"] === "string" ? { type: "slice_plan", id: p["sliceId"] }