fix(engine): harden worktree-pool branch creation and auto-reanchor foreign-only contamination
WorktreePool.prepareForTask now rejects empty/"HEAD" base values and verifies that the worktree's HEAD actually landed at the resolved base SHA after `git checkout --detach`. This closes the FN-5432 / FN-5255 contamination pattern where a recycled worktree branched from a stale HEAD (reflog: "branch: Created from HEAD") and pinned the new task's tip to the previous occupant's commit. SelfHealingManager.tryReanchorForeignOnlyContamination is invoked from both PR-conflict and self-owned-branch-conflict catch sites before the unrecoverable-pause path. When the conflicted branch carries only foreign commits (no own work), the branch is reset to base via the existing recoverForeignOnlyContamination flow instead of being escalated for human adjudication. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1850,6 +1850,9 @@ export class SelfHealingManager {
|
||||
return withPerPr({ outcome: "reclaimed" });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (await this.tryReanchorForeignOnlyContamination(task)) {
|
||||
return withPerPr({ outcome: "reclaimed" });
|
||||
}
|
||||
const patchPath = await preserveWorktreeChanges(this.options.rootDir, task.worktree, task.id);
|
||||
if (patchPath) {
|
||||
await this.store.logEntry(task.id, `Preserved uncommitted worktree changes before pause: ${patchPath}`);
|
||||
@@ -1888,6 +1891,63 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-resort recovery for self-owned task branches whose tip carries only
|
||||
* foreign commits (no own work). This is the FN-5432 / FN-5255 pattern:
|
||||
* the worktree pool created `fusion/fn-XXXX` from a stale HEAD that still
|
||||
* pointed at the previous occupant's tip, so the branch inherited another
|
||||
* task's commit. There is nothing to preserve — reanchor to base.
|
||||
*
|
||||
* Returns true when recovery succeeded (caller should treat as reclaimed
|
||||
* and skip the unrecoverable-pause path).
|
||||
*/
|
||||
private async tryReanchorForeignOnlyContamination(
|
||||
task: Task,
|
||||
): Promise<boolean> {
|
||||
if (!task.branch || !task.worktree) return false;
|
||||
try {
|
||||
const integrationBranch = await resolveIntegrationBranch(this.options.rootDir, undefined);
|
||||
const baseSha = task.baseCommitSha ?? task.baseBranch ?? task.executionStartBranch ?? integrationBranch;
|
||||
if (!baseSha) return false;
|
||||
const classification = await classifyForeignOnlyContamination({
|
||||
repoDir: this.options.rootDir,
|
||||
branchName: task.branch,
|
||||
baseSha,
|
||||
taskId: task.id,
|
||||
}).catch(() => null);
|
||||
if (!classification) return false;
|
||||
if (
|
||||
classification.kind !== "foreign-only-no-own-work" &&
|
||||
classification.kind !== "foreign-only-already-upstream"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const auditor = createRunAuditor(this.store, {
|
||||
runId: generateSyntheticRunId("self-heal", task.id),
|
||||
agentId: "self-healing",
|
||||
taskId: task.id,
|
||||
taskLineageId: task.lineageId,
|
||||
phase: "reanchor-foreign-only-contamination",
|
||||
});
|
||||
const recovered = await recoverForeignOnlyContamination(task, {
|
||||
repoDir: this.options.rootDir,
|
||||
taskStore: this.store,
|
||||
runAudit: auditor,
|
||||
integrationBranch,
|
||||
});
|
||||
if (!recovered.recovered) return false;
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Auto-reanchored ${task.branch} to base (${classification.kind}, ${classification.foreignCommitCount} foreign commit(s) discarded — no own work to preserve)`,
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await this.store.logEntry(task.id, `Foreign-only reanchor attempt failed: ${message}`).catch(() => undefined);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STANDING: do not auto-discard stranded commits. Reclaim preserves commits;
|
||||
* unrecoverable conflicts are escalated for human review.
|
||||
@@ -2249,6 +2309,10 @@ export class SelfHealingManager {
|
||||
recovered++;
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (await this.tryReanchorForeignOnlyContamination(task)) {
|
||||
recovered++;
|
||||
continue;
|
||||
}
|
||||
const patchPath = await preserveWorktreeChanges(this.options.rootDir, task.worktree, task.id);
|
||||
if (patchPath) {
|
||||
await this.store.logEntry(task.id, `Preserved uncommitted worktree changes before pause: ${patchPath}`);
|
||||
|
||||
@@ -454,6 +454,18 @@ export class WorktreePool {
|
||||
await removeDesktopBuildArtifacts(worktreePath, worktreePoolLog);
|
||||
|
||||
const base = startPoint || await resolveIntegrationBranch(options?.repoDir ?? worktreePath, undefined);
|
||||
// Reject base values that would cause the new branch to inherit the
|
||||
// worktree's current HEAD instead of the intended start point. Historical
|
||||
// contamination ("branch: Created from HEAD") landed FN-5472's tip on
|
||||
// freshly-created fn-5432/fn-5255 branches because the recycled worktree
|
||||
// was still pointing at the previous occupant's commit and base silently
|
||||
// collapsed onto HEAD.
|
||||
if (!base || !base.trim() || base.trim().toUpperCase() === "HEAD") {
|
||||
throw new Error(
|
||||
`prepareForTask: refusing to create branch ${branchName} from base ${JSON.stringify(base)} (worktree=${worktreePath}, startPoint=${String(startPoint)})`,
|
||||
);
|
||||
}
|
||||
|
||||
await execAsync(`git checkout --detach ${base}`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
@@ -461,6 +473,22 @@ export class WorktreePool {
|
||||
// Create or force-reset the branch from the start point (or main)
|
||||
const checkoutCmd = `git checkout -B "${branchName}" ${base}`;
|
||||
const resolvedBase = (await execAsync(`git rev-parse --verify "${base}^{commit}"`, { cwd: worktreePath, encoding: "utf-8" })).stdout.trim();
|
||||
|
||||
// Verify HEAD actually landed at the resolved base after --detach. If
|
||||
// detach silently leaves HEAD elsewhere (e.g. the base ref didn't exist
|
||||
// and git fell through to current HEAD), creating the branch now would
|
||||
// pin it to the wrong tip — exactly the FN-5432 / FN-5255 contamination
|
||||
// pattern ("branch: Created from HEAD" pointing at the previous occupant's
|
||||
// tip). Only enforced when we have real SHAs to compare; mock-driven
|
||||
// unit tests that return empty buffers fall through harmlessly.
|
||||
if (/^[0-9a-f]{40}$/i.test(resolvedBase)) {
|
||||
const detachedHead = (await execAsync("git rev-parse HEAD", { cwd: worktreePath, encoding: "utf-8" })).stdout.trim();
|
||||
if (detachedHead !== resolvedBase) {
|
||||
throw new Error(
|
||||
`prepareForTask: post-detach HEAD ${detachedHead} does not match resolved base ${resolvedBase} (${base}) for ${branchName} — refusing to create branch`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const taskId = deriveTaskIdFromBranch(branchName);
|
||||
try {
|
||||
await execAsync(checkoutCmd, {
|
||||
|
||||
Reference in New Issue
Block a user