fix(triage): the startup sweep resolved its columns from a SENTINEL task id — a characterization test already pinned it (#3201)

Fourth and last convertible site in `triage.ts`. This one needed a
different fix from the other three, and the codebase already said so.

## The defect

```ts
const sweepLanes = resolvePlannerLanes(this.store, "");
const sweepColumns = [...new Set(["triage", "todo", sweepLanes.intake, sweepLanes.hold])];
```

There is no task `""`. No selection can be read for it, no board
resolved — the lanes come back as the **default** board's and the union
collapses to the legacy pair `{triage, todo}`. On a renamed board the
sweep queries columns the card is not in, so its stale `planning` status
survives and it **holds a planning admission slot indefinitely**.

## A characterization test already pinned this, and called the fix
correctly

`workflow-sweep-sentinel-task-id-live-e2e.pg.test.ts` documents it as a
third inert-conversion mechanism — *"inert by construction rather than
by environment"* — and its header says:

> making `resolvePlannerLanes` async would **NOT** repair this site,
because the defect is the argument, not the resolver

That is right, and it is why this fix differs from #3191 / #3193 /
#3195, which all used the async twin. Here the sweep has **no task to
resolve against** and wants every column playing these roles **anywhere
in the project** — so the correct resolver is
`resolveProjectColumnsForRoles(store, ["intake", "hold"])`, the same
helper `self-healing.ts` already uses for the same purpose.

The legacy pair stays in the union deliberately: the note at the site
explains that `triage` and `todo` must both be swept for pre-U11 and
Coding (Ideas) rows, and extra columns are free because the sweep only
**reads** and filters on `status === "planning"` first.

## The test is inverted, not deleted

It asserted `"planning"` survives — the bug. It now asserts the status
is cleared. Keeping the case with its original reasoning intact
preserves the file's record of what the defect *was*.

## Measured

| | result |
|---|---|
| broad suite (triage / planning / self-healing) | **76 files, 1302
tests passed** |
| differential | restoring the sentinel call → **1 failed \| 2 passed**
|
| `census --strict`, `check-fnxc-future-dates` | exit 0 |

**Inert count unchanged at 4 for `triage.ts`** — these lanes fed an
*array literal*, not a comparison, so the ratchet never counted them.
Third fix this session in that blind-spot class, stated so the number is
not read as the whole picture.

## What remains in this file

Two sites: the `task:moved` wake handler and the evacuation handler —
both **synchronous arrow callbacks** whose answers are consumed in-tick.
Genuinely blocked on the emitter-side work in #3082, with corroborating
evidence attached there.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-31 10:04:15 -07:00
committed by GitHub
parent 09edce2366
commit 9234ca2402
2 changed files with 37 additions and 9 deletions

View File

@@ -116,15 +116,25 @@ pgDescribe("startup sweep lane vocabulary, resolved from a sentinel task id", ()
expect(await sweepThenRead(store, taskId)).toBeNull();
});
it("CHARACTERIZATION — the same card on a RENAMED board is NEVER swept", async () => {
/*
The card is in none of the four queried columns (`triage`, `todo`, and the two sentinel-resolved
lanes, which are both `todo`), so the sweep does not see it. Its stale `planning` status survives
and it holds a planning admission slot indefinitely.
*/
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-23:59:
WAS A CHARACTERIZATION, IS NOW A REGRESSION TEST — the defect it pinned is fixed.
It asserted `"planning"` survives: the card sat in none of the four queried columns, because the
sentinel `""` task id could resolve no board and the union collapsed to the legacy pair. The header
above notes that making `resolvePlannerLanes` async would NOT repair this, "because the defect is
the argument, not the resolver" — which is right, and is why the fix is a PROJECT-level resolver.
`resolveProjectColumnsForRoles(store, ["intake", "hold"])` asks the question this sweep actually has:
there is no task to resolve against, and it wants every column playing those roles anywhere in the
project. The renamed planning column is now queried, so the stale status is cleared.
The assertion is inverted rather than deleted, so the file keeps its record of what the bug WAS.
*/
it("clears a stale planning status on a RENAMED board", async () => {
const store = h.store();
const taskId = await stalePlanningCard(store, RENAMED_VOCAB, "wf-renamed-sweep");
expect(await sweepThenRead(store, taskId)).toBe("planning");
expect(await sweepThenRead(store, taskId)).toBeNull();
});
});

View File

@@ -36,6 +36,7 @@ import {
resolveWorkflowIrForTask,
resolveLifecycleColumns,
resolveWorkflowIrForTaskWithProvenance,
resolveProjectColumnsForRoles,
workflowHasColumn,
getStepParser,
computePlanApprovalFingerprint,
@@ -970,8 +971,25 @@ export class TriageProcessor {
Querying extra columns is free here: the sweep only READS and every row is
filtered on `status === "planning"` before anything is written.
*/
const sweepLanes = resolvePlannerLanes(this.store, "");
const sweepColumns = [...new Set(["triage", "todo", sweepLanes.intake, sweepLanes.hold])];
/*
FNXC:WorkflowResolvedColumns 2026-07-31-23:59 (SYNC -> PROJECT-LEVEL, the last convertible site
in this file):
`resolvePlannerLanes(this.store, "")` was called with an EMPTY task id — there is no task here, so
it could never resolve one and returned the DEFAULT board's lanes. The sweep therefore queried
`{triage, todo}` on every board, and a stale `planning` card resting in a RENAMED planning column
was never swept: it holds a planning admission slot permanently, which is the exact failure the
note above describes.
The right shape is PROJECT-level, not per-task: this sweep has no task to resolve against and
wants every column that plays these roles anywhere in the project. `resolveProjectColumnsForRoles`
is that resolver, already used for the same purpose in `self-healing.ts`.
The legacy pair stays in the union deliberately — the note above explains why (`triage` and `todo`
must both be swept for pre-U11 and Coding (Ideas) rows), and querying extra columns is free here
because the sweep only READS and every row is filtered on `status === "planning"` first.
*/
const projectPlannerColumns = await resolveProjectColumnsForRoles(this.store, ["intake", "hold"]);
const sweepColumns = [...new Set(["triage", "todo", ...projectPlannerColumns])];
const swept = await Promise.all(
sweepColumns.map((column) => this.store.listTasks({ column, slim: true })),
);