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:
107
packages/engine/src/auto-recovery-handlers/file-scope.ts
Normal file
107
packages/engine/src/auto-recovery-handlers/file-scope.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user