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:
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user