test(engine): pin the startup stale-planning sweep's planner-lane read (#3221)

## What

Pins the **startup stale-planning sweep's** planner-lane read in
`triage.ts`. Test-only — no product change.

A card can hold `status: "planning"` while triage specifies it in place.
A crash or restart before planning completes leaves that status set, and
a startup sweep clears it. If the sweep misses the card, it occupies a
planning admission slot **permanently** and new triage work is never
admitted.

The lane read was converted from `resolvePlannerLanes(store, "")` —
called with an **empty task id**, so it could never resolve a task and
always answered with the default board — to a project-level
`resolveProjectColumnsForRoles(store, ["intake", "hold"])`.

**No test could observe that conversion.** All 25 existing triage test
files stayed green (375/375) with the resolver neutered — under *both*
blinds tried.

## Measured

| | default (control) | legacy ids | renamed | differential |
|---|---|---|---|---|
| converted | pass | pass | pass | pass |
| blinded to empty set | pass | pass | **FAIL** | **FAIL** |
| blinded to legacy pair | pass | pass | **FAIL** | **FAIL** |

```
converted:            Tests 4 passed (4)
blinded to empty set: Tests 2 failed | 2 passed (4)
blinded to legacy:    Tests 2 failed | 2 passed (4)
existing 25 files:    green under BOTH blinds  (only this new file fails)
triage suite: 25 files/375 tests -> 26 files/379 tests, all green
lint clean; fnxc-future-dates: none added; census unchanged
```

## A prediction I got wrong, and what it changes

The site is **seed-then-union**:

```ts
const sweepColumns = [...new Set(["triage", "todo", ...projectPlannerColumns])];
```

I expected blinding the resolver to its legacy pair `["triage","todo"]`
to be a **no-op by construction** — the seed already contains both. It
is not. That reasoning holds only for a *default* board; on a renamed
board the resolver is the sole contributor of `drafting`, so the legacy
blind drops it and the renamed cases fail.

The corrected rule, which is narrower than the one I was carrying: **a
seed-then-union site hides a defect only while every lane you assert on
is already in the seed.** Assert on a lane that is not, and the union
stops protecting it. The empty-set blind remains the stricter of the two
because it also models a resolver returning nothing at all. Both are
recorded in the test file so the next person does not re-derive it.

## A tooling failure worth naming

My first triage measurement reported **375/375 green under blinding** —
and was a lie. The blinding script had no mapping for
`["intake","hold"]` and exited `2` **silently**; the `&&`
short-circuited the check while a `;` let vitest run against
**unmodified source**. A no-op blind produces a green run that is
indistinguishable from real coverage.

The script now prints what it substituted and where, fails loudly on an
unmapped role or a missing variable, and I self-tested both directions
(bogus var → rc 2 with a message; real var → rc 0 with the substitution
echoed) before trusting any number above. This is the same standard I
have been applying to other people's guards — *a guard that reports
success without checking anything is worse than no guard* — and my own
instrument failed it.

## Also pinned

The **legacy half** of the union. `triage`/`todo` stay in the sweep even
on a board whose workflow declares neither, because pre-U11 and Coding
(Ideas) rows can rest there. Dropping them in favour of the resolved
lanes alone would strand exactly those rows, so there is now a case
asserting it.

## Flagged, not guessed

- The FNXC stamp at `triage.ts:987` reads `2026-07-31-23:59`, hours
ahead of the `date -u` clock. It is one of the 181 grandfathered stamps
so the gate is green; I left it rather than widen this PR.
This commit is contained in:
gsxdsm
2026-07-31 12:01:00 -07:00
committed by GitHub
parent 9ab9822b8d
commit 719cf281fd

View File

