FN-6783: recover orphaned task dirs into the task index

Reconcile on-disk task.json records that are missing from SQLite without resurrecting tombstoned IDs.

- Add TaskStore orphaned task directory scanning with metadata validation, FTS-safe insertion, cache updates, lifecycle events, and run-audit entries.
- Run the reconcile during store init and self-healing maintenance for tasks created after startup.
- Cover recovery, skip, and maintenance behavior with core and engine regression tests.
- Document task index reconciliation and add the published package changeset.

Files changed:
 .changeset/fn-6783-orphaned-task-dir-reconcile.md  |   5 +
 AGENTS.md                                          |   1 +
 docs/architecture.md                               |   2 +
 docs/storage.md                                    |   8 +
 .../store-orphaned-task-dir-reconcile.test.ts      | 179 +++++++++++++++++++++
 packages/core/src/store.ts                         | 170 ++++++++++++++++++-
 .../self-healing-orphaned-task-dirs.test.ts        |  40 +++++
 packages/engine/src/self-healing.ts                |  14 ++
 8 files changed, 412 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-6783
Fusion-Task-Lineage: 5c8d4690-5278-4c29-8f02-32d9f01d581d
This commit is contained in:
gsxdsm
2026-06-20 04:40:31 -07:00
parent adbb1bbaaf
commit c32c925321
8 changed files with 412 additions and 7 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Repair task-store startup and self-healing consistency by non-destructively re-importing orphaned live `.fusion/tasks/{ID}/task.json` records into the SQLite task index while preserving soft-deleted, archived, and tombstoned IDs.

View File

@@ -192,6 +192,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
- FN-5419: git run-audit now includes `pull:fast-forward` and `stash:pop-conflict`; dashboard git surfaces now include the extended `POST /api/git/pull` integration-worktree path plus companion `POST /api/git/stash-resolve`, `POST /api/git/stash-drop`, and `POST /api/git/stash-apply` routes.
- FN-6292: self-healing emits `task:reconcile-dependency-blocking-lease` when it rebounds an in-progress holder whose stale file-scope lease blocks an unmet dependency, and `task:reconcile-dependency-blocking-lease-no-action` when triple-proof blocks that backward move.
- FN-6736: self-healing emits `task:reclaim-phantom-executor-binding` when it proves an in-memory executor-active binding is stale, clears the binding, and requeues the in-progress task with worktree/progress preserved.
- FN-6783: task-store open and self-healing housekeeping emit `task:reconcile-orphaned-task-dir` when they non-destructively re-import a valid live `.fusion/tasks/{ID}/task.json` directory that has no task row anywhere, preserving soft-deleted/archived/tombstoned IDs.
## Reference docs (deeper detail)

View File

