feat(FN-4535): add file-scope violation auto-recovery handler

Adds a new `auto-recovery-handlers/file-scope.ts` skeleton to handle file-scope invariant violations and wires it into the executor, establishing the first step of FN-4535's auto-recovery pipeline.

Fusion-Task-Id: FN-4535
This commit is contained in:
Fusion
2026-05-14 18:24:04 -07:00
committed by gsxdsm
parent b2306e88d9
commit 2cd7f43627
2 changed files with 134 additions and 9 deletions

View File

@@ -0,0 +1,107 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import type { ProjectSettings, Task, TaskStore } from "@fusion/core";
import type { AutoRecoveryContext, AutoRecoveryDecision, AutoRecoveryFailure } from "../auto-recovery.js";
import type { Logger } from "../logger.js";
import type { RunAuditor } from "../run-audit.js";
const execAsync = promisify(exec);
export type ExecutorSpawnAgentSurface = (params: {
name: string;
role: "triage" | "executor" | "reviewer" | "merger" | "engineer" | "custom";
task: string;
}) => Promise<{ agentId: string }>;
export type PatchIdClassifier = (args: {
repoDir: string;
branchName: string;
integrationBranch?: string;
}) => Promise<{
unique: Array<{ sha: string }>;
alreadyUpstream: Array<{ sha: string }>;
}>;
export interface FileScopeRecoveryDeps {
taskStore: TaskStore;
runAudit: RunAuditor;
logger: Logger;
exec: typeof execAsync;
spawnAgent: ExecutorSpawnAgentSurface;
classifyPatchIds: PatchIdClassifier;
settings: () => ProjectSettings;
now?: () => Date;
}
export interface FileScopeClassificationResult {
kind: "all-in-scope" | "all-off-scope" | "unambiguous-split" | "ambiguous" | "destructive-ambiguity";
inScope: string[];
offScope: string[];
ambiguousFiles?: string[];
}
export interface FileScopeSplitPlan {
keep: string[];
defer: string[];
deferDiff: string;
}
export function classifyStagedSet(
staged: readonly string[],
declaredScope: readonly string[],
branchRangeDiff: Readonly<Record<string, "in-scope" | "off-scope" | "ambiguous">>,
): FileScopeClassificationResult {
const inScope = staged.filter((file) => branchRangeDiff[file] === "in-scope");
const offScope = staged.filter((file) => branchRangeDiff[file] === "off-scope");
const ambiguousFiles = staged.filter((file) => branchRangeDiff[file] === "ambiguous");
if (staged.length > 0 && inScope.length === staged.length) {
return { kind: "all-in-scope", inScope, offScope: [] };
}
if (staged.length > 0 && offScope.length === staged.length) {
return { kind: "all-off-scope", inScope: [], offScope };
}
if (inScope.length > 0 && offScope.length > 0 && ambiguousFiles.length === 0) {
return { kind: "unambiguous-split", inScope, offScope };
}
if (ambiguousFiles.length > 0 && inScope.length === 0) {
return { kind: "destructive-ambiguity", inScope, offScope, ambiguousFiles };
}
if (declaredScope.length === 0) {
return { kind: "ambiguous", inScope, offScope, ambiguousFiles };
}
return { kind: "ambiguous", inScope, offScope, ambiguousFiles };
}
export function computeSplitPlan(
commits: readonly string[],
patchIds: readonly string[],
classification: FileScopeClassificationResult,
): FileScopeSplitPlan {
void commits;
void patchIds;
return {
keep: classification.inScope,
defer: classification.offScope,
deferDiff: "",
};
}
export class FileScopeAutoRecoveryHandler {
constructor(private readonly deps: FileScopeRecoveryDeps) {}
async issueRetry(_failure: AutoRecoveryFailure, _decision: AutoRecoveryDecision, _ctx: AutoRecoveryContext): Promise<void> {
// Implemented in FN-4535 steps 2/4.
}
async spawnAiRecovery(_failure: AutoRecoveryFailure, _decision: AutoRecoveryDecision, _ctx: AutoRecoveryContext): Promise<void> {
// Implemented in FN-4535 step 3.
}
}
export function createFileScopeAutoRecoveryHandler(deps: Omit<FileScopeRecoveryDeps, "exec"> & { exec?: typeof execAsync }): FileScopeAutoRecoveryHandler {
return new FileScopeAutoRecoveryHandler({
...deps,
exec: deps.exec ?? execAsync,
});
}

View File

