fix(gsd): critical state machine data integrity fixes (wave 1/5)
Four critical fixes for the GSD state machine:
1. Event log cmd format mismatch — completion tools write hyphenated cmds
("complete-task") but replayEvents handled only underscored ("complete_task").
Worktree reconciliation replay was completely broken for modern completions.
Fix: normalize cmd via replace(/-/g, "_") in both replayEvents and
extractEntityKey. Also adds complete_milestone replay handler and warns
on unknown commands instead of silently skipping.
2. Dead if-block at state.ts:434-440 — empty block with misleading comments
wasted getMilestoneSlices() + every() computation. Removed and replaced
with clear comment explaining why all-slices-done milestones without
SUMMARY are intentionally not added to completeMilestoneIds.
3. getActiveMilestoneId missing "skipped" status — checked complete/done/parked
but not skipped. isStatusDone() includes skipped, creating divergence where
a skipped milestone could become permanently "active". Fix: use
isClosedStatus() || parked check.
4. executeReplan disk-file fallback — triage-resolution.ts writes replan
trigger to disk and DB (best-effort). If DB write fails, deriveStateFromDb
only checked the DB column, making the trigger invisible. Fix: fall back
to checking the disk REPLAN-TRIGGER file when DB column is null.
This commit is contained in:
parent
0dd7c31213
commit
9f7071ea6f
3 changed files with 34 additions and 13 deletions
|
|
@ -189,7 +189,7 @@ export async function getActiveMilestoneId(basePath: string): Promise<string | n
|
|||
const byId = new Map(allMilestones.map(m => [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<GSDState> {
|
|||
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<GSDState> {
|
|||
// ── 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) {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
ts: string; // ISO 8601
|
||||
hash: string; // content hash (hex, 16 chars)
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue