Files
fusion/packages/engine/src/__tests__/executor-graph-requeue-gate.test.ts
gsxdsm ca88a6c6a2 fix: don't flag a task failed while its code-review remediation is still executing
A pre-merge-remediation/plan-replan node (e.g. code-review-remediation) is a
fire-and-forget async scheduler with no failure out-edge. When its schedule call
can't re-arm (missing rehydrated failureContext after restart,
remediation-not-scheduled, or an exhausted rework budget), the failure bubbled
out as the terminal graph outcome and handleGraphFailure stamped status:"failed"
— surfacing a spurious "Task Failed" even while the previously-scheduled
fix/reviewer session was still live.

Guard the terminal sink: skip the failed park when the failed node is a
remediation node AND a live agent session surface is still registered for the
task. Scoped via isRemediationGraphNode (IR workflowAction + built-in node-id
fallback) and hasLiveTaskSessionSurface; genuine execute/merge failures and
remediation failures with no live session still park failed unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:30:48 -07:00

287 lines
9.7 KiB
TypeScript

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(),
);
});
it("moves in-review graph failures with incomplete steps back to todo for resume", async () => {
resetExecutorMocks();
const store = createMockStore();
const live = task({
id: "FN-7228",
column: "in-review",
status: "failed",
error: "Workflow graph terminated with failure at node 'parse'",
steps: [
{ name: "Preflight", status: "in-progress" },
{ name: "Implement", status: "in-progress" },
{ name: "Testing & Verification", status: "pending" },
],
});
store.getTask.mockResolvedValue(live);
const executor = new TaskExecutor(store, "/tmp/test");
/*
* FNXC:WorkflowLifecycle 2026-06-29-11:12:
* FN-7228/FN-7229 proved that restart-time graph failures can surface after a
* stale handoff put the card in `in-review` with unfinished steps. Review is
* not an error bucket; bounce that shape back to `todo` preserving step
* progress so the engine can resume the correct unfinished step.
*/
await (executor as any).handleGraphFailure(live, {
disposition: "failed",
outcome: "failure",
visitedNodeIds: ["parse"],
context: { "node:parse:value": "parse-error" },
});
expect(store.updateTask).toHaveBeenCalledWith(
live.id,
expect.objectContaining({ status: null, error: null }),
undefined,
);
expect(store.moveTask).toHaveBeenCalledWith(
live.id,
"todo",
expect.objectContaining({ preserveProgress: true, moveSource: "engine", recoveryRehome: true }),
);
expect(store.handoffToReview).not.toHaveBeenCalled();
});
it("moves premature merge failures with incomplete in-progress steps back to todo", async () => {
resetExecutorMocks();
const store = createMockStore();
const live = task({
id: "FN-7261",
column: "in-progress",
status: null,
error: null,
steps: [
{ name: "Preflight", status: "in-progress" },
{ name: "Implement", status: "pending" },
],
});
store.getTask.mockResolvedValue(live);
const executor = new TaskExecutor(store, "/tmp/test");
/*
* FNXC:WorkflowMerge 2026-06-29-23:18:
* Fast-mode graph traversal must not turn an unfinished legacy checklist into a no-op merge. If the merge node is reached before implementation proof exists, recover by requeueing executable work instead of parking the task failed in-progress.
*/
await (executor as any).handleGraphFailure(live, {
disposition: "failed",
outcome: "failure",
visitedNodeIds: ["merge"],
context: { "node:merge:value": "implementation-incomplete" },
});
expect(store.updateTask).toHaveBeenCalledWith(
live.id,
expect.objectContaining({ status: null, error: null }),
undefined,
);
expect(store.moveTask).toHaveBeenCalledWith(
live.id,
"todo",
expect.objectContaining({ preserveProgress: true, moveSource: "engine", recoveryRehome: true }),
);
expect(store.updateTask).not.toHaveBeenCalledWith(
live.id,
expect.objectContaining({ status: "failed" }),
expect.anything(),
);
});
it("does not flag a remediation-node graph failure as failed while a live agent session is executing", async () => {
resetExecutorMocks();
const store = createMockStore();
const live = task({
id: "FN-REMEDIATION-LIVE",
column: "in-progress",
steps: [{ name: "Implement", status: "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:WorkflowRemediation 2026-07-01-23:40:
* `code-review-remediation` is a fire-and-forget async scheduler with no
* `failure` out-edge; a failed re-arm bubbles out as the terminal graph
* outcome. When a SEPARATE live agent session surface is still registered
* (the previously-scheduled fix/reviewer is mid-flight), the terminal sink
* must NOT stamp `status:"failed"` over live work.
*/
(executor as any).activeSessions.set(live.id, { session: {} });
await (executor as any).handleGraphFailure(live, {
disposition: "failed",
outcome: "failure",
visitedNodeIds: ["code-review", "code-review-remediation"],
context: {},
});
expect(store.updateTask).not.toHaveBeenCalledWith(
live.id,
expect.objectContaining({ status: "failed" }),
expect.anything(),
);
expect(store.logEntry).toHaveBeenCalledWith(
live.id,
expect.stringContaining("not flagging as failed"),
undefined,
undefined,
);
expect(store.moveTask).not.toHaveBeenCalledWith(live.id, "in-review", expect.anything());
});
it("still parks a remediation-node graph failure as failed when NO live session exists", async () => {
resetExecutorMocks();
const store = createMockStore();
const live = task({
id: "FN-REMEDIATION-DEAD",
column: "in-progress",
steps: [{ name: "Implement", status: "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");
// Surface enumeration: the guard is scoped to a LIVE session surface. With
// no session (e.g. a genuinely exhausted rework budget), the remediation
// failure remains terminal and parks failed exactly as before.
await (executor as any).handleGraphFailure(live, {
disposition: "failed",
outcome: "failure",
visitedNodeIds: ["code-review", "code-review-remediation"],
context: {},
});
expect(store.updateTask).toHaveBeenCalledWith(
live.id,
expect.objectContaining({
status: "failed",
error: expect.stringContaining("Workflow graph terminated with failure at node 'code-review-remediation'"),
}),
undefined,
);
});
it("does not hand generic graph failures to review", async () => {
resetExecutorMocks();
const store = createMockStore();
const live = task({
id: "FN-7229",
column: "in-progress",
steps: [
{ name: "Preflight", status: "done" },
{ name: "Implement", status: "in-progress" },
],
});
store.getTask.mockResolvedValue(live);
const executor = new TaskExecutor(store, "/tmp/test");
await (executor as any).handleGraphFailure(live, {
disposition: "failed",
outcome: "failure",
visitedNodeIds: ["parse"],
context: { "node:parse:value": "parse-error" },
});
expect(store.updateTask).toHaveBeenCalledWith(
live.id,
expect.objectContaining({
status: "failed",
error: expect.stringContaining("Workflow graph terminated with failure at node 'parse'"),
}),
undefined,
);
expect(store.handoffToReview).not.toHaveBeenCalled();
expect(store.moveTask).not.toHaveBeenCalledWith(live.id, "in-review", expect.anything());
});
});