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:
parent
b357411a0f
commit
40b893ea22
5 changed files with 94 additions and 18 deletions
|
|
@ -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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: [] };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue