fix(FN-4397): add branch-conflict tripwire and tighten stale detection

Fusion-Task-Id: FN-4397
Fusion-Task-Lineage: 1ffac4f7-adf5-4115-b588-108d75eadde7
This commit is contained in:
Fusion
2026-05-13 16:00:25 -07:00
committed by gsxdsm
parent 263de0cb3d
commit 09e6302798
2 changed files with 71 additions and 0 deletions

View File

@@ -862,6 +862,44 @@ describe("TaskExecutor worktree recovery", () => {
);
});
it("FN-4397 tripwire pauses on 6th branch conflict and suppresses additional recovery-required agent logs", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
const conflictError = new BranchConflictError({
branchName: "fusion/fn-050",
conflictingWorktreePath: "/tmp/test/.worktrees/green-sage",
existingTipSha: "abc123def456",
strandedCommits: [],
startPoint: "HEAD",
recommendedAction: "Reclaim the existing task branch/worktree or explicitly discard prior work before retrying.",
});
const handleSpy = vi.spyOn(executor as any, "handleBranchConflict").mockImplementation(async () => {
await store.appendAgentLog("FN-050", "Branch conflict recovery required", "tool_error", "mock", "executor");
return "sticky";
});
vi.spyOn(executor as any, "createWorktree").mockRejectedValue(conflictError);
for (let i = 0; i < 6; i += 1) {
await executor.execute(makeTask());
}
expect(handleSpy).toHaveBeenCalledTimes(5);
const tripwireLogCall = vi.mocked(store.logEntry).mock.calls.find((call) =>
call[0] === "FN-050" && String(call[1]).includes("Branch conflict tripwire fired after 6 events"),
);
expect(tripwireLogCall).toBeDefined();
expect(store.updateTask).toHaveBeenCalledWith(
"FN-050",
expect.objectContaining({
status: "failed",
paused: true,
pausedReason: "branch-conflict-tripwire",
}),
);
expect(store.appendAgentLog).toHaveBeenCalledTimes(5);
});
it("falls back to default base and clears task.executionStartBranch when the configured base ref is missing (FN-2165)", async () => {
const store = createMockStore();

View File

@@ -747,6 +747,8 @@ export class TaskExecutor {
private spawnedAgents = new Map<string, Set<string>>();
/** Per-task baseline of session stats used for delta persistence across repeated updates. */
private tokenUsageBaselines = new Map<string, { inputTokens: number; outputTokens: number; cachedTokens: number; totalTokens: number }>();
/** In-memory branch conflict error counters per task for tripwire protection. */
private branchConflictErrorCount = new Map<string, number>();
/** One-shot watchdogs for completed tasks that should have transitioned to in-review. */
private completedTaskWatchdogs = new Map<string, ReturnType<typeof setTimeout>>();
/** One-shot watchdogs for workflow reruns that should have bounced back to in-progress. */
@@ -3947,6 +3949,27 @@ export class TaskExecutor {
});
// Fall through to terminal failure marking
} else if (isBranchConflictError(err)) {
const conflictCount = (this.branchConflictErrorCount.get(task.id) ?? 0) + 1;
this.branchConflictErrorCount.set(task.id, conflictCount);
if (conflictCount > this.BRANCH_CONFLICT_TRIPWIRE_THRESHOLD) {
const details = [
`branch=${err.branchName}`,
`worktree=${err.conflictingWorktreePath}`,
`existingTipSha=${err.existingTipSha}`,
`startPoint=${err.startPoint}`,
].join(" ");
const tripwireMessage = `Branch conflict tripwire fired after ${conflictCount} events (threshold ${this.BRANCH_CONFLICT_TRIPWIRE_THRESHOLD}). ${details}`;
await this.store.logEntry(task.id, `[recovery] ${tripwireMessage}`, undefined, this.currentRunContext);
await this.store.updateTask(task.id, {
status: "failed",
error: tripwireMessage,
paused: true,
pausedReason: "branch-conflict-tripwire",
});
return;
}
let outcome: "retry" | "reclaimed" | "sticky" = "sticky";
for (let attempt = 1; attempt <= this.MAX_AUTO_RECOVERY_ATTEMPTS; attempt += 1) {
outcome = await this.handleBranchConflict(task, err);
@@ -4054,6 +4077,15 @@ export class TaskExecutor {
this.loopRecoveryState.delete(task.id);
this.tokenUsageBaselines.delete(task.id);
if (taskDone) {
this.branchConflictErrorCount.delete(task.id);
} else {
const latestTask = await this.store.getTask(task.id);
if (latestTask.column === "done" || latestTask.column === "archived") {
this.branchConflictErrorCount.delete(task.id);
}
}
// Requeue stuck-killed task AFTER this.executing is cleared.
// This prevents the race where the scheduler re-dispatches the task
// (via task:moved → execute()) while the old execution guard is still set,
@@ -6378,6 +6410,7 @@ and show an appropriate message to the user.\`
}
private readonly MAX_AUTO_RECOVERY_ATTEMPTS = 3;
private readonly BRANCH_CONFLICT_TRIPWIRE_THRESHOLD = 5;
private async reclaimExistingWorktree(
task: Task,