**I flagged these four as unconvertible in #3104. #3109 landed and dissolved both of my reasons, so the flag comes off.** Leaving a "blocked" note standing behind a blocker that no longer exists is the exact decay this program keeps paying for — I have now found three other people's deferrals in that state this session, and I am not adding a fourth of my own. ## Both blockers, and why they are gone | my stated blocker | why it is gone | |---|---| | **A.** `trackTaskDisposal` writes `pendingTaskDisposals` in *this* tick, and the wip branch reads that map to serialise a fast bounce (FN-5256). Deferring branch selection to a microtask reopens that race. | Reading `lanes` off the payload needs **no await**. The prologue stays synchronous and the race stays closed. | | **B.** It is an if / else-if **chain**, so the guards are entangled and convert together or not at all. | They convert together here. | #3109 made the **emitter** carry the resolved lanes, which is the one route that removes the dilemma instead of trading one horn for the other. `lanes` is optional and fail-soft to `undefined` — *"unknown, never legacy"* — so each guard keeps its literal as the fallback, following the `mergeParkedColumns` convention #3109 established in `scheduler.ts`. An emit path that cannot resolve is no worse than before. ## What it fixes On a renamed board: execution never started on a move into the board's own wip lane, terminal session release never ran on a move into its archive lane, and neither `from` guard fired — so in-flight work was not aborted when a card left implementation. Nothing errored; the engine simply stopped reacting. ## Census | | before | after | |---|---|---| | `executor.ts` | 4 | **0** | | repo backlog | 45 | **41** | ## Measured - 3 new cases added to the FN-7717 suite; file **13/13 pass**. - **MUTATION**: restoring the `archived` literal fails the renamed case. - **The paired negative is the load-bearing one.** `done`/`in-review` deliberately keep their merge leases across the transition (FN-6736 / Phase C–D). The renamed **complete** lane must therefore *not* release — a conversion that released on every terminal-ish lane would satisfy the positive case and quietly break the guarantee that file already exists to protect. - A **fail-soft** case pins that an emit carrying no `lanes` behaves exactly as before. - `src/__tests__/executor*` — **84 files / 853 tests pass**. - `tsc --noEmit -p packages/engine` clean; census `--strict`, `check-lane-wiring`, `check-inert-sync-lane-conversions`, `check-fnxc-future-dates` clean. ## Note on #3104 That PR (merged) added the flag and the sharpened reasoning. This one removes it. The reasoning there was correct at the time and is what made it possible to check quickly whether #3109 actually addressed it — a flag that states its blocker precisely is cheap to retire, which is the argument for writing them that way. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -357,3 +357,75 @@ describe("workflow graph column boundaries do not abort their own run", () => {
|
||||
expect(abortSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-23:20:
|
||||
THE SAME RELEASE, ON A BOARD THAT DOES NOT CALL ITS ARCHIVE LANE `archived`.
|
||||
|
||||
The branch above was keyed on the `archived` literal, so on a renamed board the archive transition
|
||||
fell through to the narrower `from === wip` arm — or to no arm at all — and the registry entry leaked
|
||||
exactly as it did before FN-7717, for the same downstream cost: a successor task cannot acquire the
|
||||
same session path.
|
||||
|
||||
#3109 made `task:moved` carry the emitter-resolved lanes, so the guard reads the answer off the
|
||||
payload with NO await. That matters here specifically: this listener's disposal bookkeeping is written
|
||||
in the handler's own tick and read by the NEXT event's prologue (the FN-5256 fast-bounce path), so a
|
||||
guard that had to await could not be used without reopening that race.
|
||||
|
||||
The paired negative is the load-bearing half. `done`/`in-review` deliberately keep their merge leases
|
||||
across the transition (FN-6736 / Phase C-D), so a resolved-lane guard must not start releasing them —
|
||||
a conversion that released on every terminal-ish lane would satisfy the positive and break the
|
||||
guarantee this file already protects.
|
||||
*/
|
||||
describe("archive release follows the board's own archive lane", () => {
|
||||
beforeEach(() => activeSessionRegistry.clear());
|
||||
afterEach(() => activeSessionRegistry.clear());
|
||||
|
||||
/** Archive lane `filed`, wip `building` — the shape `moves.ts` now puts on the payload. */
|
||||
const RENAMED_LANES = { hold: "drafting", intake: "inbox", wip: "building", review: "checking", complete: "shipped", archived: "filed" };
|
||||
|
||||
it("releases a held session when the card moves into a RENAMED archive lane", async () => {
|
||||
const { executor, store } = makeExecutor();
|
||||
|
||||
(executor as any).setActiveWorkflowStepSession("TASK-RENAMED", {}, SHARED_ROOT);
|
||||
expect(activeSessionRegistry.pathsForTask("TASK-RENAMED").length).toBe(1);
|
||||
|
||||
store.emit("task:moved", {
|
||||
task: { id: "TASK-RENAMED", column: "filed" } as never,
|
||||
from: "drafting", to: "filed", source: "user", lanes: RENAMED_LANES,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(activeSessionRegistry.pathsForTask("TASK-RENAMED")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does NOT release a session when the card moves into the board's own COMPLETE lane", async () => {
|
||||
const { executor, store } = makeExecutor();
|
||||
|
||||
(executor as any).setActiveWorkflowStepSession("TASK-KEEP", {}, SHARED_ROOT);
|
||||
expect(activeSessionRegistry.pathsForTask("TASK-KEEP").length).toBe(1);
|
||||
|
||||
/* `shipped` is complete, not archived: the merge lease must survive, exactly as `done` does. */
|
||||
store.emit("task:moved", {
|
||||
task: { id: "TASK-KEEP", column: "shipped" } as never,
|
||||
from: "checking", to: "shipped", source: "engine", lanes: RENAMED_LANES,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(activeSessionRegistry.pathsForTask("TASK-KEEP").length).toBe(1);
|
||||
});
|
||||
|
||||
/*
|
||||
The fail-soft case. `lanes` is optional — an emit path that cannot resolve sends none — and the
|
||||
guard must then behave exactly as it did before #3109 rather than matching nothing.
|
||||
*/
|
||||
it("falls back to the legacy id when the emitter sent no lanes", async () => {
|
||||
const { executor, store } = makeExecutor();
|
||||
|
||||
(executor as any).setActiveWorkflowStepSession("TASK-LEGACY", {}, SHARED_ROOT);
|
||||
store.emit("task:moved", { task: makeTask("TASK-LEGACY"), from: "todo", to: "archived", source: "user" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(activeSessionRegistry.pathsForTask("TASK-LEGACY")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user