@@ -672,6 +672,7 @@ Runtime action-gate flow (v1):
- `GridlockDetector` (`gridlock-detector.ts`) — detects all-blocked todo pipelines and emits notification events (plus explicit clear signals when gridlock resolves)
- `TransientErrorDetector` (`transient-error-detector.ts`) — retriable error classification
- `SelfHealingManager` (`self-healing.ts`) — auto-unpause/maintenance recovery actions
- Batch 1 maintenance now includes `reconcile-orphaned-task-dirs` (FN-6783), a paused-safe housekeeping step that calls `TaskStore.reconcileOrphanedTaskDirs()` so valid live `.fusion/tasks/{ID}/task.json` records missing from the SQLite index become visible without waiting for process restart. The store-level guard skips any ID already present in active, soft-deleted, archived, or tombstoned storage and emits `task:reconcile-orphaned-task-dir` only for recovered rows.
- Batch 1 maintenance now includes one `fts-maintenance` step for both search indexes. The live `tasks_fts` branch still runs `merge` every tick, `optimize` every 4th tick, and `rebuild` above `32 MiB` or `1 MiB × live task count`. The archive `archived_tasks_fts` branch is lighter because archive writes are mostly append-only: `merge` every 8th tick, `optimize` every 24th tick, and `rebuild` above `64 MiB` or `512 KiB × archived row count`. Each branch is independently guarded by `fts5Available` and emits `task:fts-maintenance` run-audit telemetry with distinct `target` values (`tasks_fts` vs `archived_tasks_fts`).
- AI merge clean-room worktrees are created under the configured worktrees directory's hidden container, `<worktreesDir>/.ai-merge/`, as `fusion-ai-merge-fn-<id>-<random>` detached worktrees. When that container is repo-local, its relative path is added to the repo's local git exclude when possible (alongside the legacy `.fusion/ai-merge/` entry) so an in-flight clean room does not dirty the integration checkout. After `git worktree add` and before the merge/review loop, `runAiMerge` bootstraps the clean room with the shared merge dependency-sync helper: a configured `worktreeInitCommand` is authoritative and always runs, while unset settings infer `pnpm`/`npm`/`yarn`/`bun` installs from lockfiles and can skip only when the `node_modules/.fusion-install-marker` hash still matches. Failures and aborts hard-stop the AI merge before merge agents or verification run, and `merge:ai-deps-sync` records the command, skip state, and duration. Inline cleanup runs from `runAiMerge`'s clean-room `finally` for successful lands, empty/no-op finalization, concurrent-advance retries, and thrown/aborted merges. Cleanup canonicalizes the path, attempts `git worktree remove --force`, always falls back to filesystem removal, then runs `git worktree prune` so stale or partial registrations (including `git worktree add` failures) do not dangle. Cleanup emits `merge:ai-worktree-cleanup` audit events for git-remove, fs-rm, and prune phases; benign already-absent/de-registered paths are treated as idempotent success, while genuine filesystem-removal failures are logged/audited with `success: false` rather than silently swallowed.
- Worktrees-dir sweeps that list direct children of `<worktreesDir>` (pool idle scan, orphan cleanup/reap, self-healing unregistered-orphan reap, and cap enforcement) must exclude the `.ai-merge` container by name; those one-level sweeps never inspect or recycle clean rooms beneath it. Batch 1 sweeps stale AI merge clean-room worktrees under the new `<worktreesDir>/.ai-merge/` root and still scans legacy `.fusion/ai-merge/` plus legacy `tmpdir()` locations for pre-relocation leftovers; candidates are bounded to names starting with `fusion-ai-merge-`. `runAiMerge` registers each live clean-room worktree in `activeSessionRegistry` with kind `ai-merge` as soon as the directory exists and keeps both raw and canonical paths registered for the duration of the merge, so the dedicated periodic sweep and pre-merge prune defer when either path is active (including concurrent same-task merge attempts). The default age gate is 2 hours; task-aware cleanup uses a 10-minute grace period for `done`/`archived` tasks and for genuinely missing/deleted task rows, and every removal path is clamped by the same 10-minute minimum-age floor so a freshly created worktree is never reaped. Transient `getTask` lookup failures (for example SQLite busy/parse errors) are not treated as deletion evidence; they log a warning, emit `lookup-error` only if eventually removed, and retain the conservative 2-hour gate. The sweep canonicalizes paths before checking `activeSessionRegistry`, attempts `git worktree remove --force <path>` before filesystem removal, runs `git worktree prune` after cleanup attempts, and emits `worktree:tempdir-sweep` run-audit telemetry for removal attempts and failures. Fresh directories, active-session paths, and individual removal failures are skipped/logged without aborting the maintenance cycle.
@@ -1086,6 +1087,7 @@ The run-audit system records every mutation performed by the engine across four
- **Database / `task:soft-delete-column-reconciled`** — emitted by `reconcileSoftDeletedColumnDrift` (FN-5566, re-land FN-5446) when a soft-deleted row (`deletedAt IS NOT NULL`) is found with legacy `column != 'archived'`; rewrites only `column` (no resurrection), with metadata `{ previousColumn }`.
- **Database / `session:runtime-resolved`** — emitted once per `createResolvedAgentSession` call with metadata `{ sessionPurpose, runtimeId, wasConfigured, provider, modelId, mockProviderActive, testModeActive, runtimeHint? }` for per-lane runtime/provider attribution.
- **Database / `task:reconcile-dependency-blocking-lease`** — emitted by `reconcileDependencyBlockingLeases()` (FN-6292) when self-healing rebounds an `in-progress` holder to `todo` because an unmet dependency is blocked by the holder's stale file-scope lease. Metadata includes the dependency ID, blocked-by marker, and unmet dependency list.
- **Database / `task:reconcile-orphaned-task-dir`** — emitted by `TaskStore.reconcileOrphanedTaskDirs()` (FN-6783) when store open or self-healing Batch 1 re-imports a valid live `.fusion/tasks/{ID}/task.json` directory with no SQLite task row anywhere. Metadata includes the recovered ID, column, status, and task JSON path.
- **Database / `task:*-no-action` backward-move family (FN-5335)** — backward self-healing sweeps now emit annotation-only events when triple proof fails instead of mutating lifecycle state. New mutation types: `task:reclaim-pr-conflict-no-action`, `task:reclaim-self-owned-branch-conflict-no-action`, `task:auto-rebound-scope-decay-no-action`, `task:finalize-no-op-review-no-action`, `task:stale-incomplete-review-no-action`, `task:ghost-review-no-action`, `task:stuck-merge-deadlock-no-action`, `task:no-progress-no-task-done-no-action`, `task:missing-worktree-review-no-action`, `task:partial-progress-no-task-done-no-action`, `task:reconcile-dependency-blocking-lease-no-action`. See `docs/self-healing-backward-move-audit.md` for per-stage disposition.
- **Filesystem** — file:write, prompt:write, attachment:create, etc.
- **Sandbox** — backend lifecycle events from `SandboxBackend` wiring in executor/merger/routine-runner (`sandbox:prepare`, `sandbox:run`, `sandbox:failure`, `sandbox:fallback`) introduced after FN-4636.

