feat(FN-4887): complete Step 2-3 — add foreign-only recovery sweep and shared handler
Fusion-Task-Id: FN-4887 Fusion-Task-Lineage: 559690d8-cea6-4323-a522-9ebb6aa25731
This commit is contained in:
committed by
gsxdsm
parent
91df093502
commit
b5ed3d0c9c
136
packages/engine/src/recovery/foreign-only-contamination.ts
Normal file
136
packages/engine/src/recovery/foreign-only-contamination.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { promisify } from "node:util";
|
||||
import type { Task, TaskStore } from "@fusion/core";
|
||||
import { activeSessionRegistry } from "../active-session-registry.js";
|
||||
import {
|
||||
classifyForeignOnlyContamination,
|
||||
reanchorBranchToBase,
|
||||
type ClassifyForeignOnlyContaminationResult,
|
||||
} from "../branch-conflicts.js";
|
||||
import type { RunAuditor } from "../run-audit.js";
|
||||
import { isUsableTaskWorktree } from "../worktree-pool.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const GIT_TIMEOUT_MS = 30_000;
|
||||
const GIT_MAX_BUFFER = 10 * 1024 * 1024;
|
||||
|
||||
function quote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
export interface RecoverForeignOnlyContaminationDeps {
|
||||
repoDir: string;
|
||||
taskStore: TaskStore;
|
||||
runAudit: RunAuditor;
|
||||
}
|
||||
|
||||
export interface RecoverForeignOnlyContaminationResult {
|
||||
recovered: boolean;
|
||||
subtype?: "reanchor" | "branch-discard";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export async function recoverForeignOnlyContamination(
|
||||
task: Task,
|
||||
deps: RecoverForeignOnlyContaminationDeps,
|
||||
): Promise<RecoverForeignOnlyContaminationResult> {
|
||||
if (!task.branch || !task.worktree) return { recovered: false, reason: "missing-branch-or-worktree" };
|
||||
|
||||
const baseSha = task.baseCommitSha ?? task.baseBranch ?? task.executionStartBranch ?? "main";
|
||||
if (!baseSha) {
|
||||
await deps.runAudit.database({
|
||||
type: "task:auto-recover-foreign-only-contamination-skipped",
|
||||
target: task.id,
|
||||
metadata: { reason: "baseSha-unresolved" },
|
||||
});
|
||||
return { recovered: false, reason: "baseSha-unresolved" };
|
||||
}
|
||||
|
||||
const classification = await classifyForeignOnlyContamination({
|
||||
repoDir: deps.repoDir,
|
||||
branchName: task.branch,
|
||||
baseSha,
|
||||
taskId: task.id,
|
||||
});
|
||||
|
||||
if (classification.kind !== "foreign-only-no-own-work" && classification.kind !== "foreign-only-already-upstream") {
|
||||
await deps.runAudit.database({
|
||||
type: "task:auto-recover-foreign-only-contamination-skipped",
|
||||
target: task.id,
|
||||
metadata: { reason: "ambiguous", kind: classification.kind },
|
||||
});
|
||||
return { recovered: false, reason: "ambiguous" };
|
||||
}
|
||||
|
||||
if (await isUsableTaskWorktree(task.worktree)) {
|
||||
await reanchorBranchToBase({
|
||||
repoDir: deps.repoDir,
|
||||
worktreePath: task.worktree,
|
||||
branchName: task.branch,
|
||||
baseSha,
|
||||
taskId: task.id,
|
||||
});
|
||||
|
||||
await deps.taskStore.moveTask(task.id, "todo", {
|
||||
moveSource: "engine",
|
||||
preserveResumeState: true,
|
||||
preserveProgress: true,
|
||||
preserveWorktree: true,
|
||||
});
|
||||
await deps.taskStore.updateTask(task.id, {
|
||||
recoveryRetryCount: 0,
|
||||
nextRecoveryAt: null,
|
||||
error: null,
|
||||
paused: false,
|
||||
pausedReason: null,
|
||||
});
|
||||
await deps.runAudit.database({
|
||||
type: "task:auto-recover-foreign-only-contamination",
|
||||
target: task.id,
|
||||
metadata: { subtype: "reanchor", kind: classification.kind, baseSha },
|
||||
});
|
||||
return { recovered: true, subtype: "reanchor" };
|
||||
}
|
||||
|
||||
if (activeSessionRegistry.isPathActive(task.worktree)) {
|
||||
await deps.runAudit.database({
|
||||
type: "task:auto-recover-foreign-only-contamination-skipped",
|
||||
target: task.id,
|
||||
metadata: { reason: "active-session", kind: classification.kind },
|
||||
});
|
||||
return { recovered: false, reason: "active-session" };
|
||||
}
|
||||
|
||||
await execAsync("git worktree prune", { cwd: deps.repoDir, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER }).catch(() => undefined);
|
||||
await execAsync(`git branch -D ${quote(task.branch)}`, { cwd: deps.repoDir, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER }).catch(() => undefined);
|
||||
|
||||
await deps.taskStore.moveTask(task.id, "todo", {
|
||||
moveSource: "engine",
|
||||
preserveResumeState: true,
|
||||
preserveProgress: true,
|
||||
preserveWorktree: false,
|
||||
});
|
||||
await deps.taskStore.updateTask(task.id, {
|
||||
recoveryRetryCount: 0,
|
||||
nextRecoveryAt: null,
|
||||
error: null,
|
||||
paused: false,
|
||||
pausedReason: null,
|
||||
worktree: null,
|
||||
branch: null,
|
||||
baseCommitSha: null,
|
||||
modifiedFiles: [],
|
||||
});
|
||||
await deps.runAudit.database({
|
||||
type: "task:auto-recover-foreign-only-contamination",
|
||||
target: task.id,
|
||||
metadata: {
|
||||
subtype: "branch-discard",
|
||||
kind: classification.kind,
|
||||
baseSha,
|
||||
worktreePresent: existsSync(task.worktree),
|
||||
},
|
||||
});
|
||||
return { recovered: true, subtype: "branch-discard" };
|
||||
}
|
||||
Reference in New Issue
Block a user