From 511f5b7e2bc091261e7d61aaf67ae644750d72ea Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 31 Jul 2026 02:08:49 -0700 Subject: [PATCH] fix(core): archived tasks leaked into the live feed on a renamed board (#3041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .changeset/sse-archive-lane-leak.md | 7 ++ .../modified-since-archived-lane.pg.test.ts | 110 ++++++++++++++++++ packages/core/src/task-store/reads.ts | 26 ++++- 3 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 .changeset/sse-archive-lane-leak.md create mode 100644 packages/core/src/__tests__/postgres/modified-since-archived-lane.pg.test.ts diff --git a/.changeset/sse-archive-lane-leak.md b/.changeset/sse-archive-lane-leak.md new file mode 100644 index 0000000000..3747572a77 --- /dev/null +++ b/.changeset/sse-archive-lane-leak.md @@ -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. diff --git a/packages/core/src/__tests__/postgres/modified-since-archived-lane.pg.test.ts b/packages/core/src/__tests__/postgres/modified-since-archived-lane.pg.test.ts new file mode 100644 index 0000000000..e4181d3547 --- /dev/null +++ b/packages/core/src/__tests__/postgres/modified-since-archived-lane.pg.test.ts @@ -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 { + 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 { + 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"); + }); +}); diff --git a/packages/core/src/task-store/reads.ts b/packages/core/src/task-store/reads.ts index ad25b6b58d..35200ef09e 100644 --- a/packages/core/src/task-store/reads.ts +++ b/packages/core/src/task-store/reads.ts @@ -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(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