From 33f4797ceb7e99c90524efc30dbb217833596ceb Mon Sep 17 00:00:00 2001 From: Fusion Agent Date: Sat, 22 Aug 2026 01:39:23 +0000 Subject: [PATCH] FN-126: fix inert sync-lane validation Prevent inert synchronization lanes from bypassing merge-gate validation while preserving task-lane cache and archive lifecycle behavior. - Reject sync-resolved lane conversions in the static validator. - Preserve task-lane cache emissions and active-session cleanup across task mutations and archival. - Add regression coverage, baseline updates, documentation, and a release changeset. Files changed: .changeset/fn-126-inert-sync-lane.md | 7 + .../a-falling-count-is-not-evidence.md | 12 + .../task-lane-cache-emitter-preservation.test.ts | 269 +++++++++++++++++++++ .../core/src/task-store/archive-lifecycle-2.ts | 3 +- packages/core/src/task-store/moves.ts | 3 +- packages/core/src/task-store/task-artifacts-ops.ts | 3 +- packages/core/src/task-store/task-update.ts | 3 +- packages/core/src/task-store/update-task-deps.ts | 3 +- ...xecutor-archive-releases-active-session.test.ts | 80 ++++++ packages/engine/src/executor.ts | 4 +- .../src/executor/executor-side-effect-hosts.ts | 2 +- .../executor/is-backward-move-out-of-planning.ts | 7 +- .../src/executor/task-executor-graph-facades.ts | 2 +- .../engine/src/executor/task-executor-imports.ts | 1 - .../engine/src/executor/wire-executor-lifecycle.ts | 18 +- .../check-inert-sync-lane-conversions.test.mjs | 83 +++++-- scripts/check-inert-sync-lane-conversions.mjs | 19 +- scripts/lib/inert-sync-lane-baseline.json | 6 +- 18 files changed, 479 insertions(+), 46 deletions(-) Fusion-Task-Id: FN-126 Fusion-Task-Lineage: 0c7a1a3a-9446-44f8-955d-df6c402dfd31 Co-authored-by: Fusion --- .changeset/fn-126-inert-sync-lane.md | 7 + .../a-falling-count-is-not-evidence.md | 12 + ...sk-lane-cache-emitter-preservation.test.ts | 269 ++++++++++++++++++ .../src/task-store/archive-lifecycle-2.ts | 3 +- packages/core/src/task-store/moves.ts | 3 +- .../core/src/task-store/task-artifacts-ops.ts | 3 +- packages/core/src/task-store/task-update.ts | 3 +- .../core/src/task-store/update-task-deps.ts | 3 +- ...or-archive-releases-active-session.test.ts | 80 ++++++ packages/engine/src/executor.ts | 4 +- .../executor/executor-side-effect-hosts.ts | 2 +- .../is-backward-move-out-of-planning.ts | 7 +- .../executor/task-executor-graph-facades.ts | 2 +- .../src/executor/task-executor-imports.ts | 1 - .../src/executor/wire-executor-lifecycle.ts | 18 +- ...check-inert-sync-lane-conversions.test.mjs | 83 ++++-- scripts/check-inert-sync-lane-conversions.mjs | 19 +- scripts/lib/inert-sync-lane-baseline.json | 6 +- 18 files changed, 479 insertions(+), 46 deletions(-) create mode 100644 .changeset/fn-126-inert-sync-lane.md create mode 100644 packages/core/src/__tests__/task-lane-cache-emitter-preservation.test.ts diff --git a/.changeset/fn-126-inert-sync-lane.md b/.changeset/fn-126-inert-sync-lane.md new file mode 100644 index 0000000000..ac5d19ce23 --- /dev/null +++ b/.changeset/fn-126-inert-sync-lane.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep renamed-board task moves on their actual workflow lanes. +category: fix +dev: Removes the executor sync-lane fallback and hardens its static ratchet at zero. diff --git a/docs/solutions/workflow-learnings/a-falling-count-is-not-evidence.md b/docs/solutions/workflow-learnings/a-falling-count-is-not-evidence.md index 7a8862621f..a5d5c6263d 100644 --- a/docs/solutions/workflow-learnings/a-falling-count-is-not-evidence.md +++ b/docs/solutions/workflow-learnings/a-falling-count-is-not-evidence.md @@ -190,3 +190,15 @@ the person who had just written it down. - Before comparing two numbers: confirm they came from one tree. - Before converting a flagged site: read the flag. It is a claim, and it decays — but re-deriving it costs minutes and overriding it wrongly has cost three PRs. + +## FN-126: 2 → 0 was removed work, not a detection loss + +The executor planning-evacuation predicate stopped calling the synchronous workflow resolver. Its +PostgreSQL selection reader measured as the same legacy defaults already beside the predicate, so it +could not serve renamed boards. The listener now uses payload lanes, then the TTL `TaskLaneCache`, then +legacy ids. Before recording the zero baseline, staged `===` and array/filter/`includes` probes each +failed the ratchet; the scanner now also fails if no `resolveTaskWorkflowIrSync` source exists. + +The measured blind-spot table's inert-sync row now catches membership receivers containing tainted role +reads. The direct `store.resolveTaskWorkflowIrSync(...)` → `resolveLifecycleColumns` form remains out +of scope here and is separately protected by the core callsite allow-list test. diff --git a/packages/core/src/__tests__/task-lane-cache-emitter-preservation.test.ts b/packages/core/src/__tests__/task-lane-cache-emitter-preservation.test.ts new file mode 100644 index 0000000000..dce6a44001 --- /dev/null +++ b/packages/core/src/__tests__/task-lane-cache-emitter-preservation.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it, vi } from "vitest"; +import { createTaskStoreForTest, pgDescribe } from "../__test-utils__/pg-test-harness.js"; +import { TaskLaneCache } from "../task-lane-cache.js"; +import { moveToDoneImpl } from "../task-store/task-artifacts-ops.js"; +import type { Task } from "../types.js"; +import type { TaskStore } from "../store.js"; + +const workflowResolution = vi.hoisted(() => ({ fail: false, failAfter: undefined as number | undefined, calls: 0 })); + +vi.mock("../workflows/workflow-ir-resolver.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + resolveWorkflowIrForTask: (...args: Parameters) => { + workflowResolution.calls += 1; + if (workflowResolution.fail || (workflowResolution.failAfter !== undefined && workflowResolution.calls > workflowResolution.failAfter)) { + return Promise.reject(new Error("simulated workflow lookup failure")); + } + return original.resolveWorkflowIrForTask(...args); + }, + }; +}); + +/* +FNXC:WorkflowEvents 2026-08-22-00:30: +Each task:moved emitter may fail its asynchronous workflow lookup after a real lane answer was +cached. That failure must keep its optional payload undefined while preserving the warm cache until +TTL expiry. These checks invoke each production mutation path; source-text checks cannot establish +that an emitter still reaches its guarded write or that archive exposes warmth before invalidation. +*/ +const SPLIT_LANES_IR = { + version: "v2", id: "fn-126-split-lanes", name: "split lanes", + nodes: [ + { id: "start", kind: "start", column: "inbox" }, + { id: "execute", kind: "prompt", column: "building", config: { seam: "execute" } }, + { id: "merge", kind: "merge-gate", column: "signoff", config: { gate: "auto-merge" } }, + { id: "end", kind: "end", column: "shipped" }, + ], + edges: [ + { from: "start", to: "execute" }, + { from: "execute", to: "merge", condition: "success" }, + { from: "merge", to: "end", condition: "success" }, + ], + columns: [ + { id: "inbox", name: "Inbox", traits: [{ trait: "intake" }] }, + { id: "backlog", name: "Backlog", traits: [{ trait: "hold" }] }, + { id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] }, + { id: "signoff", name: "Signoff", traits: [{ trait: "merge" }] }, + { id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] }, + ], +}; + +/* +FNXC:WorkflowEvents 2026-08-22-01:02: +The completion emitter is reachable without PostgreSQL through its exported production implementation. +Drive its successful and failed resolver branches directly, while PostgreSQL integration cases execute the +public move, task-update, dependency-update, and archive producer paths rather than inspecting source. +*/ +describe("task:moved emitter lane-cache preservation", () => { + it("keeps the completion emitter's warm cache for an undefined failure payload and overwrites it on success", async () => { + const task = { + id: "FN-126", column: "in-review", title: "cache", description: "cache", steps: [], log: [], + createdAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(), + } as unknown as Task; + const cache = new TaskLaneCache(); + const emitted: Array<{ lanes?: unknown }> = []; + const store = { + getTaskWorkflowSelection: () => undefined, + getTaskWorkflowSelectionAsync: async () => undefined, + getWorkflowDefinition: async () => undefined, + clearDoneTransientFields: vi.fn(), + atomicWriteTaskJson: vi.fn(async () => undefined), + isWatching: false, + taskCache: new Map(), + laneCache: cache, + emit: (_event: string, value: { lanes?: unknown }) => emitted.push(value), + } as unknown as TaskStore; + const warm = { hold: "queued", wip: "building" }; + + cache.set(task.id, warm); + workflowResolution.calls = 0; + workflowResolution.fail = true; + await moveToDoneImpl(store, task, "/tmp/fn-126-task"); + expect(emitted.at(-1)?.lanes).toBeUndefined(); + expect(cache.get(task.id)).toEqual(warm); + + workflowResolution.fail = false; + task.column = "in-review" as never; + await moveToDoneImpl(store, task, "/tmp/fn-126-task"); + expect(emitted.at(-1)?.lanes).toBeDefined(); + expect(cache.get(task.id)).toEqual(emitted.at(-1)?.lanes); + }); + + it("preserves failed-resolution warmth, overwrites on success, and expires the retained answer", () => { + let now = 0; + const cache = new TaskLaneCache({ ttlMs: 30_000, now: () => now }); + const warm = { hold: "queued", wip: "building" }; + const resolved = { hold: "ready", wip: "running" }; + + cache.set("FN-126", warm); + const failedResolution = undefined; + if (failedResolution) cache.set("FN-126", failedResolution); + expect(cache.get("FN-126")).toEqual(warm); + + cache.set("FN-126", resolved); + expect(cache.get("FN-126")).toEqual(resolved); + + now = 30_000; + expect(cache.get("FN-126")).toBeUndefined(); + }); +}); + +/* +FNXC:WorkflowEvents 2026-08-22-00:43: +The guard is load-bearing only when the real task-store operations reach it. These integration +checks execute the ordinary move and archive producers, observe their emitted payloads, and inspect +the cache from inside the listener before archive performs its deliberate post-emit invalidation. +*/ +pgDescribe("task:moved producer cache integration", () => { + async function withFailedResolution( + description: string, + operation: (store: Awaited>["store"], taskId: string) => Promise, + ): Promise { + const harness = await createTaskStoreForTest({ prefix: `fusion_${description}_failed_lane_cache` }); + try { + const store = harness.store; + const task = await store.createTask({ description: `${description} failed lane cache` }); + const warm = { hold: "queued", wip: "building" }; + const events: Array<{ lanes?: unknown; cache: unknown }> = []; + store.on("task:moved", (event) => { + if (event.task.id === task.id) events.push({ lanes: event.lanes, cache: store.laneCache.get(task.id) }); + }); + + store.laneCache.set(task.id, warm); + workflowResolution.calls = 0; + /* FNXC:WorkflowEvents 2026-08-22-01:14: moveTask resolves once for preflight, then again for its emitted payload. */ + workflowResolution.failAfter = description === "move" ? 1 : undefined; + workflowResolution.fail = description !== "move"; + await operation(store, task.id); + + expect(events).toHaveLength(1); + expect(events[0]?.lanes).toBeUndefined(); + expect(events[0]?.cache).toEqual(warm); + expect(store.laneCache.get(task.id)).toEqual(warm); + } finally { + workflowResolution.fail = false; + workflowResolution.failAfter = undefined; + await harness.teardown(); + } + } + + it("moves overwrite a warm answer and deliver the resolved payload through the production emitter", async () => { + const harness = await createTaskStoreForTest({ prefix: "fusion_move_lane_cache" }); + try { + const store = harness.store; + const task = await store.createTask({ description: "move cache producer" }); + const warm = { hold: "queued", wip: "building" }; + const seen: Array<{ lanes?: unknown }> = []; + store.on("task:moved", (event) => { + if (event.task.id === task.id) seen.push({ lanes: event.lanes }); + }); + + store.laneCache.set(task.id, warm); + await store.moveTask(task.id, "in-progress"); + + expect(seen).toHaveLength(1); + expect(seen[0]?.lanes).toBeDefined(); + expect(seen[0]?.lanes).not.toEqual(warm); + expect(store.laneCache.get(task.id)).toEqual(seen[0]?.lanes); + } finally { + await harness.teardown(); + } + }); + + it("preserves a warm cache and exposes an undefined payload when move resolution fails", async () => { + await withFailedResolution("move", (store, id) => store.moveTask(id, "in-progress")); + }); + + it("task-update withholds an unresolved relocation event while preserving the mutation and warm cache", async () => { + const harness = await createTaskStoreForTest({ prefix: "fusion_task_update_lane_cache" }); + try { + const store = harness.store; + const workflow = await store.createWorkflowDefinition({ name: "FN-126 task update", ir: SPLIT_LANES_IR as never }); + const prerequisite = await store.createTask({ description: "task update prerequisite" }); + const extraPrerequisite = await store.createTask({ description: "task update extra prerequisite" }); + const task = await store.createTask({ description: "task update lane cache" }); + await store.selectTaskWorkflow(task.id, workflow.id); + await store.moveTask(task.id, "backlog"); + const events: Array<{ lanes?: unknown }> = []; + store.on("task:moved", (event) => { if (event.task.id === task.id) events.push({ lanes: event.lanes }); }); + + const stale = { hold: "stale-hold", wip: "stale-wip" }; + store.laneCache.set(task.id, stale); + await store.updateTask(task.id, { dependencies: [prerequisite.id] }); + expect(events).toHaveLength(1); + expect(events[0]?.lanes).toBeDefined(); + expect(store.laneCache.get(task.id)).toEqual(events[0]?.lanes); + expect(store.laneCache.get(task.id)).not.toEqual(stale); + + /* FNXC:WorkflowEvents 2026-08-22-01:02: a failed task-update lookup has no safe replan destination, so it emits no fabricated move; retain the warm answer for the next real producer. */ + const warm = { hold: "queued", wip: "building" }; + store.laneCache.set(task.id, warm); + workflowResolution.calls = 0; + workflowResolution.fail = true; + const failedRelocation = await store.updateTask(task.id, { dependencies: [prerequisite.id, extraPrerequisite.id] }); + + /* + FNXC:WorkflowEvents 2026-08-22-01:14: + An unresolved task-update replan has no safe destination. It must persist dependency invalidation + without manufacturing a task:moved event whose lanes would be unknown, leaving the warm answer intact. + */ + expect(failedRelocation.dependencies).toEqual([prerequisite.id, extraPrerequisite.id]); + expect(failedRelocation.column).toBe("inbox"); + expect(events).toHaveLength(1); + expect(store.laneCache.get(task.id)).toEqual(warm); + } finally { + workflowResolution.fail = false; + workflowResolution.failAfter = undefined; + await harness.teardown(); + } + }); + + + it("archive exposes its resolved payload to listeners before intentionally invalidating the cache", async () => { + const harness = await createTaskStoreForTest({ prefix: "fusion_archive_lane_cache" }); + try { + const store = harness.store; + const task = await store.createTask({ description: "archive cache producer" }); + const duringEmit: unknown[] = []; + const payloads: unknown[] = []; + store.on("task:moved", (event) => { + if (event.task.id !== task.id) return; + payloads.push(event.lanes); + duringEmit.push(store.laneCache.get(task.id)); + }); + + await store.archiveTask(task.id, { cleanup: false }); + + expect(payloads).toHaveLength(1); + expect(payloads[0]).toBeDefined(); + expect(duringEmit[0]).toEqual(payloads[0]); + expect(store.laneCache.get(task.id)).toBeUndefined(); + } finally { + await harness.teardown(); + } + }); + + it("archive keeps the warm cache visible to its listener when resolution fails before invalidating it", async () => { + const harness = await createTaskStoreForTest({ prefix: "fusion_archive_failed_lane_cache" }); + try { + const store = harness.store; + const task = await store.createTask({ description: "archive failed lane cache" }); + const warm = { hold: "queued", wip: "building" }; + const events: Array<{ lanes?: unknown; cache: unknown }> = []; + store.on("task:moved", (event) => { + if (event.task.id === task.id) events.push({ lanes: event.lanes, cache: store.laneCache.get(task.id) }); + }); + store.laneCache.set(task.id, warm); + workflowResolution.fail = true; + await store.archiveTask(task.id, { cleanup: false }); + + expect(events).toEqual([{ lanes: undefined, cache: warm }]); + expect(store.laneCache.get(task.id)).toBeUndefined(); + } finally { + workflowResolution.fail = false; + await harness.teardown(); + } + }); +}); diff --git a/packages/core/src/task-store/archive-lifecycle-2.ts b/packages/core/src/task-store/archive-lifecycle-2.ts index de34fd0548..5f85250482 100644 --- a/packages/core/src/task-store/archive-lifecycle-2.ts +++ b/packages/core/src/task-store/archive-lifecycle-2.ts @@ -610,7 +610,8 @@ export async function archiveTaskBackendImpl(store: TaskStore, id: string, optio lane-less left that leak reachable through this path even after the listener itself was fixed. */ const movedLanes = toTaskMoveLanes(await resolveWorkflowIrForTask(store, task.id).catch(() => undefined)); - store.laneCache.set(task.id, movedLanes); + /* FNXC:WorkflowEvents 2026-08-22-00:13: an unresolved payload is unknown; retain a warm real cache answer until its TTL expires. */ + if (movedLanes) store.laneCache.set(task.id, movedLanes); store.emit("task:moved", { task, from: fromColumn, to: "archived" as Column, source: "engine", lanes: movedLanes }); store.laneCache.invalidate(task.id); diff --git a/packages/core/src/task-store/moves.ts b/packages/core/src/task-store/moves.ts index af0f4c6780..ffc53aefca 100644 --- a/packages/core/src/task-store/moves.ts +++ b/packages/core/src/task-store/moves.ts @@ -1495,7 +1495,8 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum "legacy" — so a listener keeps its own fallback rather than being handed a wrong answer. */ const lanes = toTaskMoveLanes(await resolveWorkflowIrForTask(store, task.id).catch(() => undefined)); - store.laneCache.set(task.id, lanes); + /* FNXC:WorkflowEvents 2026-08-22-00:13: an unresolved payload is unknown; retain a warm real cache answer until its TTL expires. */ + if (lanes) store.laneCache.set(task.id, lanes); store.emit("task:moved", { task, from: fromColumn, to: toColumn, source: moveSource, lanes }); /* FNXC:WorkflowEvents 2026-07-27-11:45 (U3 / R5, R6): diff --git a/packages/core/src/task-store/task-artifacts-ops.ts b/packages/core/src/task-store/task-artifacts-ops.ts index 83174cbd2b..838d23df29 100644 --- a/packages/core/src/task-store/task-artifacts-ops.ts +++ b/packages/core/src/task-store/task-artifacts-ops.ts @@ -590,7 +590,8 @@ export async function moveToDoneImpl(store: TaskStore, task: Task, dir: string): lane-less left that leak reachable through this path even after the listener itself was fixed. */ const movedLanes = toTaskMoveLanes(await resolveWorkflowIrForTask(store, task.id).catch(() => undefined)); - store.laneCache.set(task.id, movedLanes); + /* FNXC:WorkflowEvents 2026-08-22-00:13: an unresolved payload is unknown; retain a warm real cache answer until its TTL expires. */ + if (movedLanes) store.laneCache.set(task.id, movedLanes); store.emit("task:moved", { task, from: fromColumn, to: completeColumn as Column, source: "engine", lanes: movedLanes }); } diff --git a/packages/core/src/task-store/task-update.ts b/packages/core/src/task-store/task-update.ts index 0e7ea8f068..05a660da8d 100644 --- a/packages/core/src/task-store/task-update.ts +++ b/packages/core/src/task-store/task-update.ts @@ -1338,7 +1338,8 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat FNXC:WorkflowEvents 2026-08-03-02:16: emit only when respecifyMoveLanes is present from the same IR used for the relocation — never a second IR lookup that can fail after the move. */ - store.laneCache.set(task.id, respecifyMoveLanes); + /* FNXC:WorkflowEvents 2026-08-22-00:13: an unresolved payload is unknown; retain a warm real cache answer until its TTL expires. */ + if (respecifyMoveLanes) store.laneCache.set(task.id, respecifyMoveLanes); store.emit("task:moved", { task, from: respecifyFromColumn as Column, diff --git a/packages/core/src/task-store/update-task-deps.ts b/packages/core/src/task-store/update-task-deps.ts index 015651a8f5..9ef62f20a5 100644 --- a/packages/core/src/task-store/update-task-deps.ts +++ b/packages/core/src/task-store/update-task-deps.ts @@ -575,7 +575,8 @@ async function updateTaskDependenciesWithTaskLockImpl(store: TaskStore, id: stri */ if (movedToTriage && respecifyFromColumn !== task.column) { const lanes = toTaskMoveLanes(await resolveWorkflowIrForTask(store, task.id).catch(() => undefined)); - store.laneCache.set(task.id, lanes); + /* FNXC:WorkflowEvents 2026-08-22-00:13: an unresolved payload is unknown; retain a warm real cache answer until its TTL expires. */ + if (lanes) store.laneCache.set(task.id, lanes); store.emit("task:moved", { task, from: respecifyFromColumn as Column, diff --git a/packages/engine/src/__tests__/executor-archive-releases-active-session.test.ts b/packages/engine/src/__tests__/executor-archive-releases-active-session.test.ts index 93315dac15..6364465d5a 100644 --- a/packages/engine/src/__tests__/executor-archive-releases-active-session.test.ts +++ b/packages/engine/src/__tests__/executor-archive-releases-active-session.test.ts @@ -429,3 +429,83 @@ describe("archive release follows the board's own archive lane", () => { expect(activeSessionRegistry.pathsForTask("TASK-LEGACY")).toEqual([]); }); }); + +describe("task:moved lane cache fallback", () => { + it("uses a warm renamed cache answer when an optional payload omits lanes", async () => { + const { executor, store } = makeExecutor(); + (store as any).laneCache = { get: vi.fn(() => ({ archived: "shipped", hold: "backlog", wip: "building" })) }; + (executor as any).setActiveWorkflowStepSession("TASK-CACHE", {}, SHARED_ROOT); + const [heldPath] = activeSessionRegistry.pathsForTask("TASK-CACHE"); + store.emit("task:moved", { task: makeTask("TASK-CACHE"), from: "backlog", to: "shipped", source: "user" }); + await (executor as any).pendingTaskDisposals.get("TASK-CACHE"); + expect(activeSessionRegistry.isPathActive(heldPath)).toBe(false); + }); + + /* + FNXC:PlanningEvacuation 2026-08-22-00:30: + The cache tier is load-bearing for the reported planning evacuation, not only archive disposal. + With no payload, a warm renamed answer must reach the real listener's backward-move branch; a cold + cache must retain the legacy compatibility answer for untyped test stores and older emitters. + */ + it("starts execution in a renamed warm-cache wip lane when payload lanes are absent", async () => { + const { executor, store } = makeExecutor(); + const task = makeTask("TASK-CACHE-WIP"); + const execute = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined); + vi.spyOn(executor as any, "resetMergeStateIfNeeded").mockResolvedValue(task); + (store as any).laneCache = { get: vi.fn(() => ({ wip: "building" })) }; + + store.emit("task:moved", { task, from: "inbox", to: "building", source: "engine" }); + + await new Promise((resolve) => setImmediate(resolve)); + expect(execute).toHaveBeenCalledWith(task); + }); + + it("aborts work leaving a renamed warm-cache wip lane when payload lanes are absent", async () => { + const { executor, store } = makeExecutor(); + vi.spyOn(executor as any, "isBackwardMoveOutOfPlanning").mockReturnValue(false); + const abort = vi.spyOn(executor as any, "awaitAbortInFlightTaskWork").mockResolvedValue(undefined); + (store as any).laneCache = { get: vi.fn(() => ({ hold: "backlog", wip: "building" })) }; + + store.emit("task:moved", { task: makeTask("TASK-CACHE-LEAVE-WIP"), from: "building", to: "checking", source: "engine" }); + + await (executor as any).pendingTaskDisposals.get("TASK-CACHE-LEAVE-WIP"); + expect(abort).toHaveBeenCalledOnce(); + }); + + it("evacuates a renamed planner lane from the warm cache when payload lanes are absent", async () => { + const { executor, store } = makeExecutor(); + const abort = vi.spyOn(executor as any, "awaitAbortInFlightTaskWork").mockResolvedValue(undefined); + vi.spyOn(executor as any, "releasePreExecutionWorktree").mockResolvedValue(undefined); + (store as any).laneCache = { get: vi.fn(() => ({ intake: "inbox", hold: "queued", wip: "building", review: "checking", complete: "shipped" })) }; + + store.emit("task:moved", { task: makeTask("TASK-CACHE-PLAN"), from: "queued", to: "ideas", source: "user" }); + + await (executor as any).pendingTaskDisposals.get("TASK-CACHE-PLAN"); + expect(abort).toHaveBeenCalledOnce(); + expect(String(abort.mock.calls[0]?.[1] ?? "")).toContain("out of planning"); + }); + + it("uses legacy planner ids when the optional payload and cache are both absent", async () => { + const { executor, store } = makeExecutor(); + const abort = vi.spyOn(executor as any, "awaitAbortInFlightTaskWork").mockResolvedValue(undefined); + vi.spyOn(executor as any, "releasePreExecutionWorktree").mockResolvedValue(undefined); + + store.emit("task:moved", { task: makeTask("TASK-COLD-PLAN"), from: "todo", to: "ideas", source: "user" }); + + await (executor as any).pendingTaskDisposals.get("TASK-COLD-PLAN"); + expect(abort).toHaveBeenCalledOnce(); + expect(String(abort.mock.calls[0]?.[1] ?? "")).toContain("out of planning"); + }); + + it("does not treat a roleless lane answer as the legacy wip column", async () => { + const { executor, store } = makeExecutor(); + const execute = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined); + + store.emit("task:moved", { + task: makeTask("TASK-NO-WIP"), from: "parking", to: "in-progress", source: "user", lanes: { hold: "backlog" }, + }); + + await Promise.resolve(); + expect(execute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index ec1035e55b..87b5eb2c1d 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -1,8 +1,8 @@ // port-4040-allowlist: never kill port 4040. FNXC:CodeOrganization 2026-08-04-09:45: thin TaskExecutor shell (U4). export * from "./executor/executor-reexports.js"; -import { type TaskStore, type Task, type MergeResult, type TaskMoveLanes, resolvePlannerLanes, dropPreHeldExecutorSlot, wireTaskExecutorLifecycle, type TaskExecutorOptions, TaskExecutorGraphFacades } from "./executor/task-executor-imports.js"; +import { type TaskStore, type Task, type MergeResult, type TaskMoveLanes, dropPreHeldExecutorSlot, wireTaskExecutorLifecycle, type TaskExecutorOptions, TaskExecutorGraphFacades } from "./executor/task-executor-imports.js"; export class TaskExecutor extends TaskExecutorGraphFacades { - private isBackwardMoveOutOfPlanning(taskId: string, from: string, to: string, moveLanes: TaskMoveLanes | undefined): boolean { const sync = moveLanes ? undefined : resolvePlannerLanes(this.store, taskId); const lanes = { hold: moveLanes?.hold ?? sync?.hold ?? "todo", intake: moveLanes?.intake ?? sync?.intake ?? "triage", wip: moveLanes?.wip ?? sync?.wip ?? "in-progress", review: moveLanes?.review ?? sync?.review ?? "in-review", complete: moveLanes?.complete ?? sync?.complete ?? "done" }; return (from === lanes.hold || from === lanes.intake) && ![lanes.wip, lanes.review, lanes.complete].filter((c): c is string => typeof c === "string").includes(to); } + private isBackwardMoveOutOfPlanning(_taskId: string, from: string, to: string, moveLanes: TaskMoveLanes | undefined): boolean { const lanes = moveLanes ?? { hold: "todo", intake: "triage", wip: "in-progress", review: "in-review", complete: "done" }; return (from === lanes.hold || from === lanes.intake) && ![lanes.wip, lanes.review, lanes.complete].filter((c): c is string => typeof c === "string").includes(to); } setOnExecutorLogFlushed(cb: TaskExecutorOptions["onExecutorLogFlushed"]): void { this.options = { ...this.options, onExecutorLogFlushed: cb }; } constructor(store: TaskStore, rootDir: string, options: TaskExecutorOptions = {}) { super(); this.store = store; this.rootDir = rootDir; this.options = options; wireTaskExecutorLifecycle(this); } setMergeRequester(requestMerge: (taskId: string, options?: { signal?: AbortSignal }) => Promise): void { this.mergeRequester = requestMerge; } diff --git a/packages/engine/src/executor/executor-side-effect-hosts.ts b/packages/engine/src/executor/executor-side-effect-hosts.ts index 7d1aa32bb0..4ca7446ac3 100644 --- a/packages/engine/src/executor/executor-side-effect-hosts.ts +++ b/packages/engine/src/executor/executor-side-effect-hosts.ts @@ -2,7 +2,7 @@ * FNXC:CodeOrganization 2026-08-04-07:15: * Single side-effect import for TaskExecutor FNXC/doc hosts (U4) so executor.ts * does not spend a line per host module. isBackwardMoveOutOfPlanning body stays - * on TaskExecutor for inert-sync-lane (2 guards). + * on TaskExecutor for payload/cache/legacy lane tiering; no sync lane resolver is permitted. */ import "./is-backward-move-out-of-planning.js"; import "./task-executor-fields.js"; diff --git a/packages/engine/src/executor/is-backward-move-out-of-planning.ts b/packages/engine/src/executor/is-backward-move-out-of-planning.ts index 7520490787..768dda1896 100644 --- a/packages/engine/src/executor/is-backward-move-out-of-planning.ts +++ b/packages/engine/src/executor/is-backward-move-out-of-planning.ts @@ -1,8 +1,11 @@ /** * FNXC:CodeOrganization 2026-08-04-06:20: * Host for isBackwardMoveOutOfPlanning requirement history (U4). The method body stays on - * TaskExecutor so `check-inert-sync-lanes` keeps counting the two resolvePlannerLanes guards - * in executor.ts — do not free-peel that body without re-proving the inert-sync baseline. + * TaskExecutor and consumes only task:moved payload/cache lanes; it must never resolve lanes synchronously. + * + * FNXC:WorkflowResolvedColumns 2026-08-22-00:13: + * Measurement showed the PostgreSQL sync reader returns exactly the legacy defaults, so its fallback + * bought no renamed-board correctness. The listener now tiers payload, TaskLaneCache, then literals. * * FNXC:WorkflowLifecycleColumns 2026-07-30-16:55 (PR #2628 review, greptile P1): * THE FORWARD EXCLUSIONS MUST RESOLVE TOO, and leaving them literal made this branch WORSE diff --git a/packages/engine/src/executor/task-executor-graph-facades.ts b/packages/engine/src/executor/task-executor-graph-facades.ts index 661424b0ac..41a969fd00 100644 --- a/packages/engine/src/executor/task-executor-graph-facades.ts +++ b/packages/engine/src/executor/task-executor-graph-facades.ts @@ -1,7 +1,7 @@ /** * FNXC:CodeOrganization 2026-08-04-09:20: * Workflow graph / merge-boundary / graph-failure routing facades peeled from TaskExecutor (U4). - * isBackwardMoveOutOfPlanning stays on TaskExecutor for inert-sync-lane (2 guards). + * isBackwardMoveOutOfPlanning stays on TaskExecutor for payload/cache/legacy lane tiering; no sync lane resolver is permitted. */ import type { Task, TaskDetail, Settings, Agent, ResolvedTaskOutputLanguage, WorkflowIr, WorkflowColumnAgent } from "@fusion/core"; import * as impl from "./impl-bindings.js"; diff --git a/packages/engine/src/executor/task-executor-imports.ts b/packages/engine/src/executor/task-executor-imports.ts index ad5e7361c9..26df3628d2 100644 --- a/packages/engine/src/executor/task-executor-imports.ts +++ b/packages/engine/src/executor/task-executor-imports.ts @@ -12,7 +12,6 @@ export type { Agent, MergeResult, WorkflowIrNode, WorkflowIr, WorkflowColumnAgent, TaskMoveLanes, ApprovalRequestStore, } from "@fusion/core"; -export { resolvePlannerLanes } from "../execution/replan-target.js"; export type { WorkflowGraphTaskRunResult } from "../workflows/workflow-graph-task-runner.js"; export type { WorkflowLegacySeams } from "../workflows/workflow-node-handlers.js"; export type { WorkflowRuntimePrimitives } from "../execution/runtime-primitives.js"; diff --git a/packages/engine/src/executor/wire-executor-lifecycle.ts b/packages/engine/src/executor/wire-executor-lifecycle.ts index 22481ed99b..a8c693dda5 100644 --- a/packages/engine/src/executor/wire-executor-lifecycle.ts +++ b/packages/engine/src/executor/wire-executor-lifecycle.ts @@ -269,6 +269,7 @@ export function wireExecutorLifecycle(deps: WireExecutorLifecycleDeps): WireExec asserts every `task:moved` emit site supplies it. Until one of those lands, treat the fallback as a live inertness path rather than defensive dead code. */ + /* FNXC:WorkflowResolvedColumns 2026-08-22-00:13: This supersedes the prior residual-risk note: optional emitter payloads now consult TaskLaneCache before legacy ids; making lanes required and bridge forwarding remain separate follow-ups. */ deps.store.on("task:moved", ({ task, from, to, source, lanes }) => { /* FNXC:Diagnostics 2026-08-10-18:32: @@ -292,9 +293,18 @@ export function wireExecutorLifecycle(deps: WireExecutorLifecycleDeps): WireExec of this payload. `wipLane`/`archivedLane`/`holdLane` are read as SINGLE ids rather than sets because each branch below is a lane-identity test on one column, which is what the literals were. */ - const wipLane = lanes?.wip ?? "in-progress"; - const archivedLane = lanes?.archived ?? "archived"; - const holdLane = lanes?.hold ?? "todo"; + /* + FNXC:WorkflowResolvedColumns 2026-08-22-00:13: + Optional payload lanes win, then the store's synchronous TTL cache preserves the last real + answer after an emitter resolution miss; literals are only the cold-cache compatibility tier. + Runtime/project bridges re-emit on their own EventEmitters, not TaskStore, so they cannot feed + this listener and intentionally remain outside this contract. + */ + const effectiveLanes = lanes ?? deps.store.laneCache?.get(task.id); + /* FNXC:WorkflowResolvedColumns 2026-08-22-00:28: fall back only when no lane answer exists; an answer with an absent role must not invent a legacy role-named column. */ + const wipLane = effectiveLanes ? effectiveLanes.wip : "in-progress"; + const archivedLane = effectiveLanes ? effectiveLanes.archived : "archived"; + const holdLane = effectiveLanes ? effectiveLanes.hold : "todo"; if (to === wipLane) { deps.userCanceledTaskIds.delete(task.id); if (deps.recoveringCompleted.has(task.id)) { @@ -354,7 +364,7 @@ export function wireExecutorLifecycle(deps: WireExecutorLifecycleDeps): WireExec } }), ); - } else if (deps.isBackwardMoveOutOfPlanning(task.id, from, to, lanes)) { + } else if (deps.isBackwardMoveOutOfPlanning(task.id, from, to, effectiveLanes)) { /* FNXC:PlanningEvacuation 2026-07-25-23:00: A card pulled BACKWARD out of a planner lane (the reported case: todo → Ideas) must stop all diff --git a/scripts/__tests__/check-inert-sync-lane-conversions.test.mjs b/scripts/__tests__/check-inert-sync-lane-conversions.test.mjs index 5e8d49ac61..08e24fc506 100644 --- a/scripts/__tests__/check-inert-sync-lane-conversions.test.mjs +++ b/scripts/__tests__/check-inert-sync-lane-conversions.test.mjs @@ -22,7 +22,7 @@ about its output. import test from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, writeFileSync, readFileSync, copyFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -95,26 +95,19 @@ test("an unrecorded DROP fails, so the allowance cannot stay stale-high", () => assert.match(`${result.stdout}${result.stderr}`, /--update-baseline/); }); -test("a RISE still fails, and names the file it rose in", () => { - /* - FNXC:WorkflowResolvedColumns 2026-07-31-23:55: - The expected filename is DERIVED, not written down. My first version asserted `scheduler.ts`, which - #3128 then took to zero inert guards — so the case failed for a reason unrelated to the gate. The - same coupling mistake as asserting the committed baseline matches the tree, one line lower. - */ - const live = liveCounts(); - const [someFile] = Object.keys(live.byFile); - const result = withBaseline( - (baseline) => ({ - ...baseline, - total: Math.max(0, baseline.total - 1), - byFile: Object.fromEntries(Object.entries(baseline.byFile).map(([f, n]) => [f, Math.max(0, n - 1)])), - }), - runGate, - ); - - assert.equal(result.status, 1, "more inert conversions than the baseline must fail"); - assert.match(`${result.stdout}${result.stderr}`, new RegExp(someFile.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); +test("a RISE still fails, and names the staged file", () => { + const probe = join(REPO_ROOT, "packages/engine/src/__probe-inert-rise.ts"); + writeFileSync(probe, [ + `import { resolveTaskWorkflowIrSync } from "@fusion/core";`, + `function localSync(store: unknown, id: string) { return resolveTaskWorkflowIrSync(store as never, id); }`, + `export function probe(store: unknown, id: string, column: string) { const lanes = localSync(store, id); return column === lanes.hold; }`, + "", + ].join("\n")); + try { + const result = withBaseline((baseline) => ({ ...baseline, total: 0, byFile: {} }), runGate); + assert.equal(result.status, 1, "more inert conversions than the baseline must fail"); + assert.match(`${result.stdout}${result.stderr}`, /__probe-inert-rise\.ts/); + } finally { rmSync(probe, { force: true }); } }); /* @@ -122,10 +115,16 @@ ANTI-VACUITY. The two cases above mutate the baseline, so they would both keep p stopped scanning any source at all and simply compared a number to itself. This asserts the scan still finds the guards it is supposed to be counting. */ -test("the gate is still actually scanning source, not just comparing numbers", () => { - const result = runGate(); - assert.match(`${result.stdout}${result.stderr}`, /guard\(s\) consuming a sync-resolved lane/); - assert.ok(JSON.parse(readFileSync(BASELINE, "utf8")).total > 0, "baseline should not be empty"); +test("the gate scans a staged source even with a zero baseline", () => { + const probe = join(REPO_ROOT, "packages/engine/src/__probe-inert-scan.ts"); + writeFileSync(probe, [ + `import { resolveTaskWorkflowIrSync } from "@fusion/core";`, + `function localSync(store: unknown, id: string) { return resolveTaskWorkflowIrSync(store as never, id); }`, + `export function probe(store: unknown, id: string, column: string) { return column === localSync(store, id).hold; }`, + "", + ].join("\n")); + try { assert.equal(liveCounts().byFile["packages/engine/src/__probe-inert-scan.ts"], 1); } + finally { rmSync(probe, { force: true }); } }); /* @@ -303,3 +302,37 @@ test("does NOT count an object whose KEY merely shares a sync local's name", () rmSync(probe, { force: true }); } }); + +test("counts a tainted role inside an array/filter membership receiver once", () => { + const probe = join(REPO_ROOT, "packages/engine/src/__probe-inert-membership.ts"); + writeFileSync(probe, [ + `import { resolveTaskWorkflowIrSync } from "@fusion/core";`, + `function localSync(store: unknown, id: string) { return resolveTaskWorkflowIrSync(store as never, id); }`, + `export function probe(store: unknown, id: string, column: string) { const lanes = localSync(store, id); return ![lanes.wip, lanes.review].filter(Boolean).includes(column); }`, + "", + ].join("\n")); + try { assert.equal(liveCounts().byFile["packages/engine/src/__probe-inert-membership.ts"], 1); } + finally { rmSync(probe, { force: true }); } +}); + +/* +FNXC:WorkflowResolvedColumns 2026-08-22-00:30: +A zero baseline must not turn a renamed or moved reader into a permanent green. Run a copied checker +from an empty in-repository tree so its own repository-root calculation sees no source while Node can +still resolve TypeScript through this workspace's node_modules. +*/ +test("fails closed when the scanned tree has no sync-lane source", () => { + const root = mkdtempSync(join(REPO_ROOT, ".inert-sync-empty-")); + const script = join(root, "scripts/check-inert-sync-lane-conversions.mjs"); + try { + mkdirSync(join(root, "scripts/lib"), { recursive: true }); + mkdirSync(join(root, "packages"), { recursive: true }); + copyFileSync(SCRIPT, script); + writeFileSync(join(root, "scripts/lib/inert-sync-lane-baseline.json"), "{\n \"total\": 0,\n \"byFile\": {}\n}\n"); + const result = spawnSync(process.execPath, [script], { cwd: root, encoding: "utf8" }); + assert.equal(result.status, 1); + assert.match(`${result.stdout}${result.stderr}`, /no resolveTaskWorkflowIrSync source found under/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/check-inert-sync-lane-conversions.mjs b/scripts/check-inert-sync-lane-conversions.mjs index ab82af69ff..9552a6baa6 100644 --- a/scripts/check-inert-sync-lane-conversions.mjs +++ b/scripts/check-inert-sync-lane-conversions.mjs @@ -332,6 +332,16 @@ function countInertGuards(sf, locals, sources) { } return undefined; }; + const consumptionWithin = (expr) => { + let found; + const walk = (candidate) => { + if (found) return; + found = consumesLocal(candidate); + if (!found) ts.forEachChild(candidate, walk); + }; + walk(expr); + return found; + }; const visit = (node) => { /* `to === parked.review` — one lane id, compared. */ if (ts.isBinaryExpression(node)) { @@ -348,7 +358,8 @@ function countInertGuards(sf, locals, sources) { if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { const method = node.expression.name.getText(sf); if (method === "has" || method === "includes") { - const hit = consumesLocal(node.expression.expression); + /* FNXC:LifecycleColumnCensus 2026-08-22-00:13: membership receivers can be array/filter expressions; inspect their contained role read and count this membership site once. */ + const hit = consumptionWithin(node.expression.expression); if (hit) { hits.push({ line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1, expr: `${hit}.${method}(...)` }); } @@ -371,6 +382,12 @@ for (const file of files) { for (const name of syncLaneSources(sf)) sources.add(name); } +/* A zero baseline is only meaningful while the scanner still sees a source to police. */ +if (sources.size === 0) { + console.error(`inert-sync-lane: no ${SYNC_IR_READER} source found under ${join(REPO, "packages")}`); + process.exit(1); +} + /* PASS 2 — guards consuming any of them, anywhere. */ const byFile = {}; const detail = {}; diff --git a/scripts/lib/inert-sync-lane-baseline.json b/scripts/lib/inert-sync-lane-baseline.json index e755b05c52..1d5172ac4a 100644 --- a/scripts/lib/inert-sync-lane-baseline.json +++ b/scripts/lib/inert-sync-lane-baseline.json @@ -1,6 +1,4 @@ { - "total": 2, - "byFile": { - "packages/engine/src/executor.ts": 2 - } + "total": 0, + "byFile": {} }