From acf0fff4139f59b379969f358166f4aec8013390 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 22:22:37 -0700 Subject: [PATCH] test(engine): cover workflow cutover recovery guards --- .../executor-graph-requeue-gate.test.ts | 79 +++++++++++++++++++ .../engine/src/__tests__/hold-release.test.ts | 36 +++++++++ packages/engine/src/hold-release.ts | 10 ++- packages/engine/vitest.config.ts | 4 + 4 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 packages/engine/src/__tests__/executor-graph-requeue-gate.test.ts diff --git a/packages/engine/src/__tests__/executor-graph-requeue-gate.test.ts b/packages/engine/src/__tests__/executor-graph-requeue-gate.test.ts new file mode 100644 index 0000000000..65ddd1a5fa --- /dev/null +++ b/packages/engine/src/__tests__/executor-graph-requeue-gate.test.ts @@ -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 { + 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(), + ); + }); +}); diff --git a/packages/engine/src/__tests__/hold-release.test.ts b/packages/engine/src/__tests__/hold-release.test.ts index f813f77b25..43ffc1a259 100644 --- a/packages/engine/src/__tests__/hold-release.test.ts +++ b/packages/engine/src/__tests__/hold-release.test.ts @@ -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[0]); const a = await seedTodoCard(); diff --git a/packages/engine/src/hold-release.ts b/packages/engine/src/hold-release.ts index 637166fb06..c00481d9b2 100644 --- a/packages/engine/src/hold-release.ts +++ b/packages/engine/src/hold-release.ts @@ -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(); diff --git a/packages/engine/vitest.config.ts b/packages/engine/vitest.config.ts index 83bafab4d8..e641118710 100644 --- a/packages/engine/vitest.config.ts +++ b/packages/engine/vitest.config.ts @@ -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",