fix(core): archived tasks leaked into the live feed on a renamed board (#3041)
From my own #2839, re-measured today. Of that issue's SQL-literal sites, this is the one that decides what a **live view** shows. ## The defect `listTasksModifiedSinceImpl` backs the SSE watcher and modified-since polling — the incremental feed the dashboard applies to its task list. Its `includeArchived: false` branch excluded the literal `archived`: ```ts conditions.push(sql`${schema.project.tasks.column} != 'archived'`); ``` On a board whose archive lane is named anything else, that predicate matches **every** row and excludes nothing. Archived cards arrive in the live feed and reappear on the board. Nothing errors, and a full refetch filters archived rows by another path — so the symptom is archived work that comes back until the next reload. That gets reported as *"the board is flaky"*, not as a bug. ## The fix `resolveProjectColumnsForRoles` seeds the legacy ids before adding resolved ones, so the set is never empty and an **unconverted board excludes exactly `archived` as before**. The literal stays as the resolution-failure fallback, where excluding nothing would be worse than excluding the legacy id. ## Surface enumeration — three of four sites are dead Four sites share this invariant. Verified rather than assumed: | site | status | |---|---| | `reads.ts:558` (SSE / modified-since) | **live** — converted here | | `liveParentFilter` | **no references anywhere** in `packages/` or `plugins/` | | `listLiveTaskDocuments`, `listLiveArtifacts` | referenced **only** by `taskstore-remaining.test.ts` | That's why this PR converts one site rather than four — the other three are production-dead, and converting dead code would add risk for no behaviour. ## Measured | check | result | |---|---| | new PG suite | **4 cases** — legacy control, the renamed defect, a live-lane negative, and the forensic `includeArchived: true` read | | mutation (force the legacy fallback) | fails **exactly** the renamed case; the other three hold | | six gates + `tsc` | green | The negative case is the one that matters most: resolving the archive role must not start excluding **live** work, or the board silently stops updating for real tasks — a worse failure than the leak this fixes. ## One process note I corrupted this file mid-session by mutation-testing it while uncommitted: a failed restore left a half-applied block, and a later `git checkout --` discarded the fix entirely. Both were caught by re-grepping for the symbol rather than trusting the restore. The reliable pattern is **commit first, then mutate, then `git checkout` to restore** — which is how the proof above was actually run. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/sse-archive-lane-leak.md
Normal file
7
.changeset/sse-archive-lane-leak.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Archived tasks no longer reappear on boards whose archive lane is renamed.
|
||||
category: fix
|
||||
dev: `listTasksModifiedSinceImpl` excluded the literal `archived`; it now excludes the project's resolved archive columns, keeping the literal as the no-resolution fallback.
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-09:20 (archived rows leaked into the live stream):
|
||||
|
||||
`listTasksModifiedSinceImpl` backs the SSE watcher and modified-since polling — the incremental feed
|
||||
the dashboard applies to its live task list. Its `includeArchived: false` branch excluded the LITERAL
|
||||
`archived`, so on a board whose archive lane is named anything else the predicate matched every row
|
||||
and excluded nothing. Archived cards arrived in the live feed and reappeared on the board.
|
||||
|
||||
Nothing errors, and a full refetch filters archived rows by another path, so the symptom is archived
|
||||
work that comes back until the next reload — the kind of thing an operator reports as "the board is
|
||||
flaky" rather than as a bug.
|
||||
|
||||
The cases are DIFFERENTIAL: the same archived task under two vocabularies whose roles are identical
|
||||
and only the ids differ. `filed` collides with no legacy id, so a surviving `'archived'` literal
|
||||
cannot pass by luck.
|
||||
*/
|
||||
|
||||
import { it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
pgDescribe,
|
||||
createSharedPgTaskStoreTestHarness,
|
||||
type SharedPgTaskStoreHarness,
|
||||
} from "../../__test-utils__/pg-test-harness.js";
|
||||
import { BUILTIN_CODING_WORKFLOW_IR } from "../../index.js";
|
||||
|
||||
const SINCE = "2026-06-01T00:00:00.000Z";
|
||||
const TOUCHED = "2026-06-15T12:00:00.000Z";
|
||||
|
||||
pgDescribe("the modified-since feed under a renamed archive lane", () => {
|
||||
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
|
||||
prefix: "fusion_modsince_archived",
|
||||
});
|
||||
|
||||
beforeAll(h.beforeAll);
|
||||
beforeEach(h.beforeEach);
|
||||
afterEach(h.afterEach);
|
||||
afterAll(h.afterAll);
|
||||
|
||||
/** The builtin coding workflow with only its archive column renamed. */
|
||||
async function seedRenamedWorkflow(): Promise<void> {
|
||||
const ir = JSON.parse(JSON.stringify(BUILTIN_CODING_WORKFLOW_IR)) as {
|
||||
id: string;
|
||||
nodes?: { column?: string }[];
|
||||
columns?: { id: string }[];
|
||||
};
|
||||
ir.id = "custom:renamed-archive";
|
||||
for (const node of ir.nodes ?? []) if (node.column === "archived") node.column = "filed";
|
||||
for (const column of ir.columns ?? []) if (column.id === "archived") column.id = "filed";
|
||||
|
||||
const ids = (ir.columns ?? []).map((column) => column.id);
|
||||
expect(ids).toContain("filed");
|
||||
expect(ids).not.toContain("archived");
|
||||
|
||||
await h.store().createWorkflowDefinition({ name: "Renamed archive", kind: "workflow", ir } as never);
|
||||
}
|
||||
|
||||
/** A task parked in whichever lane plays the archive role, touched after `SINCE`. */
|
||||
async function seedArchivedTask(lane: string, id = "KB-ARCH"): Promise<void> {
|
||||
const store = h.store();
|
||||
await store.createTaskWithReservedId(
|
||||
{ description: id, column: "todo" },
|
||||
{ taskId: id, createdAt: SINCE, updatedAt: SINCE, applyDefaultWorkflowSteps: false },
|
||||
);
|
||||
/* Seeded directly: `moveTask` would stamp `updatedAt` with `now`, and this feed is keyed on it. */
|
||||
await h.adminDb().execute(sql`
|
||||
UPDATE project.tasks SET "column" = ${lane}, updated_at = ${TOUCHED} WHERE id = ${id}`);
|
||||
store.taskCache.delete(id);
|
||||
}
|
||||
|
||||
const feed = async (includeArchived: boolean) => {
|
||||
const { tasks } = await h.store().listTasksModifiedSince(SINCE, 50, { includeArchived });
|
||||
return tasks.map((task) => task.id);
|
||||
};
|
||||
|
||||
/* Control: the legacy vocabulary already excluded it. Passes before and after the fix. */
|
||||
it("default vocabulary: an `archived` task stays out of the live feed", async () => {
|
||||
await seedArchivedTask("archived");
|
||||
|
||||
expect(await feed(false)).not.toContain("KB-ARCH");
|
||||
});
|
||||
|
||||
/* The defect: `!= 'archived'` matched every row on this board, so nothing was excluded. */
|
||||
it("renamed vocabulary: a task in the RENAMED archive lane stays out of the live feed", async () => {
|
||||
await seedRenamedWorkflow();
|
||||
await seedArchivedTask("filed");
|
||||
|
||||
expect(await feed(false)).not.toContain("KB-ARCH");
|
||||
});
|
||||
|
||||
/*
|
||||
The paired negative: resolving the archive role must not start excluding live work. A card in the
|
||||
renamed WIP lane belongs in the feed — otherwise the fix trades leaked archived rows for missing
|
||||
live ones, which is the worse direction: the board would silently stop updating for real work.
|
||||
*/
|
||||
it("renamed vocabulary: a task in a LIVE lane still reaches the feed", async () => {
|
||||
await seedRenamedWorkflow();
|
||||
await seedArchivedTask("in-progress", "KB-LIVE");
|
||||
|
||||
expect(await feed(false)).toContain("KB-LIVE");
|
||||
});
|
||||
|
||||
/* `includeArchived: true` is the forensic read and must still surface the renamed archive lane. */
|
||||
it("renamed vocabulary: includeArchived still returns the archived task", async () => {
|
||||
await seedRenamedWorkflow();
|
||||
await seedArchivedTask("filed");
|
||||
|
||||
expect(await feed(true)).toContain("KB-ARCH");
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import {computeRetrySummary} from "../retry-summary.js";
|
||||
// FNXC:TaskLookup404 2026-07-26-11:20: typed miss signal so API boundaries can
|
||||
// answer 404 instead of 500 (see TaskNotFoundError in task-store/errors.ts).
|
||||
import {TaskNotFoundError} from "../task-store/errors.js";
|
||||
import { resolveProjectColumnsForRoles } from "../project-lane-vocabulary.js";
|
||||
|
||||
/** Merge storage tiers while preserving primary-source authority and order. */
|
||||
function mergePrimaryById<T extends { id: string }>(primary: T[], secondary: T[]): T[] {
|
||||
@@ -548,14 +549,35 @@ export async function listTasksModifiedSinceImpl(store: TaskStore, since: string
|
||||
};
|
||||
let disableAgeStalenessHydration = false;
|
||||
|
||||
const { and, asc, eq, gt, sql } = await import("drizzle-orm");
|
||||
const { and, asc, eq, gt, notInArray, sql } = await import("drizzle-orm");
|
||||
const schema = await import("../postgres/schema/index.js");
|
||||
const conditions = [
|
||||
sql`(${schema.project.tasks.deletedAt} IS NULL)`,
|
||||
gt(schema.project.tasks.updatedAt, since),
|
||||
];
|
||||
if (!includeArchived) {
|
||||
conditions.push(sql`${schema.project.tasks.column} != 'archived'`);
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-09:10:
|
||||
ARCHIVED ROWS LEAKED INTO THE LIVE STREAM ON A RENAMED BOARD.
|
||||
|
||||
This filter backs the SSE watcher and modified-since polling — the incremental feed the
|
||||
dashboard applies to its live task list. It excluded the literal `archived`, so on a board
|
||||
whose archive lane is named anything else the predicate matched EVERY row and excluded
|
||||
nothing: archived cards arrived in the live feed and reappeared on the board.
|
||||
|
||||
Nothing errors, and a full refetch filters archived rows by another path, so the symptom is
|
||||
archived work that comes back until the next reload.
|
||||
|
||||
`resolveProjectColumnsForRoles` seeds the legacy ids before adding resolved ones, so the set is
|
||||
never empty and an unconverted board excludes exactly `archived` as before. The fallback covers
|
||||
a resolution failure, where excluding nothing would be worse than excluding the legacy id.
|
||||
*/
|
||||
const archivedColumns = await resolveProjectColumnsForRoles(store, ["archived"]).catch(() => undefined);
|
||||
if (archivedColumns && archivedColumns.size > 0) {
|
||||
conditions.push(notInArray(schema.project.tasks.column, [...archivedColumns]));
|
||||
} else {
|
||||
conditions.push(sql`${schema.project.tasks.column} != 'archived'`);
|
||||
}
|
||||
}
|
||||
const layer = store.asyncLayer!;
|
||||
// FNXC:MultiProjectIsolation 2026-07-10: scope the incremental-sync scan
|
||||
|
||||
Reference in New Issue
Block a user