fix: reconcile stale task DB status from disk artifacts (#2514)

When a session disconnects after the agent writes SUMMARY + VERIFY
files but before postUnitPostVerification updates the DB, tasks
remain 'pending' in the DB despite being complete on disk.

deriveStateFromDb now checks each non-done task for a SUMMARY file
on disk before selecting the active task. If found, it updates the
DB to 'complete' and logs to stderr for observability.

Fixes #2514
This commit is contained in:
ahwlsqja 2026-03-26 02:01:57 +09:00
parent c6328a229f
commit 0e7a01f49c

View file

@ -49,6 +49,7 @@ import {
getReplanHistory, getReplanHistory,
getSlice, getSlice,
insertMilestone, insertMilestone,
updateTaskStatus,
type MilestoneRow, type MilestoneRow,
type SliceRow, type SliceRow,
type TaskRow, type TaskRow,
@ -629,7 +630,38 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
} }
// ── Get tasks from DB ──────────────────────────────────────────────── // ── Get tasks from DB ────────────────────────────────────────────────
const tasks = getSliceTasks(activeMilestone.id, activeSlice.id); let tasks = getSliceTasks(activeMilestone.id, activeSlice.id);
// ── Reconcile stale task status (#2514) ──────────────────────────────
// When a session disconnects after the agent writes SUMMARY + VERIFY
// artifacts but before postUnitPostVerification updates the DB, tasks
// remain "pending" in the DB despite being complete on disk. Without
// reconciliation, deriveState keeps returning the stale task as active,
// causing the dispatcher to re-dispatch the same completed task forever.
let reconciled = false;
for (const t of tasks) {
if (isStatusDone(t.status)) continue;
const summaryPath = resolveTaskFile(basePath, activeMilestone.id, activeSlice.id, t.id, "SUMMARY");
if (summaryPath && existsSync(summaryPath)) {
try {
updateTaskStatus(activeMilestone.id, activeSlice.id, t.id, "complete");
process.stderr.write(
`gsd-reconcile: task ${activeMilestone.id}/${activeSlice.id}/${t.id} had SUMMARY on disk but DB status was "${t.status}" — updated to "complete" (#2514)\n`,
);
reconciled = true;
} catch (e) {
// DB write failed — continue with stale status rather than crash
process.stderr.write(
`gsd-reconcile: failed to update task ${t.id}: ${(e as Error).message}\n`,
);
}
}
}
// Re-fetch tasks if any were reconciled so downstream logic sees fresh status
if (reconciled) {
tasks = getSliceTasks(activeMilestone.id, activeSlice.id);
}
const taskProgress = { const taskProgress = {
done: tasks.filter(t => isStatusDone(t.status)).length, done: tasks.filter(t => isStatusDone(t.status)).length,
total: tasks.length, total: tasks.length,