Prevent single-repository worktree acquisition from proceeding without explicit multi-repository workspace context. - Require workspace-aware acquisition for single-repository tasks. - Propagate acquisition origin and workspace configuration through executor, merger, and recovery paths. - Add regression coverage, documentation, and a release changeset for the blocked behavior. Files changed: .changeset/fn-107-single-repo-worktree.md | 7 ++ docs/architecture.md | 4 + docs/testing.md | 4 + packages/core/src/branch/branch-assignment.ts | 12 +++ packages/core/src/index.gate.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/task-store/task-creation.ts | 4 +- packages/core/src/task-store/task-update.ts | 4 +- .../src/routes/register-planning-subtask-routes.ts | 1 + .../engine/src/__tests__/executor-test-helpers.ts | 12 +++ .../engine/src/__tests__/executor-worktree.test.ts | 44 ++++++--- .../src/__tests__/node-worktree-isolation.test.ts | 7 +- .../task-branch-write-origin-census.test.ts | 49 ++++++++++ .../worktree-acquisition-workspace.test.ts | 6 +- .../src/__tests__/worktree-acquisition.test.ts | 103 ++++++++++++++++++--- .../engine/src/executor/handle-graph-failure.ts | 16 +++ .../src/executor/workspace-config-resolver.ts | 1 + .../executor/worktree-branch-conflict-handle.ts | 1 + packages/engine/src/merger.ts | 3 +- packages/engine/src/self-healing.ts | 6 + .../self-healing/auto-recover-worktree-session.ts | 10 +- .../src/workflows/workflow-graph-executor.ts | 7 +- .../engine/src/worktree/worktree-acquisition.ts | 73 +++++++++++++- 23 files changed, 334 insertions(+), 42 deletions(-) Fusion-Task-Id: FN-107 Fusion-Task-Lineage: 80df9dae-59a0-416e-8a02-6ab2dba9bb00 Co-authored-by: Fusion <noreply@runfusion.ai>
204 lines
9.1 KiB
TypeScript
204 lines
9.1 KiB
TypeScript
/**
|
|
* FNXC:CodeOrganization 2026-08-03-16:05:
|
|
* reclaimExistingWorktree + handleBranchConflict peeled from TaskExecutor (U4 Slice B).
|
|
* Branch-conflict recovery lifecycle: inspect → reclaim/retry/sticky, with FN-4811 live-owner guard.
|
|
*/
|
|
import { exec } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { isFusionDeletableBranch, type Settings, type Task, type TaskStore } from "@fusion/core";
|
|
import {
|
|
assertCleanBranchAtBase,
|
|
BranchConflictError,
|
|
inspectBranchConflict,
|
|
} from "../execution/branch-conflicts.js";
|
|
import { resolveIntegrationBranch } from "../merge/integration-branch.js";
|
|
import { mergeEffectiveSettings } from "../project/effective-settings.js";
|
|
import { preservedWorktreeTargetPathForTask } from "../worktree/worktree-pinning.js";
|
|
import { executorLog } from "../logger.js";
|
|
import type { AutoRecoveryDispatcher } from "../healing/auto-recovery.js";
|
|
import type { EngineRunContext, RunAuditor } from "../util/run-audit.js";
|
|
import { resolveDiffBaseRef } from "./worktree-git-refs.js";
|
|
import { getWorktreeBranchMap } from "./worktree-registry-helpers.js";
|
|
import {
|
|
formatBranchConflictAgentLog,
|
|
formatBranchConflictLifecycleLog,
|
|
} from "./branch-conflict-format.js";
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
export type BranchConflictHandleDeps = {
|
|
rootDir: string;
|
|
store: TaskStore;
|
|
getRunContextFor: (taskId: string) => EngineRunContext | undefined;
|
|
findActiveWorktreeOwner: (worktreePath: string, requestingTaskId: string) => Promise<string | null>;
|
|
normalizeReclaimableWorktreePath: (
|
|
sourcePath: string,
|
|
targetPath: string,
|
|
taskId: string,
|
|
settings: Partial<Settings>,
|
|
) => Promise<string>;
|
|
cleanupConflictingWorktree: (worktreePath: string, branch: string, taskId: string) => Promise<boolean>;
|
|
getAutoRecoveryDispatcher: (audit: RunAuditor) => AutoRecoveryDispatcher;
|
|
createRunAuditor: (runContext: EngineRunContext | undefined) => RunAuditor;
|
|
persistTokenUsage: (taskId: string) => Promise<void>;
|
|
onError?: (task: Task, error: Error) => void;
|
|
};
|
|
|
|
export async function reclaimExistingWorktree(
|
|
deps: BranchConflictHandleDeps,
|
|
task: Task,
|
|
livePath: string,
|
|
branch: string,
|
|
tipSha: string,
|
|
count: number,
|
|
settings: Partial<Settings>,
|
|
): Promise<void> {
|
|
const targetPath = preservedWorktreeTargetPathForTask(task.id, livePath, settings, deps.rootDir);
|
|
const normalizedPath = await deps.normalizeReclaimableWorktreePath(livePath, targetPath, task.id, settings);
|
|
await deps.store.updateTask(task.id, { worktree: normalizedPath, branch });
|
|
const latestTask = await deps.store.getTask(task.id);
|
|
const baseRef = await resolveDiffBaseRef(normalizedPath, latestTask.baseCommitSha);
|
|
if (baseRef) {
|
|
await assertCleanBranchAtBase(deps.rootDir, branch, baseRef, task.id);
|
|
}
|
|
const message = `[recovery] reclaimed existing worktree for ${task.id} at ${normalizedPath} (${count} commits preserved, tip ${tipSha.slice(0, 12)})`;
|
|
await deps.store.logEntry(task.id, message, undefined, deps.getRunContextFor(task.id));
|
|
await deps.store.appendAgentLog(task.id, "Branch conflict auto-recovery", "status", message, "executor");
|
|
}
|
|
|
|
export async function handleBranchConflict(
|
|
deps: BranchConflictHandleDeps,
|
|
task: Task,
|
|
error: BranchConflictError,
|
|
): Promise<"retry" | "reclaimed" | "sticky"> {
|
|
// FN-4811: Before invoking inspection-based recovery (which may force-remove the
|
|
// conflicting worktree), verify the conflict isn't currently bound to a live session.
|
|
// If it is, refuse the whole recovery dance — a force-remove here would yank an active
|
|
// task's filesystem out from under it, producing FN-4781/FN-4804-style cascade failures.
|
|
const activeOwner = await deps.findActiveWorktreeOwner(error.conflictingWorktreePath, task.id);
|
|
if (activeOwner !== null) {
|
|
const refusalMessage = `[FN-4811] Branch conflict on ${error.branchName} deferred: conflicting worktree ${error.conflictingWorktreePath} is actively owned by ${activeOwner}`;
|
|
executorLog.warn(refusalMessage);
|
|
await deps.store.logEntry(task.id, refusalMessage, undefined, deps.getRunContextFor(task.id));
|
|
return "sticky";
|
|
}
|
|
const settings = await mergeEffectiveSettings(deps.store, task, await deps.store.getSettings());
|
|
|
|
const integrationRef = task.mergeDetails?.mergeTargetBranch ?? task.baseBranch ?? task.executionStartBranch ?? await resolveIntegrationBranch(deps.rootDir, undefined);
|
|
const inspection = await inspectBranchConflict({
|
|
repoDir: deps.rootDir,
|
|
branchName: error.branchName,
|
|
conflictingWorktreePath: error.conflictingWorktreePath,
|
|
requestingTaskId: task.id,
|
|
ownerTaskId: task.id,
|
|
startPoint: error.startPoint,
|
|
integrationRef,
|
|
});
|
|
|
|
if (inspection.kind === "stale-resolved") {
|
|
await deps.store.updateTask(task.id, { worktree: null, branch: null, branchWriteOrigin: "engine" as const, baseCommitSha: null });
|
|
const message = `[recovery] ${task.id} stage-A: pruned stale admin entry for ${error.branchName}`;
|
|
await deps.store.logEntry(task.id, message, undefined, deps.getRunContextFor(task.id));
|
|
await deps.store.appendAgentLog(task.id, "Branch conflict auto-recovery", "status", message, "executor");
|
|
return "retry";
|
|
}
|
|
|
|
if (inspection.kind === "tip-already-merged") {
|
|
if (inspection.livePath) {
|
|
await deps.cleanupConflictingWorktree(inspection.livePath, error.branchName, task.id);
|
|
}
|
|
try {
|
|
await execAsync("git worktree prune", {
|
|
cwd: deps.rootDir,
|
|
timeout: 120_000,
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
});
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
if (isFusionDeletableBranch(task, error.branchName)) try {
|
|
await execAsync(`git branch -D ${JSON.stringify(error.branchName)}`, {
|
|
cwd: deps.rootDir,
|
|
timeout: 120_000,
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
});
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
await deps.store.updateTask(task.id, { worktree: null, branch: null, branchWriteOrigin: "engine" as const, baseCommitSha: null });
|
|
const message = `[recovery] ${task.id} stage-A: tip-already-merged cleanup for ${error.branchName} (${inspection.tipSha.slice(0, 12)} on ${inspection.integrationRef})`;
|
|
await deps.store.logEntry(task.id, message, undefined, deps.getRunContextFor(task.id));
|
|
await deps.store.appendAgentLog(task.id, "Branch conflict auto-recovery", "status", message, "executor");
|
|
return "retry";
|
|
}
|
|
|
|
if (inspection.kind === "reclaimable") {
|
|
await reclaimExistingWorktree(deps, task, inspection.livePath, error.branchName, inspection.tipSha, inspection.taskAttributedCommitCount, settings);
|
|
return "reclaimed";
|
|
}
|
|
|
|
if (inspection.kind === "fully-subsumed") {
|
|
await reclaimExistingWorktree(deps, task, inspection.livePath, error.branchName, inspection.tipSha, 0, settings);
|
|
return "reclaimed";
|
|
}
|
|
|
|
if (inspection.kind === "live-foreign") {
|
|
const cleanupSuccess = await deps.cleanupConflictingWorktree(inspection.livePath, error.branchName, task.id);
|
|
if (cleanupSuccess) {
|
|
try {
|
|
await execAsync("git worktree prune", { cwd: deps.rootDir });
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
try {
|
|
const worktreeMap = await getWorktreeBranchMap(deps.rootDir);
|
|
if (!worktreeMap.has(error.branchName) && isFusionDeletableBranch(task, error.branchName)) {
|
|
await execAsync(`git branch -D "${error.branchName}"`, { cwd: deps.rootDir });
|
|
}
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
return "retry";
|
|
}
|
|
}
|
|
|
|
const conflictMessage = `Task branch conflict: ${error.branchName} is already checked out at ${error.conflictingWorktreePath}. ` +
|
|
`Resolve the local branch/worktree conflict with git tooling (inspect/reclaim or discard) before retrying.`;
|
|
await deps.store.logEntry(task.id, formatBranchConflictLifecycleLog(task.id, error), undefined, deps.getRunContextFor(task.id));
|
|
await deps.store.appendAgentLog(task.id, "Branch conflict recovery required", "tool_error", formatBranchConflictAgentLog(task.id, error), "executor");
|
|
const autoRecoveryDispatcher = deps.getAutoRecoveryDispatcher(deps.createRunAuditor(deps.getRunContextFor(task.id)));
|
|
const decision = await autoRecoveryDispatcher.dispatch({
|
|
class: "branch-conflict-unrecoverable",
|
|
taskId: task.id,
|
|
runId: deps.getRunContextFor(task.id)?.runId,
|
|
pausedReason: "branch-conflict-unrecoverable",
|
|
evidence: {
|
|
branchName: error.branchName,
|
|
conflictingWorktreePath: error.conflictingWorktreePath,
|
|
},
|
|
underlyingError: error,
|
|
}, {
|
|
task,
|
|
retryCount: task.recoveryRetryCount ?? 0,
|
|
settings: (await deps.store.getSettings()).autoRecovery ?? { mode: "deterministic-only", maxRetries: 3 },
|
|
});
|
|
|
|
if (decision.action === "pause") {
|
|
await deps.store.updateTask(task.id, {
|
|
status: "failed",
|
|
error: conflictMessage,
|
|
branch: error.branchName,
|
|
branchWriteOrigin: "engine" as const,
|
|
worktree: error.conflictingWorktreePath,
|
|
paused: true,
|
|
pausedReason: "branch-conflict-unrecoverable",
|
|
});
|
|
await deps.persistTokenUsage(task.id);
|
|
executorLog.warn(`✗ ${task.id} branch conflict sticky failure: ${error.branchName} @ ${error.conflictingWorktreePath}`);
|
|
deps.onError?.(task, error);
|
|
return "sticky";
|
|
}
|
|
|
|
return "retry";
|
|
}
|