View File

@@ -15,6 +15,14 @@
- Archived-task flows (`archiveTask`, archived cleanup/migration) still hard-delete from the active `tasks` table after copying to cold storage (`archive.db`).
- ID reservation is unchanged: soft-deleted IDs remain reserved. `distributed-task-id` and `task-id-integrity` intentionally scan all task rows (including soft-deleted rows), and must not filter on `deletedAt`.
### Orphaned task-dir reconciliation (FN-6783)
- Disk-backed `TaskStore` instances reconcile `.fusion/tasks/{ID}/task.json` directories against the SQLite `tasks` index on store open and during `SelfHealingManager` Batch 1 maintenance (`reconcile-orphaned-task-dirs`). This closes the visibility gap where a heartbeat-created task could exist on disk but be absent from `getTask`/`listTasks` and the dashboard board.
- The reconcile is non-destructive: when an ID already exists anywhere the create path would reserve it (active task row, soft-deleted row, archived table/archive DB, or tombstone), the scan skips the directory and never overwrites or resurrects that ID. Only a valid live `task.json` with no DB record anywhere is re-imported.
- Recovered rows preserve the on-disk task metadata, including `column`, `status`, dependencies, steps, and log, after the same defensive disk normalization used by task JSON fallback reads. Malformed or unparseable `task.json` files are skipped with a warning instead of failing store open or maintenance.
- Recovery is visible: each inserted orphan emits a store warning, a `task:reconcile-orphaned-task-dir` run-audit event, and a `task:created` lifecycle event so live boards can render the recovered card.
- On-disk retention matters for scan safety. `deleteTask()` leaves `.fusion/tasks/{ID}/task.json` and `agent-log.jsonl` on disk for forensics while marking the row `deletedAt`; the reconcile must skip those soft-deleted IDs. `archiveTask(id)` with the default cleanup removes the task directory, but `archiveTask(id, false)` and legacy archives can leave a `task.json` behind, so archived IDs are also guarded and skipped.
### Agent log storage + soft-delete visibility (FN-5143 / FN-5911)
- Agent logs are no longer stored in SQLite. Each task now appends newline-delimited JSON records to `<rootDir>/.fusion/tasks/{ID}/agent-log.jsonl`.

View File

