diff --git a/.changeset/fix-reconcile-no-resurrect-stale-task-dirs.md b/.changeset/fix-reconcile-no-resurrect-stale-task-dirs.md new file mode 100644 index 0000000000..f881888813 --- /dev/null +++ b/.changeset/fix-reconcile-no-resurrect-stale-task-dirs.md @@ -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//` 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. diff --git a/packages/core/src/__tests__/store-orphaned-task-dir-reconcile.test.ts b/packages/core/src/__tests__/store-orphaned-task-dir-reconcile.test.ts index 352757d725..58542bbb55 100644 --- a/packages/core/src/__tests__/store-orphaned-task-dir-reconcile.test.ts +++ b/packages/core/src/__tests__/store-orphaned-task-dir-reconcile.test.ts @@ -1,5 +1,5 @@ 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 { join } from "node:path"; 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"); }); + 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 () => { const malformedDir = join(rootDir, ".fusion", "tasks", "FN-9106"); await mkdir(malformedDir, { recursive: true }); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index d10e2f1119..1629f0763b 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1,6 +1,6 @@ import { EventEmitter } from "node:events"; 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 { 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"; @@ -717,6 +717,12 @@ let taskActivityLogEntryLimit = DEFAULT_TASK_ACTIVITY_LOG_ENTRY_LIMIT; let taskActivityLogOutcomeLimit = DEFAULT_TASK_ACTIVITY_LOG_OUTCOME_LIMIT; const ARCHIVE_AGENT_LOG_SNAPSHOT_LIMIT = 25; 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 coreLog = createLogger("core"); @@ -3264,6 +3270,35 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} 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; try { const raw = await readFile(taskJsonPath, "utf-8");