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
This commit is contained in:
gsxdsm
2026-06-26 13:39:33 -07:00
parent 0afc66b6be
commit 74d37785d2
7 changed files with 282 additions and 1 deletions

View File

@@ -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.

View File

@@ -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-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-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-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-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. - 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/<id>` 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. - 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/<id>` 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.

View File

@@ -675,6 +675,7 @@ Runtime action-gate flow (v1):
- `TransientErrorDetector` (`transient-error-detector.ts`) — retriable error classification - `TransientErrorDetector` (`transient-error-detector.ts`) — retriable error classification
- `SelfHealingManager` (`self-healing.ts`) — auto-unpause/maintenance recovery actions - `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 `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`). - 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. - 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. - 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.
@@ -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 - `AgentLogger` (`agent-logger.ts`) — structured per-agent run logging
- `RunAudit` (`run-audit.ts`) — mutation audit tracking (DB/git/filesystem) - `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-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-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. - 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.

View File

@@ -117,6 +117,11 @@ describe("TaskStore Archive and Search", () => {
await expect(store.archiveTask(task.id)).rejects.toThrow("already archived"); 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 () => { it("updates columnMovedAt timestamp", async () => {
const task = await store.createTask({ description: "Test task" }); const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo"); await store.moveTask(task.id, "todo");

View File

@@ -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 <id> 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<Task> {
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" });
});
});

View File

@@ -1956,6 +1956,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
error: err instanceof Error ? err.message : String(err), 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 // Write config.json for backward compatibility if it doesn't exist
if (!existsSync(this.configPath)) { if (!existsSync(this.configPath)) {
@@ -3507,6 +3515,90 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
return result; 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<Task> { private async readTaskJson(dir: string): Promise<Task> {
const id = this.getTaskIdFromDir(dir); 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). // Fallback to file-based reading (for legacy compatibility when no DB row exists).
const filePath = join(dir, "task.json"); 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 { try {
return this.normalizeTaskFromDisk(JSON.parse(raw) as Task); return this.normalizeTaskFromDisk(JSON.parse(raw) as Task);
} catch (err) { } catch (err) {

View File

@@ -2112,6 +2112,20 @@ export class SelfHealingManager {
return result; 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", name: "cleanup-old-chats",
fn: async () => { fn: async () => {