@@ -0,0 +1,179 @@
import { mkdtempSync } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { TaskStore } from "../store.js";
import type { Task } from "../types.js";
async function rewriteTaskJson(rootDir: string, task: Task): Promise<void> {
const dir = join(rootDir, ".fusion", "tasks", task.id);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "task.json"), JSON.stringify(task), "utf-8");
}
describe("TaskStore orphaned task-dir reconciliation", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "fusion-orphaned-task-dir-"));
globalDir = mkdtempSync(join(tmpdir(), "fusion-orphaned-task-dir-global-"));
store = new TaskStore(rootDir, globalDir);
await store.init();
});
afterEach(async () => {
store.close();
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
async function createDiskOnlyTask(id: string, patch: Partial<Task> = {}): Promise<Task> {
const task = await store.createTaskWithReservedId(
{ description: `Disk-only ${id}`, column: "triage" },
{ taskId: id, applyDefaultWorkflowSteps: false, invokeTaskCreatedHook: false },
);
const diskTask: Task = { ...task, status: "planning", ...patch };
await rewriteTaskJson(rootDir, diskTask);
(store as any).db.prepare("DELETE FROM tasks WHERE id = ?").run(id);
(store as any).db.bumpLastModified();
return diskTask;
}
it("re-imports a valid task.json with no DB row so getTask and listTasks agree", async () => {
const orphan = await createDiskOnlyTask("FN-9101", {
dependencies: ["FN-1"],
steps: [{ name: "Preflight", status: "pending" }],
});
expect((await store.listTasks({ includeArchived: false })).some((task) => task.id === orphan.id)).toBe(false);
await expect(store.getTask(orphan.id)).rejects.toThrow("Task FN-9101 not found");
const result = await store.reconcileOrphanedTaskDirs();
expect(result.recovered).toEqual([orphan.id]);
const detail = await store.getTask(orphan.id);
expect(detail.id).toBe(orphan.id);
expect(detail.column).toBe("triage");
expect(detail.status).toBe("planning");
expect(detail.dependencies).toEqual(["FN-1"]);
expect((await store.listTasks({ includeArchived: false })).map((task) => task.id)).toContain(orphan.id);
expect(store.getRunAuditEvents({ taskId: orphan.id, mutationType: "task:reconcile-orphaned-task-dir" })).toHaveLength(1);
});
it("re-imports orphaned task dirs during disk-backed store open", async () => {
const orphan = await createDiskOnlyTask("FN-9102");
store.close();
store = new TaskStore(rootDir, globalDir);
await store.init();
expect((await store.getTask(orphan.id)).status).toBe("planning");
expect((await store.listTasks({ includeArchived: false })).map((task) => task.id)).toContain(orphan.id);
});
it("does not overwrite an already-present DB row", async () => {
const task = await store.createTaskWithReservedId(
{ description: "Authoritative DB row", title: "Original title" },
{ taskId: "FN-9103", applyDefaultWorkflowSteps: false, invokeTaskCreatedHook: false },
);
await rewriteTaskJson(rootDir, { ...task, title: "Disk drift title", description: "Disk drift" });
const result = await store.reconcileOrphanedTaskDirs();
expect(result.recovered).not.toContain(task.id);
const detail = await store.getTask(task.id);
expect(detail.title).toBe("Original title");
expect(detail.description).toBe("Authoritative DB row");
});
it("skips soft-deleted and tombstoned task IDs without resurrection", async () => {
const task = await store.createTaskWithReservedId(
{ description: "Delete me" },
{ taskId: "FN-9104", applyDefaultWorkflowSteps: false, invokeTaskCreatedHook: false },
);
await store.deleteTask(task.id);
const result = await store.reconcileOrphanedTaskDirs();
expect(result.recovered).not.toContain(task.id);
expect((await store.listTasks({ includeArchived: true })).map((candidate) => candidate.id)).not.toContain(task.id);
await expect(store.getTask(task.id)).rejects.toThrow("Task FN-9104 not found");
expect(await store.getTask(task.id, { includeDeleted: true })).toMatchObject({ id: task.id, deletedAt: expect.any(String) });
});
it("skips archived IDs that still have or regain a task.json", async () => {
const task = await store.createTaskWithReservedId(
{ description: "Archive me" },
{ taskId: "FN-9105", applyDefaultWorkflowSteps: false, invokeTaskCreatedHook: false },
);
await store.archiveTask(task.id, true);
await rewriteTaskJson(rootDir, { ...task, column: "triage", status: "planning" });
const result = await store.reconcileOrphanedTaskDirs();
expect(result.recovered).not.toContain(task.id);
expect((await store.listTasks({ includeArchived: false })).map((candidate) => candidate.id)).not.toContain(task.id);
expect((await store.listTasks({ includeArchived: true })).map((candidate) => candidate.id)).toContain(task.id);
expect((await store.getTask(task.id)).column).toBe("archived");
});
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 });
await writeFile(join(malformedDir, "task.json"), "{ nope", "utf-8");
await mkdir(join(rootDir, ".fusion", "tasks", "FN-9107"), { recursive: true });
const result = await store.reconcileOrphanedTaskDirs();
expect(result.recovered).toEqual([]);
expect(result.skipped).toEqual(expect.arrayContaining([
expect.objectContaining({ id: "FN-9106", reason: expect.stringContaining("malformed-task-json") }),
{ id: "FN-9107", reason: "missing-task-json" },
]));
});
it("reports malformed live task metadata without overwriting the DB row", async () => {
const task = await store.createTaskWithReservedId(
{ description: "Malformed file but valid DB" },
{ taskId: "FN-9108", applyDefaultWorkflowSteps: false, invokeTaskCreatedHook: false },
);
await rewriteTaskJson(rootDir, { ...task, createdAt: "riage-FN-6750-1781908063" });
const result = await store.reconcileOrphanedTaskDirs();
expect(result.recovered).not.toContain(task.id);
expect(result.skipped).toEqual(expect.arrayContaining([
expect.objectContaining({ id: task.id, reason: expect.stringContaining("malformed-task-metadata") }),
]));
expect((await store.getTask(task.id)).createdAt).toBe(task.createdAt);
});
it("is a safe no-op for in-memory stores even if task dirs exist", async () => {
store.close();
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
const now = new Date().toISOString();
await rewriteTaskJson(rootDir, {
id: "FN-9109",
description: "Ignored in-memory orphan",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: now,
updatedAt: now,
columnMovedAt: now,
status: "planning",
});
const result = await store.reconcileOrphanedTaskDirs();
expect(result).toEqual({ recovered: [], skipped: [] });
expect((await store.listTasks({ includeArchived: false })).map((task) => task.id)).not.toContain("FN-9109");
});
});

