feat(FN-4068): add branch conflict detection and recovery for stale worktre

Implements branch conflict detection and recovery across the Fusion engine, CLI, and dashboard — surfacing git worktree conflicts when tasks conflict with unrelated branch state, and providing a recovery workflow to resolve them. The executor and worktree pool now integrate typed branch conflict che

Fusion-Task-Id: FN-4068
This commit is contained in:
Fusion
2026-05-12 17:10:26 -07:00
committed by gsxdsm
parent ceccc382b1
commit bddcc3a931
28 changed files with 1341 additions and 209 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix `fn_task_done` failing to clear `paused`/`pausedByAgentId` when called on a paused task (FN-3964). Before this fix, a task with `task.paused=true` in `in-progress` or `todo` would land in a contradictory `todo + paused` state after completion, blocking future scheduler picks. Now the executor always clears task-level pause flags on explicit agent completion.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Make executor branch-name collisions fail loudly by default, add a legacy opt-in escape hatch, and introduce CLI branch recovery commands for reclaiming or discarding stranded task branches.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix the dashboard Research view layout so the sidebar, reader pane, actions, findings, and stats render cleanly without overlap on desktop and mobile.

View File

@@ -459,6 +459,20 @@ fn task pause FN-001
fn task unpause FN-001 fn task unpause FN-001
``` ```
### Branch recovery
```bash
fn task branch-recovery FN-001
fn task branch-recovery FN-001 --reclaim fusion/fn-001
fn task branch-recovery FN-001 --discard fusion/fn-001-2 --yes
```
Use `fn task branch-recovery` when executor branch allocation fails because the canonical task branch is already checked out elsewhere.
- No flags: inspect canonical + sibling recovery candidates, including tip SHA, attached worktree path, and stranded commits.
- `--reclaim <branch>`: point the task back at an existing canonical/sibling branch so the next executor run resumes from that branch.
- `--discard <branch> --yes`: explicitly delete a stranded branch/worktree. `--yes` is required for destructive cleanup.
### Node routing controls ### Node routing controls
```bash ```bash

View File

@@ -209,6 +209,7 @@ Override precedence for direct merges is:
| `testCommand` | `string` | `undefined` | Merge-time test command (hard gate). When unset, Fusion auto-detects from lockfile. | | `testCommand` | `string` | `undefined` | Merge-time test command (hard gate). When unset, Fusion auto-detects from lockfile. |
| `buildCommand` | `string` | `undefined` | Merge-time build command (hard gate). | | `buildCommand` | `string` | `undefined` | Merge-time build command (hard gate). |
| `recycleWorktrees` | `boolean` | `false` | Reuse worktrees from a pool for faster startup. | | `recycleWorktrees` | `boolean` | `false` | Reuse worktrees from a pool for faster startup. |
| `executorAllowSiblingBranchRename` | `boolean` | `false` | Escape hatch for legacy branch-collision behavior. When `false` (default), executor branch-name collisions fail loudly, leave the task in `todo` with `status: "failed"`, and surface stranded commits for explicit recovery. When `true`, Fusion restores the old silent sibling-branch rename flow (`fusion/<task-id>-2`, `-3`, …), which is discouraged because it can hide prior work behind suffixed branches. |
| `worktreeNaming` | `"random" \| "task-id" \| "task-title"` | `"random"` | Naming mode for new worktree directories. | | `worktreeNaming` | `"random" \| "task-id" \| "task-title"` | `"random"` | Naming mode for new worktree directories. |
| `taskPrefix` | `string` | `"FN"` | Prefix used for newly generated task IDs. | | `taskPrefix` | `string` | `"FN"` | Prefix used for newly generated task IDs. |
| `includeTaskIdInCommit` | `boolean` | `true` | Include task ID as commit scope in generated commits. | | `includeTaskIdInCommit` | `boolean` | `true` | Include task ID as commit scope in generated commits. |

View File

@@ -24,21 +24,6 @@ The script checks for:
Treat flagged candidates as recovery leads, not automatic truth: review the surviving task files, logs, and commit history, then file a follow-up recovery task for any confirmed overwrite. Treat flagged candidates as recovery leads, not automatic truth: review the surviving task files, logs, and commit history, then file a follow-up recovery task for any confirmed overwrite.
### Reconciling stale task title/description vs canonical PROMPT.md
Use the one-shot reconciliation script only when the surviving evidence agrees on a single canonical task identity and the ambiguity is limited to stale metadata fields on that same task row:
```bash
node scripts/reconcile-fn-3909-identity.mjs [--project-root /path/to/project] [--apply]
```
The script is intentionally narrow and idempotent:
- dry-run is the default and prints the before/after title + description diff without mutating anything
- `--apply` only updates task `FN-3909` through `TaskStore.updateTask(...)` and appends an audit log entry referencing `FN-4194`
- the script refuses to run if `PROMPT.md` no longer matches the expected canonical heading, if the stale heartbeat-scope row contents are not present, or if the row is already canonical without the reconciliation marker
Use this path for the confirmed FN-3909 mismatch (canonical UI-fix prompt/merge history, stale heartbeat-scope title/description). Do **not** use it for allocator-collision or overwrite incidents that may involve multiple tasks or conflicting survivors; run `scripts/audit-task-id-collisions.mjs` first and treat those cases as recovery/postmortem work instead of automatic metadata repair.
## SQLite write-path lock recovery (FN-4042 / FN-4083) ## SQLite write-path lock recovery (FN-4042 / FN-4083)
- Every disk-backed SQLite connection that Fusion opens for project storage (`fusion.db`), the central registry (`fusion-central.db`), archives (`archive.db`), and worktree hydration explicitly sets `PRAGMA busy_timeout = 5000` and `PRAGMA journal_mode = WAL` at connection open time before write work begins. - Every disk-backed SQLite connection that Fusion opens for project storage (`fusion.db`), the central registry (`fusion-central.db`), archives (`archive.db`), and worktree hydration explicitly sets `PRAGMA busy_timeout = 5000` and `PRAGMA journal_mode = WAL` at connection open time before write work begins.

View File

@@ -117,6 +117,14 @@ fn task archive FN-001
fn task unarchive FN-001 fn task unarchive FN-001
``` ```
### Lifecycle invariants
**Paused-state normalization on reopen:** When a task is moved from `in-progress`, `in-review`, or `done` back to `todo` or `triage` (retry/requeue), Fusion clears `task.paused` and `task.pausedByAgentId` to prevent contradictory `todo + paused` or `in-progress + paused` states. A paused task in `todo` is excluded from scheduler dispatch.
**Paused-state normalization on explicit completion:** When an agent calls `fn_task_done` on a paused task, Fusion clears `task.paused` and `task.pausedByAgentId` regardless of the task's column (`in-progress` or `todo`). `task.paused` prevents new work from starting, but does not block an agent from completing in-flight work and transitioning the task to `done`. The scheduler respects `globalPause` independently.
**Global pause vs task pause:** `settings.globalPause` gates new scheduler dispatches and is checked by the `fn_task_done` handoff logic. Task-level `task.paused` is a per-task gate that blocks execution start. They are independent — a task can be paused individually even when `globalPause` is `false`, and clearing `task.paused` does not affect `globalPause`.
### Branch metadata semantics ### Branch metadata semantics
Task cards on the board only surface branch metadata when it is non-default/user-meaningful: they hide the conventional auto-generated working branch (`fusion/<task-id>` and suffixed variants) and hide the default merge target (`main`), while still showing custom working branches and non-default merge targets. Task cards on the board only surface branch metadata when it is non-default/user-meaningful: they hide the conventional auto-generated working branch (`fusion/<task-id>` and suffixed variants) and hide the default merge target (`main`), while still showing custom working branches and non-default merge targets.
@@ -135,6 +143,30 @@ Task branch fields are intentionally distinct:
`PrInfo.baseBranch` is unchanged and continues to represent pull-request target branch metadata. `PrInfo.baseBranch` is unchanged and continues to represent pull-request target branch metadata.
### Loud branch-conflict recovery
When the executor tries to allocate the canonical task branch (`fusion/<task-id>`) and finds that branch already checked out in another live worktree, Fusion now **fails loudly by default** instead of silently forking work onto `fusion/<task-id>-2`, `-3`, and similar siblings.
Default behavior:
- task moves from `in-progress` back to `todo`
- task `status` becomes `"failed"`
- task keeps recovery context on the canonical branch/worktree metadata
- task lifecycle logs and agent logs include the existing tip SHA plus stranded commit subjects
Recovery is explicit:
```bash
fn task branch-recovery FN-001
fn task branch-recovery FN-001 --reclaim fusion/fn-001
fn task branch-recovery FN-001 --discard fusion/fn-001-2 --yes
```
- **Inspect** lists the canonical branch and any sibling branches with tip SHA, attached worktree path, and stranded commits.
- **Reclaim** updates task metadata so the next executor run resumes from the chosen branch/worktree instead of allocating a fresh sibling.
- **Discard** removes a selected stranded branch/worktree only when `--yes` is supplied.
If you must preserve the old silent suffixing flow for a legacy workflow, set project setting `executorAllowSiblingBranchRename=true`. This is discouraged because it can hide earlier commits behind suffixed sibling branches and make recovery less obvious.
### Dependency reconciliation guidance ### Dependency reconciliation guidance
When a task was created to resolve a temporary failure state in another task (for example, a preserved `in-review/failed` merge condition), its dependency contract may become stale after recovery. When a task was created to resolve a temporary failure state in another task (for example, a preserved `in-review/failed` merge condition), its dependency contract may become stale after recovery.

View File

