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:
gsxdsm
2026-07-31 02:08:49 -07:00
committed by GitHub
parent 0b10f6ccd3
commit 511f5b7e2b
3 changed files with 141 additions and 2 deletions

View 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.

View File

@@ -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");
});
});

View File

@@ -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