From 9234ca24021809a50dad9c838091c5cc82e5e171 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 31 Jul 2026 10:04:15 -0700 Subject: [PATCH] =?UTF-8?q?fix(triage):=20the=20startup=20sweep=20resolved?= =?UTF-8?q?=20its=20columns=20from=20a=20SENTINEL=20task=20id=20=E2=80=94?= =?UTF-8?q?=20a=20characterization=20test=20already=20pinned=20it=20(#3201?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...sweep-sentinel-task-id-live-e2e.pg.test.ts | 24 +++++++++++++------ packages/engine/src/triage.ts | 22 +++++++++++++++-- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/packages/engine/src/__tests__/workflow-sweep-sentinel-task-id-live-e2e.pg.test.ts b/packages/engine/src/__tests__/workflow-sweep-sentinel-task-id-live-e2e.pg.test.ts index de1f91e444..f9ed404101 100644 --- a/packages/engine/src/__tests__/workflow-sweep-sentinel-task-id-live-e2e.pg.test.ts +++ b/packages/engine/src/__tests__/workflow-sweep-sentinel-task-id-live-e2e.pg.test.ts @@ -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(); }); }); diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index eff94864c1..24ce724eb1 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -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 })), );