From 74d37785d2a498d0a08946a281b56087ce05c87e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 26 Jun 2026 13:39:33 -0700 Subject: [PATCH] FN-7069: reconcile phantom task reservations Harden task-store startup and maintenance against phantom committed task reservations. - Reconcile committed reservation phantoms without freeing reserved task IDs. - Prune orphaned child rows and emit durable run-audit evidence. - Normalize missing legacy task.json reads to clean not-found errors. - Cover archive/search and phantom-reservation reconciliation behavior with tests. Files changed: .changeset/fn-7069-phantom-task-reconcile.md | 7 + AGENTS.md | 1 + docs/architecture.md | 2 + .../src/__tests__/store-archive-search.test.ts | 5 + .../store-phantom-reservation-reconcile.test.ts | 148 +++++++++++++++++++++ packages/core/src/store.ts | 106 ++++++++++++++- packages/engine/src/self-healing.ts | 14 ++ 7 files changed, 282 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-7069 Fusion-Task-Lineage: 772aad37-db30-4310-a968-c6e5306c73bb --- .changeset/fn-7069-phantom-task-reconcile.md | 7 + AGENTS.md | 1 + docs/architecture.md | 2 + .../__tests__/store-archive-search.test.ts | 5 + ...tore-phantom-reservation-reconcile.test.ts | 148 ++++++++++++++++++ packages/core/src/store.ts | 106 ++++++++++++- packages/engine/src/self-healing.ts | 14 ++ 7 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 .changeset/fn-7069-phantom-task-reconcile.md create mode 100644 packages/core/src/__tests__/store-phantom-reservation-reconcile.test.ts diff --git a/.changeset/fn-7069-phantom-task-reconcile.md b/.changeset/fn-7069-phantom-task-reconcile.md new file mode 100644 index 0000000000..80f5832973 --- /dev/null +++ b/.changeset/fn-7069-phantom-task-reconcile.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Phantom duplicate tasks no longer break archive with an ENOENT error. +category: fix +dev: readTaskJson reports clean not-found when no DB row and no task.json exist; reconcilePhantomCommittedReservations prunes orphaned activityLog and agents/agentRuns for committed-reservation phantoms while preserving runAuditEvents and the committed reservation. diff --git a/AGENTS.md b/AGENTS.md index 61da3d0d63..6185d3bd75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -224,6 +224,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme - 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. +- FN-7069: task-store open and self-healing housekeeping emit `task:reconcile-phantom-committed-reservation` when they prune orphaned child rows for a committed task-ID reservation that has no live/soft-deleted/archived task row and no task directory, while preserving the committed reservation so the ID is never reused. - FN-6782/FN-6796: self-healing emits `task:auto-recover-paused-abort-park` when it clears a benign pause-abort operator park, requeueing safe `todo`/`in-progress` rows or preserving a clean auto-merge-eligible `in-review` row for review progression. - FN-6793/FN-6797: self-healing emits `task:reconcile-in-review-unmet-dependencies` when it rebounds an `in-review` task whose declared dependencies are still unmet, and `task:reconcile-in-review-unmet-dependencies-no-action` when pause/user-pause, `autoMerge:false`, live execution/checkout proof, or a failed rebound mutation blocks that backward move. - Workspace (Phase D U1): self-healing emits `task:reconcile-workspace-partial-land` when it re-enqueues a partial/zero-landed workspace task's per-repo land (or parks it `failed` when a sub-repo's `fusion/` branch is gone with no `landedSha`), and `task:reconcile-workspace-partial-land-no-action` when `autoMerge:false`, user-pause, or a live sub-repo worktree (workspace-aware liveness) blocks that backward move. diff --git a/docs/architecture.md b/docs/architecture.md index 3058aa9a2c..d1745fde1e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -675,6 +675,7 @@ Runtime action-gate flow (v1): - `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 also includes `reconcile-phantom-committed-reservations` (FN-7069), which calls `TaskStore.reconcilePhantomCommittedReservations()` for committed task-ID reservations that have no live/soft-deleted/archived task row and no `.fusion/tasks/{ID}/task.json`. The sweep prunes orphaned `activityLog` rows and `agents`/cascaded `agentRuns`, preserves `runAuditEvents`, and keeps the reservation `committed` per FN-5105 so the ID is permanently reserved rather than resurrected or handed out again. - 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, `/.ai-merge/`, as `fusion-ai-merge-fn--` 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 `` (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 `/.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 ` 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. @@ -734,6 +735,7 @@ Guardrails: this routine does **not** retry merges, does **not** apply to mixed/ - `AgentLogger` (`agent-logger.ts`) — structured per-agent run logging - `RunAudit` (`run-audit.ts`) — mutation audit tracking (DB/git/filesystem) - FN-6782/FN-6796: `task:auto-recover-paused-abort-park` records self-healing recovery of pause-abort operator parks. Metadata includes the source column and whether recovery preserved a clean `in-review` row instead of requeueing to `todo`. + - FN-7069: `task:reconcile-phantom-committed-reservation` records task-store startup or self-healing cleanup of committed-reservation-without-task phantoms. Metadata includes `reservationStatus: "committed"` plus pruned `activityLog` and `agents` counts; `runAuditEvents` and the committed reservation are intentionally retained for auditability and ID permanence. - FN-4956: Layer 3 merge-conflict arbitration now scope-partitions conflicted files before AI resolution. Out-of-scope conflicts are deterministically resolved to the integration branch (`git checkout --ours`) and unstaged, while only in-scope conflicts flow to AI. Integration branch defaults are resolved via `resolveIntegrationBranch(rootDir, settings)`. Audit events: `merge:layer3:foreign-file-skipped` and `merge:layer3:scope-override-bypass`. - FN-5655 goal anchoring observability adds `database`-domain mutation types `goal:injection-applied`, `goal:injection-skipped`, and `goal:retrieval-invoked` so Slice 2 cite-rate tracking has a prompt-independent signal. Metadata uses counts/IDs only (`count`, `lane`, `toolName`, optional `truncated`/`reason`/`notFound`) and never stores prompt bodies or goal titles/descriptions. These events surface through `GET /api/agents/:id/runs/:runId/audit` and support the existing `startTime`/`endTime` filters. diff --git a/packages/core/src/__tests__/store-archive-search.test.ts b/packages/core/src/__tests__/store-archive-search.test.ts index 4e58664133..dcc7224870 100644 --- a/packages/core/src/__tests__/store-archive-search.test.ts +++ b/packages/core/src/__tests__/store-archive-search.test.ts @@ -117,6 +117,11 @@ describe("TaskStore Archive and Search", () => { await expect(store.archiveTask(task.id)).rejects.toThrow("already archived"); }); + it("reports clean not-found when no DB row or task.json exists", async () => { + await expect(store.archiveTask("FN-7067")).rejects.toThrow("Task FN-7067 not found"); + await expect(store.archiveTask("FN-7067")).rejects.not.toThrow(/ENOENT/); + }); + it("updates columnMovedAt timestamp", async () => { const task = await store.createTask({ description: "Test task" }); await store.moveTask(task.id, "todo"); diff --git a/packages/core/src/__tests__/store-phantom-reservation-reconcile.test.ts b/packages/core/src/__tests__/store-phantom-reservation-reconcile.test.ts new file mode 100644 index 0000000000..4d30f6a6a7 --- /dev/null +++ b/packages/core/src/__tests__/store-phantom-reservation-reconcile.test.ts @@ -0,0 +1,148 @@ +import { mkdtempSync } from "node:fs"; +import { rm } 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"; + +/* + * FNXC:TaskStoreConsistency 2026-06-26-00:00: + * FN-7069 surface checklist covered by this file: + * - readTaskJson/archiveTask both-absent path returns clean Task not found, not ENOENT. + * - DB row present + dir missing still archives via DB-first read. + * - committed reservation with task row is skipped; committed reservation without row/archive/task.json is reconciled. + * - inMemoryDb reconcile is a no-op. + * - store init entry point runs the same reconcile as the direct API. Desktop/mobile UI surfaces are N/A. + */ + +describe("TaskStore phantom committed-reservation reconciliation", () => { + let rootDir: string; + let globalDir: string; + let store: TaskStore; + + beforeEach(async () => { + rootDir = mkdtempSync(join(tmpdir(), "fusion-phantom-reservation-")); + globalDir = mkdtempSync(join(tmpdir(), "fusion-phantom-reservation-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 createCommittedReservationPhantom(description = "Phantom committed reservation"): Promise { + const task = await store.createTask({ description }); + await rm(join(rootDir, ".fusion", "tasks", task.id), { recursive: true, force: true }); + store.getDatabase().prepare("DELETE FROM tasks WHERE id = ?").run(task.id); + store.getDatabase().bumpLastModified(); + return task; + } + + function seedOrphanedChildRows(taskId: string): { preexistingAuditId: string; agentId: string; runId: string } { + const now = new Date().toISOString(); + const db = store.getDatabase(); + const agentId = `agent-${taskId}`; + const runId = `run-${taskId}`; + const preexistingAuditId = `audit-${taskId}`; + + db.prepare( + `INSERT INTO activityLog (id, timestamp, type, taskId, taskTitle, details, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run(`activity-${taskId}`, now, "task:created", taskId, "Phantom", "orphan activity", "{}"); + db.prepare( + `INSERT INTO agents (id, name, role, state, taskId, createdAt, updatedAt, metadata, data) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run(agentId, `Agent ${taskId}`, "executor", "idle", taskId, now, now, "{}", "{}"); + db.prepare( + `INSERT INTO agentRuns (id, agentId, data, startedAt, endedAt, status) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run(runId, agentId, "{}", now, null, "running"); + db.prepare( + `INSERT INTO runAuditEvents (id, timestamp, taskId, agentId, runId, domain, mutationType, target, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run(preexistingAuditId, now, taskId, "forensic-agent", `forensic-${taskId}`, "database", "task:forensic-preexisting", taskId, "{}"); + + return { preexistingAuditId, agentId, runId }; + } + + function reservationStatus(taskId: string): string | undefined { + const row = store + .getDatabase() + .prepare("SELECT status FROM distributed_task_id_reservations WHERE taskId = ?") + .get(taskId) as { status?: string } | undefined; + return row?.status; + } + + it("archiveTask rejects cleanly when neither DB row nor task.json exists", async () => { + await expect(store.archiveTask("FN-7999")).rejects.toThrow("Task FN-7999 not found"); + await expect(store.archiveTask("FN-7999")).rejects.not.toThrow(/ENOENT/); + }); + + it("prunes orphaned child rows for a phantom while preserving reservation and runAuditEvents", async () => { + const phantom = await createCommittedReservationPhantom(); + const live = await store.createTask({ description: "Legitimate committed reservation with task row" }); + const { preexistingAuditId, agentId, runId } = seedOrphanedChildRows(phantom.id); + + const result = await store.reconcilePhantomCommittedReservations(); + + expect(result.reconciled).toContain(phantom.id); + expect(result.reconciled).not.toContain(live.id); + expect(result.skipped).toEqual(expect.arrayContaining([{ id: live.id, reason: "task-row-present" }])); + expect(store.getDatabase().prepare("SELECT COUNT(*) AS count FROM activityLog WHERE taskId = ?").get(phantom.id)).toMatchObject({ count: 0 }); + expect(store.getDatabase().prepare("SELECT COUNT(*) AS count FROM agents WHERE taskId = ?").get(phantom.id)).toMatchObject({ count: 0 }); + expect(store.getDatabase().prepare("SELECT COUNT(*) AS count FROM agentRuns WHERE id = ?").get(runId)).toMatchObject({ count: 0 }); + expect(store.getDatabase().prepare("SELECT COUNT(*) AS count FROM runAuditEvents WHERE id = ?").get(preexistingAuditId)).toMatchObject({ count: 1 }); + expect(store.getDatabase().prepare("SELECT COUNT(*) AS count FROM agents WHERE id = ?").get(agentId)).toMatchObject({ count: 0 }); + expect(reservationStatus(phantom.id)).toBe("committed"); + + const events = store.getRunAuditEvents({ taskId: phantom.id, mutationType: "task:reconcile-phantom-committed-reservation" }); + expect(events).toHaveLength(1); + expect(events[0]?.metadata).toMatchObject({ + reservationStatus: "committed", + prunedAgents: 1, + }); + expect(Number(events[0]?.metadata?.prunedActivityLog)).toBeGreaterThanOrEqual(1); + }); + + it("reconciles phantoms automatically during disk-backed store open", async () => { + const phantom = await createCommittedReservationPhantom("Store-open phantom"); + const { preexistingAuditId, runId } = seedOrphanedChildRows(phantom.id); + + store.close(); + store = new TaskStore(rootDir, globalDir); + await store.init(); + + expect(reservationStatus(phantom.id)).toBe("committed"); + expect(store.getDatabase().prepare("SELECT COUNT(*) AS count FROM activityLog WHERE taskId = ?").get(phantom.id)).toMatchObject({ count: 0 }); + expect(store.getDatabase().prepare("SELECT COUNT(*) AS count FROM agentRuns WHERE id = ?").get(runId)).toMatchObject({ count: 0 }); + expect(store.getDatabase().prepare("SELECT COUNT(*) AS count FROM runAuditEvents WHERE id = ?").get(preexistingAuditId)).toMatchObject({ count: 1 }); + expect(store.getRunAuditEvents({ taskId: phantom.id, mutationType: "task:reconcile-phantom-committed-reservation" })).toHaveLength(1); + }); + + it("is a safe no-op for in-memory stores", async () => { + store.close(); + store = new TaskStore(rootDir, globalDir, { inMemoryDb: true }); + await store.init(); + const phantom = await createCommittedReservationPhantom("In-memory phantom remains untouched"); + + const result = await store.reconcilePhantomCommittedReservations(); + + expect(result).toEqual({ reconciled: [], skipped: [] }); + expect(reservationStatus(phantom.id)).toBe("committed"); + }); + + it("archives a DB-backed task even when its task directory is missing", async () => { + const task = await store.createTask({ description: "Archive without task dir" }); + await rm(join(rootDir, ".fusion", "tasks", task.id), { recursive: true, force: true }); + + const archived = await store.archiveTask(task.id, false); + + expect(archived).toMatchObject({ id: task.id, column: "archived" }); + expect(await store.getTask(task.id)).toMatchObject({ id: task.id, column: "archived" }); + }); +}); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index a3781312a3..ee43f2d87b 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1956,6 +1956,14 @@ export class TaskStore extends EventEmitter { error: err instanceof Error ? err.message : String(err), }); } + try { + await this.reconcilePhantomCommittedReservations(); + } catch (err) { + storeLog.warn("Phantom committed-reservation reconcile failed during init (non-fatal)", { + phase: "init:phantom-reservation-reconcile", + error: err instanceof Error ? err.message : String(err), + }); + } // Write config.json for backward compatibility if it doesn't exist if (!existsSync(this.configPath)) { @@ -3507,6 +3515,90 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return result; } + /** + * FNXC:TaskStoreConsistency 2026-06-26-00:00: + * FN-7069 reconciles committed reservation phantoms that have no live, soft-deleted, archived, or disk task. Preserve the committed reservation per FN-5105 so the distributed ID allocator never re-hands out the task ID, prune only orphaned child state, and keep runAuditEvents as the durable audit trail. + */ + async reconcilePhantomCommittedReservations(): Promise<{ + reconciled: string[]; + skipped: Array<{ id: string; reason: string }>; + }> { + const result: { reconciled: string[]; skipped: Array<{ id: string; reason: string }> } = { + reconciled: [], + skipped: [], + }; + + if (this.inMemoryDb) { + return result; + } + + const reservations = this.db + .prepare( + `SELECT taskId, status + FROM distributed_task_id_reservations + WHERE status = 'committed' + ORDER BY prefix, sequence`, + ) + .all() as Array<{ taskId: string; status: "committed" }>; + + for (const reservation of reservations) { + const id = reservation.taskId; + if (this.readTaskFromDb(id, { includeDeleted: true })) { + result.skipped.push({ id, reason: "task-row-present" }); + continue; + } + if (this.isTaskIdPresentInArchivedTasksTable(id) || this.archiveDb.get(id) !== undefined) { + result.skipped.push({ id, reason: "archived-task-present" }); + continue; + } + + const taskJsonPath = join(this.taskDir(id), "task.json"); + if (existsSync(taskJsonPath)) { + result.skipped.push({ id, reason: "task-json-present" }); + continue; + } + + try { + this.db.transactionImmediate(() => { + const prunedActivityLog = this.db.prepare("DELETE FROM activityLog WHERE taskId = ?").run(id).changes; + this.db.prepare("DELETE FROM agentRuns WHERE agentId IN (SELECT id FROM agents WHERE taskId = ?)").run(id); + const prunedAgents = this.db.prepare("DELETE FROM agents WHERE taskId = ?").run(id).changes; + this.insertRunAuditEventRow({ + mutationType: "task:reconcile-phantom-committed-reservation", + taskId: id, + domain: "database", + target: id, + metadata: { + reservationStatus: reservation.status, + prunedActivityLog, + prunedAgents, + }, + }); + if (prunedActivityLog > 0 || prunedAgents > 0) { + this.db.bumpLastModified(); + } + }); + } catch (error) { + const reason = `reconcile-failed: ${error instanceof Error ? error.message : String(error)}`; + result.skipped.push({ id, reason }); + storeLog.warn("Skipping phantom committed-reservation reconcile after non-fatal error", { + phase: "reconcilePhantomCommittedReservations:prune", + taskId: id, + error: error instanceof Error ? error.message : String(error), + }); + continue; + } + + result.reconciled.push(id); + storeLog.warn("Reconciled phantom committed task-id reservation", { + phase: "reconcilePhantomCommittedReservations:reconciled", + taskId: id, + }); + } + + return result; + } + private async readTaskJson(dir: string): Promise { const id = this.getTaskIdFromDir(dir); @@ -3520,7 +3612,19 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} // Fallback to file-based reading (for legacy compatibility when no DB row exists). const filePath = join(dir, "task.json"); - const raw = await readFile(filePath, "utf-8"); + let raw: string; + try { + raw = await readFile(filePath, "utf-8"); + } catch (err) { + /* + * FNXC:TaskStoreConsistency 2026-06-26-00:00: + * FN-7069 requires a task with no SQLite row and no legacy task.json to report the same clean not-found family as DB-first callers. Do not leak raw ENOENT paths to archive/get-style surfaces for phantom committed reservations. + */ + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(`Task ${id} not found`); + } + throw err; + } try { return this.normalizeTaskFromDisk(JSON.parse(raw) as Task); } catch (err) { diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 3b2e620edf..b23e82d7cc 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -2112,6 +2112,20 @@ export class SelfHealingManager { return result; }, }, + { + name: "reconcile-phantom-committed-reservations", + fn: async () => { + /* + * FNXC:TaskStoreConsistency 2026-06-26-00:00: + * FN-7069 phantoms are committed task-id reservations without any task row or task.json. Maintenance must prune their orphaned child rows without resurrecting/freeing the ID, matching the startup reconcile. + */ + const result = await this.store.reconcilePhantomCommittedReservations(); + if (result.reconciled.length > 0) { + log.warn(`Maintenance batch 1 step "reconcile-phantom-committed-reservations" reconciled=${result.reconciled.length} skipped=${result.skipped.length}`); + } + return result; + }, + }, { name: "cleanup-old-chats", fn: async () => {