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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user