@@ -0,0 +1,167 @@
import { describe, expect, it, vi } from "vitest";
import { TriageProcessor } from "../triage.js";
import type { Settings, Task, TaskStore, WorkflowIr } from "@fusion/core";
/*
FNXC:WorkflowResolvedColumns 2026-07-31-19:10:
THE STARTUP STALE-PLANNING SWEEP, on a RENAMED board.
A card can carry `status: "planning"` while triage specifies it in place. A crash or restart before
planning completes leaves that status set, so a startup sweep clears it — otherwise the card occupies
a planning admission slot PERMANENTLY and new triage work never gets admitted.
WHY THIS FILE EXISTS. The sweep's lane read was converted from `resolvePlannerLanes(store, "")` (an
empty task id, so it could only ever answer with the DEFAULT board) to a project-level
`resolveProjectColumnsForRoles(store, ["intake", "hold"])`. No test could observe that conversion:
neutering the resolver to an empty set left all 25 triage test files green (375/375).
THE SITE IS SEED-THEN-UNION, which changes what a blind means:
const sweepColumns = [...new Set(["triage", "todo", ...projectPlannerColumns])];
Blinding the resolver to its legacy pair `["triage","todo"]` is a no-op FOR A DEFAULT BOARD — the
seed already contains both, so the union is unchanged. It is NOT a no-op for a renamed board, where
the resolver is the only contributor of `drafting`. Both blinds therefore fail the renamed cases
below, and the empty-set blind is the stricter one because it also models a resolver that returns
nothing at all.
(I predicted the legacy blind would be a no-op here and it was not. Recorded because the seed-then-
union caution is real but narrower than it first looks: it hides a defect only while every lane you
assert on is already in the seed. Assert on a lane that is not, and the union stops protecting it.)
WHAT BREAKS WITHOUT THE CONVERSION. On a board whose planner lane is `drafting`, the sweep queries
`{triage, todo}`, never lists the card, and never clears its stale `planning`. The slot is held
forever — the exact permanent-occupancy failure the sweep exists to prevent.
DIFFERENTIAL. Both vocabularies run the same workflow SHAPE with identical traits; only the ids
differ, and no renamed id collides with a legacy literal. The default-vocabulary run is the control:
it passes with or without the conversion (its lane is in the literal seed), so a generic break in
this path cannot hide here.
*/
const WF = "custom:renamed-planner-lane";
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-001",
title: "task",
description: "",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
} as Task;
}
/**
* One merged planner column carrying BOTH planner roles, which is the post-#2515 default shape:
* `intake` and `hold` are the same column, not two.
*/
function ir(plannerColumn: string): WorkflowIr {
return {
version: "v2",
id: WF,
nodes: [],
edges: [],
columns: [
{
id: plannerColumn,
label: "Planner",
traits: [{ trait: "intake" }, { trait: "hold", config: { release: "capacity" } }],
},
{ id: "building", label: "Wip", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
{ id: "shipped", label: "Complete", traits: [{ trait: "complete" }] },
],
} as unknown as WorkflowIr;
}
function createStore(tasks: Task[], workflowIr: WorkflowIr, settings: Partial<Settings> = {}) {
const resolved = { maxConcurrent: 10, maxWorktrees: 10, ...settings };
const selection = { workflowId: WF, stepIds: [] };
const updateTask = vi.fn(async (id: string, patch: Partial<Task>) => {
const task = tasks.find((candidate) => candidate.id === id);
if (task) Object.assign(task, patch);
return task as Task;
});
const store = {
/* Honours the `column` filter; a mock returning every task cannot detect a wrong lane. */
listTasks: vi.fn(async (query?: { column?: string }) =>
(query?.column ? tasks.filter((task) => task.column === query.column) : tasks)),
getSettings: vi.fn(async () => resolved),
updateTask,
getTask: vi.fn(async (id: string) => tasks.find((task) => task.id === id) ?? null),
logEntry: vi.fn(async () => undefined),
getRootDir: vi.fn(() => "/tmp/project"),
getTasksDir: vi.fn(() => "/tmp/project/.fusion/tasks"),
on: vi.fn(),
off: vi.fn(),
recordRunAuditEvent: vi.fn(async () => undefined),
getTaskWorkflowSelection: vi.fn(() => selection),
getTaskWorkflowSelectionAsync: vi.fn(async () => selection),
getWorkflowDefinition: vi.fn(async () => ({ ir: workflowIr })),
/* Without this the resolver hands back legacy ids only, and the conversion is untestable. */
listWorkflowDefinitions: vi.fn(async () => [{ ir: workflowIr }]),
} as unknown as TaskStore;
return { store, updateTask };
}
/**
* A card resting in the board's PLANNER lane with a stale `planning` status, reported in role terms
* so both vocabularies are directly comparable.
*/
async function staleePlanningScenario(plannerColumn: string) {
const stale = makeTask({ id: "FN-STALE", column: plannerColumn, status: "planning" } as Partial<Task>);
const { store, updateTask } = createStore([stale], ir(plannerColumn));
const processor = new TriageProcessor(store, "/tmp/project");
/*
The sweep is private and `start()` would also spin pollers and timers that are not what these cases
are about. Invoked directly so a failure here can only be the sweep.
*/
await (processor as unknown as { clearStaleSpecifyingStatuses(): Promise<void> }).clearStaleSpecifyingStatuses();
processor.stop();
return { updateTask, stale };
}
describe("triage startup stale-planning sweep reaches a RENAMED planner lane", () => {
it("default vocabulary: a stale planning card in the planner lane is cleared", async () => {
const { updateTask, stale } = await staleePlanningScenario("todo");
expect(updateTask).toHaveBeenCalledWith("FN-STALE", { status: null });
expect(stale.status).toBeNull();
});
it("renamed vocabulary: a stale planning card in the planner lane is cleared", async () => {
const { updateTask, stale } = await staleePlanningScenario("drafting");
expect(updateTask).toHaveBeenCalledWith("FN-STALE", { status: null });
expect(stale.status).toBeNull();
});
it("both vocabularies reach the SAME outcome — no column-id literal survives on this path", async () => {
const defaultRun = await staleePlanningScenario("todo");
const renamedRun = await staleePlanningScenario("drafting");
expect(renamedRun.stale.status).toBe(defaultRun.stale.status);
expect(renamedRun.stale.status).toBeNull();
});
it("the legacy planner ids stay in the sweep even when the workflow does not name them", async () => {
/*
The union keeps `triage`/`todo` deliberately: pre-U11 and Coding (Ideas) rows can rest there on a
board whose workflow declares neither. Dropping them in favour of the resolved lanes alone would
strand exactly those rows, so this pins the legacy half of the union too.
*/
const legacyRow = makeTask({ id: "FN-LEGACY", column: "triage", status: "planning" } as Partial<Task>);
const { store, updateTask } = createStore([legacyRow], ir("drafting"));
const processor = new TriageProcessor(store, "/tmp/project");
await (processor as unknown as { clearStaleSpecifyingStatuses(): Promise<void> }).clearStaleSpecifyingStatuses();
processor.stop();
expect(updateTask).toHaveBeenCalledWith("FN-LEGACY", { status: null });
});
});