@@ -5,7 +5,7 @@ const execAsync = promisify(exec);
import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
import { existsSync, realpathSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode } from "@fusion/core";
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings } from "@fusion/core";
import { RetryStormError, serializeRetryStormError } from "@fusion/core";
import {
ApprovalRequestStore,
@@ -70,8 +70,9 @@ import {
} from "./agent-instructions.js";
import { buildPromptLayers, collapsePromptLayers } from "./prompt-layers.js";
import type { AgentReflectionService } from "./agent-reflection.js";
import { createRunAuditor, generateSyntheticRunId, type EngineRunContext } from "./run-audit.js";
import { createRunAuditor, generateSyntheticRunId, type EngineRunContext, type RunAuditor } from "./run-audit.js";
import { AutoRecoveryDispatcher } from "./auto-recovery.js";
import { createFileScopeAutoRecoveryHandler } from "./auto-recovery-handlers/file-scope.js";
import { ReadonlyViolationError, filterCustomToolsForReadonly } from "./workflow-step-tool-policy.js";
import { evaluateSpecStaleness, getPromptPath } from "./spec-staleness.js";
import {
@@ -831,6 +832,26 @@ export class TaskExecutor {
/** Set of ephemeral spawned agent IDs with in-flight cleanup (prevents duplicate deletion attempts). */
private pendingEphemeralDeletions = new Set<string>();
private getAutoRecoveryDispatcher(audit: RunAuditor): AutoRecoveryDispatcher {
if (this.options.autoRecoveryDispatcher) return this.options.autoRecoveryDispatcher;
const fileScopeHandler = createFileScopeAutoRecoveryHandler({
taskStore: this.store,
runAudit: audit,
logger: executorLog,
spawnAgent: async () => ({ agentId: "unavailable" }),
classifyPatchIds: async () => ({ unique: [], alreadyUpstream: [] }),
settings: () => ({ autoRecovery: { mode: "deterministic-only", maxRetries: 3 } } as ProjectSettings),
});
return new AutoRecoveryDispatcher({
taskStore: this.store,
auditEmitter: audit,
handlers: {
issueRetry: fileScopeHandler.issueRetry.bind(fileScopeHandler),
spawnAiRecovery: fileScopeHandler.spawnAiRecovery.bind(fileScopeHandler),
},
});
}
private async renewTaskLease(
taskId: string,
agentId: string,
@@ -4255,7 +4276,7 @@ export class TaskExecutor {
await this.store.logEntry(task.id, `[recovery] contamination auto-recovery failed: ${recoveryMessage}`, undefined, this.currentRunContext);
}
const autoRecoveryDispatcher = this.options.autoRecoveryDispatcher ?? new AutoRecoveryDispatcher({ taskStore: this.store, auditEmitter: audit });
const autoRecoveryDispatcher = this.getAutoRecoveryDispatcher(audit);
const decision = await autoRecoveryDispatcher.dispatch({
class: "branch-cross-contamination",
taskId: task.id,
@@ -4293,7 +4314,7 @@ 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);
const autoRecoveryDispatcher = this.options.autoRecoveryDispatcher ?? new AutoRecoveryDispatcher({ taskStore: this.store, auditEmitter: audit });
const autoRecoveryDispatcher = this.getAutoRecoveryDispatcher(audit);
const decision = await autoRecoveryDispatcher.dispatch({
class: "branch-conflict-tripwire",
taskId: task.id,
@@ -4337,7 +4358,7 @@ export class TaskExecutor {
});
}
if (outcome === "retry") {
const autoRecoveryDispatcher = this.options.autoRecoveryDispatcher ?? new AutoRecoveryDispatcher({ taskStore: this.store, auditEmitter: audit });
const autoRecoveryDispatcher = this.getAutoRecoveryDispatcher(audit);
const decision = await autoRecoveryDispatcher.dispatch({
class: "branch-conflict-recovery-exhausted",
taskId: task.id,
@@ -7267,10 +7288,7 @@ 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");
const autoRecoveryDispatcher = this.options.autoRecoveryDispatcher ?? new AutoRecoveryDispatcher({
taskStore: this.store,
auditEmitter: createRunAuditor(this.store, this.currentRunContext),
});
const autoRecoveryDispatcher = this.getAutoRecoveryDispatcher(createRunAuditor(this.store, this.currentRunContext));
const decision = await autoRecoveryDispatcher.dispatch({
class: "branch-conflict-unrecoverable",
taskId: task.id,