Stop reconcile from resurrecting long-deleted task dirs

On restart, reconcileOrphanedTaskDirs re-imported ancient .fusion/tasks/<id>/
directories that had no DB row, surfacing old low-numbered tasks (FN-001, ...)
onto the live board — looking like "all task IDs reset / starting over".

The sweep is meant to recover dirs that appear after store init (heartbeat
races) or rows lost to recent DB corruption. Modern deletes leave a soft-delete
tombstone (caught by taskIdExistsAnywhere), but legacy hard-deletes left none,
so a months-old task.json with no DB row was silently re-imported.

Gate recovery on a 7-day recency window (task.json mtime). Older orphans are
skipped (reason: stale-orphan-dir-beyond-recency-window) and left for explicit
recovery or directory cleanup; heartbeat-race and recent-corruption recovery
still work. Adds a regression test for the stale-dir skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-20 12:00:51 -07:00
parent b2f564c2be
commit 26ebb9206f
3 changed files with 64 additions and 2 deletions

View File

@@ -0,0 +1,9 @@
---
"@fusion/core": patch
---
Fix `reconcileOrphanedTaskDirs` silently resurrecting long-deleted tasks onto the live board after a restart ("all task IDs reset / starting over").
The sweep re-imports `.fusion/tasks/<id>/` directories that have no DB row, to recover heartbeat-created dirs that race store init or rows lost to a recent DB corruption. But it didn't distinguish a genuinely-recent orphan from an ancient deleted-task dir that merely lingered on disk. Modern deletes leave a soft-delete tombstone (caught by `taskIdExistsAnywhere`), but legacy hard-deletes left no tombstone — so a months-old `task.json` with no DB row was re-imported as a live task, surfacing old low-numbered IDs (FN-001, FN-002, …) at the top of the board.
Reconcile now gates recovery on a recency window (`task.json` modified within the last 7 days). Older orphan dirs are skipped with reason `stale-orphan-dir-beyond-recency-window` and left for explicit recovery (unarchive/restore) or directory cleanup, while heartbeat-race and recent-corruption recovery still work.

View File

@@ -1,5 +1,5 @@
import { mkdtempSync } from "node:fs"; import { mkdtempSync } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises"; import { mkdir, rm, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -121,6 +121,24 @@ describe("TaskStore orphaned task-dir reconciliation", () => {
expect((await store.getTask(task.id)).column).toBe("archived"); expect((await store.getTask(task.id)).column).toBe("archived");
}); });
it("skips a stale orphan task dir beyond the recency window (no resurrection of old deleted tasks)", async () => {
// Regression: legacy hard-deletes left no tombstone, so an ancient task.json lingering
// on disk was silently re-imported onto the live board ("all task IDs reset" failure).
const orphan = await createDiskOnlyTask("FN-9110");
// Backdate the task.json well beyond the 7-day recency window.
const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
const taskJsonPath = join(rootDir, ".fusion", "tasks", orphan.id, "task.json");
await utimes(taskJsonPath, eightDaysAgo, eightDaysAgo);
const result = await store.reconcileOrphanedTaskDirs();
expect(result.recovered).not.toContain(orphan.id);
expect(result.skipped).toEqual(expect.arrayContaining([
expect.objectContaining({ id: orphan.id, reason: "stale-orphan-dir-beyond-recency-window" }),
]));
await expect(store.getTask(orphan.id)).rejects.toThrow("Task FN-9110 not found");
});
it("skips malformed task.json and directories without task.json without throwing", async () => { it("skips malformed task.json and directories without task.json without throwing", async () => {
const malformedDir = join(rootDir, ".fusion", "tasks", "FN-9106"); const malformedDir = join(rootDir, ".fusion", "tasks", "FN-9106");
await mkdir(malformedDir, { recursive: true }); await mkdir(malformedDir, { recursive: true });

View File

@@ -1,6 +1,6 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises"; import { mkdir, readdir, readFile, stat, writeFile, rename, unlink } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { existsSync, watch, type Dirent, type FSWatcher } from "node:fs"; import { existsSync, watch, type Dirent, type FSWatcher } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrEntityState, PrThreadState, PrThreadOutcome, PrConflictState, PrChecksRollup, PrReviewDecision, PluginActivation, PluginActivationInput } from "./types.js"; import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrEntityState, PrThreadState, PrThreadOutcome, PrConflictState, PrChecksRollup, PrReviewDecision, PluginActivation, PluginActivationInput } from "./types.js";
@@ -717,6 +717,12 @@ let taskActivityLogEntryLimit = DEFAULT_TASK_ACTIVITY_LOG_ENTRY_LIMIT;
let taskActivityLogOutcomeLimit = DEFAULT_TASK_ACTIVITY_LOG_OUTCOME_LIMIT; let taskActivityLogOutcomeLimit = DEFAULT_TASK_ACTIVITY_LOG_OUTCOME_LIMIT;
const ARCHIVE_AGENT_LOG_SNAPSHOT_LIMIT = 25; const ARCHIVE_AGENT_LOG_SNAPSHOT_LIMIT = 25;
const ARCHIVE_AGENT_LOG_SNIPPET_LIMIT = 160; const ARCHIVE_AGENT_LOG_SNIPPET_LIMIT = 160;
// reconcileOrphanedTaskDirs only recovers task dirs whose task.json was modified within
// this window. Bounds the sweep to genuinely-recent orphans (heartbeat races, rows lost
// to a recent DB corruption) and prevents silent resurrection of ancient deleted-task
// dirs that merely lingered on disk (legacy hard-deletes left no tombstone). 7 days is
// generous enough to cover an engine that was offline for a while.
const RECONCILE_ORPHAN_TASK_DIR_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const storeLog = createLogger("task-store"); const storeLog = createLogger("task-store");
const coreLog = createLogger("core"); const coreLog = createLogger("core");
@@ -3264,6 +3270,35 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
continue; continue;
} }
// FN: recency gate. This sweep exists to recover task dirs that "appear after
// store init" — heartbeat-created dirs that race startup, or rows lost to a
// recent DB corruption while their task.json survived on disk. It must NOT
// resurrect *ancient* deleted-task dirs that merely lingered on disk: modern
// deletes leave a soft-delete tombstone (taskIdExistsAnywhere catches those),
// but legacy hard-deletes left no tombstone, so a months-old task.json with no
// DB row would otherwise be silently re-imported onto the live board (the
// "all task IDs reset / starting over" failure). Only reconcile dirs whose
// task.json was modified within the recency window; older orphans are left for
// explicit recovery (unarchive/restore) or directory cleanup.
try {
const { mtimeMs } = await stat(taskJsonPath);
const ageMs = Date.now() - mtimeMs;
if (ageMs > RECONCILE_ORPHAN_TASK_DIR_MAX_AGE_MS) {
result.skipped.push({ id, reason: "stale-orphan-dir-beyond-recency-window" });
storeLog.warn("Skipping stale orphaned task-dir reconcile (beyond recency window)", {
phase: "reconcileOrphanedTaskDirs:recency",
taskId: id,
taskJsonPath,
ageMs,
maxAgeMs: RECONCILE_ORPHAN_TASK_DIR_MAX_AGE_MS,
});
continue;
}
} catch (error) {
result.skipped.push({ id, reason: `stat-failed: ${error instanceof Error ? error.message : String(error)}` });
continue;
}
let task: Task; let task: Task;
try { try {
const raw = await readFile(taskJsonPath, "utf-8"); const raw = await readFile(taskJsonPath, "utf-8");