FN-6851: prevent stale task board entries
Keep board listings consistent after task dependency moves.\n\n- Sync the watched task cache after dependency updates write todo-to-triage re-specification moves.\n- Deduplicate listTasks results so active task rows win over archived snapshots.\n- Cover dependency edits, archive snapshots, soft deletes, done rows, and orphan reconciliation with regression tests.\n- Add a patch changeset for the published Fusion package.\n\nFiles changed:\n .../fn-6851-stale-board-entries-after-move.md | 5 +\n .../store-stale-board-entries-after-move.test.ts | 144 +++++++++++++++++++++\n packages/core/src/store.ts | 18 ++-\n 3 files changed, 157 insertions(+), 10 deletions(-) Fusion-Task-Id: FN-6851 Fusion-Task-Lineage: 4622439e-631c-4410-be96-9a9f38c1815e
This commit is contained in:
5
.changeset/fn-6851-stale-board-entries-after-move.md
Normal file
5
.changeset/fn-6851-stale-board-entries-after-move.md
Normal file
@@ -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.
|
||||
@@ -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<string, Task> }).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<string, Task> }).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<string, Task> }).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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, Task>(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" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user