View File

@@ -2,7 +2,7 @@ import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto";
import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync, watch, 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 { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js";
import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isColumn, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
@@ -1865,6 +1865,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
await this.importLegacyAgentLogsOnce();
this.taskIdStateReconciled = false;
this.reconcileDistributedTaskIdStateOnOpen();
try {
await this.reconcileOrphanedTaskDirs();
} catch (err) {
storeLog.warn("Orphaned task-dir reconcile failed during init (non-fatal)", {
phase: "init:orphaned-task-dir-reconcile",
error: err instanceof Error ? err.message : String(err),
});
}
// Write config.json for backward compatibility if it doesn't exist
if (!existsSync(this.configPath)) {
@@ -3175,6 +3183,159 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
* Read a task from SQLite by ID (extracted from dir path for backward compat).
* Falls back to file-based reading only when no DB row exists at all.
*/
private normalizeTaskFromDisk(task: Task): Task {
if (!Array.isArray(task.log)) task.log = [];
if (!Array.isArray(task.dependencies)) task.dependencies = [];
if (!Array.isArray(task.steps)) task.steps = [];
task.priority = normalizeTaskPriority(task.priority);
return task;
}
private getMalformedTaskMetadataReason(task: Partial<Task>, expectedId: string): string | undefined {
if (task.id !== expectedId) {
return `task.json id ${typeof task.id === "string" ? task.id : "<missing>"} does not match directory ${expectedId}`;
}
if (typeof task.description !== "string") {
return "task.json description must be a string";
}
if (typeof task.column !== "string") {
return "task.json column must be a string";
}
if (typeof task.createdAt !== "string" || Number.isNaN(Date.parse(task.createdAt))) {
return "task.json createdAt must be a valid ISO timestamp string";
}
if (typeof task.updatedAt !== "string" || Number.isNaN(Date.parse(task.updatedAt))) {
return "task.json updatedAt must be a valid ISO timestamp string";
}
return undefined;
}
/*
* FNXC:TaskStoreConsistency 2026-06-20-00:00:
* Heartbeat-created tasks persisted on disk but missing from the SQLite index were invisible to fn_task_list/fn_task_show (FN-6783/FN-6784). Reconcile re-imports orphaned task.json rows non-destructively and uses the same exists-anywhere guard as create-time ID allocation so soft-deleted, archived, and tombstoned IDs are never resurrected.
*/
async reconcileOrphanedTaskDirs(): Promise<{ recovered: string[]; skipped: Array<{ id: string; reason: string }> }> {
const result: { recovered: string[]; skipped: Array<{ id: string; reason: string }> } = {
recovered: [],
skipped: [],
};
if (this.inMemoryDb || !existsSync(this.tasksDir)) {
return result;
}
let entries: Dirent[];
try {
entries = await readdir(this.tasksDir, { withFileTypes: true });
} catch (error) {
storeLog.warn("Skipping orphaned task-dir reconcile because tasksDir is unreadable", {
phase: "reconcileOrphanedTaskDirs:scan",
tasksDir: this.tasksDir,
error: error instanceof Error ? error.message : String(error),
});
return result;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const id = entry.name;
const taskDir = join(this.tasksDir, id);
const taskJsonPath = join(taskDir, "task.json");
if (!existsSync(taskJsonPath)) {
result.skipped.push({ id, reason: "missing-task-json" });
continue;
}
let task: Task;
try {
const raw = await readFile(taskJsonPath, "utf-8");
task = this.normalizeTaskFromDisk(JSON.parse(raw) as Task);
} catch (error) {
const reason = `malformed-task-json: ${error instanceof Error ? error.message : String(error)}`;
result.skipped.push({ id, reason });
storeLog.warn("Skipping malformed task.json during orphaned task-dir reconcile", {
phase: "reconcileOrphanedTaskDirs:parse",
taskId: id,
taskJsonPath,
error: error instanceof Error ? error.message : String(error),
});
continue;
}
const malformedReason = this.getMalformedTaskMetadataReason(task, id);
if (malformedReason) {
result.skipped.push({ id, reason: `malformed-task-metadata: ${malformedReason}` });
storeLog.warn("Skipping malformed task metadata during orphaned task-dir reconcile", {
phase: "reconcileOrphanedTaskDirs:validate",
taskId: id,
taskJsonPath,
reason: malformedReason,
});
continue;
}
let recovered = false;
let skipReason: string | undefined;
try {
this.db.transactionImmediate(() => {
if (this.taskIdExistsAnywhere(id)) {
skipReason = "id-exists-anywhere";
return;
}
try {
this.insertTaskWithFtsRecovery(task, "reconcileOrphanedTaskDirs");
this.insertRunAuditEventRow({
taskId: id,
domain: "database",
mutationType: "task:reconcile-orphaned-task-dir",
target: id,
metadata: {
id,
column: task.column,
status: task.status ?? null,
taskJsonPath,
},
});
recovered = true;
} catch (error) {
if (this.isTaskIdConflictError(error) || /Task ID already exists/i.test(error instanceof Error ? error.message : String(error))) {
skipReason = "id-conflict-during-insert";
return;
}
throw error;
}
});
} catch (error) {
const reason = `insert-failed: ${error instanceof Error ? error.message : String(error)}`;
result.skipped.push({ id, reason });
storeLog.warn("Skipping orphaned task-dir reconcile insert after non-fatal error", {
phase: "reconcileOrphanedTaskDirs:insert",
taskId: id,
taskJsonPath,
error: error instanceof Error ? error.message : String(error),
});
continue;
}
if (recovered) {
result.recovered.push(id);
if (this.isWatching) this.taskCache.set(id, { ...task });
storeLog.warn("Recovered orphaned task.json into SQLite task index", {
phase: "reconcileOrphanedTaskDirs:recovered",
taskId: id,
column: task.column,
status: task.status,
taskJsonPath,
});
this.emitTaskLifecycleEventSafely("task:created", [task]);
} else {
result.skipped.push({ id, reason: skipReason ?? "not-recovered" });
}
}
return result;
}
private async readTaskJson(dir: string): Promise<Task> {
const id = this.getTaskIdFromDir(dir);
@@ -3190,12 +3351,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
const filePath = join(dir, "task.json");
const raw = await readFile(filePath, "utf-8");
try {
const fileTask = JSON.parse(raw) as Task;
if (!Array.isArray(fileTask.log)) fileTask.log = [];
if (!Array.isArray(fileTask.dependencies)) fileTask.dependencies = [];
if (!Array.isArray(fileTask.steps)) fileTask.steps = [];
fileTask.priority = normalizeTaskPriority(fileTask.priority);
return fileTask;
return this.normalizeTaskFromDisk(JSON.parse(raw) as Task);
} catch (err) {
throw new Error(
`Failed to parse task.json at ${filePath}: ${(err as Error).message}`,

View File

@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from "vitest";
import { SelfHealingManager } from "../self-healing.js";
describe("SelfHealingManager orphaned task-dir maintenance", () => {
it("runs orphaned task-dir reconcile in pause-safe housekeeping", async () => {
const reconcileOrphanedTaskDirs = vi.fn(async () => ({
recovered: ["FN-9201"],
skipped: [],
}));
const store = {
getSettings: vi.fn(async () => ({
maintenanceIntervalMs: 0,
globalPause: true,
enginePaused: false,
chatAutoCleanupDays: 0,
mailAutoCleanupDays: 0,
operationalLogRetentionDays: 0,
agentLogFileRetentionDays: 0,
})),
reconcileOrphanedTaskDirs,
pruneOperationalLogs: vi.fn(() => ({ deletedTotal: 0, deletedByTable: {} })),
pruneAgentLogFiles: vi.fn(() => ({ prunedFiles: 0, prunedEntries: 0, freedBytes: 0 })),
} as any;
const manager = new SelfHealingManager(store, { rootDir: "/tmp/fusion-self-healing-orphaned-task-dirs-test" });
vi.spyOn(manager as any, "pruneWorktrees").mockResolvedValue(undefined);
vi.spyOn(manager as any, "cleanupOrphans").mockResolvedValue(undefined);
vi.spyOn(manager as any, "cleanupStaleTempMergeWorktrees").mockResolvedValue(0);
vi.spyOn(manager as any, "cleanupOrphanedBranches").mockResolvedValue(undefined);
vi.spyOn(manager as any, "maintainTaskFts").mockResolvedValue(undefined);
vi.spyOn(manager as any, "checkpointWal").mockReturnValue(undefined);
vi.spyOn(manager as any, "enforceWorktreeCap").mockResolvedValue(undefined);
vi.spyOn(manager, "archiveStaleDoneTasks").mockResolvedValue(0);
await (manager as any).runMaintenance();
expect(reconcileOrphanedTaskDirs).toHaveBeenCalledTimes(1);
});
});

View File

@@ -2004,6 +2004,20 @@ export class SelfHealingManager {
},
},
{ name: "cleanup-orphaned-branches", fn: () => this.cleanupOrphanedBranches() },
{
name: "reconcile-orphaned-task-dirs",
fn: async () => {
/*
* FNXC:TaskStoreConsistency 2026-06-20-00:00:
* Runtime heartbeat-created task dirs can appear after store init, so paused-safe housekeeping must reconcile orphaned task.json rows during maintenance instead of waiting for a restart.
*/
const result = await this.store.reconcileOrphanedTaskDirs();
if (result.recovered.length > 0 || result.skipped.some((entry) => entry.reason.startsWith("malformed"))) {
log.warn(`Maintenance batch 1 step "reconcile-orphaned-task-dirs" recovered=${result.recovered.length} skipped=${result.skipped.length}`);
}
return result;
},
},
{
name: "cleanup-old-chats",
fn: async () => {