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:
7
.changeset/fn-7069-phantom-task-reconcile.md
Normal file
7
.changeset/fn-7069-phantom-task-reconcile.md
Normal 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.
|
||||
@@ -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/<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.
|
||||
|
||||
@@ -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, `<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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -1956,6 +1956,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
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<Task> {
|
||||
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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user