Merge upstream/main into fix/state-machine-wave2-events

Resolve conflict in workflow-reconcile.ts: keep upstream's
complete_milestone invariant check (getMilestoneSlices guard) and
HEAD's plan_milestone/plan_slice/plan_task replay handlers.
This commit is contained in:
Jeremy 2026-04-07 17:48:24 -05:00
commit ae8d3b6983
2 changed files with 68 additions and 5 deletions

View file

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

View file

@ -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"] }