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:
9
.changeset/fix-reconcile-no-resurrect-stale-task-dirs.md
Normal file
9
.changeset/fix-reconcile-no-resurrect-stale-task-dirs.md
Normal 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.
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user