diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index db97248f6..26a9b319e 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -189,7 +189,7 @@ export async function getActiveMilestoneId(basePath: string): Promise [m.id, m])); for (const id of sortedIds) { const m = byId.get(id)!; - if (m.status === "complete" || m.status === "done" || m.status === "parked") continue; + if (isClosedStatus(m.status) || m.status === "parked") continue; return m.id; } return null; @@ -442,13 +442,10 @@ export async function deriveStateFromDb(basePath: string): Promise { continue; } - // Check roadmap: all slices done means milestone is complete - const slices = getMilestoneSlices(m.id); - if (slices.length > 0 && slices.every(s => isStatusDone(s.status))) { - // All slices done but no summary — still counts as complete for dep resolution - // if a summary file exists - // Note: without summary file, the milestone is in validating/completing state, not complete - } + // Milestones with all slices done but no SUMMARY file are in + // validating/completing state — intentionally NOT added to + // completeMilestoneIds. The SUMMARY file (checked above) is the + // terminal artifact that proves completion per #864. } // Phase 2: Build registry and find active milestone @@ -954,7 +951,12 @@ export async function deriveStateFromDb(basePath: string): Promise { // ── REPLAN-TRIGGER detection ───────────────────────────────────────── if (!blockerTaskId) { const sliceRow = getSlice(activeMilestone.id, activeSlice.id); - if (sliceRow?.replan_triggered_at) { + // Check DB column first, fall back to disk trigger file when DB write + // was best-effort and failed (triage-resolution.ts dual-write gap). + const dbTriggered = !!sliceRow?.replan_triggered_at; + const diskTriggered = !dbTriggered && + !!resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "REPLAN-TRIGGER"); + if (dbTriggered || diskTriggered) { // Loop protection: if replan_history has entries, replan was already done const replanHistory = getReplanHistory(activeMilestone.id, activeSlice.id); if (replanHistory.length === 0) { diff --git a/src/resources/extensions/gsd/workflow-events.ts b/src/resources/extensions/gsd/workflow-events.ts index 3569bb674..c4cb2dabc 100644 --- a/src/resources/extensions/gsd/workflow-events.ts +++ b/src/resources/extensions/gsd/workflow-events.ts @@ -19,7 +19,7 @@ export function getSessionId(): string { // ─── Event Types ───────────────────────────────────────────────────────── export interface WorkflowEvent { - cmd: string; // e.g. "complete_task" + cmd: string; // e.g. "complete-task" (canonical: hyphens; legacy: underscores — both accepted by replay) params: Record; ts: string; // ISO 8601 hash: string; // content hash (hex, 16 chars) diff --git a/src/resources/extensions/gsd/workflow-reconcile.ts b/src/resources/extensions/gsd/workflow-reconcile.ts index 80a8c48f5..580473ad7 100644 --- a/src/resources/extensions/gsd/workflow-reconcile.ts +++ b/src/resources/extensions/gsd/workflow-reconcile.ts @@ -7,6 +7,7 @@ import { transaction, updateTaskStatus, updateSliceStatus, + updateMilestoneStatus, getSliceTasks, insertVerificationEvidence, upsertDecision, @@ -74,7 +75,10 @@ function replayEvents(events: WorkflowEvent[]): void { transaction(() => { for (const event of events) { const p = event.params; - switch (event.cmd) { + // Normalize cmd format: completion tools write hyphens ("complete-task"), + // legacy logs use underscores ("complete_task"). Accept both formats. + const cmd = event.cmd.replace(/-/g, "_"); + switch (cmd) { case "complete_task": { const milestoneId = p["milestoneId"] as string; const sliceId = p["sliceId"] as string; @@ -119,6 +123,14 @@ function replayEvents(events: WorkflowEvent[]): void { replaySliceComplete(milestoneId, sliceId, event.ts); break; } + case "complete_milestone": { + const milestoneId = p["milestoneId"] as string; + // Milestone completion via worktree replay — update status to complete + if (milestoneId) { + updateMilestoneStatus(milestoneId, "complete", event.ts); + } + break; + } case "plan_slice": { // plan_slice events are informational — slice should already exist. // No DB mutation needed during replay (the slice was inserted at plan time). @@ -139,7 +151,7 @@ function replayEvents(events: WorkflowEvent[]): void { break; } default: - // Unknown commands are silently skipped during replay + logWarning("reconcile", `Unknown event cmd during replay: "${event.cmd}" — skipped`); break; } } @@ -157,8 +169,10 @@ export function extractEntityKey( event: WorkflowEvent, ): { type: string; id: string } | null { const p = event.params; + // Normalize cmd format: accept both hyphens and underscores + const cmd = event.cmd.replace(/-/g, "_"); - switch (event.cmd) { + switch (cmd) { case "complete_task": case "start_task": case "report_blocker": @@ -172,6 +186,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"] }