fix(FN-4847): discard foreign branch and recreate on branch-conflict-unrecoverable

Production failure shape:
  Auto-recovery failed: branch conflict unrecoverable \u2014
  Branch fusion/fn-4847 is already checked out at /.../deft-crane
  (tip a881ccc86660, 24 stranded commits since 0b28388876).
  Run branch recovery and explicitly choose whether to reclaim or
  discard prior work.

The 24 stranded commits are cross-task contamination residue from the
FN-4781/FN-4804/FN-4814 worktree-race era \u2014 they are NOT FN-4847's work.
Previously this paused the task with pausedReason='branch-conflict-
unrecoverable' and the task got stuck forever waiting for human
adjudication.

User intent (FN-4847): 'just create a new branch and keep going and
discard the old one'. Implementation:

1. auto-recovery.ts:actionForMode \u2014 in 'deterministic-only' mode (the
   default), branch-conflict-unrecoverable now returns 'retry' (was
   'pause'). This routes the failure to the handler instead of pausing.

2. auto-recovery-handlers/branch-worktree.ts \u2014 'live-foreign' inspection
   no longer emits irreducible-pause. Instead:
   - Check FN-4811 active-session registry. If the foreign worktree is
     bound to a live executor/merger session, do NOT force-remove it
     (would yank the live agent's filesystem). Just requeue and let
     downstream conflict-recovery handle it.
   - Otherwise: force-delete the foreign worktree (--force) + prune git
     worktree admin entries + force-delete the branch. Errors at each
     step are best-effort and logged.
   - Emit new audit event 'branch-worktree:foreign-branch-discarded'
     with stranded-commit count, live-ownership flag, success flags.
   - Requeue task to 'todo' with preserveProgress, clearing
     branch+baseCommitSha.

3. run-audit.ts \u2014 register new DatabaseMutationType.

4. executor-worktree.test.ts \u2014 update the 'records recovery context'
   test to assert the new retry+requeue contract (was asserting the old
   pause-with-status-failed contract).

Verification:
  - Targeted suite (4 files, 343 tests): pass.
  - pnpm --filter @fusion/engine build: clean.
  - pnpm lint: clean.

Fusion-Task-Id: FN-4847
This commit is contained in:
Fusion
2026-05-16 23:53:56 -07:00
parent 01f0bf625a
commit aa6d1c9851
5 changed files with 103 additions and 14 deletions

View File

@@ -788,12 +788,18 @@ describe("TaskExecutor worktree recovery", () => {
);
});
it("records recovery context when handling a branch conflict", async () => {
it("records recovery context when handling a branch conflict (FN-4847: now discards + requeues instead of pausing)", async () => {
// FN-4847: branch-conflict-unrecoverable previously paused the task with
// status=failed + pausedReason="branch-conflict-unrecoverable". The user has
// opted into discard-and-recreate, so the executor's handleBranchConflict now
// delegates to the auto-recovery dispatcher which in 'deterministic-only' mode
// returns action='retry'. The handler discards the foreign branch and requeues
// the task to todo. status='failed' is no longer set; moveTask IS called.
const store = createMockStore();
const onError = vi.fn();
const executor = new TaskExecutor(store, "/tmp/test", { onError });
await (executor as any).handleBranchConflict(
const result = await (executor as any).handleBranchConflict(
makeTask(),
new BranchConflictError({
branchName: "fusion/fn-050",
@@ -808,15 +814,14 @@ describe("TaskExecutor worktree recovery", () => {
}),
);
expect(store.updateTask).toHaveBeenCalledWith(
// New contract: handleBranchConflict returns 'retry' (not 'sticky') and does
// NOT mark the task failed. The branch-conflict context still gets logged and
// surfaced for observability, but the task continues via requeue.
expect(result).toBe("retry");
expect(store.updateTask).not.toHaveBeenCalledWith(
"FN-050",
expect.objectContaining({
status: "failed",
branch: "fusion/fn-050",
worktree: "/tmp/test/.worktrees/green-sage",
}),
expect.objectContaining({ status: "failed" }),
);
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
expect.stringContaining("Existing tip: abc123def456"),
@@ -830,7 +835,8 @@ describe("TaskExecutor worktree recovery", () => {
expect.stringContaining("stranded=aaa111 Preserve prior fix"),
"executor",
);
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-050" }), expect.any(BranchConflictError));
// onError no longer fires for the recoverable branch-conflict-unrecoverable path.
expect(onError).not.toHaveBeenCalled();
});
it("FN-4397 reproduces repeated branch-conflict recovery-required emissions for the same task", async () => {

View File

@@ -8,6 +8,7 @@ import {
reanchorBranchToBase,
} from "../branch-conflicts.js";
import type { AutoRecoveryContext, AutoRecoveryDecision, AutoRecoveryFailure } from "../auto-recovery.js";
import { activeSessionRegistry } from "../active-session-registry.js";
import { createLogger, type Logger } from "../logger.js";
import type { RunAuditor } from "../run-audit.js";
@@ -222,9 +223,67 @@ export class BranchWorktreeAutoRecoveryHandler {
}
if (inspection.kind === "live-foreign") {
await this.emitIrreduciblePause(ctx.task, failure, "live-foreign", {
branchName,
conflictingWorktreePath,
// FN-4847: discard-and-recreate. Previously this emitted irreducible-pause and the
// task got stuck with "branch conflict unrecoverable". The user opted into
// force-deleting the foreign branch (with stranded contamination commits) and
// requeuing so the executor's next pickup creates a fresh `fusion/<task-id>`.
// Safety: respect FN-4811 active-session gate — don't yank live worktrees.
const tipSha = await this.getTipSha(repoDir, branchName);
const isLiveOwned = activeSessionRegistry.isPathActive(conflictingWorktreePath);
let branchDeleted = false;
let worktreeRemoved = false;
if (!isLiveOwned) {
if (existsSync(conflictingWorktreePath)) {
try {
await execAsync(`git worktree remove --force ${this.quote(conflictingWorktreePath)}`, {
cwd: repoDir,
timeout: GIT_TIMEOUT_MS,
maxBuffer: GIT_MAX_BUFFER,
});
worktreeRemoved = true;
} catch (err) {
this.logger.warn(`FN-4847 discard: worktree remove failed for ${conflictingWorktreePath}: ${err instanceof Error ? err.message : String(err)}`);
}
}
try {
await execAsync("git worktree prune", {
cwd: repoDir,
timeout: GIT_TIMEOUT_MS,
maxBuffer: GIT_MAX_BUFFER,
});
} catch {
// best-effort
}
try {
await execAsync(`git branch -D ${this.quote(branchName)}`, {
cwd: repoDir,
timeout: GIT_TIMEOUT_MS,
maxBuffer: GIT_MAX_BUFFER,
});
branchDeleted = true;
} catch (err) {
this.logger.warn(`FN-4847 discard: branch -D failed for ${branchName}: ${err instanceof Error ? err.message : String(err)}`);
}
}
await this.deps.runAudit.database({
type: "branch-worktree:foreign-branch-discarded",
target: ctx.task.id,
metadata: {
class: failure.class,
branchName,
conflictingWorktreePath,
inspectionKind: inspection.kind,
tipSha,
isLiveOwned,
branchDeleted,
worktreeRemoved,
rationale: "FN-4847 user-opted discard-and-recreate for cross-task contamination residue",
},
});
await this.requeueAfterRecovery(ctx.task, failure, "live-foreign-discard-and-recreate", {
branchExists: !branchDeleted,
worktreePresent: !worktreeRemoved && existsSync(conflictingWorktreePath),
tipSha,
inspectionKind: inspection.kind,
});
return;

View File

@@ -35,7 +35,15 @@ export interface AutoRecoveryHandlers {
const autoRecoveryLog = createLogger("auto-recovery");
function actionForMode(mode: AutoRecoveryMode, failureClass: AutoRecoveryFailureClass): AutoRecoveryAction {
if (mode === "off" || mode === "deterministic-only") return "pause";
if (mode === "off") return "pause";
if (mode === "deterministic-only") {
// FN-4847: branch-conflict-unrecoverable is deterministically retryable via the
// discard-and-recreate path in BranchWorktreeAutoRecoveryHandler. The user has
// explicitly opted into discarding stranded commits on contaminated branches
// rather than pausing the task with "branch conflict unrecoverable".
if (failureClass === "branch-conflict-unrecoverable") return "retry";
return "pause";
}
if (mode === "programmatic") {
if (failureClass === "file-scope-invariant" || failureClass === "post-squash-audit-blocker") return "pause";
return "retry";

View File

@@ -178,6 +178,7 @@ export type DatabaseMutationType =
| "branch-worktree:auto-requeue"
| "branch-worktree:ai-session-spawned"
| "branch-worktree:irreducible-pause"
| "branch-worktree:foreign-branch-discarded"
| "document:write"
| "workflow-step:result"
| "agent:create:requested"