test(engine): cover workflow cutover recovery guards
This commit is contained in:
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user