@@ -29,6 +29,7 @@ const commandMocks = vi.hoisted(() => ({
runTaskPlan: vi.fn(), runTaskPlan: vi.fn(),
runTaskDelete: vi.fn(), runTaskDelete: vi.fn(),
runTaskRetry: vi.fn(), runTaskRetry: vi.fn(),
runTaskBranchRecovery: vi.fn(),
runTaskComment: vi.fn(), runTaskComment: vi.fn(),
runTaskComments: vi.fn(), runTaskComments: vi.fn(),
runTaskSteer: vi.fn(), runTaskSteer: vi.fn(),
@@ -133,6 +134,7 @@ vi.mock("../commands/task.js", () => ({
runTaskPlan: commandMocks.runTaskPlan, runTaskPlan: commandMocks.runTaskPlan,
runTaskDelete: commandMocks.runTaskDelete, runTaskDelete: commandMocks.runTaskDelete,
runTaskRetry: commandMocks.runTaskRetry, runTaskRetry: commandMocks.runTaskRetry,
runTaskBranchRecovery: commandMocks.runTaskBranchRecovery,
runTaskComment: commandMocks.runTaskComment, runTaskComment: commandMocks.runTaskComment,
runTaskComments: commandMocks.runTaskComments, runTaskComments: commandMocks.runTaskComments,
runTaskSteer: commandMocks.runTaskSteer, runTaskSteer: commandMocks.runTaskSteer,
@@ -389,6 +391,29 @@ describe("bin command routing and fallbacks", () => {
expect(errorSpy).toHaveBeenCalledWith("Usage: fn task show <id>"); expect(errorSpy).toHaveBeenCalledWith("Usage: fn task show <id>");
}); });
it("routes task branch-recovery with reclaim/discard flags", async () => {
await runBin(["task", "branch-recovery", "FN-123", "--reclaim", "fusion/fn-123-2", "-P", "demo"]);
await runBin(["task", "branch-recovery", "FN-123", "--discard", "fusion/fn-123-2", "--yes", "-P", "demo"]);
expect(commandMocks.runTaskBranchRecovery).toHaveBeenNthCalledWith(1, "FN-123", {
reclaim: "fusion/fn-123-2",
discard: undefined,
yes: false,
}, "demo");
expect(commandMocks.runTaskBranchRecovery).toHaveBeenNthCalledWith(2, "FN-123", {
reclaim: undefined,
discard: "fusion/fn-123-2",
yes: true,
}, "demo");
});
it("errors for task branch-recovery missing id", async () => {
await expect(runBin(["task", "branch-recovery"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(
"Usage: fn task branch-recovery <id> [--reclaim <branch>] [--discard <branch> --yes]",
);
});
it("routes agent subcommands stop/start/import/mailbox", async () => { it("routes agent subcommands stop/start/import/mailbox", async () => {
await runBin(["agent", "stop", "agent-1", "-P", "demo"]); await runBin(["agent", "stop", "agent-1", "-P", "demo"]);
await runBin(["agent", "start", "agent-1", "-P", "demo"]); await runBin(["agent", "start", "agent-1", "-P", "demo"]);

View File

@@ -118,7 +118,7 @@ async function loadCommandHandlers() {
const { runServe } = await import("./commands/serve.js"); const { runServe } = await import("./commands/serve.js");
const { runDaemon } = await import("./commands/daemon.js"); const { runDaemon } = await import("./commands/daemon.js");
const { runDesktop } = await import("./commands/desktop.js"); const { runDesktop } = await import("./commands/desktop.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskPrCreate } = await import("./commands/task.js"); const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskPrCreate, runTaskBranchRecovery } = await import("./commands/task.js");
const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js"); const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js");
const { runSettingsExport } = await import("./commands/settings-export.js"); const { runSettingsExport } = await import("./commands/settings-export.js");
const { runSettingsImport } = await import("./commands/settings-import.js"); const { runSettingsImport } = await import("./commands/settings-import.js");
@@ -163,6 +163,7 @@ async function loadCommandHandlers() {
runTaskPlan, runTaskPlan,
runTaskDelete, runTaskDelete,
runTaskRetry, runTaskRetry,
runTaskBranchRecovery,
runTaskComment, runTaskComment,
runTaskComments, runTaskComments,
runTaskSteer, runTaskSteer,
@@ -280,6 +281,8 @@ Usage:
fn task set-node <id> <node-name-or-id> Set a per-task node override fn task set-node <id> <node-name-or-id> Set a per-task node override
fn task clear-node <id> Clear a per-task node override fn task clear-node <id> Clear a per-task node override
fn task retry <id> Retry a failed task (clears error, moves to todo) fn task retry <id> Retry a failed task (clears error, moves to todo)
fn task branch-recovery <id> [--reclaim <branch>] [--discard <branch> --yes]
Inspect, reclaim, or discard stranded task branches
fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>] fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]
Create a GitHub PR for an in-review task Create a GitHub PR for an in-review task
fn task import <owner/repo> [opts] Import GitHub issues as tasks fn task import <owner/repo> [opts] Import GitHub issues as tasks
@@ -522,6 +525,7 @@ async function main() {
runTaskPlan, runTaskPlan,
runTaskDelete, runTaskDelete,
runTaskRetry, runTaskRetry,
runTaskBranchRecovery,
runTaskComment, runTaskComment,
runTaskComments, runTaskComments,
runTaskSteer, runTaskSteer,
@@ -1117,6 +1121,20 @@ async function main() {
await runTaskRetry(id, projectName); await runTaskRetry(id, projectName);
break; break;
} }
case "branch-recovery": {
const id = args[2];
if (!id) {
console.error("Usage: fn task branch-recovery <id> [--reclaim <branch>] [--discard <branch> --yes]");
process.exit(1);
}
const reclaimIdx = args.indexOf("--reclaim");
const discardIdx = args.indexOf("--discard");
const reclaim = reclaimIdx !== -1 && reclaimIdx + 1 < args.length ? args[reclaimIdx + 1] : undefined;
const discard = discardIdx !== -1 && discardIdx + 1 < args.length ? args[discardIdx + 1] : undefined;
const yes = args.includes("--yes");
await runTaskBranchRecovery(id, { reclaim, discard, yes }, projectName);
break;
}
case "pr-create": { case "pr-create": {
const id = args[2]; const id = args[2];
if (!id) { if (!id) {

View File

@@ -14,6 +14,19 @@ vi.mock("node:fs", () => ({
readFileSync: vi.fn(), readFileSync: vi.fn(),
})); }));
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const execFn = vi.fn((cmd: string, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
if (typeof callback === "function") callback(null, "", "");
}) as any;
execFn[promisify.custom] = (cmd: string, opts?: any) =>
new Promise((resolve) => {
execFn(cmd, opts, (_err: any, stdout: string, stderr: string) => resolve({ stdout, stderr }));
});
return { exec: execFn };
});
// Mock @fusion/core before importing the module under test // Mock @fusion/core before importing the module under test
vi.mock("@fusion/core", () => { vi.mock("@fusion/core", () => {
const COLUMNS = ["triage", "specified", "in-progress", "review", "done"]; const COLUMNS = ["triage", "specified", "in-progress", "review", "done"];
@@ -45,7 +58,7 @@ vi.mock("@fusion/core", () => {
}); });
// Mock @fusion/engine // Mock @fusion/engine
vi.mock("@fusion/engine", () => ({ aiMergeTask: vi.fn() })); vi.mock("@fusion/engine", () => ({ aiMergeTask: vi.fn(), listBranchRecoveryCandidates: vi.fn() }));
// Mock @fusion/dashboard // Mock @fusion/dashboard
vi.mock("@fusion/dashboard", () => ({ vi.mock("@fusion/dashboard", () => ({
@@ -83,7 +96,8 @@ vi.mock("../../project-context.js", () => ({
import { createInterface } from "node:readline/promises"; import { createInterface } from "node:readline/promises";
import { TaskStore, CentralCore } from "@fusion/core"; import { TaskStore, CentralCore } from "@fusion/core";
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs"; import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "../task.js"; import { exec } from "node:child_process";
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskBranchRecovery, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "../task.js";
import { import {
getCurrentRepo, getCurrentRepo,
isGhAuthenticated, isGhAuthenticated,
@@ -93,7 +107,9 @@ import {
import { GitHubClient } from "@fusion/dashboard"; import { GitHubClient } from "@fusion/dashboard";
import { createSession, submitResponse } from "@fusion/dashboard/planning"; import { createSession, submitResponse } from "@fusion/dashboard/planning";
import { resolveProject } from "../../project-context.js"; import { resolveProject } from "../../project-context.js";
import { aiMergeTask } from "@fusion/engine"; import { aiMergeTask, listBranchRecoveryCandidates } from "@fusion/engine";
const mockedExec = vi.mocked(exec);
function makeTask(overrides: Record<string, unknown> = {}) { function makeTask(overrides: Record<string, unknown> = {}) {
return { return {
@@ -2052,6 +2068,153 @@ describe("runTaskRetry", () => {
}); });
}); });
describe("runTaskBranchRecovery", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
let mockGetTask: ReturnType<typeof vi.fn>;
let mockUpdateTask: ReturnType<typeof vi.fn>;
let mockLogEntry: ReturnType<typeof vi.fn>;
beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
mockGetTask = vi.fn().mockResolvedValue(makeTask({
id: "FN-001",
branch: "fusion/fn-001",
worktree: "/tmp/fn-001",
executionStartBranch: "main",
status: "failed",
column: "todo",
}));
mockUpdateTask = vi.fn().mockResolvedValue(undefined);
mockLogEntry = vi.fn().mockResolvedValue(undefined);
mockedExec.mockReset();
vi.mocked(listBranchRecoveryCandidates).mockReset();
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: mockGetTask,
updateTask: mockUpdateTask,
logEntry: mockLogEntry,
}));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("prints branch recovery candidates", async () => {
vi.mocked(listBranchRecoveryCandidates).mockResolvedValue([
{
branchName: "fusion/fn-001",
tipSha: "abc123def456",
worktreePath: "/tmp/fn-001",
strandedCommits: [{ sha: "aaa111", subject: "Canonical fix" }],
isCanonical: true,
},
{
branchName: "fusion/fn-001-2",
tipSha: "bbb222ccc333",
worktreePath: "/tmp/fn-001-2",
strandedCommits: [{ sha: "bbb222", subject: "Sibling patch" }],
isCanonical: false,
},
]);
await runTaskBranchRecovery("FN-001");
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("Branch recovery candidates for FN-001");
expect(output).toContain("fusion/fn-001 (canonical)");
expect(output).toContain("abc123def456");
expect(output).toContain("Sibling patch");
});
it("reclaims the selected branch for the next run", async () => {
vi.mocked(listBranchRecoveryCandidates).mockResolvedValue([
{
branchName: "fusion/fn-001",
tipSha: "abc123def456",
worktreePath: "/tmp/fn-001",
strandedCommits: [],
isCanonical: true,
},
{
branchName: "fusion/fn-001-2",
tipSha: "bbb222ccc333",
worktreePath: "/tmp/fn-001-2",
strandedCommits: [{ sha: "bbb222", subject: "Sibling patch" }],
isCanonical: false,
},
]);
await runTaskBranchRecovery("FN-001", { reclaim: "fusion/fn-001-2" });
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
branch: "fusion/fn-001-2",
worktree: "/tmp/fn-001-2",
status: null,
error: null,
});
expect(mockLogEntry).toHaveBeenCalledWith(
"FN-001",
"Branch recovery: reclaimed fusion/fn-001-2",
"bbb222ccc333 @ /tmp/fn-001-2",
);
});
it("refuses discard without explicit confirmation", async () => {
vi.mocked(listBranchRecoveryCandidates).mockResolvedValue([
{
branchName: "fusion/fn-001-2",
tipSha: "bbb222ccc333",
worktreePath: "/tmp/fn-001-2",
strandedCommits: [{ sha: "bbb222", subject: "Sibling patch" }],
isCanonical: false,
},
]);
await expect(runTaskBranchRecovery("FN-001", { discard: "fusion/fn-001-2" })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: Refusing to discard branch recovery state without --yes");
expect(mockedExec).not.toHaveBeenCalled();
});
it("discards the selected branch and worktree when confirmed", async () => {
vi.mocked(listBranchRecoveryCandidates).mockResolvedValue([
{
branchName: "fusion/fn-001-2",
tipSha: "bbb222ccc333",
worktreePath: "/tmp/fn-001-2",
strandedCommits: [{ sha: "bbb222", subject: "Sibling patch" }],
isCanonical: false,
},
]);
await runTaskBranchRecovery("FN-001", { discard: "fusion/fn-001-2", yes: true });
expect(mockedExec).toHaveBeenCalledWith(
"git worktree remove '/tmp/fn-001-2' --force",
expect.objectContaining({ cwd: expect.any(String), encoding: "utf-8" }),
expect.any(Function),
);
expect(mockedExec).toHaveBeenCalledWith(
"git branch -D 'fusion/fn-001-2'",
expect.objectContaining({ cwd: expect.any(String), encoding: "utf-8" }),
expect.any(Function),
);
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", { status: null, error: null });
expect(mockLogEntry).toHaveBeenCalledWith(
"FN-001",
"Branch recovery: discarded fusion/fn-001-2",
"bbb222ccc333 @ /tmp/fn-001-2",
);
});
});
// --- Logs Tests --- // --- Logs Tests ---
describe("runTaskLogs", () => { describe("runTaskLogs", () => {

View File

@@ -1,5 +1,7 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry } from "@fusion/core"; import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry } from "@fusion/core";
import { aiMergeTask } from "@fusion/engine"; import { aiMergeTask, listBranchRecoveryCandidates, type BranchRecoveryCandidate } from "@fusion/engine";
import { createInterface } from "node:readline/promises"; import { createInterface } from "node:readline/promises";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import { createSession, submitResponse, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning"; import { createSession, submitResponse, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning";
@@ -16,6 +18,7 @@ import {
import { resolveProject, type ProjectContext } from "../project-context.js"; import { resolveProject, type ProjectContext } from "../project-context.js";
import { findNodeByNameOrId } from "./node.js"; import { findNodeByNameOrId } from "./node.js";
const execAsync = promisify(exec);
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"]; const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
function getGitHubIssueUrl(sourceMetadata: unknown): string | undefined { function getGitHubIssueUrl(sourceMetadata: unknown): string | undefined {
@@ -161,6 +164,70 @@ async function getProjectPath(projectName?: string): Promise<string> {
return (await getCommandContext(projectName)).projectPath; return (await getCommandContext(projectName)).projectPath;
} }
function quoteShellArg(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function getCanonicalTaskBranch(taskId: string): string {
return `fusion/${taskId.toLowerCase()}`;
}
function formatRecoveryCandidate(candidate: BranchRecoveryCandidate): string[] {
const lines = [
`${candidate.branchName}${candidate.isCanonical ? " (canonical)" : ""}`,
` tip: ${candidate.tipSha}`,
` worktree: ${candidate.worktreePath ?? "(not attached to a worktree)"}`,
];
if (candidate.strandedCommits.length === 0) {
lines.push(" stranded commits: none");
} else {
lines.push(" stranded commits:");
for (const commit of candidate.strandedCommits) {
lines.push(` - ${commit.sha.slice(0, 12)} ${commit.subject}`);
}
}
return lines;
}
async function runGit(projectPath: string, command: string): Promise<string> {
const { stdout } = await execAsync(command, { cwd: projectPath, encoding: "utf-8" });
return stdout.trim();
}
async function resolveBranchRecoveryCandidates(id: string, projectName?: string): Promise<{
store: TaskStore;
projectPath: string;
task: Awaited<ReturnType<TaskStore["getTask"]>>;
canonicalBranch: string;
candidates: BranchRecoveryCandidate[];
}> {
const context = await getCommandContext(projectName);
const task = await context.store.getTask(id);
const canonicalBranch = getCanonicalTaskBranch(task.id);
const candidates = await listBranchRecoveryCandidates({
repoDir: context.projectPath,
branchName: canonicalBranch,
startPoint: task.executionStartBranch ?? undefined,
});
return {
store: context.store,
projectPath: context.projectPath,
task,
canonicalBranch,
candidates,
};
}
async function resolveRecoveryCandidateOrExit(id: string, branch: string, projectName?: string) {
const resolved = await resolveBranchRecoveryCandidates(id, projectName);
const candidate = resolved.candidates.find((entry) => entry.branchName === branch);
if (!candidate) {
console.error(`Error: Branch recovery candidate not found for ${id}: ${branch}`);
process.exit(1);
}
return { ...resolved, candidate };
}
async function resolveNodeByNameOrId(nodeNameOrId: string): Promise<{ id: string; name?: string }> { async function resolveNodeByNameOrId(nodeNameOrId: string): Promise<{ id: string; name?: string }> {
const central = new CentralCore(); const central = new CentralCore();
await central.init(); await central.init();
@@ -830,6 +897,95 @@ export async function runTaskRetry(id: string, projectName?: string) {
console.log(); console.log();
} }
export async function runTaskBranchRecovery(
id: string,
options: { reclaim?: string; discard?: string; yes?: boolean } = {},
projectName?: string,
) {
if (options.reclaim && options.discard) {
console.error("Error: --reclaim and --discard are mutually exclusive");
process.exit(1);
}
if (options.reclaim) {
const { store, task, candidate } = await resolveRecoveryCandidateOrExit(id, options.reclaim, projectName);
await store.updateTask(task.id, {
branch: candidate.branchName,
worktree: candidate.worktreePath,
status: null,
error: null,
});
await store.logEntry(
task.id,
`Branch recovery: reclaimed ${candidate.branchName}`,
`${candidate.tipSha}${candidate.worktreePath ? ` @ ${candidate.worktreePath}` : ""}`,
);
console.log();
console.log(` ✓ Reclaimed ${candidate.branchName} for ${task.id}`);
console.log(` Tip: ${candidate.tipSha}`);
console.log(` Worktree: ${candidate.worktreePath ?? "(none)"}`);
console.log();
return;
}
if (options.discard) {
if (!options.yes) {
console.error("Error: Refusing to discard branch recovery state without --yes");
process.exit(1);
}
const { store, projectPath, task, candidate } = await resolveRecoveryCandidateOrExit(id, options.discard, projectName);
if (candidate.worktreePath) {
await runGit(projectPath, `git worktree remove ${quoteShellArg(candidate.worktreePath)} --force`);
}
await runGit(projectPath, `git branch -D ${quoteShellArg(candidate.branchName)}`);
const patch: Record<string, unknown> = { status: null, error: null };
if (task.branch === candidate.branchName) {
patch.branch = null;
}
if (task.worktree && task.worktree === candidate.worktreePath) {
patch.worktree = null;
}
await store.updateTask(task.id, patch);
await store.logEntry(
task.id,
`Branch recovery: discarded ${candidate.branchName}`,
`${candidate.tipSha}${candidate.worktreePath ? ` @ ${candidate.worktreePath}` : ""}`,
);
console.log();
console.log(` ✓ Discarded ${candidate.branchName} for ${task.id}`);
if (candidate.worktreePath) {
console.log(` Removed worktree: ${candidate.worktreePath}`);
}
console.log(` Deleted branch tip: ${candidate.tipSha}`);
console.log();
return;
}
const { task, candidates, canonicalBranch } = await resolveBranchRecoveryCandidates(id, projectName);
console.log();
console.log(` Branch recovery candidates for ${task.id}`);
console.log(` Canonical branch: ${canonicalBranch}`);
console.log(` Current task branch: ${task.branch ?? "(none)"}`);
console.log(` Current task worktree: ${task.worktree ?? "(none)"}`);
if (candidates.length === 0) {
console.log(" No matching canonical or sibling branches were found.");
console.log();
return;
}
for (const candidate of candidates) {
for (const line of formatRecoveryCandidate(candidate)) {
console.log(line);
}
}
console.log();
}
export async function runTaskDelete(id: string, force?: boolean, projectName?: string) { export async function runTaskDelete(id: string, force?: boolean, projectName?: string) {
const store = await getStore(projectName); const store = await getStore(projectName);

View File

@@ -593,5 +593,5 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => {
await rm(stagedRoot, { recursive: true, force: true }); await rm(stagedRoot, { recursive: true, force: true });
await rm(pluginStateRoot, { recursive: true, force: true }); await rm(pluginStateRoot, { recursive: true, force: true });
} }
}); }, 20_000);
}); });

View File

@@ -73,6 +73,12 @@ describe("settings key parity", () => {
expect(DEFAULT_PROJECT_SETTINGS.heartbeatMultiplier).toBe(1); expect(DEFAULT_PROJECT_SETTINGS.heartbeatMultiplier).toBe(1);
}); });
it("defaults sibling branch rename escape hatch to disabled", () => {
expect(DEFAULT_PROJECT_SETTINGS.executorAllowSiblingBranchRename).toBe(false);
expect(isProjectSettingsKey("executorAllowSiblingBranchRename")).toBe(true);
expect(isGlobalSettingsKey("executorAllowSiblingBranchRename")).toBe(false);
});
it("defaults completionDocumentationMode to off", () => { it("defaults completionDocumentationMode to off", () => {
expect(DEFAULT_PROJECT_SETTINGS.completionDocumentationMode).toBe("off"); expect(DEFAULT_PROJECT_SETTINGS.completionDocumentationMode).toBe("off");
}); });

View File

@@ -180,6 +180,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
testCommand: undefined, testCommand: undefined,
buildCommand: undefined, buildCommand: undefined,
recycleWorktrees: false, recycleWorktrees: false,
executorAllowSiblingBranchRename: false,
worktreeNaming: "random", worktreeNaming: "random",
taskPrefix: "FN", taskPrefix: "FN",
includeTaskIdInCommit: true, includeTaskIdInCommit: true,

View File

@@ -2081,6 +2081,10 @@ export interface ProjectSettings {
* of being deleted. New tasks acquire a warm worktree from the pool, * of being deleted. New tasks acquire a warm worktree from the pool,
* preserving build caches (node_modules, target/, dist/). Default: false. */ * preserving build caches (node_modules, target/, dist/). Default: false. */
recycleWorktrees?: boolean; recycleWorktrees?: boolean;
/** When true, restores the legacy behavior of silently creating sibling
* branches like `fusion/FN-123-2` when the canonical task branch is already
* checked out elsewhere. Default: false. */
executorAllowSiblingBranchRename?: boolean;
/** Controls how worktree directory names are generated when creating fresh worktrees. /** Controls how worktree directory names are generated when creating fresh worktrees.
* Only applies when recycleWorktrees is NOT enabled (pooled worktrees retain their existing names). * Only applies when recycleWorktrees is NOT enabled (pooled worktrees retain their existing names).
* - "random": Human-friendly adjective-noun names (e.g., swift-falcon) — default * - "random": Human-friendly adjective-noun names (e.g., swift-falcon) — default

View File

@@ -4,6 +4,7 @@
gap: var(--space-md); gap: var(--space-md);
height: 100%; height: 100%;
min-height: 0; min-height: 0;
overflow: hidden;
padding: var(--space-lg); padding: var(--space-lg);
padding-bottom: var(--space-lg); padding-bottom: var(--space-lg);
} }
@@ -30,6 +31,7 @@
gap: var(--space-md); gap: var(--space-md);
min-height: 0; min-height: 0;
flex: 1; flex: 1;
overflow: hidden;
} }
.research-view__sidebar, .research-view__sidebar,
@@ -39,6 +41,26 @@
flex-direction: column; flex-direction: column;
gap: var(--space-md); gap: var(--space-md);
min-height: 0; min-height: 0;
overflow: hidden;
}
.research-view__reader {
overflow: auto;
}
.research-view__reader-content {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--space-md);
min-height: 0;
}
.research-view__run-detail {
display: flex;
flex-direction: column;
gap: var(--space-md);
min-height: 0;
} }
.research-view__form { .research-view__form {
@@ -127,6 +149,7 @@
.research-view__actions { .research-view__actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
gap: var(--space-xs); gap: var(--space-xs);
margin-top: var(--space-sm); margin-top: var(--space-sm);
} }
@@ -171,11 +194,13 @@
padding-left: var(--space-lg); padding-left: var(--space-lg);
} }
.research-view__stats { .research-view__stats {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-sm); gap: var(--space-sm);
margin-top: auto;
padding-top: var(--space-sm);
border-top: var(--btn-border-width) solid var(--border);
} }
.research-view__stat-card { .research-view__stat-card {
@@ -201,6 +226,7 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.research-view { .research-view {
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
padding: var(--space-md); padding: var(--space-md);
padding-bottom: calc(var(--space-md) + var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap)); padding-bottom: calc(var(--space-md) + var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
@@ -210,6 +236,21 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
gap: var(--space-md);
flex: initial;
overflow: visible;
}
.research-view__sidebar,
.research-view__reader {
overflow: visible;
}
.research-view__reader {
overflow: visible;
}
.research-view__reader-content {
flex: initial; flex: initial;
} }
@@ -223,6 +264,10 @@
flex-direction: column; flex-direction: column;
} }
.research-view__header .btn {
align-self: flex-start;
}
.research-view__stats { .research-view__stats {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }

View File

@@ -322,113 +322,115 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
{loading && <p data-testid="research-state-loading">Loading research runs</p>} {loading && <p data-testid="research-state-loading">Loading research runs</p>}
{!loading && error && <p data-testid="research-state-error">{error}</p>} {!loading && error && <p data-testid="research-state-error">{error}</p>}
{!loading && !error && runs.length === 0 && <p data-testid="research-state-empty">No research runs yet</p>} {!loading && !error && runs.length === 0 && <p data-testid="research-state-empty">No research runs yet</p>}
{selectedRun && ( <div className="research-view__reader-content">
<div> {selectedRun && (
<div className="research-view__status-row"> <div className="research-view__run-detail">
<span className={statusDotClass} /> <div className="research-view__status-row">
<strong>{statusLabel}</strong> <span className={statusDotClass} />
</div> <strong>{statusLabel}</strong>
<h3 className="research-view__run-title">{selectedRun.title}</h3>
<p className="research-view__run-query">{selectedRun.query}</p>
<p className="research-view__run-summary" data-testid="research-state-results">{selectedRun.results?.summary ?? "No summary yet."}</p>
<div className="research-view__actions">
<button
className="btn"
type="button"
title={!runActionState.cancelable ? runActionState.blockingReason : undefined}
disabled={actionLoading === "cancel" || actionLoading === "retry" || !runActionState.cancelable}
onClick={() => void runAction("cancel", () => cancelRun(selectedRun.id), "Run cancelled")}
>
Cancel
</button>
<button
className="btn"
type="button"
title={!runActionState.retryable ? runActionState.blockingReason : undefined}
disabled={actionLoading === "cancel" || actionLoading === "retry" || !runActionState.retryable}
onClick={() => void runAction("retry", () => retryRun(selectedRun.id), "Run retried")}
>
Retry
</button>
{supportedExportFormats.includes("markdown") && <button className="btn" type="button" disabled={actionLoading === "export-markdown"} onClick={() => void handleExport("markdown")}>Export MD</button>}
{supportedExportFormats.includes("json") && <button className="btn" type="button" disabled={actionLoading === "export-json"} onClick={() => void handleExport("json")}>Export JSON</button>}
{supportedExportFormats.includes("html") && <button className="btn" type="button" disabled={actionLoading === "export-html"} onClick={() => void handleExport("html")}>Export HTML</button>}
</div>
{selectedRun.error && <p className="research-view__error">{selectedRun.error}</p>}
{uiError && (
<div className="form-error" role="alert">
<p>{uiError.message}</p>
{uiError.setupHint && <p>{uiError.setupHint}</p>}
{uiError.code === "MISSING_CREDENTIALS" && (
<button className="btn btn-sm" type="button" onClick={() => onOpenSettings?.("authentication")}>
Open Authentication Settings
</button>
)}
{uiError.code === "FEATURE_DISABLED" && (
<button className="btn btn-sm" type="button" onClick={() => onOpenSettings?.("research-project")}>
Open Research Settings
</button>
)}
</div> </div>
)} <h3 className="research-view__run-title">{selectedRun.title}</h3>
{runActionState.blockingReason && ( <p className="research-view__run-query">{selectedRun.query}</p>
<p className="research-view__run-query">{runActionState.blockingReason}</p> <p className="research-view__run-summary" data-testid="research-state-results">{selectedRun.results?.summary ?? "No summary yet."}</p>
)} <div className="research-view__actions">
{Array.isArray(selectedRun.results?.findings) && selectedRun.results.findings.length > 0 && ( <button
<div className="research-view__findings"> className="btn"
{selectedRun.results.findings.map((finding, index) => { type="button"
const findingRecord = finding as { id?: string }; title={!runActionState.cancelable ? runActionState.blockingReason : undefined}
const findingId = findingRecord.id?.trim() || `finding-${index + 1}`; disabled={actionLoading === "cancel" || actionLoading === "retry" || !runActionState.cancelable}
return ( onClick={() => void runAction("cancel", () => cancelRun(selectedRun.id), "Run cancelled")}
<article key={findingId} className="research-view__finding card"> >
<h4>{finding.heading}</h4> Cancel
<p>{finding.content}</p> </button>
<div className="research-view__actions research-view__finding-actions"> <button
<button className="btn"
className="btn btn-primary btn-sm" type="button"
type="button" title={!runActionState.retryable ? runActionState.blockingReason : undefined}
onClick={() => setModalState({ mode: "create", findingId })} disabled={actionLoading === "cancel" || actionLoading === "retry" || !runActionState.retryable}
> onClick={() => void runAction("retry", () => retryRun(selectedRun.id), "Run retried")}
Create Task >
</button> Retry
<button </button>
className="btn btn-sm" {supportedExportFormats.includes("markdown") && <button className="btn" type="button" disabled={actionLoading === "export-markdown"} onClick={() => void handleExport("markdown")}>Export MD</button>}
type="button" {supportedExportFormats.includes("json") && <button className="btn" type="button" disabled={actionLoading === "export-json"} onClick={() => void handleExport("json")}>Export JSON</button>}
onClick={() => setModalState({ mode: "enrich", findingId })} {supportedExportFormats.includes("html") && <button className="btn" type="button" disabled={actionLoading === "export-html"} onClick={() => void handleExport("html")}>Export HTML</button>}
>
Enrich Task
</button>
</div>
</article>
);
})}
</div> </div>
)} {selectedRun.error && <p className="research-view__error">{selectedRun.error}</p>}
{Array.isArray(selectedRun.results?.citations) && selectedRun.results!.citations!.length > 0 && ( {uiError && (
<ul className="research-view__citations"> <div className="form-error" role="alert">
{selectedRun.results!.citations!.map((citation) => ( <p>{uiError.message}</p>
<li key={citation}><a href={citation} target="_blank" rel="noreferrer">{citation}</a></li> {uiError.setupHint && <p>{uiError.setupHint}</p>}
))} {uiError.code === "MISSING_CREDENTIALS" && (
</ul> <button className="btn btn-sm" type="button" onClick={() => onOpenSettings?.("authentication")}>
)} Open Authentication Settings
{selectedRun.events.length > 0 && ( </button>
<details> )}
<summary>Run history</summary> {uiError.code === "FEATURE_DISABLED" && (
<ul className="research-view__events"> <button className="btn btn-sm" type="button" onClick={() => onOpenSettings?.("research-project")}>
{selectedRun.events.map((event) => ( Open Research Settings
<li key={event.id}>{event.message}</li> </button>
)}
</div>
)}
{runActionState.blockingReason && (
<p className="research-view__run-query">{runActionState.blockingReason}</p>
)}
{Array.isArray(selectedRun.results?.findings) && selectedRun.results.findings.length > 0 && (
<div className="research-view__findings">
{selectedRun.results.findings.map((finding, index) => {
const findingRecord = finding as { id?: string };
const findingId = findingRecord.id?.trim() || `finding-${index + 1}`;
return (
<article key={findingId} className="research-view__finding card">
<h4>{finding.heading}</h4>
<p>{finding.content}</p>
<div className="research-view__actions research-view__finding-actions">
<button
className="btn btn-primary btn-sm"
type="button"
onClick={() => setModalState({ mode: "create", findingId })}
>
Create Task
</button>
<button
className="btn btn-sm"
type="button"
onClick={() => setModalState({ mode: "enrich", findingId })}
>
Enrich Task
</button>
</div>
</article>
);
})}
</div>
)}
{Array.isArray(selectedRun.results?.citations) && selectedRun.results!.citations!.length > 0 && (
<ul className="research-view__citations">
{selectedRun.results!.citations!.map((citation) => (
<li key={citation}><a href={citation} target="_blank" rel="noreferrer">{citation}</a></li>
))} ))}
</ul> </ul>
</details> )}
)} {selectedRun.events.length > 0 && (
</div> <details>
)} <summary>Run history</summary>
{!selectedRun && runs.length > 0 && <p>Select a run to view details.</p>} <ul className="research-view__events">
{selectedRun.events.map((event) => (
<li key={event.id}>{event.message}</li>
))}
</ul>
</details>
)}
</div>
)}
{!selectedRun && runs.length > 0 && <p>Select a run to view details.</p>}
<div className="research-view__stats"> <div className="research-view__stats">
<div className="research-view__stat-card"><div className="research-view__stat-label">Running</div><div className="research-view__stat-value">{statusCounts.running}</div></div> <div className="research-view__stat-card"><div className="research-view__stat-label">Running</div><div className="research-view__stat-value">{statusCounts.running}</div></div>
<div className="research-view__stat-card"><div className="research-view__stat-label">Completed</div><div className="research-view__stat-value">{statusCounts.completed}</div></div> <div className="research-view__stat-card"><div className="research-view__stat-label">Completed</div><div className="research-view__stat-value">{statusCounts.completed}</div></div>
<div className="research-view__stat-card"><div className="research-view__stat-label">Failed</div><div className="research-view__stat-value">{statusCounts.failed}</div></div> <div className="research-view__stat-card"><div className="research-view__stat-label">Failed</div><div className="research-view__stat-value">{statusCounts.failed}</div></div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -394,6 +394,7 @@ export function SettingsModal({
autoMerge: true, autoMerge: true,
mergeStrategy: "direct", mergeStrategy: "direct",
recycleWorktrees: false, recycleWorktrees: false,
executorAllowSiblingBranchRename: false,
worktreeNaming: "random", worktreeNaming: "random",
includeTaskIdInCommit: true, includeTaskIdInCommit: true,
worktreeInitCommand: "", worktreeInitCommand: "",
@@ -3485,6 +3486,22 @@ export function SettingsModal({
</label> </label>
<small>When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup</small> <small>When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup</small>
</div> </div>
<div className="form-group">
<label htmlFor="executorAllowSiblingBranchRename" className="checkbox-label">
<input
id="executorAllowSiblingBranchRename"
type="checkbox"
checked={form.executorAllowSiblingBranchRename === true}
onChange={(e) =>
setForm((f) => ({ ...f, executorAllowSiblingBranchRename: e.target.checked }))
}
/>
Allow silent sibling branch rename during executor conflicts
</label>
<small>
Discouraged. This restores the legacy behavior where a live <code>fusion/&lt;task-id&gt;</code> branch collision silently forks work onto sibling branches like <code>-2</code> and can hide prior commits from the default recovery flow.
</small>
</div>
<div className="form-group"> <div className="form-group">
<label htmlFor="worktreeNaming">Worktree Naming Style</label> <label htmlFor="worktreeNaming">Worktree Naming Style</label>
<select <select

View File

@@ -525,12 +525,27 @@ describe("ResearchView", () => {
await waitFor(() => expect(enrichButton).not.toBeDisabled()); await waitFor(() => expect(enrichButton).not.toBeDisabled());
}); });
it("FN-3912: research view content is scrollable on mobile", () => { it("FN-3912: keeps the desktop research split view as a bounded grid", () => {
const css = loadAllAppCss();
const baseCss = loadAllAppCssBaseOnly(); const baseCss = loadAllAppCssBaseOnly();
expect(baseCss).toMatch(/\.research-view__layout\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s*minmax\(0,\s*2fr\);[^}]*\}/); expect(baseCss).toMatch(/\.research-view__layout\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s*minmax\(0,\s*2fr\);[^}]*overflow:\s*hidden;[^}]*\}/);
expect(baseCss).toMatch(/\.research-view__stats\s*\{[^}]*margin-top:\s*auto;[^}]*border-top:\s*var\(--btn-border-width\)\s+solid\s+var\(--border\);[^}]*\}/);
expect(baseCss).not.toMatch(/\.research-view__stats\s*\{[^}]*position:\s*absolute;[^}]*\}/);
});
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)\s*\{[^}]*\.research-view\s*\{[^}]*overflow-y:\s*auto;[^}]*-webkit-overflow-scrolling:\s*touch;[^}]*padding-bottom:\s*calc\(var\(--space-md\)\s*\+\s*var\(--mobile-nav-height\)\s*\+\s*env\(safe-area-inset-bottom,\s*0px\)\s*\+\s*var\(--standalone-bottom-gap\)\);[^}]*\}/); it("FN-3912: keeps the desktop reader pane scroll-contained", () => {
const baseCss = loadAllAppCssBaseOnly();
expect(baseCss).toMatch(/\.research-view__sidebar,\s*\.research-view__reader\s*\{[^}]*min-height:\s*0;[^}]*\}/);
expect(baseCss).toMatch(/\.research-view__reader\s*\{[^}]*overflow:\s*auto;[^}]*\}/);
expect(baseCss).not.toMatch(/\.research-view__reader\s*\{[^}]*position:\s*(absolute|fixed);[^}]*\}/);
expect(baseCss).toMatch(/\.research-view__reader-content\s*\{[^}]*display:\s*flex;[^}]*flex:\s*1(?:\s+1\s+0%)?;[^}]*min-height:\s*0;[^}]*\}/);
});
it("FN-3912: research view content is scrollable on mobile", () => {
const css = loadAllAppCss();
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*?\.research-view\s*\{[^}]*overflow-y:\s*auto;[^}]*padding-bottom:\s*calc\(var\(--space-md\)\s*\+\s*var\(--mobile-nav-height\)\s*\+\s*env\(safe-area-inset-bottom,\s*0px\)\s*\+\s*var\(--standalone-bottom-gap\)\);[^}]*\}/);
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*?\.research-view__layout\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;[^}]*gap:\s*var\(--space-md\);[^}]*\}/);
}); });
}); });

View File

@@ -183,6 +183,7 @@ const defaultSettings = {
verificationFixRetries: 2, verificationFixRetries: 2,
workflowRevisionForkOnScopeMismatch: true, workflowRevisionForkOnScopeMismatch: true,
recycleWorktrees: false, recycleWorktrees: false,
executorAllowSiblingBranchRename: false,
worktreeNaming: "random", worktreeNaming: "random",
includeTaskIdInCommit: true, includeTaskIdInCommit: true,
worktreeInitCommand: "", worktreeInitCommand: "",
@@ -260,6 +261,27 @@ describe("SettingsModal", () => {
expect(screen.queryByLabelText("Direct merge commit routing")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Direct merge commit routing")).not.toBeInTheDocument();
}); });
it("persists the legacy sibling branch rename escape hatch in worktree settings", async () => {
renderModal();
await waitForSettingsModalReady();
await userEvent.click(screen.getByRole("button", { name: /^Worktrees$/ }));
const checkbox = screen.getByRole("checkbox", { name: "Allow silent sibling branch rename during executor conflicts" });
expect(checkbox).not.toBeChecked();
await userEvent.click(checkbox);
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledWith(
expect.objectContaining({ executorAllowSiblingBranchRename: true }),
undefined,
);
});
expect(screen.getByText(/restores the legacy behavior/i)).toBeInTheDocument();
});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
clearPluginUiSlotsCache(); clearPluginUiSlotsCache();

View File

@@ -11,7 +11,7 @@ const qualityAppTests = [
"app/api/**/*.test.ts", "app/api/**/*.test.ts",
// Representative workflow/component coverage. Exhaustive modal/view suites // Representative workflow/component coverage. Exhaustive modal/view suites
// stay available in the full `dashboard-app` project. // stay available in the full `dashboard-app` project.
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,AuthTokenRecoveryDialog,Board,board-mobile-view-switch,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,StashRecoveryView,TaskCard,TaskChangesTab,TaskComments,TaskDocumentsTab,TaskForm,ThemeSelectorSwatchContract,WorkflowResultsTab}.test.tsx", "app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,AuthTokenRecoveryDialog,Board,board-mobile-view-switch,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,ResearchView,StashRecoveryView,TaskCard,TaskChangesTab,TaskComments,TaskDocumentsTab,TaskForm,ThemeSelectorSwatchContract,WorkflowResultsTab}.test.tsx",
// Hooks and utilities are fast, user-visible state/formatting behavior. // Hooks and utilities are fast, user-visible state/formatting behavior.
"app/context/**/*.test.tsx", "app/context/**/*.test.tsx",
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}", "app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",

View File

@@ -0,0 +1,170 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { ExecException } from "node:child_process";
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const execSyncFn = vi.fn();
const execFn: any = vi.fn((cmd: string, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
const options = typeof opts === "function" ? {} : (opts ?? {});
try {
const out = execSyncFn(cmd, { ...options, stdio: ["pipe", "pipe", "pipe"] });
const stdout = out === undefined ? "" : out.toString();
if (typeof callback === "function") callback(null, stdout, "");
} catch (err) {
if (typeof callback === "function") {
const error = err as ExecException & { stdout?: string; stderr?: string };
callback(err, error?.stdout?.toString?.() ?? "", error?.stderr?.toString?.() ?? "");
}
}
});
execFn[promisify.custom] = (cmd: string, opts?: any) =>
new Promise((resolve, reject) => {
execFn(cmd, opts, (err: any, stdout: string, stderr: string) => {
if (err) {
(err as Record<string, unknown>).stdout = stdout;
(err as Record<string, unknown>).stderr = stderr;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
return { exec: execFn, execSync: execSyncFn };
});
vi.mock("node:fs", () => ({
existsSync: vi.fn(),
}));
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { inspectBranchConflict, listBranchRecoveryCandidates, BranchConflictError } from "../branch-conflicts.js";
const mockedExecSync = vi.mocked(execSync);
const mockedExistsSync = vi.mocked(existsSync);
describe("branch-conflicts", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
});
it("classifies missing conflicting worktrees as stale", async () => {
mockedExistsSync.mockImplementation((value) => value !== "/tmp/missing-wt");
const result = await inspectBranchConflict({
repoDir: "/tmp/repo",
branchName: "fusion/fn-4068",
conflictingWorktreePath: "/tmp/missing-wt",
startPoint: "main",
});
expect(result).toEqual({ kind: "stale" });
expect(mockedExecSync).not.toHaveBeenCalled();
});
it("returns a typed live conflict with stranded commits", async () => {
mockedExecSync.mockImplementation((cmd: string | string[]) => {
const command = typeof cmd === "string" ? cmd : cmd[0];
if (command.includes("git rev-parse --verify 'fusion/fn-4068^{commit}'")) {
return Buffer.from("abc123def456\n");
}
if (command.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-4068'")) {
return Buffer.from("aaa111\tPreserve prior fix\nbbb222\tAdd regression coverage\n");
}
throw new Error(`Unexpected command: ${command}`);
});
const result = await inspectBranchConflict({
repoDir: "/tmp/repo",
branchName: "fusion/fn-4068",
conflictingWorktreePath: "/tmp/existing-wt",
startPoint: "main",
});
expect(result.kind).toBe("live");
if (result.kind !== "live") {
throw new Error("expected live conflict");
}
expect(result.error).toBeInstanceOf(BranchConflictError);
expect(result.error).toMatchObject({
branchName: "fusion/fn-4068",
conflictingWorktreePath: "/tmp/existing-wt",
existingTipSha: "abc123def456",
startPoint: "main",
});
expect(result.error.strandedCommits).toEqual([
{ sha: "aaa111", subject: "Preserve prior fix" },
{ sha: "bbb222", subject: "Add regression coverage" },
]);
expect(result.error.message).toContain("2 stranded commits since main");
});
it("lists canonical and sibling recovery candidates with worktrees and stranded commits", async () => {
mockedExecSync.mockImplementation((cmd: string | string[]) => {
const command = typeof cmd === "string" ? cmd : cmd[0];
if (command === "git for-each-ref --format='%(refname:short)' refs/heads/fusion/fn-4068 refs/heads/fusion/fn-4068-*") {
return Buffer.from("fusion/fn-4068\nfusion/fn-4068-2\n");
}
if (command === "git worktree list --porcelain") {
return Buffer.from([
"worktree /tmp/repo",
"HEAD 1111111",
"branch refs/heads/main",
"",
"worktree /tmp/fn-4068",
"HEAD 2222222",
"branch refs/heads/fusion/fn-4068",
"",
"worktree /tmp/fn-4068-2",
"HEAD 3333333",
"branch refs/heads/fusion/fn-4068-2",
"",
].join("\n"));
}
if (command.includes("git rev-parse --verify 'fusion/fn-4068^{commit}'")) {
return Buffer.from("abc123\n");
}
if (command.includes("git rev-parse --verify 'fusion/fn-4068-2^{commit}'")) {
return Buffer.from("def456\n");
}
if (command.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-4068'")) {
return Buffer.from("aaa111\tCanonical fix\n");
}
if (command.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-4068-2'")) {
return Buffer.from("bbb222\tSibling patch\nccc333\tMore work\n");
}
throw new Error(`Unexpected command: ${command}`);
});
const result = await listBranchRecoveryCandidates({
repoDir: "/tmp/repo",
branchName: "fusion/fn-4068",
startPoint: "main",
});
expect(result).toEqual([
{
branchName: "fusion/fn-4068",
tipSha: "abc123",
worktreePath: "/tmp/fn-4068",
strandedCommits: [{ sha: "aaa111", subject: "Canonical fix" }],
isCanonical: true,
},
{
branchName: "fusion/fn-4068-2",
tipSha: "def456",
worktreePath: "/tmp/fn-4068-2",
strandedCommits: [
{ sha: "bbb222", subject: "Sibling patch" },
{ sha: "ccc333", subject: "More work" },
],
isCanonical: false,
},
]);
});
});

View File

@@ -10,6 +10,7 @@ import { reviewStep as mockedReviewStepFn } from "../reviewer.js";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { findWorktreeUser, aiMergeTask } from "../merger.js"; import { findWorktreeUser, aiMergeTask } from "../merger.js";
import { WorktreePool } from "../worktree-pool.js"; import { WorktreePool } from "../worktree-pool.js";
import { BranchConflictError } from "../branch-conflicts.js";
import { generateWorktreeName, slugify } from "../worktree-names.js"; import { generateWorktreeName, slugify } from "../worktree-names.js";
import type { Task, TaskDetail } from "@fusion/core"; import type { Task, TaskDetail } from "@fusion/core";
import { SessionManager } from "@mariozechner/pi-coding-agent"; import { SessionManager } from "@mariozechner/pi-coding-agent";
@@ -780,39 +781,49 @@ describe("TaskExecutor worktree recovery", () => {
); );
}); });
it("recovers from worktree conflict and retries", async () => { it("records recovery context when handling a branch conflict", async () => {
const store = createMockStore(); const store = createMockStore();
let callCount = 0; const onError = vi.fn();
const executor = new TaskExecutor(store, "/tmp/test", { onError });
// First call fails with conflict, second succeeds await (executor as any).handleBranchConflict(
mockedExecSync.mockImplementation((cmd: string | string[]) => { makeTask(),
const command = typeof cmd === "string" ? cmd : cmd[0]; new BranchConflictError({
if (command.includes("git worktree add") && callCount++ === 0) { branchName: "fusion/fn-050",
const error: any = new Error( conflictingWorktreePath: "/tmp/test/.worktrees/green-sage",
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'", existingTipSha: "abc123def456",
); strandedCommits: [
error.stderr = Buffer.from( { sha: "aaa111", subject: "Preserve prior fix" },
"fatal: 'fusion/fn-050' is already used by worktree at '/tmp/test/.worktrees/green-sage'", { sha: "bbb222", subject: "Add regression coverage" },
); ],
throw error; startPoint: "HEAD",
} recommendedAction: "Reclaim the existing task branch/worktree or explicitly discard prior work before retrying.",
return Buffer.from(""); }),
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask());
// Should have logged cleanup and retry
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
expect.stringContaining("Cleaned up conflicting worktree, retrying"),
"/tmp/test/.worktrees/swift-falcon",
); );
// Should eventually succeed
expect(store.updateTask).toHaveBeenCalledWith( expect(store.updateTask).toHaveBeenCalledWith(
"FN-050", "FN-050",
expect.objectContaining({ worktree: expect.any(String) }), expect.objectContaining({
status: "failed",
branch: "fusion/fn-050",
worktree: "/tmp/test/.worktrees/green-sage",
}),
); );
expect(store.moveTask).toHaveBeenCalledWith("FN-050", "todo", { preserveProgress: true });
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
expect.stringContaining("Existing tip: abc123def456"),
undefined,
undefined,
);
expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-050",
"Branch conflict recovery required",
"tool_error",
expect.stringContaining("stranded=aaa111 Preserve prior fix"),
"executor",
);
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-050" }), expect.any(BranchConflictError));
}); });
it("falls back to default base and clears task.executionStartBranch when the configured base ref is missing (FN-2165)", async () => { it("falls back to default base and clears task.executionStartBranch when the configured base ref is missing (FN-2165)", async () => {
@@ -1060,8 +1071,16 @@ describe("TaskExecutor worktree recovery", () => {
); );
}); });
it("generates new worktree name when conflicting worktree belongs to active task", async () => { it("generates new worktree name when conflicting worktree belongs to active task in legacy rename mode", async () => {
const store = createMockStore(); const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
executorAllowSiblingBranchRename: true,
});
store.listTasks.mockResolvedValue([ store.listTasks.mockResolvedValue([
{ {
id: "FN-049", id: "FN-049",
@@ -1079,6 +1098,7 @@ describe("TaskExecutor worktree recovery", () => {
]); ]);
mockedFindWorktreeUser.mockResolvedValue("FN-049"); mockedFindWorktreeUser.mockResolvedValue("FN-049");
mockedExistsSync.mockImplementation((path) => path === "/tmp/test/.worktrees/green-sage");
let callCount = 0; let callCount = 0;
mockedExecSync.mockImplementation((cmd: string | string[]) => { mockedExecSync.mockImplementation((cmd: string | string[]) => {
@@ -1734,6 +1754,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
"/tmp/test/.worktrees/idle-wt", "/tmp/test/.worktrees/idle-wt",
"fusion/fn-064", "fusion/fn-064",
"fusion/fn-063", "fusion/fn-063",
{ allowSiblingBranchRename: false, repoDir: "/tmp/test" },
); );
}); });
@@ -1766,6 +1787,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
"/tmp/test/.worktrees/idle-wt", "/tmp/test/.worktrees/idle-wt",
"fusion/fn-065", "fusion/fn-065",
undefined, undefined,
{ allowSiblingBranchRename: false, repoDir: "/tmp/test" },
); );
}); });
@@ -1994,11 +2016,9 @@ describe("TaskExecutor worktree pool integration", () => {
it("falls through to fresh worktree when pool prepareForTask throws", async () => { it("falls through to fresh worktree when pool prepareForTask throws", async () => {
const pool = new WorktreePool(); const pool = new WorktreePool();
pool.release("/tmp/test/.worktrees/bad-wt"); pool.release("/tmp/test/.worktrees/bad-wt");
// Pool path must exist on disk for acquire() to return it
mockedExistsSync.mockImplementation( mockedExistsSync.mockImplementation(
(p) => p === "/tmp/test/.worktrees/bad-wt", (p) => p === "/tmp/test/.worktrees/bad-wt",
); );
// Make prepareForTask throw
vi.spyOn(pool, "prepareForTask").mockImplementation(() => { vi.spyOn(pool, "prepareForTask").mockImplementation(() => {
throw new Error("branch conflict unrecoverable"); throw new Error("branch conflict unrecoverable");
}); });
@@ -2017,16 +2037,13 @@ describe("TaskExecutor worktree pool integration", () => {
const executor = new TaskExecutor(store, "/tmp/test", { pool }); const executor = new TaskExecutor(store, "/tmp/test", { pool });
await executor.execute(makeTask()); await executor.execute(makeTask());
// Should have released the bad worktree back to pool
expect(releaseSpy).toHaveBeenCalledWith("/tmp/test/.worktrees/bad-wt"); expect(releaseSpy).toHaveBeenCalledWith("/tmp/test/.worktrees/bad-wt");
// Should have fallen through to fresh worktree creation
const worktreeAddCalls = mockedExecSync.mock.calls.filter( const worktreeAddCalls = mockedExecSync.mock.calls.filter(
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"), (c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
); );
expect(worktreeAddCalls.length).toBeGreaterThan(0); expect(worktreeAddCalls.length).toBeGreaterThan(0);
// Should log the pool failure
expect(store.logEntry).toHaveBeenCalledWith( expect(store.logEntry).toHaveBeenCalledWith(
"FN-020", "FN-020",
expect.stringContaining("Pool worktree preparation failed"), expect.stringContaining("Pool worktree preparation failed"),
@@ -2034,6 +2051,45 @@ describe("TaskExecutor worktree pool integration", () => {
expect.objectContaining({ agentId: "executor" }), expect.objectContaining({ agentId: "executor" }),
); );
}); });
it("does not fall through to a fresh worktree when pooled preparation hits a typed branch conflict", async () => {
const pool = new WorktreePool();
pool.release("/tmp/test/.worktrees/warm-wt");
mockedExistsSync.mockImplementation((p) => p === "/tmp/test/.worktrees/warm-wt");
vi.spyOn(pool, "prepareForTask").mockRejectedValue(
new BranchConflictError({
branchName: "fusion/fn-020",
conflictingWorktreePath: "/tmp/test/.worktrees/existing-fn-020",
existingTipSha: "abc123def456",
strandedCommits: [{ sha: "aaa111", subject: "Preserve prior fix" }],
startPoint: "main",
recommendedAction: "Reclaim the existing task branch/worktree or explicitly discard prior work before retrying.",
}),
);
const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
recycleWorktrees: true,
});
const executor = new TaskExecutor(store, "/tmp/test", { pool });
await executor.execute(makeTask("FN-020"));
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
);
expect(worktreeAddCalls).toHaveLength(0);
expect(store.moveTask).toHaveBeenCalledWith("FN-020", "todo", { preserveProgress: true });
expect(store.updateTask).toHaveBeenCalledWith(
"FN-020",
expect.objectContaining({ branch: "fusion/fn-020", worktree: "/tmp/test/.worktrees/existing-fn-020" }),
);
});
}); });
describe("WorktreePool capacity", () => { describe("WorktreePool capacity", () => {

View File

@@ -54,6 +54,7 @@ import {
reapOrphanWorktrees, reapOrphanWorktrees,
scanOrphanedBranches, scanOrphanedBranches,
} from "../worktree-pool.js"; } from "../worktree-pool.js";
import { BranchConflictError } from "../branch-conflicts.js";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs"; import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
import type { Task, Column } from "@fusion/core"; import type { Task, Column } from "@fusion/core";
@@ -260,9 +261,8 @@ describe("WorktreePool", () => {
); );
}); });
it("uses suffixed branch name when original is in use by an active worktree", async () => { it("throws a typed branch conflict when the canonical branch is already live elsewhere by default", async () => {
mockedExistsSync.mockImplementation((p) => { mockedExistsSync.mockImplementation((p) => {
// The conflicting worktree exists on disk
if (p === "/other/wt") return true; if (p === "/other/wt") return true;
return true; return true;
}); });
@@ -276,20 +276,26 @@ describe("WorktreePool", () => {
); );
throw err; throw err;
} }
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) {
return Buffer.from("abc123def456\n");
}
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042'")) {
return Buffer.from("aaa111\tPreserve prior fix\n");
}
return Buffer.from(""); return Buffer.from("");
}); });
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042"); await expect(
expect(result).toBe("fusion/fn-042-2"); pool.prepareForTask("/tmp/wt", "fusion/fn-042", undefined, { repoDir: "/tmp/repo" })
).rejects.toBeInstanceOf(BranchConflictError);
// Verify the suffixed checkout was called
const checkoutCalls = mockedExecSync.mock.calls const checkoutCalls = mockedExecSync.mock.calls
.map((c) => c[0]) .map((c) => c[0])
.filter((c) => typeof c === "string" && c.includes("checkout -B")); .filter((c) => typeof c === "string" && c.includes("checkout -B"));
expect(checkoutCalls).toContain('git checkout -B "fusion/fn-042-2" fusion/fn-042'); expect(checkoutCalls).not.toContain('git checkout -B "fusion/fn-042-2" fusion/fn-042');
}); });
it("seeds suffixed retry branches from the original branch instead of the generic base", async () => { it("restores legacy suffixed branch behavior only when explicitly enabled", async () => {
mockedExistsSync.mockReturnValue(true); mockedExistsSync.mockReturnValue(true);
mockedExecSync.mockImplementation((cmd: any) => { mockedExecSync.mockImplementation((cmd: any) => {
@@ -301,10 +307,21 @@ describe("WorktreePool", () => {
); );
throw err; throw err;
} }
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) {
return Buffer.from("abc123def456\n");
}
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'fusion/fn-041..fusion/fn-042'")) {
return Buffer.from("aaa111\tPreserve prior fix\n");
}
return Buffer.from(""); return Buffer.from("");
}); });
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042", "fusion/fn-041"); const result = await pool.prepareForTask(
"/tmp/wt",
"fusion/fn-042",
"fusion/fn-041",
{ allowSiblingBranchRename: true, repoDir: "/tmp/repo" },
);
expect(result).toBe("fusion/fn-042-2"); expect(result).toBe("fusion/fn-042-2");
const checkoutCalls = mockedExecSync.mock.calls const checkoutCalls = mockedExecSync.mock.calls
@@ -314,12 +331,11 @@ describe("WorktreePool", () => {
expect(checkoutCalls).not.toContain('git checkout -B "fusion/fn-042-2" fusion/fn-041'); expect(checkoutCalls).not.toContain('git checkout -B "fusion/fn-042-2" fusion/fn-041');
}); });
it("increments suffix when lower suffixes are also in use", async () => { it("increments suffix when lower suffixes are also in use in legacy rename mode", async () => {
mockedExistsSync.mockReturnValue(true); mockedExistsSync.mockReturnValue(true);
mockedExecSync.mockImplementation((cmd: any) => { mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd); const cmdStr = String(cmd);
// Original and -2 are both in use
if (cmdStr.startsWith('git checkout -B "fusion/fn-042" ') || if (cmdStr.startsWith('git checkout -B "fusion/fn-042" ') ||
cmdStr.startsWith('git checkout -B "fusion/fn-042-2" ')) { cmdStr.startsWith('git checkout -B "fusion/fn-042-2" ')) {
const err: any = new Error("branch conflict"); const err: any = new Error("branch conflict");
@@ -328,10 +344,27 @@ describe("WorktreePool", () => {
); );
throw err; throw err;
} }
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) {
return Buffer.from("abc123def456\n");
}
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042'")) {
return Buffer.from("aaa111\tPreserve prior fix\n");
}
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042-2^{commit}'")) {
return Buffer.from("bbb222ccc333\n");
}
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042-2'")) {
return Buffer.from("bbb222\tFirst sibling\n");
}
return Buffer.from(""); return Buffer.from("");
}); });
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042"); const result = await pool.prepareForTask(
"/tmp/wt",
"fusion/fn-042",
undefined,
{ allowSiblingBranchRename: true, repoDir: "/tmp/repo" },
);
expect(result).toBe("fusion/fn-042-3"); expect(result).toBe("fusion/fn-042-3");
const checkoutCalls = mockedExecSync.mock.calls const checkoutCalls = mockedExecSync.mock.calls
@@ -386,7 +419,7 @@ describe("WorktreePool", () => {
); );
}); });
it("throws when all suffixed names are exhausted", async () => { it("throws when all suffixed names are exhausted in legacy rename mode", async () => {
mockedExistsSync.mockReturnValue(true); mockedExistsSync.mockReturnValue(true);
mockedExecSync.mockImplementation((cmd: any) => { mockedExecSync.mockImplementation((cmd: any) => {
@@ -398,12 +431,42 @@ describe("WorktreePool", () => {
); );
throw err; throw err;
} }
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) {
return Buffer.from("abc123def456\n");
}
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042'")) {
return Buffer.from("aaa111\tPreserve prior fix\n");
}
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042-2^{commit}'")) {
return Buffer.from("bbb222ccc333\n");
}
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042-2'")) {
return Buffer.from("bbb222\tFirst sibling\n");
}
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042-3^{commit}'")) {
return Buffer.from("ccc333ddd444\n");
}
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042-3'")) {
return Buffer.from("ccc333\tSecond sibling\n");
}
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042-4^{commit}'")) {
return Buffer.from("ddd444eee555\n");
}
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042-4'")) {
return Buffer.from("ddd444\tThird sibling\n");
}
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042-5^{commit}'")) {
return Buffer.from("eee555fff666\n");
}
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042-5'")) {
return Buffer.from("eee555\tFourth sibling\n");
}
return Buffer.from(""); return Buffer.from("");
}); });
await expect(pool.prepareForTask("/tmp/wt", "fusion/fn-042")).rejects.toThrow( await expect(
/suffixes -2 through -6 are all in use/ pool.prepareForTask("/tmp/wt", "fusion/fn-042", undefined, { allowSiblingBranchRename: true, repoDir: "/tmp/repo" })
); ).rejects.toThrow(/suffixes -2 through -6 are all in use/);
}); });
}); });

