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 <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-22 01:39:23 +00:00
parent 8b681775ae
commit 33f4797ceb
18 changed files with 479 additions and 46 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<typeof import("../workflows/workflow-ir-resolver.js")>();
return {
...original,
resolveWorkflowIrForTask: (...args: Parameters<typeof original.resolveWorkflowIrForTask>) => {
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<ReturnType<typeof createTaskStoreForTest>>["store"], taskId: string) => Promise<void>,
): Promise<void> {
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();
}
});
});

View File

@@ -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);

View File

@@ -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):

View File

@@ -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 });
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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<MergeResult>): void { this.mergeRequester = requestMerge; }

View File

@@ -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";

View File

@@ -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

View File

@@ -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";

View File

@@ -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";

View File

@@ -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

View File

@@ -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 });
}
});

View File

@@ -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 = {};

View File

@@ -1,6 +1,4 @@
{
"total": 2,
"byFile": {
"packages/engine/src/executor.ts": 2
}
"total": 0,
"byFile": {}
}