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:
5
.changeset/fix-fn-task-done-paused-state.md
Normal file
5
.changeset/fix-fn-task-done-paused-state.md
Normal 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.
|
||||||
5
.changeset/fn-4068-branch-conflict-recovery.md
Normal file
5
.changeset/fn-4068-branch-conflict-recovery.md
Normal 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.
|
||||||
5
.changeset/fn-4134-research-view-layout-fix.md
Normal file
5
.changeset/fn-4134-research-view-layout-fix.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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/<task-id></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
|
||||||
|
|||||||
@@ -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\);[^}]*\}/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
170
packages/engine/src/__tests__/branch-conflicts.test.ts
Normal file
170
packages/engine/src/__tests__/branch-conflicts.test.ts
Normal 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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
198
packages/engine/src/branch-conflicts.ts
Normal file
198
packages/engine/src/branch-conflicts.ts
Normal 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.",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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`,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user