feat(FN-4534): add auto-recovery dispatcher and pause-site wiring

Fusion-Task-Id: FN-4534
Fusion-Task-Lineage: 2b46a5df-efc6-4305-a0f2-69991e58512c
This commit is contained in:
Fusion
2026-05-14 17:47:34 -07:00
committed by gsxdsm
parent 55903924ac
commit 79eb74ca7e
12 changed files with 510 additions and 32 deletions

View File

@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from "vitest";
import type { AutoRecoverySettings, Task } from "@fusion/core";
import { AutoRecoveryDispatcher, type AutoRecoveryFailure } from "../auto-recovery.js";
const task = { id: "FN-1", recoveryRetryCount: 0 } as Task;
function createDispatcher() {
const database = vi.fn(async () => {});
const dispatcher = new AutoRecoveryDispatcher({
taskStore: {} as never,
auditEmitter: { database, git: vi.fn(), filesystem: vi.fn() },
});
return { dispatcher, database };
}
const classes: AutoRecoveryFailure["class"][] = [
"file-scope-invariant",
"post-squash-audit-blocker",
"branch-cross-contamination",
"branch-conflict-tripwire",
"branch-conflict-recovery-exhausted",
"branch-conflict-unrecoverable",
];
describe("auto-recovery dispatcher", () => {
it.each(classes)("mode off preserves pause contract for %s", (klass) => {
const { dispatcher } = createDispatcher();
const decision = dispatcher.classify({ class: klass, taskId: "FN-1", pausedReason: "legacy-reason" }, {
task,
retryCount: 0,
settings: { mode: "off", maxRetries: 3 },
});
expect(decision.action).toBe("pause");
expect(decision.legacyPausedReason).toBe("legacy-reason");
expect(decision.rationale).toBe("auto-recovery-disabled");
});
it("per-class override beats global mode", () => {
const { dispatcher } = createDispatcher();
const settings: AutoRecoverySettings = {
mode: "deterministic-only",
perClass: { "branch-conflict-unrecoverable": "programmatic" },
maxRetries: 3,
};
const decision = dispatcher.classify({ class: "branch-conflict-unrecoverable", taskId: "FN-1", pausedReason: "branch-conflict-unrecoverable" }, { task, retryCount: 0, settings });
expect(decision.action).toBe("retry");
});
it("forces pause on retry budget exhausted", () => {
const { dispatcher } = createDispatcher();
const decision = dispatcher.classify({ class: "branch-conflict-tripwire", taskId: "FN-1", pausedReason: "branch-conflict-tripwire" }, {
task,
retryCount: 3,
settings: { mode: "programmatic", maxRetries: 3 },
});
expect(decision.action).toBe("pause");
expect(decision.rationale).toBe("retry-budget-exhausted");
});
it("forces pause on destructive ambiguity", () => {
const { dispatcher } = createDispatcher();
const decision = dispatcher.classify({ class: "branch-cross-contamination", taskId: "FN-1", pausedReason: "branch-cross-contamination", evidence: { ownCommits: 1, foreignAttributedCommits: 1 } }, {
task,
retryCount: 0,
settings: { mode: "ai-assisted", maxRetries: 3 },
});
expect(decision.action).toBe("pause");
expect(decision.rationale).toBe("destructive-ambiguity");
});
it("dispatch falls back to pause when handler missing", async () => {
const { dispatcher, database } = createDispatcher();
const decision = await dispatcher.dispatch({ class: "branch-conflict-unrecoverable", taskId: "FN-1", pausedReason: "branch-conflict-unrecoverable" }, {
task,
retryCount: 0,
settings: { mode: "programmatic", maxRetries: 3 },
});
expect(decision.action).toBe("pause");
expect(decision.rationale).toBe("handler-not-registered");
expect(database).toHaveBeenCalledTimes(1);
expect(database.mock.calls[0]?.[0]).toMatchObject({
type: "auto-recovery:classify-decision",
metadata: expect.objectContaining({ class: "branch-conflict-unrecoverable", mode: "programmatic", retryCount: 0 }),
});
});
});

View File

