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

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