From 40b893ea22cfc838c5290bcf187442822a3529d4 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 7 Apr 2026 12:14:46 -0500 Subject: [PATCH] fix(gsd): event log and reconciliation robustness (wave 2/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five fixes for event log integrity and worktree reconciliation: 1. writeBlockerPlaceholder now appends event log entries after DB writes so recovery-path completions are visible to worktree reconciliation. 2. appendEvent failure is no longer silently swallowed in completion tools. Each post-mutation step (projections, manifest, event log) now has its own try/catch so a projection failure cannot prevent the event log entry. Event log failures use logError (persisted to audit-log.jsonl) instead of logWarning. 3. verification_evidence dedup confirmed already in place — INSERT OR IGNORE with unique index on (task_id, slice_id, milestone_id, command, verdict). 4. New entity replay handlers added to replayEvents: plan_milestone (creates milestone via INSERT OR IGNORE), plan_slice (creates slice), plan_task (creates task), replan_slice (informational no-op). Also added to extractEntityKey for conflict detection. 5. Post-reconcile cache invalidation added — targeted invalidation (invalidateStateCache + clearPathCache + clearParseCache) at the end of reconcileWorktreeLogs so deriveState() sees post-reconcile DB state. --- src/resources/extensions/gsd/auto-recovery.ts | 9 ++- .../gsd/tools/complete-milestone.ts | 16 +++++- .../extensions/gsd/tools/complete-slice.ts | 16 +++++- .../extensions/gsd/tools/complete-task.ts | 16 +++++- .../extensions/gsd/workflow-reconcile.ts | 55 ++++++++++++++++--- 5 files changed, 94 insertions(+), 18 deletions(-) diff --git a/src/resources/extensions/gsd/auto-recovery.ts b/src/resources/extensions/gsd/auto-recovery.ts index 1fabee1a1..c8b9b0637 100644 --- a/src/resources/extensions/gsd/auto-recovery.ts +++ b/src/resources/extensions/gsd/auto-recovery.ts @@ -9,6 +9,7 @@ import type { ExtensionContext } from "@gsd/pi-coding-agent"; import { parseUnitId } from "./unit-id.js"; +import { appendEvent } from "./workflow-events.js"; import { atomicWriteSync } from "./atomic-write.js"; import { clearParseCache } from "./files.js"; import { parseRoadmap as parseLegacyRoadmap, parsePlan as parseLegacyPlan } from "./parsers-legacy.js"; @@ -431,11 +432,15 @@ export function writeBlockerPlaceholder( // re-derives the same unit indefinitely (#2531, #2653). if (isDbAvailable()) { const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); + const ts = new Date().toISOString(); if (unitType === "execute-task" && mid && sid && tid) { - try { updateTaskStatus(mid, sid, tid, "complete", new Date().toISOString()); } catch (e) { logWarning("recovery", `updateTaskStatus failed during context exhaustion: ${e instanceof Error ? e.message : String(e)}`); } + try { updateTaskStatus(mid, sid, tid, "complete", ts); } catch (e) { logWarning("recovery", `updateTaskStatus failed during context exhaustion: ${e instanceof Error ? e.message : String(e)}`); } + // Append event so worktree reconciliation can replay this recovery completion + try { appendEvent(base, { cmd: "complete-task", params: { milestoneId: mid, sliceId: sid, taskId: tid }, ts, actor: "system", trigger_reason: "blocker-placeholder-recovery" }); } catch { /* non-fatal */ } } if (unitType === "complete-slice" && mid && sid) { - try { updateSliceStatus(mid, sid, "complete", new Date().toISOString()); } catch (e) { logWarning("recovery", `updateSliceStatus failed during context exhaustion: ${e instanceof Error ? e.message : String(e)}`); } + try { updateSliceStatus(mid, sid, "complete", ts); } catch (e) { logWarning("recovery", `updateSliceStatus failed during context exhaustion: ${e instanceof Error ? e.message : String(e)}`); } + try { appendEvent(base, { cmd: "complete-slice", params: { milestoneId: mid, sliceId: sid }, ts, actor: "system", trigger_reason: "blocker-placeholder-recovery" }); } catch { /* non-fatal */ } } } diff --git a/src/resources/extensions/gsd/tools/complete-milestone.ts b/src/resources/extensions/gsd/tools/complete-milestone.ts index 64d36f2e2..f20bb69f5 100644 --- a/src/resources/extensions/gsd/tools/complete-milestone.ts +++ b/src/resources/extensions/gsd/tools/complete-milestone.ts @@ -23,7 +23,7 @@ import { invalidateStateCache } from "../state.js"; import { renderAllProjections, stripIdPrefix } from "../workflow-projections.js"; import { writeManifest } from "../workflow-manifest.js"; import { appendEvent } from "../workflow-events.js"; -import { logWarning } from "../workflow-logger.js"; +import { logWarning, logError } from "../workflow-logger.js"; export interface CompleteMilestoneParams { milestoneId: string; @@ -218,9 +218,19 @@ export async function handleCompleteMilestone( clearParseCache(); // ── Post-mutation hook: projections, manifest, event log ─────────────── + // Separate try/catch per step so a projection failure doesn't prevent + // the event log entry (critical for worktree reconciliation). try { await renderAllProjections(basePath, params.milestoneId); + } catch (projErr) { + logWarning("tool", `complete-milestone projection warning: ${(projErr as Error).message}`); + } + try { writeManifest(basePath); + } catch (mfErr) { + logWarning("tool", `complete-milestone manifest warning: ${(mfErr as Error).message}`); + } + try { appendEvent(basePath, { cmd: "complete-milestone", params: { milestoneId: params.milestoneId }, @@ -229,8 +239,8 @@ export async function handleCompleteMilestone( actor_name: params.actorName, trigger_reason: params.triggerReason, }); - } catch (hookErr) { - logWarning("tool", `complete-milestone post-mutation hook warning: ${(hookErr as Error).message}`); + } catch (eventErr) { + logError("tool", `complete-milestone event log FAILED — completion invisible to reconciliation`, { error: (eventErr as Error).message }); } return { diff --git a/src/resources/extensions/gsd/tools/complete-slice.ts b/src/resources/extensions/gsd/tools/complete-slice.ts index 3f1dd51ae..5863a586f 100644 --- a/src/resources/extensions/gsd/tools/complete-slice.ts +++ b/src/resources/extensions/gsd/tools/complete-slice.ts @@ -30,7 +30,7 @@ import { renderRoadmapCheckboxes } from "../markdown-renderer.js"; import { renderAllProjections } from "../workflow-projections.js"; import { writeManifest } from "../workflow-manifest.js"; import { appendEvent } from "../workflow-events.js"; -import { logWarning } from "../workflow-logger.js"; +import { logWarning, logError } from "../workflow-logger.js"; export interface CompleteSliceResult { sliceId: string; @@ -336,9 +336,19 @@ export async function handleCompleteSlice( clearParseCache(); // ── Post-mutation hook: projections, manifest, event log ─────────────── + // Separate try/catch per step so a projection failure doesn't prevent + // the event log entry (critical for worktree reconciliation). try { await renderAllProjections(basePath, params.milestoneId); + } catch (projErr) { + logWarning("tool", `complete-slice projection warning for ${params.milestoneId}/${params.sliceId}: ${(projErr as Error).message}`); + } + try { writeManifest(basePath); + } catch (mfErr) { + logWarning("tool", `complete-slice manifest warning: ${(mfErr as Error).message}`); + } + try { appendEvent(basePath, { cmd: "complete-slice", params: { milestoneId: params.milestoneId, sliceId: params.sliceId }, @@ -347,8 +357,8 @@ export async function handleCompleteSlice( actor_name: params.actorName, trigger_reason: params.triggerReason, }); - } catch (hookErr) { - logWarning("tool", `complete-slice post-mutation hook failed for ${params.milestoneId}/${params.sliceId}`, { error: (hookErr as Error).message }); + } catch (eventErr) { + logError("tool", `complete-slice event log FAILED — completion invisible to reconciliation`, { error: (eventErr as Error).message }); } return { diff --git a/src/resources/extensions/gsd/tools/complete-task.ts b/src/resources/extensions/gsd/tools/complete-task.ts index 5d9fc99df..00cfa78d8 100644 --- a/src/resources/extensions/gsd/tools/complete-task.ts +++ b/src/resources/extensions/gsd/tools/complete-task.ts @@ -33,7 +33,7 @@ import { renderPlanCheckboxes } from "../markdown-renderer.js"; import { renderAllProjections, renderSummaryContent } from "../workflow-projections.js"; import { writeManifest } from "../workflow-manifest.js"; import { appendEvent } from "../workflow-events.js"; -import { logWarning } from "../workflow-logger.js"; +import { logWarning, logError } from "../workflow-logger.js"; export interface CompleteTaskResult { taskId: string; @@ -242,9 +242,19 @@ export async function handleCompleteTask( clearParseCache(); // ── Post-mutation hook: projections, manifest, event log ─────────────── + // Separate try/catch per step so a projection failure doesn't prevent + // the event log entry (critical for worktree reconciliation). try { await renderAllProjections(basePath, params.milestoneId); + } catch (projErr) { + logWarning("tool", `complete-task projection warning: ${(projErr as Error).message}`); + } + try { writeManifest(basePath); + } catch (mfErr) { + logWarning("tool", `complete-task manifest warning: ${(mfErr as Error).message}`); + } + try { appendEvent(basePath, { cmd: "complete-task", params: { milestoneId: params.milestoneId, sliceId: params.sliceId, taskId: params.taskId }, @@ -253,8 +263,8 @@ export async function handleCompleteTask( actor_name: params.actorName, trigger_reason: params.triggerReason, }); - } catch (hookErr) { - logWarning("tool", `complete-task post-mutation hook warning: ${(hookErr as Error).message}`); + } catch (eventErr) { + logError("tool", `complete-task event log FAILED — completion invisible to reconciliation`, { error: (eventErr as Error).message }); } return { diff --git a/src/resources/extensions/gsd/workflow-reconcile.ts b/src/resources/extensions/gsd/workflow-reconcile.ts index 580473ad7..06a4f4530 100644 --- a/src/resources/extensions/gsd/workflow-reconcile.ts +++ b/src/resources/extensions/gsd/workflow-reconcile.ts @@ -9,12 +9,18 @@ import { updateSliceStatus, updateMilestoneStatus, getSliceTasks, + insertMilestone, + insertSlice, + insertTask, insertVerificationEvidence, upsertDecision, openDatabase, setTaskBlockerDiscovered, } from "./gsd-db.js"; import { isClosedStatus } from "./status-guards.js"; +import { invalidateStateCache } from "./state.js"; +import { clearPathCache } from "./paths.js"; +import { clearParseCache } from "./files.js"; import { writeManifest } from "./workflow-manifest.js"; import { atomicWriteSync } from "./atomic-write.js"; import { acquireSyncLock, releaseSyncLock } from "./sync-lock.js"; @@ -131,9 +137,35 @@ function replayEvents(events: WorkflowEvent[]): void { } break; } + case "plan_milestone": { + // Replay milestone creation from worktree — INSERT OR IGNORE is safe + const milestoneId = p["milestoneId"] as string; + if (milestoneId) { + insertMilestone({ id: milestoneId, title: (p["title"] as string) ?? milestoneId }); + } + 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). + // Replay slice creation from worktree — INSERT OR IGNORE is safe + const milestoneId = p["milestoneId"] as string; + const sliceId = p["sliceId"] as string; + if (milestoneId && sliceId) { + insertSlice({ id: sliceId, milestoneId, title: (p["title"] as string) ?? sliceId }); + } + break; + } + case "plan_task": { + // Replay task creation from worktree — INSERT OR IGNORE is safe + const milestoneId = p["milestoneId"] as string; + const sliceId = p["sliceId"] as string; + const taskId = p["taskId"] as string; + if (milestoneId && sliceId && taskId) { + insertTask({ id: taskId, sliceId, milestoneId, title: (p["title"] as string) ?? taskId }); + } + break; + } + case "replan_slice": { + // Informational — replan events don't mutate DB during replay break; } case "save_decision": { @@ -177,25 +209,28 @@ export function extractEntityKey( case "start_task": case "report_blocker": case "record_verification": + case "plan_task": return typeof p["taskId"] === "string" ? { type: "task", id: p["taskId"] } : null; case "complete_slice": + case "replan_slice": return typeof p["sliceId"] === "string" ? { 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"] } : null; + case "complete_milestone": + case "plan_milestone": + return typeof p["milestoneId"] === "string" + ? { type: "milestone", id: p["milestoneId"] } + : null; + case "save_decision": if (typeof p["scope"] === "string" && typeof p["decision"] === "string") { return { type: "decision", id: `${p["scope"]}:${p["decision"]}` }; @@ -395,6 +430,12 @@ function _reconcileWorktreeLogsInner( logWarning("reconcile", "manifest write failed (non-fatal)", { error: (err as Error).message }); } + // Step 10: Invalidate caches so deriveState() sees post-reconcile DB state. + // Use targeted invalidation (not invalidateAllCaches) to avoid wiping artifacts table. + invalidateStateCache(); + clearPathCache(); + clearParseCache(); + return { autoMerged: merged.length, conflicts: [] }; }