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:
7
.changeset/fn-126-inert-sync-lane.md
Normal file
7
.changeset/fn-126-inert-sync-lane.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"total": 2,
|
||||
"byFile": {
|
||||
"packages/engine/src/executor.ts": 2
|
||||
}
|
||||
"total": 0,
|
||||
"byFile": {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user