From f5926d3b54296c83542f734ff2b127fd1ab2432f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 31 Jul 2026 04:40:21 -0700 Subject: [PATCH] =?UTF-8?q?docs(engine):=20flag=20triage's=20evacuation=20?= =?UTF-8?q?guard=20=E2=80=94=20it=20looks=20two-thirds=20converted=20and?= =?UTF-8?q?=20is=20fully=20literal=20(#3108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the sync-listener audit across the three files holding the remaining blocked guards — `executor.ts` (#3104), `scheduler.ts` (#3100), and this one. Triage is the most misleading of the three. ## The shape lies The guard reads as **two resolved arms and one literal**: > `task.column === disposeLanes.hold || task.column === disposeLanes.intake || task.column === "in-progress"` So the obvious next move is to convert the third arm with the same helper. That is wrong twice: **1. The two "resolved" arms are not resolved.** `resolvePlannerLanes` goes through `resolveTaskWorkflowIrSync`, which cannot answer for a **custom** workflow — the sync selection reader returns `undefined` unconditionally, *and* the custom-workflow IR read goes through `store.db`, whose implementation is an unconditional throw (#3103). So `disposeLanes.hold` / `.intake` are `todo` / `triage` on every board. **All three arms are literal in effect.** Converting the third the same way adds a third inert comparison and retires a census entry that is currently telling the truth. **2. The guard's answer is consumed synchronously** — the criterion I had to correct in #3104. Below it, `pauseAborted.add`, `session.dispose()` and `activeSessions.delete` mutate in-memory state in this tick, and other paths read those maps. Contrast `self-healing.ts`'s fan-out, where three of four guards only gated work the listener already `void`s and so *were* convertible via the async resolver (#3094). ## What it costs, and the obvious reading is backwards An evacuation **into** a renamed destination still falls through and disposes correctly — no bug there. The failure is the other direction: on a board whose **hold or intake** lane is renamed, arms 1 and 2 stop matching, so a card **sitting still in its own planning lane** is treated as evacuated and its live triage session is aborted mid-run. I state it that way because "renamed board → guard misses → nothing happens" is the pattern everywhere else in this program, and here it inverts. ## Census **Unchanged at 1**, deliberately. Blocked, and now documented as *fully literal* rather than part-converted — which is the fact a future pass needs in order not to make it worse. ## Measured - Comment-only. - `src/__tests__/triage*` — **25 files / 374 tests pass**. - `tsc --noEmit -p packages/engine` clean; `check-fnxc-future-dates` clean. Co-authored-by: Claude Opus 5 (1M context) --- packages/engine/src/triage.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 57ccc8b35b..1e8b2c16cb 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -720,6 +720,33 @@ export class TriageProcessor { an unrelated update. */ if (typeof task.column !== "string") return; + /* + FNXC:WorkflowResolvedColumns 2026-07-31-23:55 (FLAGGED AND LEFT COUNTED — and the shape here is + misleading, which is why it needs saying): + + This guard LOOKS two-thirds converted — two resolved arms and one literal — so the obvious next + move is to convert the third with the same helper. That would be wrong twice over. + + 1. `resolvePlannerLanes` goes through `resolveTaskWorkflowIrSync`, which cannot answer for a + CUSTOM workflow: the sync selection reader returns `undefined` unconditionally, AND the + custom-workflow IR read goes through `store.db`, whose implementation is an unconditional + throw (`sync-workflow-ir-second-blocker.test.ts`). So `disposeLanes.hold`/`.intake` are + `todo`/`triage` on every board. All THREE arms are literal in effect; converting the third + via the same helper adds a third inert comparison and removes a census entry that is telling + the truth. + + 2. The guard's answer is consumed SYNCHRONOUSLY — that is the criterion, not "the listener is + sync". Below it, `pauseAborted.add`, `session.dispose()` and `activeSessions.delete` all + mutate in-memory state in this tick, and other paths read those maps. (Contrast + `self-healing.ts`'s fan-out, where three of four guards only gated work the listener already + `void`s and were therefore convertible via the async resolver.) + + WHAT IT COSTS TODAY: a card evacuated from planning into a lane the board renamed still matches + none of these, so the guard falls through and the session IS disposed — correct. The failure is + the other direction: a board whose HOLD or INTAKE lane is renamed no longer matches arms 1 and + 2, so a card sitting still in its own planning lane is treated as evacuated and its live triage + session is aborted mid-run. + */ const disposeLanes = resolvePlannerLanes(this.store, task.id); if (task.column === disposeLanes.hold || task.column === disposeLanes.intake || task.column === "in-progress") return; if (this.activeSubagentSessions.has(task.id)) {