@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from "vitest";
import type { Task } from "@fusion/core";
import { AutoRecoveryDispatcher } from "../../auto-recovery.js";
const baseTask = { id: "FN-1", recoveryRetryCount: 0 } as Task;
describe("reliability interaction: auto-recovery dispatcher precedence", () => {
it("mode off preserves legacy pausedReason contract across wired classes", () => {
const dispatcher = new AutoRecoveryDispatcher({
taskStore: {} as never,
auditEmitter: { database: vi.fn(async () => {}), git: vi.fn(), filesystem: vi.fn() },
});
const wired = [
"branch-cross-contamination",
"branch-conflict-tripwire",
"branch-conflict-recovery-exhausted",
"branch-conflict-unrecoverable",
] as const;
for (const klass of wired) {
const decision = dispatcher.classify({ class: klass, taskId: "FN-1", pausedReason: klass }, {
task: baseTask,
retryCount: 0,
settings: { mode: "off", maxRetries: 3 },
});
expect(decision.action).toBe("pause");
expect(decision.legacyPausedReason).toBe(klass);
}
});
it("deterministic recovery success can bypass dispatcher invocation", async () => {
const classify = vi.fn();
const deterministicFastPath = vi.fn(async () => true);
if (!(await deterministicFastPath())) {
classify();
}
expect(deterministicFastPath).toHaveBeenCalledOnce();
expect(classify).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,159 @@
import type { AutoRecoveryFailureClass, AutoRecoveryMode, AutoRecoverySettings, Task, TaskStore } from "@fusion/core";
import { createLogger, type Logger } from "./logger.js";
import type { RunAuditor } from "./run-audit.js";
export type AutoRecoveryAction = "retry" | "spawn-ai-recovery" | "pause";
export interface AutoRecoveryFailure {
class: AutoRecoveryFailureClass;
taskId: string;
runId?: string;
pausedReason: string;
evidence?: Record<string, unknown>;
underlyingError?: Error;
}
export interface AutoRecoveryDecision {
action: AutoRecoveryAction;
rationale: string;
auditMetadata: Record<string, unknown>;
legacyPausedReason: string;
}
export interface AutoRecoveryContext {
task: Task;
retryCount: number;
settings: AutoRecoverySettings;
now?: () => Date;
}
export interface AutoRecoveryHandlers {
issueRetry?: (failure: AutoRecoveryFailure, decision: AutoRecoveryDecision, ctx: AutoRecoveryContext) => Promise<void>;
spawnAiRecovery?: (failure: AutoRecoveryFailure, decision: AutoRecoveryDecision, ctx: AutoRecoveryContext) => Promise<void>;
}
const autoRecoveryLog = createLogger("auto-recovery");
function actionForMode(mode: AutoRecoveryMode, failureClass: AutoRecoveryFailureClass): AutoRecoveryAction {
if (mode === "off" || mode === "deterministic-only") return "pause";
if (mode === "programmatic") {
if (failureClass === "file-scope-invariant" || failureClass === "post-squash-audit-blocker") return "pause";
return "retry";
}
if (mode === "ai-assisted") {
if (failureClass === "file-scope-invariant" || failureClass === "post-squash-audit-blocker") return "spawn-ai-recovery";
return "retry";
}
return "pause";
}
function isDestructiveAmbiguity(failure: AutoRecoveryFailure): boolean {
if (failure.evidence?.destructiveAmbiguity === true) return true;
const own = Number(failure.evidence?.ownCommits ?? 0);
const foreign = Number(failure.evidence?.foreignAttributedCommits ?? 0);
return own > 0 && foreign > 0;
}
export class AutoRecoveryDispatcher {
private readonly taskStore: TaskStore;
private readonly auditEmitter: RunAuditor;
private readonly handlers: AutoRecoveryHandlers;
private readonly logger: Logger;
constructor(opts: { taskStore: TaskStore; auditEmitter: RunAuditor; handlers?: AutoRecoveryHandlers; logger?: Logger }) {
this.taskStore = opts.taskStore;
this.auditEmitter = opts.auditEmitter;
this.handlers = opts.handlers ?? {};
this.logger = opts.logger ?? autoRecoveryLog;
}
classify(failure: AutoRecoveryFailure, context: AutoRecoveryContext): AutoRecoveryDecision {
if (context.settings.mode === "off") {
return {
action: "pause",
rationale: "auto-recovery-disabled",
legacyPausedReason: failure.pausedReason,
auditMetadata: { class: failure.class, mode: "off", retryCount: context.retryCount, rationale: "auto-recovery-disabled" },
};
}
const effectiveMode = context.settings.perClass?.[failure.class] ?? context.settings.mode;
if (isDestructiveAmbiguity(failure)) {
return {
action: "pause",
rationale: "destructive-ambiguity",
legacyPausedReason: failure.pausedReason,
auditMetadata: { class: failure.class, mode: effectiveMode, retryCount: context.retryCount, rationale: "destructive-ambiguity" },
};
}
const maxRetries = context.settings.maxRetries ?? 3;
if (context.retryCount >= maxRetries) {
return {
action: "pause",
rationale: "retry-budget-exhausted",
legacyPausedReason: failure.pausedReason,
auditMetadata: { class: failure.class, mode: effectiveMode, retryCount: context.retryCount, rationale: "retry-budget-exhausted", maxRetries },
};
}
const action = actionForMode(effectiveMode, failure.class);
const rationale = `mode-${effectiveMode}`;
return {
action,
rationale,
legacyPausedReason: failure.pausedReason,
auditMetadata: { class: failure.class, mode: effectiveMode, retryCount: context.retryCount, rationale },
};
}
async dispatch(failure: AutoRecoveryFailure, context: AutoRecoveryContext): Promise<AutoRecoveryDecision> {
void this.taskStore;
const decision = this.classify(failure, context);
await this.auditEmitter.database({
type: "auto-recovery:classify-decision",
target: failure.taskId,
metadata: decision.auditMetadata,
});
if (decision.rationale === "destructive-ambiguity") {
await this.auditEmitter.database({
type: "auto-recovery:pause-because-destructive-ambiguity",
target: failure.taskId,
metadata: decision.auditMetadata,
});
return decision;
}
if (decision.action === "retry") {
if (!this.handlers.issueRetry) {
this.logger.warn(`auto-recovery: handler-not-registered for class=${failure.class} action=retry — falling back to pause`);
return { ...decision, action: "pause", rationale: "handler-not-registered" };
}
await this.handlers.issueRetry(failure, decision, context);
await this.auditEmitter.database({
type: "auto-recovery:retry-issued",
target: failure.taskId,
metadata: decision.auditMetadata,
});
return decision;
}
if (decision.action === "spawn-ai-recovery") {
if (!this.handlers.spawnAiRecovery) {
this.logger.warn(`auto-recovery: handler-not-registered for class=${failure.class} action=spawn-ai-recovery — falling back to pause`);
return { ...decision, action: "pause", rationale: "handler-not-registered" };
}
await this.handlers.spawnAiRecovery(failure, decision, context);
await this.auditEmitter.database({
type: "auto-recovery:ai-session-spawned",
target: failure.taskId,
metadata: decision.auditMetadata,
});
return decision;
}
return decision;
}
}

View File

@@ -71,6 +71,7 @@ import {
import { buildPromptLayers, collapsePromptLayers } from "./prompt-layers.js";
import type { AgentReflectionService } from "./agent-reflection.js";
import { createRunAuditor, generateSyntheticRunId, type EngineRunContext } from "./run-audit.js";
import { AutoRecoveryDispatcher } from "./auto-recovery.js";
import { ReadonlyViolationError, filterCustomToolsForReadonly } from "./workflow-step-tool-policy.js";
import { evaluateSpecStaleness, getPromptPath } from "./spec-staleness.js";
import {
@@ -767,6 +768,7 @@ export interface TaskExecutorOptions {
onError?: (task: Task, error: Error) => void;
onAgentText?: (taskId: string, delta: string) => void;
onAgentTool?: (taskId: string, toolName: string) => void;
autoRecoveryDispatcher?: AutoRecoveryDispatcher;
}
export class TaskExecutor {
@@ -4253,12 +4255,30 @@ export class TaskExecutor {
await this.store.logEntry(task.id, `[recovery] contamination auto-recovery failed: ${recoveryMessage}`, undefined, this.currentRunContext);
}
await this.store.updateTask(task.id, {
status: "failed",
error: err.message,
paused: true,
const autoRecoveryDispatcher = this.options.autoRecoveryDispatcher ?? new AutoRecoveryDispatcher({ taskStore: this.store, auditEmitter: audit });
const decision = await autoRecoveryDispatcher.dispatch({
class: "branch-cross-contamination",
taskId: task.id,
runId: this.currentRunContext?.runId,
pausedReason: "branch-cross-contamination",
evidence: {
ownCommits: err.foreignCommits.filter((commit) => commit.foreignTaskId === task.id).length,
foreignAttributedCommits: err.foreignCommits.filter((commit) => commit.foreignTaskId !== task.id).length,
},
underlyingError: err,
}, {
task,
retryCount: task.recoveryRetryCount ?? 0,
settings: (await this.store.getSettings()).autoRecovery ?? { mode: "deterministic-only", maxRetries: 3 },
});
if (decision.action === "pause") {
await this.store.updateTask(task.id, {
status: "failed",
error: err.message,
paused: true,
pausedReason: "branch-cross-contamination",
});
}
return;
} else if (isBranchConflictError(err)) {
const conflictCount = (this.branchConflictErrorCount.get(task.id) ?? 0) + 1;
@@ -4273,12 +4293,30 @@ export class TaskExecutor {
].join(" ");
const tripwireMessage = `Branch conflict tripwire fired after ${conflictCount} events (threshold ${this.BRANCH_CONFLICT_TRIPWIRE_THRESHOLD}). ${details}`;
await this.store.logEntry(task.id, `[recovery] ${tripwireMessage}`, undefined, this.currentRunContext);
await this.store.updateTask(task.id, {
status: "failed",
error: tripwireMessage,
paused: true,
const autoRecoveryDispatcher = this.options.autoRecoveryDispatcher ?? new AutoRecoveryDispatcher({ taskStore: this.store, auditEmitter: audit });
const decision = await autoRecoveryDispatcher.dispatch({
class: "branch-conflict-tripwire",
taskId: task.id,
runId: this.currentRunContext?.runId,
pausedReason: "branch-conflict-tripwire",
evidence: {
branchName: err.branchName,
conflictingWorktreePath: err.conflictingWorktreePath,
},
underlyingError: err,
}, {
task,
retryCount: task.recoveryRetryCount ?? 0,
settings: (await this.store.getSettings()).autoRecovery ?? { mode: "deterministic-only", maxRetries: 3 },
});
if (decision.action === "pause") {
await this.store.updateTask(task.id, {
status: "failed",
error: tripwireMessage,
paused: true,
pausedReason: "branch-conflict-tripwire",
});
}
return;
}
@@ -4299,12 +4337,30 @@ export class TaskExecutor {
});
}
if (outcome === "retry") {
await this.store.updateTask(task.id, {
status: "failed",
error: err.message,
paused: true,
const autoRecoveryDispatcher = this.options.autoRecoveryDispatcher ?? new AutoRecoveryDispatcher({ taskStore: this.store, auditEmitter: audit });
const decision = await autoRecoveryDispatcher.dispatch({
class: "branch-conflict-recovery-exhausted",
taskId: task.id,
runId: this.currentRunContext?.runId,
pausedReason: "branch-conflict-recovery-exhausted",
evidence: {
branchName: err.branchName,
conflictingWorktreePath: err.conflictingWorktreePath,
},
underlyingError: err,
}, {
task,
retryCount: task.recoveryRetryCount ?? 0,
settings: (await this.store.getSettings()).autoRecovery ?? { mode: "deterministic-only", maxRetries: 3 },
});
if (decision.action === "pause") {
await this.store.updateTask(task.id, {
status: "failed",
error: err.message,
paused: true,
pausedReason: "branch-conflict-recovery-exhausted",
});
}
return;
}
return;
@@ -7211,18 +7267,42 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
`Run 'fn task branch-recovery ${task.id}' to inspect candidates, then reclaim the existing branch or discard prior work explicitly.`;
await this.store.logEntry(task.id, this.formatBranchConflictLifecycleLog(task.id, error), undefined, this.currentRunContext);
await this.store.appendAgentLog(task.id, "Branch conflict recovery required", "tool_error", this.formatBranchConflictAgentLog(task.id, error), "executor");
await this.store.updateTask(task.id, {
status: "failed",
error: conflictMessage,
branch: error.branchName,
worktree: error.conflictingWorktreePath,
paused: true,
pausedReason: "branch-conflict-unrecoverable",
const autoRecoveryDispatcher = this.options.autoRecoveryDispatcher ?? new AutoRecoveryDispatcher({
taskStore: this.store,
auditEmitter: createRunAuditor(this.store, this.currentRunContext),
});
await this.persistTokenUsage(task.id);
executorLog.warn(`${task.id} branch conflict sticky failure: ${error.branchName} @ ${error.conflictingWorktreePath}`);
this.options.onError?.(task, error);
return "sticky";
const decision = await autoRecoveryDispatcher.dispatch({
class: "branch-conflict-unrecoverable",
taskId: task.id,
runId: this.currentRunContext?.runId,
pausedReason: "branch-conflict-unrecoverable",
evidence: {
branchName: error.branchName,
conflictingWorktreePath: error.conflictingWorktreePath,
},
underlyingError: error,
}, {
task,
retryCount: task.recoveryRetryCount ?? 0,
settings: (await this.store.getSettings()).autoRecovery ?? { mode: "deterministic-only", maxRetries: 3 },
});
if (decision.action === "pause") {
await this.store.updateTask(task.id, {
status: "failed",
error: conflictMessage,
branch: error.branchName,
worktree: error.conflictingWorktreePath,
paused: true,
pausedReason: "branch-conflict-unrecoverable",
});
await this.persistTokenUsage(task.id);
executorLog.warn(`${task.id} branch conflict sticky failure: ${error.branchName} @ ${error.conflictingWorktreePath}`);
this.options.onError?.(task, error);
return "sticky";
}
return "retry";
}
private async createWorktree(

View File

@@ -100,6 +100,10 @@ export type DatabaseMutationType =
| "task:dependency:add"
| "task:auto-recover-already-merged"
| "task:auto-recover-completion-fanout"
| "auto-recovery:classify-decision"
| "auto-recovery:retry-issued"
| "auto-recovery:ai-session-spawned"
| "auto-recovery:pause-because-destructive-ambiguity"
| "document:write"
| "workflow-step:result"
| "agent:create:requested"

View File

@@ -25,6 +25,7 @@ import { extractMissingWorktreePathFromSessionStartFailure, isMissingWorktreeSes
import { classifyError, extractMissingModulePath, isOperatorActionableAgentError, isStaleWorktreeModuleResolutionError } from "./transient-error-detector.js";
import { deriveTaskIdFromFusionBranch, inspectBranchConflict, listUniqueBranchCommits } from "./branch-conflicts.js";
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
import { AutoRecoveryDispatcher } from "./auto-recovery.js";
const log = createLogger("self-healing");
const execAsync = promisify(exec);
@@ -172,6 +173,7 @@ export interface SelfHealingOptions {
staleMergingFanoutMinAgeMs?: number;
hasActiveAgentExecution?: (agentId: string) => boolean;
restartDurableAgentHeartbeat?: (agentId: string, context: { reason: string; attempt: number }) => Promise<boolean>;
autoRecoveryDispatcher?: AutoRecoveryDispatcher;
}
const APPROVED_TRIAGE_RECOVERY_GRACE_MS = 60_000;
@@ -1735,14 +1737,39 @@ export class SelfHealingManager {
if (patchPath) {
await this.store.logEntry(task.id, `Preserved uncommitted worktree changes before pause: ${patchPath}`);
}
await this.store.updateTask(task.id, {
status: "failed",
error: `Task branch conflict: ${task.branch} is not safely reclaimable (${message})`,
paused: true,
pausedReason: "branch-conflict-unrecoverable",
const dispatcher = this.options.autoRecoveryDispatcher ?? new AutoRecoveryDispatcher({
taskStore: this.store,
auditEmitter: createRunAuditor(this.store, {
runId: generateSyntheticRunId("self-heal", task.id),
agentId: "self-healing",
taskId: task.id,
taskLineageId: task.lineageId,
phase: "reclaim-self-owned-branch-conflicts",
}),
});
await this.store.moveTask(task.id, "in-review");
await this.store.logEntry(task.id, `Auto-recovery failed: branch conflict unrecoverable${message}`);
const decision = await dispatcher.dispatch({
class: "branch-conflict-unrecoverable",
taskId: task.id,
pausedReason: "branch-conflict-unrecoverable",
evidence: {
branchName: task.branch,
worktreePath: task.worktree,
},
}, {
task,
retryCount: task.recoveryRetryCount ?? 0,
settings: (await this.store.getSettings()).autoRecovery ?? { mode: "deterministic-only", maxRetries: 3 },
});
if (decision.action === "pause") {
await this.store.updateTask(task.id, {
status: "failed",
error: `Task branch conflict: ${task.branch} is not safely reclaimable (${message})`,
paused: true,
pausedReason: "branch-conflict-unrecoverable",
});
await this.store.moveTask(task.id, "in-review");
await this.store.logEntry(task.id, `Auto-recovery failed: branch conflict unrecoverable — ${message}`);
}
}
}