View File

@@ -0,0 +1,198 @@
import { exec } from "node:child_process";
import { existsSync } from "node:fs";
import { promisify } from "node:util";
const execAsync = promisify(exec);
export interface BranchConflictCommit {
sha: string;
subject: string;
}
export interface BranchRecoveryCandidate {
branchName: string;
tipSha: string;
worktreePath: string | null;
strandedCommits: BranchConflictCommit[];
isCanonical: boolean;
}
export interface BranchConflictDetails {
branchName: string;
conflictingWorktreePath: string;
existingTipSha: string;
strandedCommits: BranchConflictCommit[];
startPoint: string;
recommendedAction: string;
}
export class BranchConflictError extends Error implements BranchConflictDetails {
readonly name = "BranchConflictError";
readonly branchName: string;
readonly conflictingWorktreePath: string;
readonly existingTipSha: string;
readonly strandedCommits: BranchConflictCommit[];
readonly startPoint: string;
readonly recommendedAction: string;
constructor(details: BranchConflictDetails) {
const commitSummary = details.strandedCommits.length > 0
? `${details.strandedCommits.length} stranded commit${details.strandedCommits.length === 1 ? "" : "s"}`
: "no stranded commits";
super(
`Branch ${details.branchName} is already checked out at ${details.conflictingWorktreePath} ` +
`(tip ${details.existingTipSha.slice(0, 12)}, ${commitSummary} since ${details.startPoint}). ` +
details.recommendedAction,
);
this.branchName = details.branchName;
this.conflictingWorktreePath = details.conflictingWorktreePath;
this.existingTipSha = details.existingTipSha;
this.strandedCommits = details.strandedCommits;
this.startPoint = details.startPoint;
this.recommendedAction = details.recommendedAction;
}
}
export function isBranchConflictError(error: unknown): error is BranchConflictError {
return error instanceof BranchConflictError;
}
export interface InspectBranchConflictInput {
repoDir: string;
branchName: string;
conflictingWorktreePath: string;
startPoint?: string;
}
export type BranchConflictInspectionResult =
| { kind: "stale" }
| { kind: "live"; error: BranchConflictError };
export interface ListBranchRecoveryCandidatesInput {
repoDir: string;
branchName: string;
startPoint?: string;
}
function quoteShellArg(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
async function runGit(repoDir: string, command: string): Promise<string> {
const { stdout } = await execAsync(command, {
cwd: repoDir,
encoding: "utf-8",
});
return stdout.trim();
}
async function revParse(repoDir: string, ref: string): Promise<string> {
return runGit(repoDir, `git rev-parse --verify ${quoteShellArg(`${ref}^{commit}`)}`);
}
async function listStrandedCommits(repoDir: string, startPoint: string, branchName: string): Promise<BranchConflictCommit[]> {
try {
const output = await runGit(
repoDir,
`git log --reverse --format=%H%x09%s ${quoteShellArg(`${startPoint}..${branchName}`)}`,
);
if (!output) return [];
return output
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [sha, ...subjectParts] = line.split("\t");
return { sha, subject: subjectParts.join("\t") };
});
} catch {
return [];
}
}
async function getWorktreeBranchMap(repoDir: string): Promise<Map<string, string>> {
const output = await runGit(repoDir, "git worktree list --porcelain");
const map = new Map<string, string>();
let currentWorktree: string | null = null;
for (const line of output.split("\n")) {
if (line.startsWith("worktree ")) {
currentWorktree = line.slice("worktree ".length).trim();
continue;
}
if (line.startsWith("branch refs/heads/") && currentWorktree) {
map.set(line.slice("branch refs/heads/".length).trim(), currentWorktree);
}
if (!line.trim()) {
currentWorktree = null;
}
}
return map;
}
function parseBranchNames(output: string): string[] {
return output
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
export async function listBranchRecoveryCandidates(
input: ListBranchRecoveryCandidatesInput,
): Promise<BranchRecoveryCandidate[]> {
const { repoDir, branchName } = input;
const startPoint = input.startPoint ?? "HEAD";
const [branchListOutput, worktreeBranches] = await Promise.all([
runGit(
repoDir,
`git for-each-ref --format='%(refname:short)' refs/heads/${branchName} refs/heads/${branchName}-*`,
),
getWorktreeBranchMap(repoDir),
]);
const candidates: BranchRecoveryCandidate[] = [];
for (const candidateName of parseBranchNames(branchListOutput)) {
const tipSha = await revParse(repoDir, candidateName);
const strandedCommits = await listStrandedCommits(repoDir, startPoint, candidateName);
candidates.push({
branchName: candidateName,
tipSha,
worktreePath: worktreeBranches.get(candidateName) ?? null,
strandedCommits,
isCanonical: candidateName === branchName,
});
}
candidates.sort((left, right) => {
if (left.branchName === branchName) return -1;
if (right.branchName === branchName) return 1;
return left.branchName.localeCompare(right.branchName);
});
return candidates;
}
export async function inspectBranchConflict(
input: InspectBranchConflictInput,
): Promise<BranchConflictInspectionResult> {
const startPoint = input.startPoint ?? "HEAD";
if (!existsSync(input.conflictingWorktreePath)) {
return { kind: "stale" };
}
const existingTipSha = await revParse(input.repoDir, input.branchName);
const strandedCommits = await listStrandedCommits(input.repoDir, startPoint, input.branchName);
return {
kind: "live",
error: new BranchConflictError({
branchName: input.branchName,
conflictingWorktreePath: input.conflictingWorktreePath,
existingTipSha,
strandedCommits,
startPoint,
recommendedAction: "Reclaim the existing task branch/worktree or explicitly discard prior work before retrying.",
}),
};
}

View File

@@ -37,6 +37,7 @@ import { reviewStep, type ReviewVerdict } from "./reviewer.js";
import { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession } from "@mariozechner/pi-coding-agent"; import { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession } from "@mariozechner/pi-coding-agent";
import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js"; import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js";
import { getRegisteredWorktreePaths, isGitRepository, isRegisteredGitWorktree, isUsableTaskWorktree, type WorktreePool } from "./worktree-pool.js"; import { getRegisteredWorktreePaths, isGitRepository, isRegisteredGitWorktree, isUsableTaskWorktree, type WorktreePool } from "./worktree-pool.js";
import { BranchConflictError, isBranchConflictError, inspectBranchConflict } from "./branch-conflicts.js";
import { AgentLogger } from "./agent-logger.js"; import { AgentLogger } from "./agent-logger.js";
import { executorLog, reviewerLog, formatError } from "./logger.js"; import { executorLog, reviewerLog, formatError } from "./logger.js";
import { TokenCapDetector } from "./token-cap-detector.js"; import { TokenCapDetector } from "./token-cap-detector.js";
@@ -2482,6 +2483,7 @@ export class TaskExecutor {
// Resolve the base branch — set by the scheduler when a dep is in-review // Resolve the base branch — set by the scheduler when a dep is in-review
const baseBranch = task.executionStartBranch || null; const baseBranch = task.executionStartBranch || null;
const allowSiblingBranchRename = settings.executorAllowSiblingBranchRename === true;
if (task.worktree && isResume && !await isUsableTaskWorktree(this.rootDir, worktreePath)) { if (task.worktree && isResume && !await isUsableTaskWorktree(this.rootDir, worktreePath)) {
const invalidWorktreePath = worktreePath; const invalidWorktreePath = worktreePath;
@@ -2504,7 +2506,12 @@ export class TaskExecutor {
const pooled = this.options.pool.acquire(); const pooled = this.options.pool.acquire();
if (pooled) { if (pooled) {
try { try {
const actualBranch = await this.options.pool.prepareForTask(pooled, branchName, baseBranch ?? undefined); const actualBranch = await this.options.pool.prepareForTask(
pooled,
branchName,
baseBranch ?? undefined,
{ allowSiblingBranchRename, repoDir: this.rootDir },
);
worktreePath = pooled; worktreePath = pooled;
acquiredFromPool = true; acquiredFromPool = true;
executorLog.log(`Acquired worktree from pool: ${pooled}`); executorLog.log(`Acquired worktree from pool: ${pooled}`);
@@ -2547,10 +2554,11 @@ export class TaskExecutor {
} }
} }
} catch (poolErr: unknown) { } catch (poolErr: unknown) {
// Pool preparation failed — release the worktree back and fall through
// to fresh worktree creation
const poolErrMessage = poolErr instanceof Error ? poolErr.message : String(poolErr);
this.options.pool.release(pooled); this.options.pool.release(pooled);
if (isBranchConflictError(poolErr)) {
throw poolErr;
}
const poolErrMessage = poolErr instanceof Error ? poolErr.message : String(poolErr);
executorLog.log(`Pool prepareForTask failed, falling through to fresh worktree: ${poolErrMessage}`); executorLog.log(`Pool prepareForTask failed, falling through to fresh worktree: ${poolErrMessage}`);
await this.store.logEntry( await this.store.logEntry(
task.id, task.id,
@@ -2564,7 +2572,7 @@ export class TaskExecutor {
// Fall through to fresh worktree creation if pool had nothing // Fall through to fresh worktree creation if pool had nothing
if (!acquiredFromPool) { if (!acquiredFromPool) {
const created = await this.createWorktree(branchName, worktreePath, task.id, baseBranch ?? undefined); const created = await this.createWorktree(branchName, worktreePath, task.id, baseBranch ?? undefined, allowSiblingBranchRename);
worktreePath = created.path; worktreePath = created.path;
await this.store.updateTask(task.id, { worktree: created.path, branch: created.branch }); await this.store.updateTask(task.id, { worktree: created.path, branch: created.branch });
// Audit trail: record worktree creation and branch creation (FN-1404) // Audit trail: record worktree creation and branch creation (FN-1404)
@@ -2701,7 +2709,7 @@ export class TaskExecutor {
} }
} else { } else {
// Directory exists at generated path but task has no worktree — create via normal flow // Directory exists at generated path but task has no worktree — create via normal flow
const created = await this.createWorktree(branchName, worktreePath, task.id); const created = await this.createWorktree(branchName, worktreePath, task.id, undefined, allowSiblingBranchRename);
worktreePath = created.path; worktreePath = created.path;
await this.store.updateTask(task.id, { worktree: created.path, branch: created.branch }); await this.store.updateTask(task.id, { worktree: created.path, branch: created.branch });
// Audit trail: record worktree creation and branch creation (FN-1404) // Audit trail: record worktree creation and branch creation (FN-1404)
@@ -4099,6 +4107,9 @@ export class TaskExecutor {
nextRecoveryAt: null, nextRecoveryAt: null,
}); });
// Fall through to terminal failure marking // Fall through to terminal failure marking
} else if (isBranchConflictError(err)) {
await this.handleBranchConflict(task, err);
return;
} else if (this.options.usageLimitPauser && isUsageLimitError(errorMessage)) { } else if (this.options.usageLimitPauser && isUsageLimitError(errorMessage)) {
await this.options.usageLimitPauser.onUsageLimitHit("executor", task.id, errorMessage); await this.options.usageLimitPauser.onUsageLimitHit("executor", task.id, errorMessage);
} else if (isTransientError(errorMessage)) { } else if (isTransientError(errorMessage)) {
@@ -6216,11 +6227,73 @@ and show an appropriate message to the user.\`
* @param startPoint - Optional base branch/commit for new branch * @param startPoint - Optional base branch/commit for new branch
* @returns The actual worktree path (may differ if recovery generated new name) * @returns The actual worktree path (may differ if recovery generated new name)
*/ */
private formatBranchConflictLifecycleLog(taskId: string, error: BranchConflictError): string {
const strandedSummary = error.strandedCommits.length > 0
? error.strandedCommits.map((commit) => `${commit.sha.slice(0, 12)} ${commit.subject}`).join("; ")
: "none";
const recommendation = `Run \`fn task branch-recovery ${taskId}\` to inspect candidates, then reclaim the existing branch or discard prior work explicitly.`;
return [
`Branch conflict: ${error.branchName} is already checked out at ${error.conflictingWorktreePath}`,
`Existing tip: ${error.existingTipSha}`,
`Stranded commits since ${error.startPoint}: ${strandedSummary}`,
recommendation,
].join("\n");
}
private formatBranchConflictAgentLog(taskId: string, error: BranchConflictError): string {
const lines = [
`branch=${error.branchName}`,
`worktree=${error.conflictingWorktreePath}`,
`existingTipSha=${error.existingTipSha}`,
`startPoint=${error.startPoint}`,
];
if (error.strandedCommits.length > 0) {
lines.push(
...error.strandedCommits.map((commit) => `stranded=${commit.sha.slice(0, 12)} ${commit.subject}`),
);
} else {
lines.push("stranded=none");
}
lines.push(
`recommendation=Run 'fn task branch-recovery ${taskId}' to inspect candidates, then reclaim the existing branch or discard prior work explicitly.`,
);
return lines.join("\n");
}
private async handleBranchConflict(task: Task, error: BranchConflictError): Promise<void> {
const conflictMessage = `Task branch conflict: ${error.branchName} is already checked out at ${error.conflictingWorktreePath}. ` +
`Run 'fn task branch-recovery ${task.id}' to inspect candidates, then reclaim the existing branch or discard prior work explicitly.`;
await this.store.logEntry(
task.id,
this.formatBranchConflictLifecycleLog(task.id, error),
undefined,
this.currentRunContext,
);
await this.store.appendAgentLog(
task.id,
"Branch conflict recovery required",
"tool_error",
this.formatBranchConflictAgentLog(task.id, error),
"executor",
);
await this.store.updateTask(task.id, {
status: "failed",
error: conflictMessage,
branch: error.branchName,
worktree: error.conflictingWorktreePath,
});
await this.persistTokenUsage(task.id);
await this.store.moveTask(task.id, "todo", { preserveProgress: true });
executorLog.warn(`${task.id} branch conflict → todo: ${error.branchName} @ ${error.conflictingWorktreePath}`);
this.options.onError?.(task, error);
}
private async createWorktree( private async createWorktree(
branch: string, branch: string,
path: string, path: string,
taskId: string, taskId: string,
startPoint?: string, startPoint?: string,
allowSiblingBranchRename = false,
): Promise<{ path: string; branch: string }> { ): Promise<{ path: string; branch: string }> {
// Track the worktree path we're attempting to use (may change during recovery) // Track the worktree path we're attempting to use (may change during recovery)
const currentPath = path; const currentPath = path;
@@ -6262,7 +6335,15 @@ and show an appropriate message to the user.\`
for (let attempt = 0; attempt < this.MAX_WORKTREE_RETRIES; attempt++) { for (let attempt = 0; attempt < this.MAX_WORKTREE_RETRIES; attempt++) {
try { try {
const result = await this.tryCreateWorktree(branch, currentPath, taskId, initialStartPoint, attempt); const result = await this.tryCreateWorktree(
branch,
currentPath,
taskId,
initialStartPoint,
attempt,
0,
allowSiblingBranchRename,
);
// Squash-import dep content into the freshly created worktree so the // Squash-import dep content into the freshly created worktree so the
// branch contains main's history + 1 import commit instead of the // branch contains main's history + 1 import commit instead of the
// dep's raw commits. // dep's raw commits.
@@ -6293,7 +6374,8 @@ and show an appropriate message to the user.\`
} catch (error: unknown) { } catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
const isLastAttempt = attempt === this.MAX_WORKTREE_RETRIES - 1; const isLastAttempt = attempt === this.MAX_WORKTREE_RETRIES - 1;
const isTerminalWorktreeError = error instanceof NonRetryableWorktreeError; const isBranchConflict = isBranchConflictError(error);
const isTerminalWorktreeError = error instanceof NonRetryableWorktreeError || isBranchConflict;
if (isLastAttempt || isTerminalWorktreeError) { if (isLastAttempt || isTerminalWorktreeError) {
await this.store.logEntry( await this.store.logEntry(
@@ -6301,6 +6383,9 @@ and show an appropriate message to the user.\`
`Worktree creation failed after ${this.MAX_WORKTREE_RETRIES} attempts`, `Worktree creation failed after ${this.MAX_WORKTREE_RETRIES} attempts`,
errorMessage, errorMessage,
); );
if (isBranchConflict) {
throw error;
}
throw new Error( throw new Error(
`Failed to create worktree after ${this.MAX_WORKTREE_RETRIES} attempts: ${errorMessage}`, `Failed to create worktree after ${this.MAX_WORKTREE_RETRIES} attempts: ${errorMessage}`,
); );
@@ -6631,6 +6716,7 @@ and show an appropriate message to the user.\`
startPoint?: string, startPoint?: string,
attemptNumber = 0, attemptNumber = 0,
recoveryDepth = 0, recoveryDepth = 0,
allowSiblingBranchRename = false,
): Promise<{ path: string; branch: string }> { ): Promise<{ path: string; branch: string }> {
// Guard: refuse to create a worktree nested inside another worktree. // Guard: refuse to create a worktree nested inside another worktree.
// Nested worktrees happen when the executor is launched with rootDir pointed // Nested worktrees happen when the executor is launched with rootDir pointed
@@ -6719,6 +6805,7 @@ and show an appropriate message to the user.\`
taskId, taskId,
startPoint, startPoint,
attemptNumber, attemptNumber,
allowSiblingBranchRename,
); );
if (result) { if (result) {
return result; return result;
@@ -6738,7 +6825,7 @@ and show an appropriate message to the user.\`
const branchCleaned = await this.cleanupStaleBranch(branch, taskId); const branchCleaned = await this.cleanupStaleBranch(branch, taskId);
if (branchCleaned) { if (branchCleaned) {
await this.store.logEntry(taskId, `Removed stale branch reference, retrying`); await this.store.logEntry(taskId, `Removed stale branch reference, retrying`);
return this.tryCreateWorktree(branch, path, taskId, startPoint, attemptNumber, recoveryDepth + 1); return this.tryCreateWorktree(branch, path, taskId, startPoint, attemptNumber, recoveryDepth + 1, allowSiblingBranchRename);
} }
throw new Error( throw new Error(
`Invalid reference for branch ${branch}: unable to clean up stale reference`, `Invalid reference for branch ${branch}: unable to clean up stale reference`,
@@ -6776,6 +6863,7 @@ and show an appropriate message to the user.\`
taskId, taskId,
startPoint, startPoint,
attemptNumber, attemptNumber,
allowSiblingBranchRename,
); );
if (result) { if (result) {
return result; return result;
@@ -6795,7 +6883,7 @@ and show an appropriate message to the user.\`
const branchCleaned = await this.cleanupStaleBranch(branch, taskId); const branchCleaned = await this.cleanupStaleBranch(branch, taskId);
if (branchCleaned) { if (branchCleaned) {
await this.store.logEntry(taskId, `Cleaned up stale reference in fallback, retrying`); await this.store.logEntry(taskId, `Cleaned up stale reference in fallback, retrying`);
return this.tryCreateWorktree(branch, path, taskId, startPoint, attemptNumber, recoveryDepth + 1); return this.tryCreateWorktree(branch, path, taskId, startPoint, attemptNumber, recoveryDepth + 1, allowSiblingBranchRename);
} }
} }
@@ -6818,6 +6906,7 @@ and show an appropriate message to the user.\`
taskId: string, taskId: string,
startPoint?: string, startPoint?: string,
attemptNumber?: number, attemptNumber?: number,
allowSiblingBranchRename = false,
): Promise<{ path: string; branch: string } | null> { ): Promise<{ path: string; branch: string } | null> {
const shouldGenerateNewName = await this.shouldGenerateNewWorktreeName( const shouldGenerateNewName = await this.shouldGenerateNewWorktreeName(
conflictPath, conflictPath,
@@ -6825,12 +6914,25 @@ and show an appropriate message to the user.\`
); );
if (shouldGenerateNewName) { if (shouldGenerateNewName) {
// Conflicting worktree belongs to an active task — generate new path AND const inspection = await inspectBranchConflict({
// use a suffixed branch name so git doesn't conflict with the branch repoDir: this.rootDir,
// already checked out in the existing worktree. Branch conflicts here branchName: branch,
// mean the original task branch already exists and is checked out conflictingWorktreePath: conflictPath,
// elsewhere, so suffix retries must branch from that task branch tip startPoint,
// rather than the stale base ref to preserve the task's commits. });
if (inspection.kind === "stale") {
const cleanupSuccess = await this.cleanupConflictingWorktree(conflictPath, branch, taskId);
if (cleanupSuccess) {
await this.store.logEntry(taskId, `Cleaned up conflicting worktree, retrying`, path);
return this.tryCreateWorktree(branch, path, taskId, startPoint, attemptNumber, 0, allowSiblingBranchRename);
}
return null;
}
if (!allowSiblingBranchRename) {
throw inspection.error;
}
const conflictStartPoint = branch; const conflictStartPoint = branch;
const newPath = join(this.rootDir, ".worktrees", generateWorktreeName(this.rootDir)); const newPath = join(this.rootDir, ".worktrees", generateWorktreeName(this.rootDir));
for (let suffix = 2; suffix <= 6; suffix++) { for (let suffix = 2; suffix <= 6; suffix++) {
@@ -6841,11 +6943,10 @@ and show an appropriate message to the user.\`
`Conflicting worktree in use by active task, trying new path with branch ${suffixedBranch}`, `Conflicting worktree in use by active task, trying new path with branch ${suffixedBranch}`,
newPath, newPath,
); );
return await this.tryCreateWorktree(suffixedBranch, newPath, taskId, conflictStartPoint, attemptNumber); return await this.tryCreateWorktree(suffixedBranch, newPath, taskId, conflictStartPoint, attemptNumber, 0, true);
} catch (suffixErr: unknown) { } catch (suffixErr: unknown) {
const info = this.extractWorktreeConflictInfo(suffixErr); const info = this.extractWorktreeConflictInfo(suffixErr);
if (info.type === "already-used") { if (info.type === "already-used") {
// This suffixed branch is also in use — try next suffix
continue; continue;
} }
throw suffixErr; throw suffixErr;
@@ -6856,11 +6957,10 @@ and show an appropriate message to the user.\`
); );
} }
// Safe to clean up - conflicting worktree is not in use
const cleanupSuccess = await this.cleanupConflictingWorktree(conflictPath, branch, taskId); const cleanupSuccess = await this.cleanupConflictingWorktree(conflictPath, branch, taskId);
if (cleanupSuccess) { if (cleanupSuccess) {
await this.store.logEntry(taskId, `Cleaned up conflicting worktree, retrying`, path); await this.store.logEntry(taskId, `Cleaned up conflicting worktree, retrying`, path);
return this.tryCreateWorktree(branch, path, taskId, startPoint, attemptNumber); return this.tryCreateWorktree(branch, path, taskId, startPoint, attemptNumber, 0, allowSiblingBranchRename);
} }
return null; return null;

View File

@@ -90,6 +90,18 @@ export {
} from "./agent-instructions.js"; } from "./agent-instructions.js";
export { HEARTBEAT_PROCEDURE, HEARTBEAT_SYSTEM_PROMPT, HEARTBEAT_NO_TASK_SYSTEM_PROMPT } from "./agent-heartbeat.js"; export { HEARTBEAT_PROCEDURE, HEARTBEAT_SYSTEM_PROMPT, HEARTBEAT_NO_TASK_SYSTEM_PROMPT } from "./agent-heartbeat.js";
export { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees, reapOrphanWorktrees } from "./worktree-pool.js"; export { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees, reapOrphanWorktrees } from "./worktree-pool.js";
export {
BranchConflictError,
isBranchConflictError,
inspectBranchConflict,
listBranchRecoveryCandidates,
type BranchConflictCommit,
type BranchConflictDetails,
type BranchRecoveryCandidate,
type BranchConflictInspectionResult,
type InspectBranchConflictInput,
type ListBranchRecoveryCandidatesInput,
} from "./branch-conflicts.js";
export { generateReservedWorktreeName, generateWorktreeName, planTaskWorktreePath, slugify } from "./worktree-names.js"; export { generateReservedWorktreeName, generateWorktreeName, planTaskWorktreePath, slugify } from "./worktree-names.js";
export { createLogger, type Logger } from "./logger.js"; export { createLogger, type Logger } from "./logger.js";
export { fetchWebContent, assertSafeUrl, WebFetchError, type WebFetchOptions, type WebFetchResult, type WebFetchErrorCode } from "./web-fetch.js"; export { fetchWebContent, assertSafeUrl, WebFetchError, type WebFetchOptions, type WebFetchResult, type WebFetchErrorCode } from "./web-fetch.js";

View File

@@ -3,6 +3,7 @@ import { promisify } from "node:util";
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs"; import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
import { join, relative, resolve, isAbsolute } from "node:path"; import { join, relative, resolve, isAbsolute } from "node:path";
import type { Column, TaskStore } from "@fusion/core"; import type { Column, TaskStore } from "@fusion/core";
import { inspectBranchConflict } from "./branch-conflicts.js";
import { worktreePoolLog } from "./logger.js"; import { worktreePoolLog } from "./logger.js";
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -181,14 +182,20 @@ export class WorktreePool {
* 4. `git checkout -B <branchName> <startPoint>` — create/reset branch from start point * 4. `git checkout -B <branchName> <startPoint>` — create/reset branch from start point
* *
* Returns the actual branch name used. This may differ from `branchName` * Returns the actual branch name used. This may differ from `branchName`
* when conflict recovery generates a suffixed name (e.g., `fusion/fn-042-2`). * when legacy conflict recovery is explicitly enabled and generates a suffixed
* name (e.g., `fusion/fn-042-2`).
* *
* @param worktreePath — Absolute path to the recycled worktree * @param worktreePath — Absolute path to the recycled worktree
* @param branchName — Branch name for the new task (e.g., `fusion/fn-042`) * @param branchName — Branch name for the new task (e.g., `fusion/fn-042`)
* @param startPoint — Git ref to branch from (e.g., `fusion/fn-041`). Defaults to `main`. * @param startPoint — Git ref to branch from (e.g., `fusion/fn-041`). Defaults to `main`.
* @returns The actual branch name checked out in the worktree * @returns The actual branch name checked out in the worktree
*/ */
async prepareForTask(worktreePath: string, branchName: string, startPoint?: string): Promise<string> { async prepareForTask(
worktreePath: string,
branchName: string,
startPoint?: string,
options?: { allowSiblingBranchRename?: boolean; repoDir?: string },
): Promise<string> {
// Clean tracked modifications // Clean tracked modifications
try { try {
await execAsync("git checkout -- .", { cwd: worktreePath }); await execAsync("git checkout -- .", { cwd: worktreePath });
@@ -223,20 +230,27 @@ export class WorktreePool {
throw err; throw err;
} }
// The branch is checked out in a different worktree. // The branch is checked out in a different worktree. Keep stale-conflict
// First check if the conflicting worktree still exists on disk. // cleanup behavior for missing paths; otherwise either surface a typed
// conflict or, when explicitly enabled, fall back to the legacy sibling
// suffix flow.
const conflictingPath = match[1]; const conflictingPath = match[1];
if (!existsSync(conflictingPath)) { const inspection = await inspectBranchConflict({
// Conflicting worktree no longer exists — prune and retry with original name repoDir: options?.repoDir ?? worktreePath,
branchName,
conflictingWorktreePath: conflictingPath,
startPoint: base,
});
if (inspection.kind === "stale") {
await execAsync("git worktree prune", { cwd: worktreePath }); await execAsync("git worktree prune", { cwd: worktreePath });
await execAsync(checkoutCmd, { cwd: worktreePath }); await execAsync(checkoutCmd, { cwd: worktreePath });
return branchName; return branchName;
} }
// Conflicting worktree exists and is active — use a suffixed branch name if (!options?.allowSiblingBranchRename) {
// to avoid disrupting the other worktree. Seed the suffix from the throw inspection.error;
// original task branch tip rather than the generic base ref so retries }
// preserve the task's commits instead of resetting to main/baseBranch.
const conflictBase = branchName; const conflictBase = branchName;
for (let suffix = 2; suffix <= 6; suffix++) { for (let suffix = 2; suffix <= 6; suffix++) {
const suffixedName = `${branchName}-${suffix}`; const suffixedName = `${branchName}-${suffix}`;
@@ -252,11 +266,9 @@ export class WorktreePool {
if (!suffixStderr.includes("already used by worktree")) { if (!suffixStderr.includes("already used by worktree")) {
throw suffixErr; throw suffixErr;
} }
// This suffixed name is also in use — try the next one
} }
} }
// All suffixed names exhausted — should not happen in practice
throw new Error( throw new Error(
`Cannot create branch for task: "${branchName}" and suffixes -2 through -6 are all in use by other worktrees`, `Cannot create branch for task: "${branchName}" and suffixes -2 through -6 are all in use by other worktrees`,
); );