From 5e74361859f99d91462a19843726e860eef3b02b Mon Sep 17 00:00:00 2001 From: Fusion Date: Tue, 12 May 2026 14:50:58 -0700 Subject: [PATCH] feat(FN-4055): add task ID collision audit script and recovery documentatio Adds a standalone audit script for detecting task ID collisions, a regression test for store creation collision handling, and documentation of the recovery path; also applies minor UX refinements to ChatView and TaskDetailModal. Fusion-Task-Id: FN-4055 --- docs/storage.md | 16 + .../__tests__/store-create-collision.test.ts | 13 +- .../dashboard/app/components/ChatView.css | 9 + .../dashboard/app/components/ChatView.tsx | 18 +- .../app/components/TaskDetailModal.tsx | 2 +- scripts/audit-task-id-collisions.mjs | 300 ++++++++++++++++++ 6 files changed, 344 insertions(+), 14 deletions(-) create mode 100644 scripts/audit-task-id-collisions.mjs diff --git a/docs/storage.md b/docs/storage.md index 9eca34a3f..291fb64a7 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -8,6 +8,22 @@ - Startup/store-open allocator reconciliation bumps each active prefix sequence to `max(current nextSequence, max(tasks suffix)+1, max(archivedTasks suffix)+1, max(reservation sequence)+1)` so stale allocator rows self-heal before local task creation resumes. - Create-class task persistence is intentionally non-destructive: new tasks use plain `INSERT` semantics, while `ON CONFLICT(id) DO UPDATE` remains update-only. If counters drift and a reserved ID still collides, the create fails and the existing SQLite row / task directory stays intact. +### Detecting historical task-ID overwrites + +If allocator state drifted before the current guards landed, historical task records may still contain overwrite evidence. Run the audit script from the project root: + +```bash +node scripts/audit-task-id-collisions.mjs [--project-root /path/to/project] +``` + +The script checks for: +- `task.json.history` timestamps older than the active DB row's `createdAt` +- task-title mismatches between SQLite and the first `#` heading in `PROMPT.md` +- task-title mismatches against the latest `Fusion-Task-Id` commit subject on `main` +- active tasks that share an ID with an `archivedTasks` row + +Treat flagged candidates as recovery leads, not automatic truth: review the surviving task files, logs, and commit history, then file a follow-up recovery task for any confirmed overwrite. + ## SQLite write-path lock recovery (FN-4042 / FN-4083) - Every disk-backed SQLite connection that Fusion opens for project storage (`fusion.db`), the central registry (`fusion-central.db`), archives (`archive.db`), and worktree hydration explicitly sets `PRAGMA busy_timeout = 5000` and `PRAGMA journal_mode = WAL` at connection open time before write work begins. diff --git a/packages/core/src/__tests__/store-create-collision.test.ts b/packages/core/src/__tests__/store-create-collision.test.ts index d3d57065a..b6401ee06 100644 --- a/packages/core/src/__tests__/store-create-collision.test.ts +++ b/packages/core/src/__tests__/store-create-collision.test.ts @@ -2,6 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; +function serializeRow(value: unknown): string { + return JSON.stringify(value, Object.keys((value as Record) ?? {}).sort()); +} + import { createTaskStoreTestHarness } from "./store-test-helpers.js"; describe("TaskStore collision guards", () => { @@ -46,10 +50,13 @@ describe("TaskStore collision guards", () => { }); }; - it("createTask throws and preserves the existing task when the allocator returns a colliding id", async () => { + it("FN-4055: createTask throws and preserves the existing task row and files when the allocator returns a colliding id", async () => { const original = await store.createTask({ title: "Original", description: "original task", column: "todo" }); const originalPromptPath = join(rootDir, ".fusion", "tasks", original.id, "PROMPT.md"); + const originalTaskJsonPath = join(rootDir, ".fusion", "tasks", original.id, "task.json"); const originalPrompt = await readFile(originalPromptPath, "utf8"); + const originalTaskJson = await readFile(originalTaskJsonPath, "utf8"); + const originalRow = store.getDatabase().prepare("SELECT * FROM tasks WHERE id = ?").get(original.id); forceAllocatorCollision(original.id); await expect( @@ -58,10 +65,14 @@ describe("TaskStore collision guards", () => { const persisted = await store.getTask(original.id); const promptAfter = await readFile(originalPromptPath, "utf8"); + const taskJsonAfter = await readFile(originalTaskJsonPath, "utf8"); + const rowAfter = store.getDatabase().prepare("SELECT * FROM tasks WHERE id = ?").get(original.id); + expect(serializeRow(rowAfter)).toBe(serializeRow(originalRow)); expect(persisted.title).toBe("Original"); expect(persisted.description).toBe("original task"); expect(promptAfter).toBe(originalPrompt); + expect(taskJsonAfter).toBe(originalTaskJson); }); it("duplicateTask throws and preserves the unrelated task when its reserved id collides", async () => { diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index 34c9bb1e6..d92dc6e1f 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -216,6 +216,15 @@ padding: 4px 8px; } +.chat-status-copy { + color: var(--text-muted); + font-size: var(--space-md); +} + +.chat-status-copy--padded { + padding: var(--space-md); +} + .chat-session-item { padding: 10px 12px; border-radius: var(--radius-md); diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 09f75e126..bde6d530a 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -2133,13 +2133,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView {/* Session list section */}
{sessionsLoading ? ( -
- Loading... -
+
Loading...
) : filteredSessions.length === 0 ? ( -
- No conversations yet -
+
No conversations yet
) : ( filteredSessions.map((session) => (
{rooms.messagesLoading ? ( -
Loading messages...
+
Loading messages...
) : rooms.messages.length === 0 ? ( -
No messages yet. Start the conversation!
+
No messages yet. Start the conversation!
) : ( rooms.messages.map((message) => { const senderName = message.senderAgentId ? (agentsMap.get(message.senderAgentId)?.name ?? message.senderAgentId.slice(0, 30)) : "You"; @@ -2626,13 +2622,11 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
) : messagesLoading ? ( -
Loading messages...
+
Loading messages...
) : messages.length === 0 && !activeSession ? ( renderEmptyState() ) : messages.length === 0 && activeSession ? ( -
- No messages yet. Start the conversation! -
+
No messages yet. Start the conversation!
) : ( <> {messages.map((message) => ( diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index c8d24b6de..a6f19ded3 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -202,7 +202,7 @@ function getStepStatusColor(status: string): string { case "done": return "var(--color-success)"; case "in-progress": - return "var(--todo)"; + return "var(--in-progress)"; case "skipped": return "var(--text-dim)"; case "pending": diff --git a/scripts/audit-task-id-collisions.mjs b/scripts/audit-task-id-collisions.mjs new file mode 100644 index 000000000..21bb21e2c --- /dev/null +++ b/scripts/audit-task-id-collisions.mjs @@ -0,0 +1,300 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; + +function parseArgs(argv) { + const args = { projectRoot: process.cwd(), json: false }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--project-root") { + args.projectRoot = path.resolve(argv[i + 1] ?? process.cwd()); + i += 1; + } else if (arg === "--json") { + args.json = true; + } + } + return args; +} + +function run(command, args, options = {}) { + return execFileSync(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }).trim(); +} + +function sqliteJson(dbPath, sql) { + const output = run("sqlite3", ["-json", dbPath, sql]); + return output ? JSON.parse(output) : []; +} + +function safeSqliteJson(dbPath, sql) { + try { + return sqliteJson(dbPath, sql); + } catch { + return []; + } +} + +function resolveMainRef(projectRoot) { + try { + run("git", ["rev-parse", "--verify", "origin/main"], { cwd: projectRoot }); + return "origin/main"; + } catch { + return "main"; + } +} + +function normalizeTitle(text) { + return String(text ?? "") + .replace(/^#\s+/, "") + .replace(/^Task:\s*/i, "") + .replace(/^[A-Z]+-\d+\s*[:-]\s*/i, "") + .replace(/\s*\[via:[^\]]+\]\s*$/i, "") + .replace(/[“”]/g, '"') + .replace(/[’]/g, "'") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); +} + +function firstHeading(promptText) { + const line = String(promptText ?? "").split(/\r?\n/).find((entry) => entry.trim().startsWith("#")); + if (!line) return null; + return line.replace(/^#+\s*/, "").trim(); +} + +const STOP_WORDS = new Set([ + "the", "and", "with", "from", "that", "this", "into", "over", "under", "after", "before", "while", + "your", "their", "have", "has", "had", "make", "task", "tasks", "agent", "dashboard", "api", "via", + "fix", "add", "create", "update", "investigate", "restore", "support", "allow", "keep", "show", "same", +]); + +function significantTokens(text) { + return new Set( + normalizeTitle(text) + .split(/[^a-z0-9]+/) + .filter((token) => token.length >= 4 && !STOP_WORDS.has(token)), + ); +} + +function tokenOverlap(left, right) { + const leftTokens = significantTokens(left); + const rightTokens = significantTokens(right); + if (leftTokens.size === 0 || rightTokens.size === 0) { + return { shared: [], ratio: 1 }; + } + const shared = [...leftTokens].filter((token) => rightTokens.has(token)); + const ratio = shared.length / Math.max(leftTokens.size, rightTokens.size); + return { shared, ratio }; +} + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +function extractHistoricalCreatedAt(taskJson) { + const candidates = []; + if (Array.isArray(taskJson?.history)) { + for (const entry of taskJson.history) { + if (!entry || typeof entry !== "object") continue; + if (typeof entry.createdAt === "string") candidates.push(entry.createdAt); + if (typeof entry.timestamp === "string") candidates.push(entry.timestamp); + } + } + if (taskJson?.history && typeof taskJson.history === "object" && !Array.isArray(taskJson.history)) { + if (typeof taskJson.history.createdAt === "string") candidates.push(taskJson.history.createdAt); + if (typeof taskJson.history.timestamp === "string") candidates.push(taskJson.history.timestamp); + } + return candidates.sort()[0] ?? null; +} + +function getLatestTaskCommit(projectRoot, mainRef, taskId) { + try { + const output = run( + "git", + ["log", mainRef, "--format=%H%x09%cI%x09%s%x09%(trailers:key=Fusion-Task-Id,valueonly)", "--all"], + { cwd: projectRoot }, + ); + if (!output) return null; + for (const line of output.split("\n")) { + const [sha, committedAt, subject, trailer] = line.split("\t"); + if ((trailer ?? "").trim() === taskId) { + return { sha, committedAt, subject }; + } + } + return null; + } catch { + return null; + } +} + +function buildReport(projectRoot) { + const dbPath = path.join(projectRoot, ".fusion", "fusion.db"); + const tasksDir = path.join(projectRoot, ".fusion", "tasks"); + const mainRef = resolveMainRef(projectRoot); + + if (!existsSync(dbPath)) { + throw new Error(`Database not found: ${dbPath}`); + } + if (!existsSync(tasksDir)) { + throw new Error(`Tasks directory not found: ${tasksDir}`); + } + + const activeTasks = sqliteJson( + dbPath, + "SELECT id, title, createdAt, updatedAt, \"column\" AS columnName FROM tasks ORDER BY id", + ); + const archivedDupes = safeSqliteJson( + dbPath, + "SELECT t.id AS id, t.title AS activeTitle, a.archivedAt AS archivedAt FROM tasks t INNER JOIN archivedTasks a ON a.id = t.id ORDER BY t.id", + ); + + const candidates = []; + let historyUnavailableCount = 0; + + for (const task of activeTasks) { + const taskDir = path.join(tasksDir, task.id); + const taskJsonPath = path.join(taskDir, "task.json"); + const promptPath = path.join(taskDir, "PROMPT.md"); + const signals = []; + + let taskJson = null; + if (existsSync(taskJsonPath)) { + taskJson = readJson(taskJsonPath); + const historicalCreatedAt = extractHistoricalCreatedAt(taskJson); + if (historicalCreatedAt && historicalCreatedAt < task.createdAt) { + signals.push({ + type: "history-created-before-db-createdAt", + detail: `history createdAt ${historicalCreatedAt} < db createdAt ${task.createdAt}`, + }); + } + if (!historicalCreatedAt) { + historyUnavailableCount += 1; + } + } + + if (existsSync(promptPath)) { + const prompt = readFileSync(promptPath, "utf8"); + const heading = firstHeading(prompt); + if (heading) { + const normalizedHeading = normalizeTitle(heading); + const normalizedTitleText = normalizeTitle(task.title); + if ( + normalizedTitleText && + normalizedHeading && + normalizedTitleText !== normalizedHeading && + !normalizedHeading.includes(normalizedTitleText) && + !normalizedTitleText.includes(normalizedHeading) + ) { + signals.push({ + type: "prompt-heading-mismatch", + detail: `db title="${task.title}" vs prompt heading="${heading}"`, + }); + } + } + } + + const commit = getLatestTaskCommit(projectRoot, mainRef, task.id); + if (commit) { + const overlap = tokenOverlap(task.title, commit.subject.replace(/^.*?:\s*/, "")); + if (overlap.ratio === 0) { + signals.push({ + type: "commit-subject-mismatch", + detail: `${commit.sha.slice(0, 9)} ${commit.subject}`, + committedAt: commit.committedAt, + }); + } + } + + if (signals.length > 0) { + candidates.push({ + id: task.id, + title: task.title, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + column: task.columnName, + taskDirExists: existsSync(taskDir), + taskDirMtime: existsSync(taskDir) ? statSync(taskDir).mtime.toISOString() : null, + signals, + }); + } + } + + for (const duplicate of archivedDupes) { + const existing = candidates.find((candidate) => candidate.id === duplicate.id); + const signal = { + type: "active-archive-duplicate-id", + detail: `active task shares ID with archivedTasks row (archivedAt ${duplicate.archivedAt})`, + }; + if (existing) { + existing.signals.push(signal); + } else { + candidates.push({ + id: duplicate.id, + title: duplicate.activeTitle, + createdAt: null, + updatedAt: null, + column: "active+archived", + taskDirExists: existsSync(path.join(tasksDir, duplicate.id)), + taskDirMtime: existsSync(path.join(tasksDir, duplicate.id)) ? statSync(path.join(tasksDir, duplicate.id)).mtime.toISOString() : null, + signals: [signal], + }); + } + } + + candidates.sort((a, b) => a.id.localeCompare(b.id)); + + return { + projectRoot, + dbPath, + tasksDir, + mainRef, + scannedActiveTasks: activeTasks.length, + candidateCount: candidates.length, + historyUnavailableCount, + candidates, + }; +} + +function toMarkdown(report) { + const lines = []; + lines.push("# Task ID collision audit report"); + lines.push(""); + lines.push(`- Project root: \ +\`${report.projectRoot}\``); + lines.push(`- Database: \ +\`${report.dbPath}\``); + lines.push(`- Git ref used for commit checks: \ +\`${report.mainRef}\``); + lines.push(`- Active tasks scanned: **${report.scannedActiveTasks}**`); + lines.push(`- Candidates flagged: **${report.candidateCount}**`); + lines.push(`- Tasks without usable \`task.json.history\` signal: **${report.historyUnavailableCount}**`); + lines.push(""); + + if (report.candidates.length === 0) { + lines.push("No candidates flagged by the configured heuristics."); + return lines.join("\n"); + } + + for (const candidate of report.candidates) { + lines.push(`## ${candidate.id} — ${candidate.title}`); + lines.push(`- Column: ${candidate.column}`); + if (candidate.createdAt) lines.push(`- DB createdAt: ${candidate.createdAt}`); + if (candidate.updatedAt) lines.push(`- DB updatedAt: ${candidate.updatedAt}`); + if (candidate.taskDirMtime) lines.push(`- Task dir mtime: ${candidate.taskDirMtime}`); + for (const signal of candidate.signals) { + lines.push(`- [${signal.type}] ${signal.detail}`); + } + lines.push(""); + } + + return lines.join("\n"); +} + +const args = parseArgs(process.argv.slice(2)); +const report = buildReport(args.projectRoot); +process.stdout.write(args.json ? `${JSON.stringify(report, null, 2)}\n` : `${toMarkdown(report)}\n`);