test(engine): cover workflow cutover recovery guards

This commit is contained in:
gsxdsm
2026-06-22 22:22:37 -07:00
parent e60b1378b8
commit acf0fff413
4 changed files with 125 additions and 4 deletions

View File

@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";
import type { TaskDetail } from "@fusion/core";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import { createMockStore, resetExecutorMocks } from "./executor-test-helpers.js";
const now = "2026-06-23T00:00:00.000Z";
function task(overrides: Partial<TaskDetail> = {}): TaskDetail {
return {
id: "FN-GRAPH-REQUEUE",
title: "Graph execute recovery",
description: "Gate coverage for execute-node self-requeue preservation",
column: "in-progress",
dependencies: [],
steps: [{ name: "Implement", status: "pending" }],
currentStep: 0,
log: [],
branch: "fusion/fn-graph-requeue",
baseBranch: "main",
worktree: "/tmp/fusion-fn-graph-requeue",
status: null,
error: null,
paused: false,
userPaused: false,
autoMerge: true,
mergeRetries: 0,
createdAt: now,
updatedAt: now,
...overrides,
} as TaskDetail;
}
describe("executor graph execute self-requeue gate", () => {
it("preserves executor todo recovery when the live refetch is stale in-progress", async () => {
resetExecutorMocks();
const store = createMockStore();
const live = task({ column: "in-progress" });
store.getTask.mockResolvedValue(live);
store.getSettings.mockResolvedValue({
autoMerge: true,
maxAutoMergeRetries: 3,
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
});
const executor = new TaskExecutor(store, "/tmp/test");
/*
FNXC:WorkflowLifecycle 2026-06-23-23:03:
The workflow cutover gate must directly cover the graph execute self-requeue guard. A stale live `in-progress` refetch after an inner executor moved the task to `todo` must not be parked in review or marked failed.
*/
(executor as any).graphRouting.add(live.id);
(executor as any).markGraphExecuteSelfRequeued(live.id);
try {
await (executor as any).handleGraphFailure(live, {
disposition: "failed",
outcome: "failure",
visitedNodeIds: ["execute"],
context: { "node:execute:value": "recoverable" },
});
} finally {
(executor as any).graphRouting.delete(live.id);
}
expect(store.logEntry).toHaveBeenCalledWith(
live.id,
expect.stringContaining("executor recovery preserved"),
undefined,
undefined,
);
expect(store.moveTask).not.toHaveBeenCalledWith(live.id, "in-review", expect.anything());
expect(store.updateTask).not.toHaveBeenCalledWith(
live.id,
expect.objectContaining({ status: "failed" }),
expect.anything(),
);
});
});

View File

@@ -146,6 +146,42 @@ describe("hold-release sweep (U6)", () => {
expect(release).not.toHaveBeenCalled();
});
it("releases reservations when an eventless move returns no task row", async () => {
const held = {
id: "FN-778",
title: "Held void",
description: "",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as Task;
const release = vi.fn();
const fakeStore = {
getSettings: vi.fn(async () => ({
maxConcurrent: 4,
experimentalFeatures: { workflowColumns: true },
})),
listTasks: vi.fn(async () => [held]),
moveTask: vi.fn(async () => undefined),
getTaskWorkflowSelection: vi.fn(() => null),
on: vi.fn(),
off: vi.fn(),
} as unknown as TaskStore;
const result = await runHoldReleaseSweep(fakeStore, {
now: () => Date.now(),
reserveSlot: () => ({ release }),
});
expect(result.released).toEqual([]);
expect(result.held).toEqual([{ taskId: "FN-778", reason: "move-rejected-or-no-slot" }]);
expect(release).toHaveBeenCalledTimes(1);
});
it("two holds, one slot: exactly one releases; the other releases next sweep after the slot frees", async () => {
await store.updateSettings({ maxConcurrent: 1 } as Parameters<typeof store.updateSettings>[0]);
const a = await seedTodoCard();

View File

@@ -453,12 +453,14 @@ async function issueRelease(
FNXC:WorkflowScheduling 2026-06-23-22:39:
Eventless-release fallback is scoped to the current task. Other cards moving to the same target column during the same sweep must not disable this task's fallback and leak its reservation.
FNXC:WorkflowScheduling 2026-06-23-22:59:
Void-returning legacy stores are ambiguous: no event plus no returned task cannot prove the current task moved. Require a returned current-task row before keeping the reservation so same-column no-ops do not leak slots.
*/
const returnedMovedTask = !sawMovedEventForTask
&& (
result === undefined
|| (result.id === task.id && result.column === target && originalColumn !== target)
);
&& result?.id === task.id
&& result.column === target
&& originalColumn !== target;
if (reservation && !movedTaskObjects.has(result) && !returnedMovedTask) {
// Same-column no-op: a racing sweep already moved this card to the target.
reservation.release();

View File

@@ -73,7 +73,11 @@ export default defineConfig({
/*
FNXC:EngineTests 2026-06-23-10:48:
Workflow columns and workflow graph execution are now the default runtime. Retire the legacy direct-dispatch executor/scheduler gate files and gate the new hold-release plus graph interpreter seams instead.
FNXC:EngineTests 2026-06-23-23:04:
The cutover gate must also keep one direct executor recovery guard for graph execute self-requeue preservation. This protects the new marker path after retiring the broad legacy executor recovery gate file.
*/
"src/__tests__/executor-graph-requeue-gate.test.ts",
"src/__tests__/hold-release.test.ts",
"src/__tests__/workflow-graph-task-runner.test.ts",
"src/__tests__/workflow-graph-executor-parity.test.ts",