diff --git a/.changeset/fn-6851-stale-board-entries-after-move.md b/.changeset/fn-6851-stale-board-entries-after-move.md new file mode 100644 index 0000000000..4861403d68 --- /dev/null +++ b/.changeset/fn-6851-stale-board-entries-after-move.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix stale board entries after dependency-driven task re-specification moves by syncing the watched task cache after `updateTaskDependencies` writes and defensively deduplicating `listTasks` rows so active task rows win over archived snapshots. diff --git a/packages/core/src/__tests__/store-stale-board-entries-after-move.test.ts b/packages/core/src/__tests__/store-stale-board-entries-after-move.test.ts new file mode 100644 index 0000000000..a8709f5b4c --- /dev/null +++ b/packages/core/src/__tests__/store-stale-board-entries-after-move.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { rm } from "node:fs/promises"; + +import { TaskStore } from "../store.js"; +import { makeTmpDir } from "./store-test-helpers.js"; +import type { Task } from "../types.js"; + +const liveColumns = new Set(["triage", "todo", "in-progress", "in-review", "done"]); + +function cachedTask(store: TaskStore, taskId: string): Task | undefined { + return (store as unknown as { taskCache: Map }).taskCache.get(taskId); +} + +async function expectSingleLiveBoardEntry(store: TaskStore, taskId: string, expectedColumn: string) { + const listed = await store.listTasks({ includeArchived: true, slim: true }); + const entries = listed.filter((task) => task.id === taskId && liveColumns.has(task.column)); + expect(entries.map((task) => task.column)).toEqual([expectedColumn]); +} + +describe("TaskStore stale board entries after task moves", () => { + let rootDir: string; + let globalDir: string; + let store: TaskStore; + + beforeEach(async () => { + rootDir = makeTmpDir(); + globalDir = makeTmpDir(); + store = new TaskStore(rootDir, globalDir); + await store.init(); + await store.watch(); + }); + + afterEach(async () => { + store.stopWatching(); + await store.close(); + await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + }); + + it("syncs taskCache after dependency-driven todo to triage re-specification moves", async () => { + const dependency = await store.createTask({ description: "unresolved dependency", column: "todo" }); + const dependent = await store.createTask({ + title: "Shadcn-family themes: left sidebar must use the theme accent color", + description: "dependent task", + column: "todo", + }); + (store as unknown as { taskCache: Map }).taskCache.set(dependent.id, { ...dependent }); + + const updated = await store.updateTaskDependencies(dependent.id, { + operation: "add", + dependency: dependency.id, + }); + const persisted = await store.getTask(dependent.id); + const cached = cachedTask(store, dependent.id); + + expect(updated.column).toBe("triage"); + expect(cached?.column).toBe("triage"); + expect(cached?.title).toBe(persisted.title); + expect(cached?.title).toBe(updated.title); + expect(persisted.column).toBe(cached?.column); + await expectSingleLiveBoardEntry(store, dependent.id, "triage"); + }); + + it("keeps one live board entry across dependency edits and triage/todo moves", async () => { + const originalDependency = await store.createTask({ description: "original unresolved dependency", column: "todo" }); + const replacementDependency = await store.createTask({ description: "replacement unresolved dependency", column: "todo" }); + const doneDependency = await store.createTask({ description: "done dependency", column: "done" }); + const dependent = await store.createTask({ description: "dependent task", column: "todo" }); + (store as unknown as { taskCache: Map }).taskCache.set(dependent.id, { ...dependent }); + + await store.updateTaskDependencies(dependent.id, { operation: "add", dependency: originalDependency.id }); + expect(cachedTask(store, dependent.id)?.column).toBe("triage"); + await expectSingleLiveBoardEntry(store, dependent.id, "triage"); + + await store.updateTaskDependencies(dependent.id, { operation: "remove", dependency: originalDependency.id }); + expect(cachedTask(store, dependent.id)?.dependencies).toEqual([]); + await expectSingleLiveBoardEntry(store, dependent.id, "triage"); + + await store.moveTask(dependent.id, "todo"); + expect(cachedTask(store, dependent.id)?.column).toBe("todo"); + await expectSingleLiveBoardEntry(store, dependent.id, "todo"); + + await store.updateTaskDependencies(dependent.id, { operation: "add", dependency: originalDependency.id }); + await store.updateTaskDependencies(dependent.id, { + operation: "replace", + from: originalDependency.id, + to: replacementDependency.id, + }); + expect(cachedTask(store, dependent.id)?.dependencies).toEqual([replacementDependency.id]); + await expectSingleLiveBoardEntry(store, dependent.id, "triage"); + + await store.updateTaskDependencies(dependent.id, { operation: "set", dependencies: [doneDependency.id] }); + expect(cachedTask(store, dependent.id)?.dependencies).toEqual([doneDependency.id]); + await expectSingleLiveBoardEntry(store, dependent.id, "triage"); + + await store.moveTask(dependent.id, "todo"); + expect(cachedTask(store, dependent.id)?.column).toBe("todo"); + await expectSingleLiveBoardEntry(store, dependent.id, "todo"); + }); + + it("dedupes listTasks with active rows authoritative over archive snapshots", async () => { + const task = await store.createTask({ title: "archived snapshot title", description: "duplicate source", column: "done" }); + await store.archiveTask(task.id, true); + const entry = (store as any).archiveDb.get(task.id); + expect(entry).toBeDefined(); + + const restored = await (store as any).restoreFromArchive(entry); + const active: Task = { + ...restored, + title: "active row title", + column: "todo", + updatedAt: new Date().toISOString(), + columnMovedAt: new Date().toISOString(), + }; + await (store as any).atomicWriteTaskJson((store as any).taskDir(task.id), active); + + const entries = (await store.listTasks({ includeArchived: true, slim: true })).filter((listed) => listed.id === task.id); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ column: "todo", title: "active row title" }); + }); + + it("preserves archived, soft-deleted, done, and orphan-reconcile list semantics", async () => { + const archivedSource = await store.createTask({ description: "archive-only task", column: "done" }); + await store.archiveTask(archivedSource.id, true); + const archivedEntries = (await store.listTasks({ includeArchived: true, slim: true })).filter((task) => task.id === archivedSource.id); + expect(archivedEntries).toHaveLength(1); + expect(archivedEntries[0].column).toBe("archived"); + + const deleted = await store.createTask({ description: "soft deleted task", column: "todo" }); + await store.deleteTask(deleted.id); + expect((await store.listTasks({ includeArchived: true, slim: true })).some((task) => task.id === deleted.id)).toBe(false); + + const done = await store.createTask({ description: "done task", column: "done" }); + await expectSingleLiveBoardEntry(store, done.id, "done"); + + const orphan = await store.createTask({ description: "orphan task", column: "todo" }); + (store as any).db.prepare("DELETE FROM tasks WHERE id = ?").run(orphan.id); + (store as any).taskCache.delete(orphan.id); + const result = await store.reconcileOrphanedTaskDirs({ ignoreRecencyWindow: true }); + expect(result.recovered).toContain(orphan.id); + await expectSingleLiveBoardEntry(store, orphan.id, "todo"); + }); +}); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 6df8418f6c..15f4586fd3 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -5771,11 +5771,11 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} const steps = await this.parseStepsFromPrompt(task.id); return steps.length > 0 ? { ...task, steps } : task; })); - const archivedTasks = includeArchived && (!columnFilter || columnFilter === "archived") - ? this.archiveDb.list().map((entry) => this.archiveEntryToTask(entry, slim)) - : []; - const tasks = [...activeTasks, ...archivedTasks]; - + const archivedTasks = includeArchived && (!columnFilter || columnFilter === "archived") ? this.archiveDb.list().map((entry) => this.archiveEntryToTask(entry, slim)) : []; + // FNXC:BoardConsistency 2026-06-21-08:34: FN-6851's cache-sync fix is primary; listTasks still collapses duplicate storage sources so one task ID cannot render in two columns. Active SQLite rows are authoritative over archive snapshots. + const tasksById = new Map(activeTasks.map((task) => [task.id, task])); + for (const task of archivedTasks) if (!tasksById.has(task.id)) tasksById.set(task.id, task); + const tasks = [...tasksById.values()]; // Sort by createdAt, then by numeric ID suffix for tie-breaking const sorted = tasks.sort((a, b) => { const cmp = a.createdAt.localeCompare(b.createdAt); @@ -5788,10 +5788,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} const offset = Math.max(0, options?.offset ?? 0); const limit = options?.limit; - if (limit === undefined) { - return sorted.slice(offset); - } - + if (limit === undefined) return sorted.slice(offset); return sorted.slice(offset, offset + Math.max(0, limit)); } @@ -7763,7 +7760,6 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } } - async updateTaskDependencies( id: string, mutation: TaskDependencyMutation, @@ -7922,6 +7918,8 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} }, }; await this.atomicWriteTaskJsonWithAudit(dir, task, auditEvent); + // FNXC:BoardConsistency 2026-06-21-08:31: updateTaskDependencies' todo→triage re-spec move can also carry title/blocker changes, and leaving taskCache on the pre-move row made watch/SSE/board consumers surface one task ID in two columns (FN-6851/FN-6812). Sync the cache after the authoritative write like sibling mutation paths. + if (this.isWatching) this.taskCache.set(id, { ...task }); if (movedToTriage) { this.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column, source: "engine" }); }