fix(gsd): event log and reconciliation robustness (wave 2/5)

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.
This commit is contained in:
Jeremy 2026-04-07 12:14:46 -05:00
parent b357411a0f
commit 40b893ea22
5 changed files with 94 additions and 18 deletions

View file

@ -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 */ }
}
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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: [] };
}