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:
86
packages/engine/src/__tests__/auto-recovery.test.ts
Normal file
86
packages/engine/src/__tests__/auto-recovery.test.ts
Normal 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 }),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
159
packages/engine/src/auto-recovery.ts
Normal file
159
packages/engine/src/auto-recovery.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user