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:
15
.changeset/FN-4847-discard-foreign-branch.md
Normal file
15
.changeset/FN-4847-discard-foreign-branch.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix(FN-4847): discard foreign branch and recreate on `branch-conflict-unrecoverable`
|
||||||
|
|
||||||
|
Branch conflicts where the existing `fusion/<task-id>` branch has stranded commits NOT attributed to the task (cross-task contamination residue from the FN-4781/FN-4804/FN-4814 worktree-race era) previously paused the task with `pausedReason: "branch-conflict-unrecoverable"` and the error message `Auto-recovery failed: branch conflict unrecoverable — Branch fusion/fn-XXX is already checked out at /.../ (tip ..., N stranded commits since ...)`. The task got stuck forever waiting for human adjudication.
|
||||||
|
|
||||||
|
The user has explicitly opted into discard-and-recreate for this case: those stranded commits aren't this task's work, just delete them and move on.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
|
||||||
|
- `auto-recovery.ts:actionForMode` — in `deterministic-only` mode, `branch-conflict-unrecoverable` now returns `"retry"` (was `"pause"`), routing the failure to the handler instead of the pause path.
|
||||||
|
- `auto-recovery-handlers/branch-worktree.ts` — `live-foreign` inspection no longer emits `irreducible-pause`. Instead: force-delete the foreign branch + worktree (safely respecting the FN-4811 active-session gate to avoid yanking live sessions), then requeue the task. The executor's next pickup creates a fresh `fusion/<task-id>` worktree with no conflict.
|
||||||
|
- New audit event `branch-worktree:foreign-branch-discarded` records the discard with stranded-commit count and live-ownership status.
|
||||||
@@ -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 store = createMockStore();
|
||||||
const onError = vi.fn();
|
const onError = vi.fn();
|
||||||
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
||||||
|
|
||||||
await (executor as any).handleBranchConflict(
|
const result = await (executor as any).handleBranchConflict(
|
||||||
makeTask(),
|
makeTask(),
|
||||||
new BranchConflictError({
|
new BranchConflictError({
|
||||||
branchName: "fusion/fn-050",
|
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",
|
"FN-050",
|
||||||
expect.objectContaining({
|
expect.objectContaining({ status: "failed" }),
|
||||||
status: "failed",
|
|
||||||
branch: "fusion/fn-050",
|
|
||||||
worktree: "/tmp/test/.worktrees/green-sage",
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
expect(store.moveTask).not.toHaveBeenCalled();
|
|
||||||
expect(store.logEntry).toHaveBeenCalledWith(
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
"FN-050",
|
"FN-050",
|
||||||
expect.stringContaining("Existing tip: abc123def456"),
|
expect.stringContaining("Existing tip: abc123def456"),
|
||||||
@@ -830,7 +835,8 @@ describe("TaskExecutor worktree recovery", () => {
|
|||||||
expect.stringContaining("stranded=aaa111 Preserve prior fix"),
|
expect.stringContaining("stranded=aaa111 Preserve prior fix"),
|
||||||
"executor",
|
"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 () => {
|
it("FN-4397 reproduces repeated branch-conflict recovery-required emissions for the same task", async () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
reanchorBranchToBase,
|
reanchorBranchToBase,
|
||||||
} from "../branch-conflicts.js";
|
} from "../branch-conflicts.js";
|
||||||
import type { AutoRecoveryContext, AutoRecoveryDecision, AutoRecoveryFailure } from "../auto-recovery.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 { createLogger, type Logger } from "../logger.js";
|
||||||
import type { RunAuditor } from "../run-audit.js";
|
import type { RunAuditor } from "../run-audit.js";
|
||||||
|
|
||||||
@@ -222,9 +223,67 @@ export class BranchWorktreeAutoRecoveryHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (inspection.kind === "live-foreign") {
|
if (inspection.kind === "live-foreign") {
|
||||||
await this.emitIrreduciblePause(ctx.task, failure, "live-foreign", {
|
// FN-4847: discard-and-recreate. Previously this emitted irreducible-pause and the
|
||||||
branchName,
|
// task got stuck with "branch conflict unrecoverable". The user opted into
|
||||||
conflictingWorktreePath,
|
// 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,
|
inspectionKind: inspection.kind,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -35,7 +35,15 @@ export interface AutoRecoveryHandlers {
|
|||||||
const autoRecoveryLog = createLogger("auto-recovery");
|
const autoRecoveryLog = createLogger("auto-recovery");
|
||||||
|
|
||||||
function actionForMode(mode: AutoRecoveryMode, failureClass: AutoRecoveryFailureClass): AutoRecoveryAction {
|
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 (mode === "programmatic") {
|
||||||
if (failureClass === "file-scope-invariant" || failureClass === "post-squash-audit-blocker") return "pause";
|
if (failureClass === "file-scope-invariant" || failureClass === "post-squash-audit-blocker") return "pause";
|
||||||
return "retry";
|
return "retry";
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ export type DatabaseMutationType =
|
|||||||
| "branch-worktree:auto-requeue"
|
| "branch-worktree:auto-requeue"
|
||||||
| "branch-worktree:ai-session-spawned"
|
| "branch-worktree:ai-session-spawned"
|
||||||
| "branch-worktree:irreducible-pause"
|
| "branch-worktree:irreducible-pause"
|
||||||
|
| "branch-worktree:foreign-branch-discarded"
|
||||||
| "document:write"
|
| "document:write"
|
||||||
| "workflow-step:result"
|
| "workflow-step:result"
|
||||||
| "agent:create:requested"
|
| "agent:create:requested"
|
||||||
|
|||||||
Reference in New Issue
Block a user