fix(FN-7225): auto-retry stale parse pause aborts
This commit is contained in:
7
.changeset/fn-7225-parse-pause-retry.md
Normal file
7
.changeset/fn-7225-parse-pause-retry.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Auto-retry stale parse pause-resume workflow failures instead of requiring operator action.
|
||||||
|
category: fix
|
||||||
|
dev: Re-enters safe in-review parse pause-abort replays with the shared graph resume retry budget.
|
||||||
@@ -448,6 +448,73 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => {
|
|||||||
expect(graphSpy).not.toHaveBeenCalled();
|
expect(graphSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("auto-retries a stale in-review parse pause/resume replay instead of requiring operator action", async () => {
|
||||||
|
const { store, task, executor } = makeHarness({ column: "in-review", graphResumeRetryCount: 0 });
|
||||||
|
(executor as any).addActiveWorktree(task.id, task.worktree);
|
||||||
|
const graphSpy = vi.spyOn(executor as any, "maybeExecuteWorkflowGraph").mockResolvedValue(true);
|
||||||
|
|
||||||
|
await invokeGraphFailure(executor, task, {
|
||||||
|
visitedNodeIds: ["parse"],
|
||||||
|
context: { "node:parse:value": "aborted" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const parkedFailed = store.updateTask.mock.calls.some(
|
||||||
|
(call: unknown[]) => (call[1] as { status?: string } | undefined)?.status === "failed",
|
||||||
|
);
|
||||||
|
expect(parkedFailed).toBe(false);
|
||||||
|
expect(logText(store)).toContain("parse node pause/resume replay surfaced after task was already in-review — auto-retrying workflow graph (1/2)");
|
||||||
|
expect(logText(store)).toContain("Auto-recovered: retrying stale in-review parse pause/resume replay");
|
||||||
|
expect(logText(store)).not.toContain("operator action required");
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith(task.id, {
|
||||||
|
graphResumeRetryCount: 1,
|
||||||
|
status: null,
|
||||||
|
error: null,
|
||||||
|
}, undefined);
|
||||||
|
expect((executor as any).pausedAborted.has(task.id)).toBe(false);
|
||||||
|
expect((executor as any).activeWorktrees.has(task.id)).toBe(false);
|
||||||
|
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
mutationType: "task:retry-stale-in-review-parse-pause-abort-replay",
|
||||||
|
metadata: expect.objectContaining({
|
||||||
|
nodeId: "parse",
|
||||||
|
fromColumn: "in-review",
|
||||||
|
attempt: 1,
|
||||||
|
abortProvenance: "hard-cancel",
|
||||||
|
clearedStaleFailure: false,
|
||||||
|
mode: "preserved-in-review-retry-graph",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
await flushScheduledRetry();
|
||||||
|
expect(graphSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a prior stale operator-action failure for an in-review parse replay and retries", async () => {
|
||||||
|
const { store, task, executor } = makeHarness({
|
||||||
|
column: "in-review",
|
||||||
|
status: "failed",
|
||||||
|
error: "Workflow graph failure surfaced after paused engine abort during pause/resume in 'in-review' at node 'parse' — operator action required; retry or explicitly unpause/resume after inspecting the task",
|
||||||
|
});
|
||||||
|
const graphSpy = vi.spyOn(executor as any, "maybeExecuteWorkflowGraph").mockResolvedValue(true);
|
||||||
|
|
||||||
|
await invokeGraphFailure(executor, task, {
|
||||||
|
visitedNodeIds: ["parse"],
|
||||||
|
context: { "node:parse:value": "aborted" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(logText(store)).toContain("Auto-recovered: retrying stale in-review parse pause/resume replay");
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith(task.id, {
|
||||||
|
graphResumeRetryCount: 1,
|
||||||
|
status: null,
|
||||||
|
error: null,
|
||||||
|
}, undefined);
|
||||||
|
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
mutationType: "task:retry-stale-in-review-parse-pause-abort-replay",
|
||||||
|
metadata: expect.objectContaining({ nodeId: "parse", clearedStaleFailure: true }),
|
||||||
|
}));
|
||||||
|
store.getTask.mockResolvedValue({ ...task, status: null, error: null, graphResumeRetryCount: 1 });
|
||||||
|
await flushScheduledRetry();
|
||||||
|
expect(graphSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps manual retry of a prior plan pause-abort park in review instead of fresh planning", async () => {
|
it("keeps manual retry of a prior plan pause-abort park in review instead of fresh planning", async () => {
|
||||||
const { store, task, executor } = makeHarness({
|
const { store, task, executor } = makeHarness({
|
||||||
column: "in-review",
|
column: "in-review",
|
||||||
|
|||||||
@@ -6995,12 +6995,12 @@ export class TaskExecutor {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private isStalePauseAbortParkFailure(live: TaskDetail): boolean {
|
private isStalePauseAbortParkFailure(live: TaskDetail, nodeId = "plan"): boolean {
|
||||||
return live.status === "failed"
|
return live.status === "failed"
|
||||||
&& typeof live.error === "string"
|
&& typeof live.error === "string"
|
||||||
&& live.error.includes(PAUSE_ABORT_PARK_ERROR_MARKER)
|
&& live.error.includes(PAUSE_ABORT_PARK_ERROR_MARKER)
|
||||||
&& live.error.includes("engine abort during pause/resume")
|
&& live.error.includes("engine abort during pause/resume")
|
||||||
&& live.error.includes("at node 'plan'");
|
&& live.error.includes(`at node '${nodeId}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleStaleInReviewPlanPauseAbortReplay(
|
private async handleStaleInReviewPlanPauseAbortReplay(
|
||||||
@@ -7031,7 +7031,7 @@ export class TaskExecutor {
|
|||||||
if (failureValue !== "aborted") return false;
|
if (failureValue !== "aborted") return false;
|
||||||
if (this.isTerminalMergeGraphFailureValue(failureValue)) return false;
|
if (this.isTerminalMergeGraphFailureValue(failureValue)) return false;
|
||||||
const cleanRow = live.status == null && live.error == null;
|
const cleanRow = live.status == null && live.error == null;
|
||||||
const staleParkedFailure = this.isStalePauseAbortParkFailure(live);
|
const staleParkedFailure = this.isStalePauseAbortParkFailure(live, "plan");
|
||||||
if (!cleanRow && !staleParkedFailure) return false;
|
if (!cleanRow && !staleParkedFailure) return false;
|
||||||
let settings: Settings;
|
let settings: Settings;
|
||||||
try {
|
try {
|
||||||
@@ -7075,6 +7075,112 @@ export class TaskExecutor {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleStaleInReviewParsePauseAbortReplay(
|
||||||
|
live: TaskDetail,
|
||||||
|
result: WorkflowGraphTaskRunResult,
|
||||||
|
abortProvenance: "global-pause" | "merge-seam" | "hard-cancel" | "completion-finalize" | undefined,
|
||||||
|
pausedAborted: boolean,
|
||||||
|
userCanceled: boolean,
|
||||||
|
): Promise<boolean> {
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowLifecycle 2026-06-29-01:18:
|
||||||
|
A stale in-review pause/resume replay at `parse` is not an operator action. Unlike `plan`, parse is a safe workflow re-entry point for review rows, so auto-retry the graph with the shared transient resume budget and suppress the parked failure notification.
|
||||||
|
*/
|
||||||
|
if (!pausedAborted) return false;
|
||||||
|
if (abortProvenance !== "hard-cancel" && abortProvenance !== "global-pause") return false;
|
||||||
|
if (userCanceled) return false;
|
||||||
|
if (live.column !== "in-review") return false;
|
||||||
|
if (live.paused || live.userPaused === true) return false;
|
||||||
|
if (live.autoMerge === false) return false;
|
||||||
|
if (live.mergeDetails?.mergeConfirmed === true) return false;
|
||||||
|
if (result.interruptedAbortKind && result.interruptedAbortKind !== WORKFLOW_NODE_ENGINE_PAUSE_ABORT_KIND) return false;
|
||||||
|
const failedNode = result.interruptedNodeId ?? result.visitedNodeIds[result.visitedNodeIds.length - 1];
|
||||||
|
if (failedNode !== "parse") return false;
|
||||||
|
const failureValue = typeof result.context?.[`node:${failedNode}:value`] === "string"
|
||||||
|
? result.context[`node:${failedNode}:value`] as string
|
||||||
|
: this.graphFailureValue(result);
|
||||||
|
if (failureValue !== "aborted") return false;
|
||||||
|
if (this.isTerminalMergeGraphFailureValue(failureValue)) return false;
|
||||||
|
const cleanRow = live.status == null && live.error == null;
|
||||||
|
const staleParkedFailure = this.isStalePauseAbortParkFailure(live, "parse");
|
||||||
|
if (!cleanRow && !staleParkedFailure) return false;
|
||||||
|
const priorRetries = live.graphResumeRetryCount ?? 0;
|
||||||
|
if (priorRetries >= MAX_TRANSIENT_GRAPH_RESUME_RETRIES) return false;
|
||||||
|
let settings: Settings;
|
||||||
|
try {
|
||||||
|
settings = await this.store.getSettings();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (settings.globalPause === true || settings.enginePaused === true) return false;
|
||||||
|
if (!allowsAutoMergeProcessing(live, settings) && !isSharedBranchGroupMemberIntegration(live)) return false;
|
||||||
|
|
||||||
|
const nextRetries = priorRetries + 1;
|
||||||
|
this.clearPausedAborted(live.id);
|
||||||
|
this.activeWorktrees.delete(live.id);
|
||||||
|
const message = `Workflow graph parse node pause/resume replay surfaced after task was already in-review — auto-retrying workflow graph (${nextRetries}/${MAX_TRANSIENT_GRAPH_RESUME_RETRIES})`;
|
||||||
|
executorLog.log(`${live.id}: ${message}`);
|
||||||
|
await this.store.logEntry(live.id, message, undefined, this.getRunContextFor(live.id));
|
||||||
|
await this.store.logEntry(live.id, "Auto-recovered: retrying stale in-review parse pause/resume replay — failure notification suppressed", undefined, this.getRunContextFor(live.id));
|
||||||
|
await this.store.updateTask(live.id, { graphResumeRetryCount: nextRetries, status: null, error: null }, this.getRunContextFor(live.id));
|
||||||
|
try {
|
||||||
|
await this.store.recordRunAuditEvent?.({
|
||||||
|
taskId: live.id,
|
||||||
|
agentId: "executor",
|
||||||
|
runId: generateSyntheticRunId("workflow-stale-parse-retry", live.id),
|
||||||
|
domain: "database",
|
||||||
|
mutationType: "task:retry-stale-in-review-parse-pause-abort-replay",
|
||||||
|
target: live.id,
|
||||||
|
metadata: {
|
||||||
|
nodeId: failedNode,
|
||||||
|
fromColumn: live.column,
|
||||||
|
attempt: nextRetries,
|
||||||
|
maxAttempts: MAX_TRANSIENT_GRAPH_RESUME_RETRIES,
|
||||||
|
abortProvenance: abortProvenance ?? "unknown",
|
||||||
|
clearedStaleFailure: staleParkedFailure,
|
||||||
|
mode: "preserved-in-review-retry-graph",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
executorLog.warn(`${live.id}: failed to record stale parse replay retry audit: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
await this.persistTokenUsage(live.id);
|
||||||
|
|
||||||
|
const scheduleRetry = () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const resumeTask = await this.store.getTask(live.id);
|
||||||
|
if (
|
||||||
|
resumeTask.deletedAt
|
||||||
|
|| resumeTask.paused
|
||||||
|
|| resumeTask.userPaused
|
||||||
|
|| resumeTask.status != null
|
||||||
|
|| resumeTask.error != null
|
||||||
|
|| resumeTask.column !== "in-review"
|
||||||
|
|| this.activeSessions.has(live.id)
|
||||||
|
|| this.activeStepExecutors.has(live.id)
|
||||||
|
|| this.activeWorkflowStepSessions.has(live.id)
|
||||||
|
|| this.activeWorkflowGraphAbortControllers.has(live.id)
|
||||||
|
|| TaskExecutor.processWideGraphRouting.has(live.id)
|
||||||
|
) {
|
||||||
|
executorLog.log(`${live.id}: skipping stale parse graph retry — task is no longer in a safe in-review resume state`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.maybeExecuteWorkflowGraph(resumeTask);
|
||||||
|
} catch (err) {
|
||||||
|
executorLog.error(`Failed stale parse graph retry for ${live.id}:`, err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
if (TRANSIENT_GRAPH_RESUME_RETRY_BACKOFF_MS > 0) {
|
||||||
|
const handle = setTimeout(scheduleRetry, TRANSIENT_GRAPH_RESUME_RETRY_BACKOFF_MS);
|
||||||
|
handle.unref?.();
|
||||||
|
} else {
|
||||||
|
setTimeout(scheduleRetry, 0).unref?.();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private async isReentrantPausedAbortedInFlightNode(
|
private async isReentrantPausedAbortedInFlightNode(
|
||||||
live: TaskDetail,
|
live: TaskDetail,
|
||||||
result: WorkflowGraphTaskRunResult,
|
result: WorkflowGraphTaskRunResult,
|
||||||
@@ -7318,6 +7424,9 @@ export class TaskExecutor {
|
|||||||
await this.persistTokenUsage(task.id);
|
await this.persistTokenUsage(task.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (genuinePauseAbort && await this.handleStaleInReviewParsePauseAbortReplay(live, result, abortProvenance, pausedAborted, this.userCanceledTaskIds.has(task.id))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (genuinePauseAbort && await this.handleStaleInReviewPlanPauseAbortReplay(live, result, abortProvenance, pausedAborted, this.userCanceledTaskIds.has(task.id))) {
|
if (genuinePauseAbort && await this.handleStaleInReviewPlanPauseAbortReplay(live, result, abortProvenance, pausedAborted, this.userCanceledTaskIds.has(task.id))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user