feat(FN-5329): remove orphan rescue and branch-recovery primitives from eng

Removes the branch-recovery CLI surface, orphan-rescue engine primitives, and their associated tests (over 1,500 lines deleted), while restoring a minimal prune-only orphan branch sweep with proper git audit mutation types. Documentation across `cli-reference.md`, `task-management.md`, and `AGENTS.m

Fusion-Task-Id: FN-5329
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 16:42:50 -07:00
committed by gsxdsm
parent 0e5cb4d292
commit fd202e9356
33 changed files with 131 additions and 1644 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
Remove the `fn task branch-recovery` surface and retire orphan-branch auto-rescue wiring.
Fusion now treats orphan `fusion/*` branches as operator-managed git state: branch conflicts still fail loudly with diagnostics, and operators resolve/reclaim/discard branches manually with standard git tooling before retrying.

View File

@@ -199,7 +199,7 @@ Port 4040 is the production dashboard port. A user's live session is typically r
Detailed mechanism logs live in `docs/architecture.md` and `docs/design/`. The contracts agents must respect:
- **Orphan `fusion/*` branches**: prune-or-rescue, never force-delete. Subsumed branches pruned; unique-commit branches rescued into triage tasks.
- **Orphan `fusion/*` branches**: branches with zero unique commits vs `main` are pruned by `cleanupOrphanedBranches` (`branch:orphan-prune`). Branches with unique commits are not auto-rescued; operators inspect and clean them manually via standard git tooling (`git branch -D`, `git worktree remove`, etc.).
- **Stale active branches**: self-healing's `reclaim-stale-active-branches` stage prunes a `fusion/<task-id>` branch with zero unique commits when no usable worktree mapping exists, then clears `task.branch`/`task.worktree`/`task.baseCommitSha`. It must defer reclaim (emit `branch:stale-active-reclaim-deferred`) when the task worktree is in `activeSessionRegistry`, when `executionStartedAt` is within `STALE_ACTIVE_BRANCH_EXECUTION_GRACE_MS` (10 minutes), or when the mapped worktree has uncommitted changes.
- **Worktree metadata reconcile ordering (FN-4962)**: `reconcile-task-worktree-metadata` must run before `reclaim-stale-active-branches`; stale `task.worktree` metadata is rebound to live `fusion/<task-id>` worktrees when present (`task:auto-recover-worktree-metadata-rebound`) or cleared (`task:auto-recover-worktree-metadata-cleared`) when absent.
- **Completion fan-out is synchronous**: `SelfHealingManager.reconcileCompletedTask()` runs on `in-review → done`. Downstream stale `blockedBy` links and residual `fusion/<task-id>` branch/worktree artifacts are reconciled immediately, not on a periodic sweep.
@@ -209,7 +209,7 @@ Detailed mechanism logs live in `docs/architecture.md` and `docs/design/`. The c
- **Stale self-owned active-session reconcile on conflict cleanup (FN-4973)**: when executor worktree-conflict cleanup finds only a same-task stale `activeSessionRegistry` entry and no live in-memory `activeWorktrees` binding for that task/path, it must unregister the stale entry before `removeWorktree` (plus one-shot backstop reconcile on same-task `ActiveSessionWorktreeRemovalError` races). Foreign-task entries remain protected by FN-4811 and must never be reconciled by the requesting task.
- **Task title/ID drift (FN-4898)**: active and archived title writes normalize foreign embedded `FN-NNN` tokens via `packages/core/src/task-title-id-drift.ts`. Empty placeholder groups (`()`, `[]`, `{}`) left behind by token stripping are also removed in both `normalizeTitleForTaskId` and `sanitizeTitle` (FN-4978). Lineage is preserved in `sourceParentTaskId` / description markers, not title embeds. FN-5077 extends drift normalization to reject dangling-connector fragments (`"Close as duplicate of"`) so token-stripped residuals never persist as task titles.
- **PR-conflict reclaim wiring (FN-4763)**: GitHub PR refresh now persists normalized `prInfo.mergeable` conflict state and, when conflicting, funnels tasks into self-healings existing reclaim machinery (`reclaimPrConflictForTask` / `reclaim-pr-conflicts` stage) so branch-conflict handling stays centralized with existing `inspectBranchConflict` outcomes and unrecoverable pause semantics. PR refresh also captures `prInfo.conflictDiagnostics` (conflicting files + suggested local recovery commands) for dashboard surfacing.
- **Worktrunk-managed lifecycles**: when `worktrunk.enabled`, self-healing defers prune/idle/worktree-cap sweeps to the worktrunk backend; branch-level reclaim and orphan rescue stay native.
- **Worktrunk-managed lifecycles**: when `worktrunk.enabled`, self-healing defers prune/idle/worktree-cap sweeps to the worktrunk backend; branch-level stale/ conflict reclaim stays native. Orphan `fusion/*` branches are operator-managed via standard git tooling (no auto-rescue task filing).
- **Post-finalize verification no-op (FN-4944)**: when auto-merge receives a delayed `VerificationError` after a task is already `done` with `mergeDetails.mergeConfirmed === true` (already-on-main fast-path), it must log one `[verification] ... no action` diagnostic and must not bounce the task back to `in-progress` / `merging-fix`. Defense-in-depth now re-checks the done+mergeConfirmed condition immediately before each verification-failure status write site, and emits `task:post-finalize-verification-no-op` database audit events with failure metadata for forensics.
- **Worktree pool exclusivity (FN-4954)**: `WorktreePool.acquire(taskId)` / `release(path, taskId?)` track a `leased` map so every pooled path is either idle or leased, never both. Cross-task double-lease detection throws `PoolDoubleLeaseError` and emits `worktree:pool-double-lease-detected`; merger Step 8 now detaches HEAD and clears `task.worktree` / `task.branch` before releasing paths back to the pool.
- **Stale registration recovery (FN-5056)**: `NativeWorktreeBackend.create` and `executor.tryCreateWorktree` detect `missing but already registered worktree` failures, run `git worktree prune` (plus `remove --force` / `add -f` fallbacks) before retrying, and emit `worktree:stale-registration-{detected,recovered,recovery-failed}` audit events.

View File

@@ -685,7 +685,6 @@ Guardrails: this routine does **not** retry merges, does **not** apply to mixed/
- `clearStaleBlockedBy()` clears `blockedBy` (and transient `status`) on todo tasks when their blocker is missing, done, archived, paused in-review, or failed in-review with merge retries exhausted. FN-3924 extends this with a dependency-integrity guard: if a task has explicit dependencies and `blockedBy` is not one of the currently unresolved deps, the stale marker is cleared. FN-4091 broadens the sweep to active `in-progress` and un-paused `in-review` tasks as well, but those repairs only null `blockedBy` (they do not rewrite scheduler-owned queued state). This repairs rows corrupted by historical overlap re-stamping and lets scheduler re-evaluate from live dependency state. The ad-hoc `scripts/recover-stale-blocked-by.mjs` remains a manual backstop for filesystem/db audits, not the primary repair path.
- `inspectBranchConflict()` now treats self-owned zero-attribution collisions as reclaimable (instead of foreign) when ownership is proven by task/worktree identity, so stranded self-branches do not enter unrecoverable loops.
- `reclaimSelfOwnedBranchConflicts()` includes paused `branch-conflict-unrecoverable` tasks (not just todo/in-progress), clearing paused/error state in one update and requeueing only when parked in `in-review`.
- `cleanupOrphanedBranches()` uses a three-way decision table: (1) subsumed/no-unique-commits branches are pruned with `branch:orphan-prune`, (2) unique-commit branches with no matching task row are rescued as new triage tasks with `branch:orphan-rescued`, and (3) unique-commit branches tied to archived tasks are left intact with one-time acknowledgement metadata. FN-5188 adds a fresh-DB gate: when `__meta.bootstrappedAt` is current-process-fresh and the task table is empty, orphan rescue/prune is skipped entirely and emits `self-healing:orphan-rescue-skipped-fresh-db`.
- Together, `recoverAlreadyMergedReviewTasks()`, `clearStaleBlockedBy()`, and paused-aware in-review scheduling prevent merge-deadlock loops by finalizing already-landed work, clearing stale dependency blockers, reclaiming self-owned conflicts, and avoiding paused review cards re-blocking overlap dispatch.
- Merge commit attribution is ownership-aware: a `mergeDetails.commitSha` is trusted only when reachable from `HEAD` **and** attributable to the task via `Fusion-Task-Id` trailer or task-ID-bearing subject. Reachable-but-unowned SHAs are rejected to prevent sibling done tasks from sharing misleading merge metadata.
- FN-4948 adds a task-worktree pre-commit branch-identity guard: provisioning paths (`NativeWorktreeBackend.create`, executor branch creation, and `StepSessionExecutor.createStepWorktree`) install a `pre-commit` hook plus `fusion-task-id` metadata under the worktree's git-path. Commits are refused unless HEAD matches `fusion/<task-id>` or the allowlist (`fusion/step-<n>-<slug>` by default).
@@ -1560,7 +1559,7 @@ The GitHub tracking state listener now attaches to every registered project stor
- `inspectBranchConflict` classifies branch collisions as `stale`, `stale-resolved`, `reclaimable`, or `live-foreign`.
- Dispatch preflight (`acquireTaskWorktree`/executor) now auto-reclaims `reclaimable` self-owned conflicts and emits `branch:auto-reclaim` run-audit events with task/branch/worktree/tip/stranded-commit metadata.
- Self-healing also runs `reclaimSelfOwnedBranchConflicts()` across idle `todo` + `in-progress` tasks; successful reclaim keeps stranded commits intact and failed reclaim escalates to `in-review`/`failed` with `branch-conflict-unrecoverable`.
- Cross-task collisions (`live-foreign`) remain manual by design and still surface `fn task branch-recovery <taskId>` as the escape hatch.
- Cross-task collisions (`live-foreign`) remain manual by design; operators resolve conflicting branches/worktrees with standard git tooling, then retry the task.
### Merge strategies
- Setting type: `MergeStrategy = "direct" | "pull-request"` (`types.ts`)

View File

@@ -530,43 +530,9 @@ fn task unarchive FN-001
fn task delete FN-001 --force
```
### Branch conflict recovery
### Branch conflict handling
Use `fn task branch-recovery` when executor branch allocation fails because the canonical task branch is already checked out elsewhere. By default, the command lists every recovery candidate for the task, including the branch tip SHA, attached worktree path (if any), and patch-id-aware stranded commit subjects unique versus `main` (`git cherry`). Fully-subsumed branches show `stranded commits: none`.
```bash
fn task branch-recovery FN-001
fn task branch-recovery FN-001 --reclaim fusion/fn-001-2
fn task branch-recovery FN-001 --discard fusion/fn-001-2 --yes
```
| Option | Description |
|---|---|
| `--reclaim <branch>` | Point the task at an existing canonical or sibling branch so the next executor run resumes from that branch without rewriting commits. |
| `--discard <branch>` | Delete a stranded sibling branch and its worktree. Fusion refuses to run this destructive action unless `--yes` is also supplied. |
| `--yes` | Confirm destructive discard when `--discard` is used. |
Example inspect output:
```bash
fn task branch-recovery FN-001
Branch recovery candidates for FN-001
Canonical branch: fusion/fn-001
Current task branch: fusion/fn-001
Current task worktree: /repo/.worktrees/fn-001
• fusion/fn-001 (canonical)
tip: 0123456789abcdef0123456789abcdef01234567
worktree: /repo/.worktrees/fn-001
stranded commits:
- 0123456789ab fix: preserve stranded commits
• fusion/fn-001-2
tip: fedcba9876543210fedcba9876543210fedcba98
worktree: (not attached to a worktree)
stranded commits: none
```
See [Task Management → Branch conflict recovery](./task-management.md#branch-conflict-recovery) for the operator workflow and [Settings Reference → executorAllowSiblingBranchRename](./settings-reference.md#executorallowsiblingbranchrename) for the legacy opt-out setting.
When executor branch allocation fails because `fusion/<task-id>` is already checked out, Fusion marks the task failed/investigable and logs conflict details (existing worktree path, tip SHA, stranded commits). Operators should inspect and resolve conflicting local branches/worktrees with standard git tooling, then retry the task.
### GitHub integration

View File

@@ -266,7 +266,7 @@ Sandbox backend precedence is:
| `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). |
| `recycleWorktrees` | `boolean` | `false` | Default: off (opt-in). Reuse worktrees from a pool for faster startup. |
| `executorAllowSiblingBranchRename` | `boolean` | `false` | Opt back into the legacy executor behavior that silently allocates sibling branches (`fusion/<task-id>-2`, `-2-2`, …) when the canonical task branch is already checked out elsewhere. When disabled (default), branch conflicts fail loudly, leave the task in `todo` with `status: "failed"`, and expose stranded commits for explicit recovery via [`fn task branch-recovery`](./cli-reference.md#fn-task). See [Task Management → Branch conflict recovery](./task-management.md#branch-conflict-recovery). The dashboard Settings modal exposes the same toggle with warning copy because this legacy mode is discouraged. |
| `executorAllowSiblingBranchRename` | `boolean` | `false` | Opt back into the legacy executor behavior that silently allocates sibling branches (`fusion/<task-id>-2`, `-2-2`, …) when the canonical task branch is already checked out elsewhere. When disabled (default), branch conflicts fail loudly and leave the task in `todo` with `status: "failed"` so operators can resolve conflicting branches/worktrees with git tooling before retrying. See [Task Management → Branch conflict handling](./task-management.md#branch-conflict-handling). The dashboard Settings modal exposes the same toggle with warning copy because this legacy mode is discouraged. |
| `worktreeNaming` | `"random" \| "task-id" \| "task-title"` | `"random"` | Naming mode for new worktree directories. |
#### Worktree backend settings

View File

@@ -410,41 +410,11 @@ Recommended pattern:
Do **not** patch `.fusion/fusion.db` directly without synchronizing `.fusion/tasks/*/task.json` through a supported store-backed path.
## Branch conflict recovery
## Branch conflict handling
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 renaming the run onto `fusion/<task-id>-2`, `-3`, or similar sibling branches. See [CLI Reference → Branch conflict recovery](./cli-reference.md#branch-conflict-recovery) for the command reference and [Settings Reference → executorAllowSiblingBranchRename](./settings-reference.md#executorallowsiblingbranchrename) for the legacy opt-out setting.
When executor branch allocation finds `fusion/<task-id>` already checked out elsewhere, Fusion fails loudly by default instead of silently renaming to sibling branches. The task is moved back to `todo` with `status: "failed"`, and logs include the conflicting worktree path, tip SHA, and stranded commits.
1. **When this happens**
The executor refuses the branch allocation, moves the task from `in-progress` back to `todo`, sets `status: "failed"`, preserves the branch/worktree recovery metadata, and records the existing tip SHA plus stranded commit subjects in the task lifecycle log and structured agent log.
2. **Inspect candidates**
Run the recovery command with no flags to list every matching canonical or sibling branch. The output includes the tip SHA, any attached worktree path, and patch-id-aware stranded commit subjects unique to the branch versus `main` (`git cherry`). Fully-subsumed branches now report no stranded commits.
```bash
fn task branch-recovery FN-001
```
3. **Reclaim**
Reclaim points the task back at an existing canonical or sibling branch so the next executor run resumes there instead of allocating a new sibling. No commits are rewritten; Fusion only updates the task metadata.
```bash
fn task branch-recovery FN-001 --reclaim fusion/fn-001-2
```
4. **Discard**
Discard deletes a stranded sibling branch and its worktree when you have confirmed the old work is no longer needed. `--yes` is mandatory because the action is destructive.
```bash
fn task branch-recovery FN-001 --discard fusion/fn-001-2 --yes
```
5. **Opt-out / legacy mode**
If you must preserve the pre-FN-4068 behavior for a legacy workflow, enable [`executorAllowSiblingBranchRename`](./settings-reference.md#executorallowsiblingbranchrename). That restores silent suffixing onto sibling branches, but it is discouraged because it recreates the same hidden-work / data-loss pattern that motivated loud branch-conflict recovery in the first place.
Fusion no longer provides a dedicated task-branch conflict CLI command. Resolve conflicting local branches/worktrees with standard git tooling, then retry the task. The legacy [`executorAllowSiblingBranchRename`](./settings-reference.md#executorallowsiblingbranchrename) setting still exists as an opt-in escape hatch for older workflows.
## Task Execution Modes

View File

@@ -45,7 +45,7 @@
| 3 | `src/__tests__/merger-diff-volume-gate.test.ts` | 13.96s | integration + gate logic | keep | Keep. |
| 4 | `src/__tests__/self-healing-already-merged.real-git.test.ts` | 10.21s | real-git recovery | keep | Keep. |
| 5 | `src/__tests__/merger-autostash-cleanup.test.ts` | 9.79s | sweep* paths | keep | Keep. |
| 6 | `src/__tests__/branch-conflicts-recovery.test.ts` | 9.39s | branch recovery classification | keep | Keep. |
| 6 | `src/__tests__/branch-conflicts-recovery.test.ts` | 9.39s | branch conflict classification | keep | Keep. |
| 7 | `src/__tests__/merger-autostash-orphan-surface.test.ts` | 8.11s | autostash orphan surface | keep | Keep. |
| 8 | `src/__tests__/merger-squash-audit.test.ts` | 7.66s | squash audit | keep | Keep. |
| 9 | `src/__tests__/self-healing-stale-merge-stats.real-git.test.ts` | 7.01s | merge metadata recovery | keep | Keep. |

View File

@@ -29,7 +29,6 @@ const commandMocks = vi.hoisted(() => ({
runTaskPlan: vi.fn(),
runTaskDelete: vi.fn(),
runTaskRetry: vi.fn(),
runTaskBranchRecovery: vi.fn(),
runTaskComment: vi.fn(),
runTaskComments: vi.fn(),
runTaskSteer: vi.fn(),
@@ -134,7 +133,6 @@ vi.mock("../commands/task.js", () => ({
runTaskPlan: commandMocks.runTaskPlan,
runTaskDelete: commandMocks.runTaskDelete,
runTaskRetry: commandMocks.runTaskRetry,
runTaskBranchRecovery: commandMocks.runTaskBranchRecovery,
runTaskComment: commandMocks.runTaskComment,
runTaskComments: commandMocks.runTaskComments,
runTaskSteer: commandMocks.runTaskSteer,
@@ -402,28 +400,9 @@ describe("bin command routing and fallbacks", () => {
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 () => {
await runBin(["agent", "stop", "agent-1", "-P", "demo"]);

View File

@@ -119,7 +119,7 @@ async function loadCommandHandlers() {
const { runServe } = await import("./commands/serve.js");
const { runDaemon } = await import("./commands/daemon.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, runTaskBranchRecovery } = 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 } = await import("./commands/task.js");
const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js");
const { runSettingsExport } = await import("./commands/settings-export.js");
const { runSettingsImport } = await import("./commands/settings-import.js");
@@ -166,7 +166,6 @@ async function loadCommandHandlers() {
runTaskPlan,
runTaskDelete,
runTaskRetry,
runTaskBranchRecovery,
runTaskComment,
runTaskComments,
runTaskSteer,
@@ -286,8 +285,6 @@ Usage:
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 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>] [--draft] [--no-ai] [--reviewer <login>]
Alias of: fn pr create
fn task import <owner/repo> [opts] Import GitHub issues as tasks
@@ -569,7 +566,6 @@ async function main() {
runTaskPlan,
runTaskDelete,
runTaskRetry,
runTaskBranchRecovery,
runTaskComment,
runTaskComments,
runTaskSteer,
@@ -1222,20 +1218,6 @@ async function main() {
await runTaskRetry(id, projectName);
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": {
const id = args[2];
if (!id) {

View File

@@ -75,7 +75,7 @@ vi.mock("@fusion/core", async (importActual) => {
});
// Mock @fusion/engine
vi.mock("@fusion/engine", () => ({ aiMergeTask: vi.fn(), listBranchRecoveryCandidates: vi.fn() }));
vi.mock("@fusion/engine", () => ({ aiMergeTask: vi.fn() }));
// Mock @fusion/dashboard
vi.mock("@fusion/dashboard", () => ({
@@ -122,7 +122,7 @@ import { createInterface } from "node:readline/promises";
import { TaskStore, CentralCore, extractIntentSignature, findNearDuplicates, runDeterministicDuplicateGuard, reconcileDeterministicDuplicate } from "@fusion/core";
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
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 { 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 {
getCurrentRepo,
isGhAuthenticated,
@@ -132,7 +132,7 @@ import {
import { GitHubClient, generatePrMetadata } from "@fusion/dashboard";
import { createSession, submitResponse } from "@fusion/dashboard/planning";
import { resolveProject } from "../../project-context.js";
import { aiMergeTask, listBranchRecoveryCandidates } from "@fusion/engine";
import { aiMergeTask } from "@fusion/engine";
const mockedExec = vi.mocked(exec);
@@ -2488,153 +2488,6 @@ 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 ---
describe("runTaskLogs", () => {

View File

@@ -1,7 +1,5 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch } from "@fusion/core";
import { aiMergeTask, listBranchRecoveryCandidates, type BranchRecoveryCandidate } from "@fusion/engine";
import { aiMergeTask } from "@fusion/engine";
import { createInterface } from "node:readline/promises";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import { createSession, submitResponse, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning";
@@ -19,7 +17,6 @@ import {
import { resolveProject, type ProjectContext } from "../project-context.js";
import { findNodeByNameOrId } from "./node.js";
const execAsync = promisify(exec);
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
// Register GitHub tracking hook so CLI task creation paths (add, duplicate,
@@ -200,70 +197,6 @@ async function getProjectPath(projectName?: string): Promise<string> {
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 }> {
const central = new CentralCore();
await central.init();
@@ -1091,95 +1024,6 @@ export async function runTaskRetry(id: string, projectName?: string) {
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) {
const store = await getStore(projectName);

View File

@@ -47,7 +47,6 @@ import {
BranchCrossContaminationError,
assertCleanBranchAtBase,
inspectBranchConflict,
listBranchRecoveryCandidates,
listUniqueBranchCommits,
} from "../branch-conflicts.js";
@@ -307,7 +306,7 @@ describe("branch-conflicts", () => {
}
expect(result.error).toBeInstanceOf(BranchConflictError);
expect(result.error.message).toContain("1 stranded commit since main");
expect(result.error.message).toContain("Run branch recovery");
expect(result.error.message).toContain("Inspect/reclaim or discard the conflicting local branch/worktree");
});
it("lists zero unique commits when git cherry has no plus entries", async () => {
@@ -429,67 +428,5 @@ describe("branch-conflicts", () => {
await expect(assertion).resolves.toBeUndefined();
});
it("lists canonical and sibling recovery candidates with worktrees and stranded commits", async () => {
mockedExecSync.mockImplementation((cmd: string | string[]) => {
const command = typeof cmd === "string" ? cmd : cmd[0];
if (command === "git for-each-ref --format='%(refname:short)' refs/heads/fusion/fn-4068 refs/heads/fusion/fn-4068-*") {
return Buffer.from("fusion/fn-4068\nfusion/fn-4068-2\n");
}
if (command === "git worktree list --porcelain") {
return Buffer.from([
"worktree /tmp/repo",
"HEAD 1111111",
"branch refs/heads/main",
"",
"worktree /tmp/fn-4068",
"HEAD 2222222",
"branch refs/heads/fusion/fn-4068",
"",
"worktree /tmp/fn-4068-2",
"HEAD 3333333",
"branch refs/heads/fusion/fn-4068-2",
"",
].join("\n"));
}
if (command.includes("git rev-parse --verify 'fusion/fn-4068^{commit}'")) {
return Buffer.from("abc123\n");
}
if (command.includes("git rev-parse --verify 'fusion/fn-4068-2^{commit}'")) {
return Buffer.from("def456\n");
}
if (command.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-4068'")) {
return Buffer.from("aaa111\tCanonical fix\n");
}
if (command.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-4068-2'")) {
return Buffer.from("bbb222\tSibling patch\nccc333\tMore work\n");
}
throw new Error(`Unexpected command: ${command}`);
});
const result = await listBranchRecoveryCandidates({
repoDir: "/tmp/repo",
branchName: "fusion/fn-4068",
startPoint: "main",
});
expect(result).toEqual([
{
branchName: "fusion/fn-4068",
tipSha: "abc123",
worktreePath: "/tmp/fn-4068",
strandedCommits: [{ sha: "aaa111", subject: "Canonical fix" }],
isCanonical: true,
},
{
branchName: "fusion/fn-4068-2",
tipSha: "def456",
worktreePath: "/tmp/fn-4068-2",
strandedCommits: [
{ sha: "bbb222", subject: "Sibling patch" },
{ sha: "ccc333", subject: "More work" },
],
isCanonical: false,
},
]);
});
});

View File

@@ -1634,7 +1634,7 @@ describe("TaskExecutor worktree recovery", () => {
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask());
// Should have triggered cleanup (stale branch recovery)
// Should have triggered cleanup (stale branch reclaim)
expect(mockedExecSync).toHaveBeenCalledWith(
expect.stringContaining("git worktree prune"),
expect.any(Object),

View File

@@ -1,111 +0,0 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../../self-healing.js";
import * as branchConflicts from "../../branch-conflicts.js";
import * as worktreePool from "../../worktree-pool.js";
import { RestartRecoveryCoordinator } from "../../restart-recovery-coordinator.js";
function createStore(): TaskStore & EventEmitter {
const emitter = new EventEmitter() as TaskStore & EventEmitter;
(emitter as any).getSettings = vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false });
(emitter as any).listTasks = vi.fn();
(emitter as any).updateTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).moveTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).logEntry = vi.fn().mockResolvedValue(undefined);
(emitter as any).recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
(emitter as any).getBootstrappedAt = vi.fn(() => null);
(emitter as any).createTask = vi.fn();
(emitter as any).clearStaleExecutionStartBranchReferences = vi.fn().mockReturnValue([]);
return emitter;
}
describe("reliability interactions: branch recovery + orphan rescue", () => {
let store: TaskStore & EventEmitter;
let manager: SelfHealingManager;
beforeEach(() => {
store = createStore();
manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
vi.spyOn(worktreePool, "isUsableTaskWorktree").mockResolvedValue(true);
});
it("keeps userPaused tasks unswept even if reclaimable", async () => {
(store.listTasks as any)
.mockResolvedValueOnce([{ id: "FN-4429", column: "todo", checkedOutBy: null, branch: "fusion/fn-4429", worktree: "/tmp/fn-4429", paused: true, userPaused: true, pausedReason: "branch-conflict-unrecoverable" }])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
const inspectSpy = vi.spyOn(branchConflicts, "inspectBranchConflict");
const recovered = await manager.reclaimSelfOwnedBranchConflicts();
expect(recovered).toBe(0);
expect(inspectSpy).not.toHaveBeenCalled();
});
it("restart recovery safe-requeue and reclaim sweep do not race on paused branch-conflict tasks", async () => {
const task: any = {
id: "FN-6000",
column: "in-progress",
checkedOutBy: null,
branch: "fusion/fn-6000",
worktree: "/tmp/fn-6000",
paused: true,
userPaused: false,
pausedReason: "branch-conflict-unrecoverable",
status: "failed",
error: "Agent exited without calling fn_task_done",
steps: [{ name: "A", status: "pending" }],
};
const statefulStore: any = createStore();
statefulStore.listTasks = vi.fn(async ({ column }: { column?: string }) => {
if (!column) return [task];
return task.column === column ? [task] : [];
});
statefulStore.updateTask = vi.fn(async (_id: string, updates: Record<string, unknown>) => {
Object.assign(task, updates);
});
statefulStore.moveTask = vi.fn(async (_id: string, column: string) => {
task.column = column;
});
const restart = new RestartRecoveryCoordinator(statefulStore, { resumeOrphaned: vi.fn().mockResolvedValue(undefined) } as any);
const localManager = new SelfHealingManager(statefulStore, { rootDir: "/tmp/repo" });
vi.spyOn(branchConflicts, "inspectBranchConflict").mockResolvedValueOnce({
kind: "reclaimable",
livePath: "/tmp/fn-6000",
tipSha: "abc123def456",
taskAttributedCommitCount: 0,
strandedCommits: [],
} as any);
await restart.recoverInterruptedRuns();
const recovered = await localManager.reclaimSelfOwnedBranchConflicts();
expect(task.column).toBe("in-progress");
expect(task.branch).toBe("fusion/fn-6000");
expect(task.worktree).toBe("/tmp/fn-6000");
expect(recovered).toBe(1);
});
it("orphan-rescue sweep is idempotent across consecutive runs", async () => {
const branch = "fusion/fn-4470";
vi.spyOn(worktreePool, "scanOrphanedBranches")
.mockResolvedValueOnce([branch])
.mockResolvedValueOnce([branch]);
vi.spyOn(manager as any, "inspectOrphanedBranch")
.mockResolvedValueOnce({ branch, tipSha: "abc123", uniqueCommitCount: 2, uniqueCommitSubjects: ["feat: keep work"], derivedTaskId: "FN-4470", registeredWorktreePath: null })
.mockResolvedValueOnce({ branch, tipSha: "abc123", uniqueCommitCount: 2, uniqueCommitSubjects: ["feat: keep work"], derivedTaskId: "FN-4470", registeredWorktreePath: null });
(store.listTasks as any)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: "FN-5001", column: "triage", branch }]);
(store.createTask as any).mockResolvedValueOnce({ id: "FN-5001", lineageId: "lin-5001" });
await manager.cleanupOrphanedBranches();
await manager.cleanupOrphanedBranches();
expect(store.createTask).toHaveBeenCalledTimes(1);
});
});

View File

@@ -32,7 +32,7 @@ function createStore(): TaskStore & EventEmitter {
return emitter;
}
describe("reliability interactions: branch recovery stale cached base", () => {
describe("reliability interactions: stale cached-base branch reclaim", () => {
let store: TaskStore & EventEmitter;
beforeEach(() => {

View File

@@ -1,129 +0,0 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const execSyncFn = vi.fn(() => Buffer.from(""));
const execFn: any = vi.fn((cmd: string, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
if (typeof callback === "function") callback(null, "", "");
});
execFn[promisify.custom] = () => Promise.resolve({ stdout: "", stderr: "" });
return { exec: execFn, execSync: execSyncFn };
});
import { execSync } from "node:child_process";
import type { TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../../self-healing.js";
import * as worktreePool from "../../worktree-pool.js";
function createStore(bootstrappedAt: number | null, tasks: any[] = []): TaskStore & EventEmitter {
const emitter = new EventEmitter() as TaskStore & EventEmitter;
(emitter as any).getBootstrappedAt = vi.fn(() => bootstrappedAt);
(emitter as any).listTasks = vi.fn().mockResolvedValue(tasks);
(emitter as any).updateTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).moveTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).logEntry = vi.fn().mockResolvedValue(undefined);
(emitter as any).recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
(emitter as any).createTask = vi.fn();
(emitter as any).clearStaleExecutionStartBranchReferences = vi.fn().mockReturnValue([]);
return emitter;
}
describe("reliability interactions: orphan-rescue fresh-db gate", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("leaves both subsumed and unique orphan branches untouched for a fresh DB", async () => {
const store = createStore(Date.now(), []);
const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
const scanSpy = vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValue([
"fusion/fn-subsumed",
"fusion/fn-unique",
]);
const inspectSpy = vi.spyOn(manager as any, "inspectOrphanedBranch");
const execSyncMock = vi.mocked(execSync);
const cleaned = await manager.cleanupOrphanedBranches();
expect(cleaned).toBe(0);
expect(scanSpy).not.toHaveBeenCalled();
expect(inspectSpy).not.toHaveBeenCalled();
expect(execSyncMock).not.toHaveBeenCalledWith(
expect.stringContaining("git branch -d"),
expect.anything(),
);
expect(store.createTask).not.toHaveBeenCalled();
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({ mutationType: "self-healing:orphan-rescue-skipped-fresh-db" }),
);
});
it("preserves prune-and-rescue behavior for non-fresh DBs", async () => {
const store = createStore(Date.now() - 1_000_000, [{ id: "FN-0001", column: "done" }]);
const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValue([
"fusion/fn-subsumed",
"fusion/fn-unique",
]);
vi.spyOn(manager as any, "inspectOrphanedBranch")
.mockResolvedValueOnce({
branch: "fusion/fn-subsumed",
tipSha: "aaa111",
uniqueCommitCount: 0,
uniqueCommitSubjects: [],
derivedTaskId: "FN-SUBSUMED",
registeredWorktreePath: null,
})
.mockResolvedValueOnce({
branch: "fusion/fn-unique",
tipSha: "bbb222",
uniqueCommitCount: 2,
uniqueCommitSubjects: ["feat: keep work"],
derivedTaskId: "FN-UNIQUE",
registeredWorktreePath: null,
});
(store.createTask as any).mockResolvedValueOnce({ id: "FN-5001", lineageId: "lin-5001" });
const cleaned = await manager.cleanupOrphanedBranches();
expect(cleaned).toBe(1);
expect(vi.mocked(execSync)).toHaveBeenCalledWith(
expect.stringContaining("git branch -d"),
expect.objectContaining({ cwd: "/tmp/repo" }),
);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({ title: "Recover orphaned branch fusion/fn-unique" }),
);
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({ mutationType: "branch:orphan-prune" }),
);
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({ mutationType: "branch:orphan-rescued" }),
);
});
it("remains idempotent across repeated fresh-DB sweeps", async () => {
const store = createStore(Date.now(), []);
const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
const scanSpy = vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValue([
"fusion/fn-subsumed",
"fusion/fn-unique",
]);
const execSyncMock = vi.mocked(execSync);
const first = await manager.cleanupOrphanedBranches();
const second = await manager.cleanupOrphanedBranches();
const third = await manager.cleanupOrphanedBranches();
expect([first, second, third]).toEqual([0, 0, 0]);
expect(scanSpy).not.toHaveBeenCalled();
expect(execSyncMock).not.toHaveBeenCalledWith(
expect.stringContaining("git branch -d"),
expect.anything(),
);
expect(store.createTask).not.toHaveBeenCalled();
expect((store as any).recordRunAuditEvent).toHaveBeenCalledTimes(3);
});
});

View File

@@ -3,12 +3,11 @@ import { EventEmitter } from "node:events";
import type { RunAuditEventInput, Settings, TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../../self-healing.js";
const { execSpy, execSyncSpy, resolveBackendSpy, scanIdleSpy, scanOrphanedBranchesSpy, readdirSpy, existsSpy, inspectBranchConflictSpy } = vi.hoisted(() => ({
const { execSpy, execSyncSpy, resolveBackendSpy, scanIdleSpy, readdirSpy, existsSpy, inspectBranchConflictSpy } = vi.hoisted(() => ({
execSpy: vi.fn(),
execSyncSpy: vi.fn(),
resolveBackendSpy: vi.fn(),
scanIdleSpy: vi.fn(),
scanOrphanedBranchesSpy: vi.fn().mockResolvedValue([]),
readdirSpy: vi.fn(),
existsSpy: vi.fn().mockReturnValue(false),
inspectBranchConflictSpy: vi.fn(),
@@ -38,7 +37,6 @@ vi.mock("../../worktree-pool.js", async () => {
...actual,
resolveWorktreeBackend: resolveBackendSpy,
scanIdleWorktrees: scanIdleSpy,
scanOrphanedBranches: scanOrphanedBranchesSpy,
isUsableTaskWorktree: vi.fn().mockResolvedValue(true),
};
});
@@ -93,8 +91,6 @@ describe("reliability interactions: worktrunk x self-healing", () => {
execSyncSpy.mockReset();
resolveBackendSpy.mockReset();
scanIdleSpy.mockReset();
scanOrphanedBranchesSpy.mockReset();
scanOrphanedBranchesSpy.mockResolvedValue([]);
readdirSpy.mockReset();
existsSpy.mockReset();
existsSpy.mockReturnValue(false);

View File

@@ -44,7 +44,6 @@ describe("FN-4733: self-healing chat cleanup maintenance", () => {
const manager = new SelfHealingManager(store, { rootDir: tmpRoot, chatStore });
vi.spyOn(manager as any, "pruneWorktrees").mockResolvedValue(undefined);
vi.spyOn(manager as any, "cleanupOrphans").mockResolvedValue(undefined);
vi.spyOn(manager as any, "cleanupOrphanedBranches").mockResolvedValue(undefined);
vi.spyOn(manager as any, "checkpointWal").mockReturnValue(undefined);
vi.spyOn(manager as any, "enforceWorktreeCap").mockResolvedValue(undefined);
vi.spyOn(manager, "archiveStaleDoneTasks").mockResolvedValue(0);

View File

@@ -33,7 +33,6 @@ function createMockStore(overrides: Record<string, unknown> = {}): TaskStore & E
const BATCH1_METHODS = [
"pruneWorktrees",
"cleanupOrphans",
"cleanupOrphanedBranches",
"enforceWorktreeCap",
] as const;

View File

@@ -32,7 +32,7 @@ function createStore(): TaskStore & EventEmitter {
return emitter;
}
describe("self-healing ghost branch recovery", () => {
describe("self-healing ghost branch reclaim", () => {
let store: TaskStore & EventEmitter;
let manager: SelfHealingManager;

View File

@@ -28,13 +28,11 @@ vi.mock("../worktree-pool.js", () => ({
SelfHealingReclaim: "self-healing-reclaim",
SelfHealingStaleActiveBranch: "self-healing-stale-active-branch",
SelfHealingBranchConflict: "self-healing-branch-conflict",
SelfHealingOrphanRescue: "self-healing-orphan-rescue",
SelfHealingIdleSweep: "self-healing-idle-sweep",
PoolPrune: "pool-prune",
},
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
scanOrphanedBranches: vi.fn().mockResolvedValue([]),
isUsableTaskWorktree: vi.fn().mockResolvedValue(true),
removeWorktree: vi.fn().mockResolvedValue(undefined),
resolveWorktreeBackend: vi.fn(),

View File

@@ -41,7 +41,6 @@ describe("FN-4743: self-healing mail cleanup maintenance", () => {
});
vi.spyOn(manager as any, "pruneWorktrees").mockResolvedValue(undefined);
vi.spyOn(manager as any, "cleanupOrphans").mockResolvedValue(undefined);
vi.spyOn(manager as any, "cleanupOrphanedBranches").mockResolvedValue(undefined);
vi.spyOn(manager as any, "checkpointWal").mockReturnValue(undefined);
vi.spyOn(manager as any, "enforceWorktreeCap").mockResolvedValue(undefined);
vi.spyOn(manager, "archiveStaleDoneTasks").mockResolvedValue(0);

View File

@@ -1,103 +0,0 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const execSyncFn = vi.fn(() => Buffer.from(""));
const execFn: any = vi.fn((cmd: string, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
if (typeof callback === "function") callback(null, "", "");
});
execFn[promisify.custom] = () => Promise.resolve({ stdout: "", stderr: "" });
return { exec: execFn, execSync: execSyncFn };
});
import type { TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../self-healing.js";
import * as worktreePool from "../worktree-pool.js";
function createStore(): TaskStore & EventEmitter {
const emitter = new EventEmitter() as TaskStore & EventEmitter;
(emitter as any).getBootstrappedAt = vi.fn(() => null);
(emitter as any).listTasks = vi.fn();
(emitter as any).createTask = vi.fn();
(emitter as any).updateTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).logEntry = vi.fn().mockResolvedValue(undefined);
(emitter as any).clearStaleExecutionStartBranchReferences = vi.fn().mockReturnValue([]);
(emitter as any).recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
return emitter;
}
describe("self-healing orphan branch rescue", () => {
let store: TaskStore & EventEmitter;
let manager: SelfHealingManager;
beforeEach(() => {
store = createStore();
manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
});
it("prunes subsumed orphan branches and emits branch:orphan-prune", async () => {
vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValueOnce(["fusion/fn-4470"]);
vi.spyOn(manager as any, "inspectOrphanedBranch").mockResolvedValueOnce({
branch: "fusion/fn-4470",
tipSha: "abc123",
uniqueCommitCount: 0,
uniqueCommitSubjects: [],
derivedTaskId: "FN-4470",
registeredWorktreePath: null,
});
vi.spyOn(store, "listTasks" as any).mockResolvedValueOnce([]);
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(1);
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ mutationType: "branch:orphan-prune" }));
});
it("creates a rescue triage task when unique commits exist and no task row exists", async () => {
vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValueOnce(["fusion/fn-4470"]);
vi.spyOn(manager as any, "inspectOrphanedBranch").mockResolvedValueOnce({
branch: "fusion/fn-4470",
tipSha: "deadbeef",
uniqueCommitCount: 2,
uniqueCommitSubjects: ["feat: preserve orphan"],
derivedTaskId: "FN-4470",
registeredWorktreePath: "/tmp/wt-fn-4470",
});
vi.spyOn(store, "listTasks" as any).mockResolvedValueOnce([]);
(store.createTask as any).mockResolvedValueOnce({ id: "FN-5000", lineageId: "lin-5000" });
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(0);
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
title: "Recover orphaned branch fusion/fn-4470",
column: "triage",
branch: "fusion/fn-4470",
}));
expect(store.updateTask).toHaveBeenCalledWith("FN-5000", { worktree: "/tmp/wt-fn-4470" });
expect(store.logEntry).toHaveBeenCalledWith("FN-5000", expect.stringContaining("[recovery] orphan-rescue-created"));
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ mutationType: "branch:orphan-rescued" }));
});
it("leaves archived matching tasks untouched and only acknowledges once", async () => {
const archivedTask = { id: "FN-4470", column: "archived", metadata: {} };
vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValueOnce(["fusion/fn-4470"]);
vi.spyOn(manager as any, "inspectOrphanedBranch").mockResolvedValueOnce({
branch: "fusion/fn-4470",
tipSha: "deadbeef",
uniqueCommitCount: 1,
uniqueCommitSubjects: ["feat: preserve orphan"],
derivedTaskId: "FN-4470",
registeredWorktreePath: null,
});
vi.spyOn(store, "listTasks" as any).mockResolvedValueOnce([archivedTask]);
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(0);
expect(store.updateTask).not.toHaveBeenCalled();
expect(store.createTask).not.toHaveBeenCalled();
});
});

View File

@@ -1,86 +0,0 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const execSyncFn = vi.fn(() => Buffer.from(""));
const execFn: any = vi.fn((cmd: string, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
if (typeof callback === "function") callback(null, "", "");
});
execFn[promisify.custom] = () => Promise.resolve({ stdout: "", stderr: "" });
return { exec: execFn, execSync: execSyncFn };
});
import type { TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../self-healing.js";
import * as worktreePool from "../worktree-pool.js";
function createStore(bootstrappedAt: number | null, tasks: any[] = []): TaskStore & EventEmitter {
const emitter = new EventEmitter() as TaskStore & EventEmitter;
(emitter as any).getBootstrappedAt = vi.fn(() => bootstrappedAt);
(emitter as any).listTasks = vi.fn().mockResolvedValue(tasks);
(emitter as any).createTask = vi.fn();
(emitter as any).updateTask = vi.fn().mockResolvedValue(undefined);
(emitter as any).logEntry = vi.fn().mockResolvedValue(undefined);
(emitter as any).clearStaleExecutionStartBranchReferences = vi.fn().mockReturnValue([]);
(emitter as any).recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
return emitter;
}
describe("self-healing fresh-db orphan rescue gate", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("skips orphan rescue entirely for fresh databases with zero task history", async () => {
const store = createStore(Date.now(), []);
const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
const scanSpy = vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValue([
"fusion/foo",
"fusion/bar",
]);
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(0);
expect(scanSpy).not.toHaveBeenCalled();
expect(store.createTask).not.toHaveBeenCalled();
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
mutationType: "self-healing:orphan-rescue-skipped-fresh-db",
metadata: expect.objectContaining({
bootstrappedAt: expect.any(Number),
processBootStartedAt: expect.any(Number),
taskCount: 0,
candidateBranches: 0,
}),
}),
);
});
it("preserves existing orphan rescue behavior when the database is not fresh", async () => {
const store = createStore(Date.now() - 1_000_000, []);
const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
vi.spyOn(worktreePool, "scanOrphanedBranches").mockResolvedValueOnce(["fusion/fn-4470"]);
vi.spyOn(manager as any, "inspectOrphanedBranch").mockResolvedValueOnce({
branch: "fusion/fn-4470",
tipSha: "deadbeef",
uniqueCommitCount: 2,
uniqueCommitSubjects: ["feat: preserve orphan"],
derivedTaskId: "FN-4470",
registeredWorktreePath: null,
});
(store.createTask as any).mockResolvedValueOnce({ id: "FN-5000", lineageId: "lin-5000" });
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(0);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({ title: "Recover orphaned branch fusion/fn-4470" }),
);
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({ mutationType: "branch:orphan-rescued" }),
);
});
});

View File

@@ -66,13 +66,12 @@ vi.mock("../worktree-pool.js", () => ({
SelfHealingReclaim: "self-healing-reclaim",
SelfHealingStaleActiveBranch: "self-healing-stale-active-branch",
SelfHealingBranchConflict: "self-healing-branch-conflict",
SelfHealingOrphanRescue: "self-healing-orphan-rescue",
SelfHealingIdleSweep: "self-healing-idle-sweep",
PoolPrune: "pool-prune",
},
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
scanOrphanedBranches: vi.fn().mockResolvedValue([]),
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
isUsableTaskWorktree: vi.fn().mockResolvedValue(true),
removeWorktree: vi.fn().mockResolvedValue(undefined),
resolveWorktreeBackend: vi.fn(),
@@ -111,11 +110,11 @@ import { classifyOwnedLandedEvidence } from "../merger.js";
const mockedExecSync = vi.mocked(execSync);
const mockedExistsSync = vi.mocked(existsSync);
const mockedScanOrphanedBranches = vi.mocked(scanOrphanedBranches);
const mockedIsUsableTaskWorktree = vi.mocked(isUsableTaskWorktree);
const mockedRemoveWorktree = vi.mocked(removeWorktree);
const mockedResolveWorktreeBackend = vi.mocked(resolveWorktreeBackend);
const mockedScanIdleWorktrees = vi.mocked(scanIdleWorktrees);
const mockedScanOrphanedBranches = vi.mocked(scanOrphanedBranches);
const mockedReaddirSync = vi.mocked(readdirSync);
const mockedCreateLogger = vi.mocked(createLogger);
const mockedClassifyOwnedLandedEvidence = vi.mocked(classifyOwnedLandedEvidence);
@@ -1366,73 +1365,6 @@ describe("SelfHealingManager", () => {
});
});
// ── cleanupOrphanedBranches ────────────────────────────────────────
describe("cleanupOrphanedBranches", () => {
it("returns 0 when no orphaned branches found", async () => {
mockedScanOrphanedBranches.mockResolvedValueOnce([]);
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(0);
expect(mockedExecSync).not.toHaveBeenCalled();
});
it("deletes only subsumed orphaned branches with safe delete (-d)", async () => {
mockedScanOrphanedBranches.mockResolvedValueOnce(["fusion/fn-001", "fusion/fn-002"]);
vi.spyOn(manager as any, "inspectOrphanedBranch")
.mockResolvedValueOnce({ branch: "fusion/fn-001", tipSha: "abc", uniqueCommitCount: 0, uniqueCommitSubjects: [], derivedTaskId: "FN-001", registeredWorktreePath: null })
.mockResolvedValueOnce({ branch: "fusion/fn-002", tipSha: "def", uniqueCommitCount: 0, uniqueCommitSubjects: [], derivedTaskId: "FN-002", registeredWorktreePath: null });
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(2);
expect(mockedExecSync).toHaveBeenCalledWith(
expect.stringContaining("git branch -d 'fusion/fn-001'"),
expect.objectContaining({ cwd: "/tmp/test-project" }),
);
expect(mockedExecSync).toHaveBeenCalledWith(
expect.stringContaining("git branch -d 'fusion/fn-002'"),
expect.objectContaining({ cwd: "/tmp/test-project" }),
);
});
it("does not force-delete unique-commit orphaned branches", async () => {
mockedScanOrphanedBranches.mockResolvedValueOnce(["fusion/fn-003"]);
vi.spyOn(manager as any, "inspectOrphanedBranch")
.mockResolvedValueOnce({ branch: "fusion/fn-003", tipSha: "abc", uniqueCommitCount: 2, uniqueCommitSubjects: ["feat: keep"], derivedTaskId: "FN-003", registeredWorktreePath: null });
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(0);
expect(mockedExecSync).not.toHaveBeenCalledWith(
expect.stringContaining('git branch -D "fusion/fn-003"'),
expect.any(Object),
);
});
it("counts only successfully pruned subsumed branches", async () => {
mockedScanOrphanedBranches.mockResolvedValueOnce(["fusion/fn-004", "fusion/fn-005"]);
vi.spyOn(manager as any, "inspectOrphanedBranch")
.mockResolvedValueOnce({ branch: "fusion/fn-004", tipSha: "abc", uniqueCommitCount: 0, uniqueCommitSubjects: [], derivedTaskId: "FN-004", registeredWorktreePath: null })
.mockResolvedValueOnce({ branch: "fusion/fn-005", tipSha: "def", uniqueCommitCount: 1, uniqueCommitSubjects: ["feat"], derivedTaskId: "FN-005", registeredWorktreePath: null });
mockedExecSync.mockReset();
mockedExecSync.mockImplementation(() => Buffer.from(""));
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(1);
});
it("returns 0 when scanOrphanedBranches throws", async () => {
mockedScanOrphanedBranches.mockRejectedValueOnce(new Error("git error"));
const result = await manager.cleanupOrphanedBranches();
expect(result).toBe(0);
});
});
// ── Auto-archive ────────────────────────────────────────────────────
describe("archiveStaleDoneTasks", () => {
@@ -7137,6 +7069,55 @@ describe("worktrunk-aware cleanup sweeps", () => {
});
});
describe("cleanupOrphanedBranches", () => {
let store: TaskStore & EventEmitter;
let manager: SelfHealingManager;
beforeEach(() => {
store = createMockStore({
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
});
manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
mockedScanOrphanedBranches.mockReset();
mockedExecSync.mockReset();
});
afterEach(() => {
manager.stop();
});
it("prunes subsumed orphan branches and emits branch:orphan-prune", async () => {
mockedScanOrphanedBranches.mockResolvedValue(["fusion/FN-777"]);
mockedExecSync.mockImplementation((command: string) => {
if (command.startsWith("git rev-parse --verify")) return "abc123\n" as any;
if (command.startsWith("git rev-list --count")) return "0\n" as any;
if (command.startsWith("git branch -d")) return "" as any;
return "" as any;
});
const result = await (manager as any).cleanupOrphanedBranches();
expect(result).toBe(1);
expect(mockedExecSync).toHaveBeenCalledWith(expect.stringContaining("git branch -d"), expect.anything());
expect(vi.mocked(store.recordRunAuditEvent)).toHaveBeenCalledWith(expect.objectContaining({ mutationType: "branch:orphan-prune" }));
});
it("leaves unique-commit orphan branches untouched", async () => {
mockedScanOrphanedBranches.mockResolvedValue(["fusion/FN-888"]);
mockedExecSync.mockImplementation((command: string) => {
if (command.startsWith("git rev-parse --verify")) return "def456\n" as any;
if (command.startsWith("git rev-list --count")) return "2\n" as any;
return "" as any;
});
const result = await (manager as any).cleanupOrphanedBranches();
expect(result).toBe(0);
expect(mockedExecSync).not.toHaveBeenCalledWith(expect.stringContaining("git branch -d"), expect.anything());
expect(vi.mocked(store.createTask)).not.toHaveBeenCalled();
});
});
describe("maintenance cycle concurrency", () => {
let store: TaskStore & EventEmitter;
let manager: SelfHealingManager;
@@ -7202,7 +7183,6 @@ describe("maintenance cycle concurrency", () => {
it("resets maintenanceRunning flag on success", async () => {
(vi.spyOn(manager as any, "pruneWorktrees").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "cleanupOrphans").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "cleanupOrphanedBranches").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "enforceWorktreeCap").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "recoverCompletedTasks").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "recoverStaleIncompleteReviewTasks").mockResolvedValue(0) as any);
@@ -7228,7 +7208,6 @@ describe("maintenance cycle concurrency", () => {
it("uses a passive WAL checkpoint during maintenance", async () => {
(vi.spyOn(manager as any, "pruneWorktrees").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "cleanupOrphans").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "cleanupOrphanedBranches").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "enforceWorktreeCap").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "recoverCompletedTasks").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "recoverStaleIncompleteReviewTasks").mockResolvedValue(0) as any);
@@ -7268,7 +7247,6 @@ describe("maintenance cycle concurrency", () => {
makeSlow("pruneWorktrees");
makeSlow("cleanupOrphans");
makeSlow("cleanupOrphanedBranches");
makeSlow("enforceWorktreeCap");
// checkpointWal is synchronous, no need to mock
@@ -7281,7 +7259,6 @@ describe("maintenance cycle concurrency", () => {
// All operations should have run
expect(executionOrder).toContain("pruneWorktrees");
expect(executionOrder).toContain("cleanupOrphans");
expect(executionOrder).toContain("cleanupOrphanedBranches");
expect(executionOrder).toContain("enforceWorktreeCap");
});
@@ -7362,7 +7339,6 @@ describe("maintenance cycle concurrency", () => {
// Mock batch 1 and 3 as well
(vi.spyOn(manager as any, "pruneWorktrees").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "cleanupOrphans").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "cleanupOrphanedBranches").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "enforceWorktreeCap").mockResolvedValue(0) as any);
(vi.spyOn(manager as any, "archiveStaleDoneTasks").mockResolvedValue(0) as any);

View File

@@ -63,7 +63,6 @@ import {
scanIdleWorktrees,
cleanupOrphanedWorktrees,
reapOrphanWorktrees,
scanOrphanedBranches,
} from "../worktree-pool.js";
import { BranchConflictError } from "../branch-conflicts.js";
import * as branchConflictModule from "../branch-conflicts.js";
@@ -398,7 +397,7 @@ describe("WorktreePool", () => {
existingTipSha: "abc123def456",
strandedCommits: [{ sha: "aaa111", subject: "Foreign fix" }],
startPoint: "main",
recommendedAction: "Run branch recovery",
recommendedAction: "Inspect/reclaim or discard the conflicting local branch/worktree with git tooling before retrying.",
}),
});
@@ -441,7 +440,7 @@ describe("WorktreePool", () => {
existingTipSha: "abc123def456",
strandedCommits: [{ sha: "aaa111", subject: "Foreign fix" }],
startPoint: "fusion/fn-041",
recommendedAction: "Run branch recovery",
recommendedAction: "Inspect/reclaim or discard the conflicting local branch/worktree with git tooling before retrying.",
}),
});
@@ -477,7 +476,7 @@ describe("WorktreePool", () => {
existingTipSha: "abc123def456",
strandedCommits: [{ sha: "aaa111", subject: "Foreign fix" }],
startPoint: "main",
recommendedAction: "Run branch recovery",
recommendedAction: "Inspect/reclaim or discard the conflicting local branch/worktree with git tooling before retrying.",
}),
});
@@ -556,7 +555,7 @@ describe("WorktreePool", () => {
existingTipSha: "abc123def456",
strandedCommits: [{ sha: "aaa111", subject: "Foreign fix" }],
startPoint: "main",
recommendedAction: "Run branch recovery",
recommendedAction: "Inspect/reclaim or discard the conflicting local branch/worktree with git tooling before retrying.",
}),
});
@@ -1010,346 +1009,3 @@ describe("cleanupOrphanedWorktrees", () => {
});
});
// ── scanOrphanedBranches tests ────────────────────────────────────────
describe("scanOrphanedBranches", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: return empty string (no branches)
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git branch")) {
return "";
}
return Buffer.from("");
});
});
it("identifies branches not associated with any active task", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git branch")) {
return " fusion/fn-001\n fusion/fn-002\n fusion/fn-003\n";
}
return Buffer.from("");
});
const store = createMockStore([
makeTask("FN-001", "in-progress"),
makeTask("FN-002", "todo"),
]);
const orphaned = await scanOrphanedBranches("/root", store);
expect(orphaned).toEqual(["fusion/fn-003"]);
});
it("excludes in-review and done tasks (merger manages those)", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git branch")) {
return " fusion/fn-001\n fusion/fn-002\n fusion/fn-003\n";
}
return Buffer.from("");
});
const store = createMockStore([
makeTask("FN-001", "in-review"),
makeTask("FN-002", "done"),
]);
const orphaned = await scanOrphanedBranches("/root", store);
expect(orphaned).toContain("fusion/fn-001");
expect(orphaned).toContain("fusion/fn-002");
expect(orphaned).toContain("fusion/fn-003");
});
it("excludes archived tasks", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git branch")) {
return " fusion/fn-001\n";
}
return Buffer.from("");
});
const store = createMockStore([
makeTask("FN-001", "archived"),
]);
const orphaned = await scanOrphanedBranches("/root", store);
expect(orphaned).toEqual(["fusion/fn-001"]);
});
it("uses task.branch field when set", async () => {
const task = makeTask("FN-001", "in-progress");
task.branch = "fusion/fn-001-custom";
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git branch")) {
return " fusion/fn-001\n fusion/fn-001-custom\n fusion/fn-002\n";
}
return Buffer.from("");
});
const store = createMockStore([task]);
const orphaned = await scanOrphanedBranches("/root", store);
expect(orphaned).toEqual(["fusion/fn-002"]);
});
it("returns empty array when git branch fails", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
if (typeof cmd === "string" && cmd.includes("git branch")) {
throw new Error("not a git repo");
}
return Buffer.from("");
});
const store = createMockStore([]);
const orphaned = await scanOrphanedBranches("/root", store);
expect(orphaned).toEqual([]);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("[worktree-pool] Failed to list fusion/* branches: not a git repo"),
);
});
it("returns empty array when no fusion/* branches exist", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git branch")) {
return "";
}
return Buffer.from("");
});
const store = createMockStore([]);
const orphaned = await scanOrphanedBranches("/root", store);
expect(orphaned).toEqual([]);
});
it("strips leading * and whitespace from branch names", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git branch")) {
return "* fusion/fn-001\n fusion/fn-002\n";
}
return Buffer.from("");
});
const store = createMockStore([]);
const orphaned = await scanOrphanedBranches("/root", store);
expect(orphaned).toContain("fusion/fn-001");
expect(orphaned).toContain("fusion/fn-002");
});
});
// ── reapOrphanWorktrees tests ─────────────────────────────────────────
describe("reapOrphanWorktrees", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: .worktrees/ exists, lstatSync returns a real directory (not a symlink)
mockedExistsSync.mockReturnValue(true);
mockedLstatSync.mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false } as any);
mockedReaddirSync.mockReturnValue([]);
// Default: no registered worktrees
mockedExecSync.mockImplementation((cmd: any) => {
if (String(cmd) === "git worktree list --porcelain") {
return "worktree /root\nHEAD abc123\nbranch refs/heads/main\n\n" as any;
}
return Buffer.from("");
});
});
it("returns 0 when .worktrees/ does not exist", async () => {
mockedExistsSync.mockReturnValue(false);
const removed = await reapOrphanWorktrees("/root");
expect(removed).toBe(0);
expect(mockedRmSync).not.toHaveBeenCalled();
});
it("returns 0 when .worktrees/ is empty", async () => {
mockedReaddirSync.mockReturnValue([] as any);
const removed = await reapOrphanWorktrees("/root");
expect(removed).toBe(0);
expect(mockedRmSync).not.toHaveBeenCalled();
});
it("removes a directory that has no .git file and is not registered", async () => {
mockedReaddirSync.mockReturnValue([makeDirEntry("pale-raven")] as any);
// .gitkeep exists but NOT a .git file — simulate with existsSync returning false for .git
mockedExistsSync.mockImplementation((p: any) => {
if (String(p) === "/root/.worktrees") return true;
if (String(p).endsWith("/.git")) return false;
return true;
});
const removed = await reapOrphanWorktrees("/root");
expect(removed).toBe(1);
expect(mockedRmSync).toHaveBeenCalledWith("/root/.worktrees/pale-raven", {
recursive: true,
force: true,
});
expect(mockedPruneWorktreeAdminEntries).toHaveBeenCalledWith(
expect.objectContaining({ reason: "pool-reap-orphan", target: "/root/.worktrees/pale-raven" }),
);
});
it("does NOT remove a directory that is a registered git worktree", async () => {
mockedReaddirSync.mockReturnValue([makeDirEntry("swift-falcon")] as any);
mockedExecSync.mockImplementation((cmd: any) => {
if (String(cmd) === "git worktree list --porcelain") {
return [
"worktree /root",
"HEAD abc123",
"branch refs/heads/main",
"",
"worktree /root/.worktrees/swift-falcon",
"HEAD def456",
"branch refs/heads/fusion/swift-falcon",
"",
].join("\n") as any;
}
return Buffer.from("");
});
const removed = await reapOrphanWorktrees("/root");
expect(removed).toBe(0);
expect(mockedRmSync).not.toHaveBeenCalled();
});
it("does NOT remove a directory that has a .git file (may be partially registered)", async () => {
mockedReaddirSync.mockReturnValue([makeDirEntry("amber-wolf")] as any);
mockedExistsSync.mockImplementation((p: any) => {
if (String(p) === "/root/.worktrees") return true;
if (String(p) === "/root/.worktrees/amber-wolf/.git") return true;
return true;
});
const removed = await reapOrphanWorktrees("/root");
expect(removed).toBe(0);
expect(mockedRmSync).not.toHaveBeenCalled();
});
it("does NOT remove symlinks", async () => {
mockedReaddirSync.mockReturnValue([
{ name: "linked-wt", isDirectory: () => true } as any,
] as any);
mockedLstatSync.mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => true } as any);
const removed = await reapOrphanWorktrees("/root");
expect(removed).toBe(0);
expect(mockedRmSync).not.toHaveBeenCalled();
});
it("handles multiple orphans and multiple registered worktrees correctly", async () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry("orphan-1"),
makeDirEntry("orphan-2"),
makeDirEntry("good-wt"),
] as any);
mockedExecSync.mockImplementation((cmd: any) => {
if (String(cmd) === "git worktree list --porcelain") {
return [
"worktree /root",
"HEAD abc123",
"branch refs/heads/main",
"",
"worktree /root/.worktrees/good-wt",
"HEAD def456",
"branch refs/heads/fusion/good-wt",
"",
].join("\n") as any;
}
return Buffer.from("");
});
mockedExistsSync.mockImplementation((p: any) => {
const ps = String(p);
if (ps === "/root/.worktrees") return true;
if (ps.endsWith("/.git")) return false;
return true;
});
const removed = await reapOrphanWorktrees("/root");
expect(removed).toBe(2);
expect(mockedRmSync).toHaveBeenCalledWith("/root/.worktrees/orphan-1", {
recursive: true,
force: true,
});
expect(mockedRmSync).toHaveBeenCalledWith("/root/.worktrees/orphan-2", {
recursive: true,
force: true,
});
expect(mockedRmSync).not.toHaveBeenCalledWith(
expect.stringContaining("good-wt"),
expect.anything(),
);
});
it("continues and logs a warning when rmSync throws for one orphan", async () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry("bad-orphan"),
makeDirEntry("good-orphan"),
] as any);
mockedExistsSync.mockImplementation((p: any) => {
const ps = String(p);
if (ps === "/root/.worktrees") return true;
if (ps.endsWith("/.git")) return false;
return true;
});
let callCount = 0;
mockedRmSync.mockImplementation(() => {
callCount++;
if (callCount === 1) throw new Error("permission denied");
});
const removed = await reapOrphanWorktrees("/root");
// Only the second one succeeds
expect(removed).toBe(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("reapOrphanWorktrees: failed to remove bad-orphan"),
);
});
it("returns 0 and logs warning when git worktree list fails", async () => {
mockedReaddirSync.mockReturnValue([makeDirEntry("some-dir")] as any);
mockedExecSync.mockImplementation((cmd: any) => {
if (String(cmd) === "git worktree list --porcelain") {
throw new Error("not a git repo");
}
return Buffer.from("");
});
mockedExistsSync.mockImplementation((p: any) => {
const ps = String(p);
if (ps === "/root/.worktrees") return true;
if (ps.endsWith("/.git")) return false;
return true;
});
// When git list fails, getRegisteredWorktreePaths returns an empty Set,
// so any unregistered dir without a .git file would be reaped.
// In this test we verify behavior is safe: no crash, returns a count.
const removed = await reapOrphanWorktrees("/root");
// some-dir has no .git, not registered (empty set due to failure) — gets reaped
expect(removed).toBe(1);
// The warn from getRegisteredWorktreePaths should appear
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to list registered worktrees"),
);
});
});

View File

@@ -16,14 +16,6 @@ export interface BranchCrossContaminationCommit extends BranchConflictCommit {
foreignTaskId: string;
}
export interface BranchRecoveryCandidate {
branchName: string;
tipSha: string;
worktreePath: string | null;
strandedCommits: BranchConflictCommit[];
isCanonical: boolean;
}
export interface BranchConflictDetails {
branchName: string;
conflictingWorktreePath: string;
@@ -113,12 +105,6 @@ interface UniqueBranchCommitListResult {
degraded: boolean;
}
export interface ListBranchRecoveryCandidatesInput {
repoDir: string;
branchName: string;
startPoint?: string;
}
function quoteShellArg(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
@@ -244,47 +230,6 @@ async function getWorktreeBranchMap(repoDir: string): Promise<Map<string, string
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;
}
interface TaskAttributionSummary {
ownCount: number;
@@ -970,7 +915,7 @@ export async function inspectBranchConflict(
existingTipSha,
strandedCommits: uniqueCommitResult.commits,
startPoint: uniqueCommitResult.mainRef,
recommendedAction: "Run branch recovery and explicitly choose whether to reclaim or discard prior work.",
recommendedAction: "Inspect/reclaim or discard the conflicting local branch/worktree with git tooling before retrying.",
}),
};
}

View File

@@ -7821,7 +7821,7 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
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.`;
const recommendation = "Resolve the local branch/worktree conflict with git tooling (inspect/reclaim or discard) before retrying.";
return [
`Branch conflict: ${error.branchName} is already checked out at ${error.conflictingWorktreePath}`,
`Existing tip: ${error.existingTipSha}`,
@@ -7845,7 +7845,7 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
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.`,
`recommendation=Resolve the local branch/worktree conflict with git tooling (inspect/reclaim or discard) before retrying.`,
);
return lines.join("\n");
}
@@ -8023,7 +8023,7 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
}
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.`;
`Resolve the local branch/worktree conflict with git tooling (inspect/reclaim or discard) before retrying.`;
await this.store.logEntry(task.id, this.formatBranchConflictLifecycleLog(task.id, error), undefined, this.getRunContextFor(task.id));
await this.store.appendAgentLog(task.id, "Branch conflict recovery required", "tool_error", this.formatBranchConflictAgentLog(task.id, error), "executor");
const autoRecoveryDispatcher = this.getAutoRecoveryDispatcher(createRunAuditor(this.store, this.getRunContextFor(task.id)));

View File

@@ -127,13 +127,10 @@ export {
classifyBootstrapMisbinding,
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 { createLogger, type Logger } from "./logger.js";

View File

@@ -167,11 +167,9 @@ export type GitMutationType =
| "branch:auto-canonicalize-case"
| "branch:stale-active-reclaim"
| "branch:stale-active-reclaim-deferred"
| "branch:orphan-prune"
// reserved; refusal currently thrown pre-audit
| "project:bootstrap-refused-linked-worktree"
| "branch:orphan-prune"
| "branch:orphan-rescued"
| "self-healing:orphan-rescue-skipped-fresh-db"
| "branch:reanchor"
| "stash:push"
| "stash:pop";

View File

@@ -21,7 +21,6 @@
* - `enforceWorktreeCap`: defer to backend prune/remove semantics
* - `reclaimSelfOwnedBranchConflicts`: remains native (branch-level)
* - `reclaimStaleActiveBranches`: remains native (branch-level)
* - `scanOrphanedBranches` rescue: remains native (branch-level)
*/
import { exec, execSync } from "node:child_process";
@@ -69,7 +68,6 @@ const DB_CORRUPTION_NOTIFICATION_COOLDOWN_MS = 60 * 60 * 1000;
export const STALE_ACTIVE_BRANCH_EXECUTION_GRACE_MS = 10 * 60_000;
export const COMPLETION_HANDOFF_LIMBO_GRACE_MS = 5 * 60_000;
export const MAX_COMPLETION_HANDOFF_LIMBO_RECOVERIES = 3;
const ORPHAN_RESCUE_FRESH_DB_GRACE_MS = 5_000;
export async function archiveAsGhostBug(
store: TaskStore,
@@ -308,7 +306,6 @@ const DURABLE_ERROR_RECOVERY_MAX_RETRIES = 5;
const DURABLE_ERROR_RECOVERY_BASE_COOLDOWN_MS = 30_000;
const DURABLE_ERROR_RECOVERY_MAX_COOLDOWN_MS = 15 * 60_000;
const RUNNING_ON_INACTIVE_TASK_STALE_RUN_MS = 5 * 60_000;
const ORPHAN_RESCUE_SUBJECT_CAP = 10;
function bumpTaskPriority(priority: TaskPriority | undefined): TaskPriority {
switch (priority ?? "normal") {
@@ -392,15 +389,6 @@ export async function autoRecoverWorktreeSessionStartFailure(
return { outcome: "requeue-todo", retries: nextCount, classification };
}
interface OrphanBranchInspection {
branch: string;
tipSha: string;
uniqueCommitCount: number;
uniqueCommitSubjects: string[];
derivedTaskId: string | null;
registeredWorktreePath: string | null;
}
type RebindOutcome =
| {
taskId: string;
@@ -531,7 +519,6 @@ export class SelfHealingManager {
// ── Per-task deadlock recovery cooldown ─────────────────────────────
private deadlockRecoveryCooldown: Map<string, number> = new Map();
private mergeStarvationDrops: Map<string, number> = new Map();
private orphanArchivedAcknowledged = new Set<string>();
private finalizeUnprovenWarned = new Set<string>();
private maintenanceTickCounter = 0;
private readonly processBootStartedAt = Date.now();
@@ -2157,6 +2144,26 @@ export class SelfHealingManager {
}
}
private async inspectOrphanedBranch(branch: string): Promise<{ tipSha: string; uniqueCommitCount: number } | null> {
try {
const tipSha = String(execSync(`git rev-parse --verify ${shellQuote(branch)}`, {
cwd: this.options.rootDir,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
})).trim();
if (!tipSha) return null;
const uniqueCommitCount = Number.parseInt(String(execSync(`git rev-list --count ${shellQuote(branch)} --not ${shellQuote("main")}`, {
cwd: this.options.rootDir,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
})).trim(), 10) || 0;
return { tipSha, uniqueCommitCount };
} catch (err: unknown) {
log.warn(`Failed to inspect branch ${branch} during stale-active reclaim: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
}
async reclaimStaleActiveBranches(): Promise<number> {
try {
const settings = await this.store.getSettings();
@@ -2198,7 +2205,7 @@ export class SelfHealingManager {
let reclaimed = 0;
for (const branch of branches) {
const derivedTaskId = this.deriveTaskIdFromFusionBranch(branch);
const derivedTaskId = deriveTaskIdFromFusionBranch(branch);
if (!derivedTaskId) continue;
const task = taskById.get(derivedTaskId.toUpperCase());
@@ -5455,7 +5462,7 @@ export class SelfHealingManager {
worktreePath: task.worktree,
settings,
taskId: task.id,
reason: RemovalReason.SelfHealingOrphanRescue,
reason: RemovalReason.SelfHealingReclaim,
}).catch(() => undefined);
}
@@ -6975,221 +6982,50 @@ export class SelfHealingManager {
return cleaned;
}
private deriveTaskIdFromFusionBranch(branch: string): string | null {
const match = /^fusion\/(fn|kb)-(\d+)$/i.exec(branch.trim());
if (!match) return null;
return `${match[1].toUpperCase()}-${match[2]}`;
}
private async getRegisteredWorktreePathForBranch(branch: string): Promise<string | null> {
try {
const stdout = execSync("git worktree list --porcelain", {
cwd: this.options.rootDir,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}) || "";
const lines = stdout.split("\n");
let currentPath: string | null = null;
for (const line of lines) {
if (line.startsWith("worktree ")) {
currentPath = line.slice("worktree ".length).trim();
continue;
}
if (line.startsWith("branch ")) {
const fullRef = line.slice("branch ".length).trim();
const branchName = fullRef.startsWith("refs/heads/") ? fullRef.slice("refs/heads/".length) : fullRef;
if (branchName === branch && currentPath) {
return currentPath;
}
}
}
} catch (err: unknown) {
log.warn(`Failed to inspect registered worktree for ${branch}: ${err instanceof Error ? err.message : String(err)}`);
}
return null;
}
private async inspectOrphanedBranch(branch: string): Promise<OrphanBranchInspection | null> {
try {
const tipSha = String(execSync(`git rev-parse --verify ${shellQuote(branch)}`, {
cwd: this.options.rootDir,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
})).trim();
if (!tipSha) return null;
const uniqueCommitCount = Number.parseInt(String(execSync(`git rev-list --count ${shellQuote(branch)} --not ${shellQuote("main")}`, {
cwd: this.options.rootDir,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
})).trim(), 10) || 0;
let uniqueCommitSubjects: string[] = [];
if (uniqueCommitCount > 0) {
const subjectOutput = String(execSync(`git log --format=%s --max-count=${ORPHAN_RESCUE_SUBJECT_CAP} ${shellQuote(branch)} --not ${shellQuote("main")}`, {
cwd: this.options.rootDir,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}));
uniqueCommitSubjects = subjectOutput.split("\n").map((line) => line.trim()).filter(Boolean);
}
return {
branch,
tipSha,
uniqueCommitCount,
uniqueCommitSubjects,
derivedTaskId: this.deriveTaskIdFromFusionBranch(branch),
registeredWorktreePath: await this.getRegisteredWorktreePathForBranch(branch),
};
} catch (err: unknown) {
log.warn(`Failed to inspect orphaned branch ${branch}: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
}
/**
* Resolve orphaned `fusion/*` branches.
* - Subsumed branches are pruned (`git branch -d`).
* - Unique-commit branches with missing task rows are rescued into triage tasks.
* - Archived matching tasks are left untouched with one-time acknowledgement logging.
* Subsumed branches are pruned. Unique-commit branches are left untouched (operator-managed).
*/
async cleanupOrphanedBranches(): Promise<number> {
try {
const bootstrappedAt = this.store.getBootstrappedAt();
const allTasks = await this.store.listTasks({ slim: true, includeArchived: true });
const taskCount = allTasks.length;
const isFreshDb =
bootstrappedAt !== null
&& bootstrappedAt >= this.processBootStartedAt - ORPHAN_RESCUE_FRESH_DB_GRACE_MS
&& taskCount === 0;
if (isFreshDb) {
log.log(
`[self-healing] orphan-rescue-skipped-fresh-db bootstrappedAt=${bootstrappedAt} processBootStartedAt=${this.processBootStartedAt} taskCount=${taskCount}`,
);
try {
const auditor = createRunAuditor(this.store, {
runId: generateSyntheticRunId("self-heal-orphan-rescue", "fresh-db"),
agentId: "self-healing",
phase: "orphan-branch-rescue",
});
await auditor.git({
type: "self-healing:orphan-rescue-skipped-fresh-db",
target: this.options.rootDir,
metadata: {
bootstrappedAt,
processBootStartedAt: this.processBootStartedAt,
taskCount,
candidateBranches: 0,
},
});
} catch (auditErr: unknown) {
log.warn(`Failed to write self-healing:orphan-rescue-skipped-fresh-db run-audit event: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
}
return 0;
}
const orphaned = await scanOrphanedBranches(this.options.rootDir, this.store);
if (orphaned.length === 0) return 0;
let cleaned = 0;
const prunedBranches: string[] = [];
const taskById = new Map(allTasks.map((task) => [task.id.toUpperCase(), task]));
for (const branch of orphaned) {
const inspection = await this.inspectOrphanedBranch(branch);
if (!inspection) continue;
if (inspection.uniqueCommitCount > 0) continue;
if (inspection.uniqueCommitCount <= 0) {
try {
execSync(`git branch -d ${shellQuote(branch)}`, {
cwd: this.options.rootDir,
stdio: ["pipe", "pipe", "pipe"],
});
prunedBranches.push(branch);
cleaned++;
try {
const auditor = createRunAuditor(this.store, {
runId: generateSyntheticRunId("self-heal", "orphan-branch"),
agentId: "self-healing",
phase: "orphan-branch-rescue",
});
await auditor.git({
type: "branch:orphan-prune",
target: branch,
metadata: {
phase: "orphan-branch-rescue",
tipSha: inspection.tipSha,
uniqueCommitCount: inspection.uniqueCommitCount,
},
});
} catch (auditErr: unknown) {
log.warn(`Failed to write branch:orphan-prune run-audit event for ${branch}: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
}
} catch (err: unknown) {
log.warn(`Failed to prune subsumed orphaned branch ${branch}: ${err instanceof Error ? err.message : String(err)} — non-fatal`);
}
continue;
}
const derivedTaskId = inspection.derivedTaskId;
const matchedTask = derivedTaskId ? taskById.get(derivedTaskId.toUpperCase()) : undefined;
const existingBranchTask = allTasks.find((task) => task.branch === branch);
if (matchedTask?.column === "archived") {
if (!this.orphanArchivedAcknowledged.has(matchedTask.id)) {
this.orphanArchivedAcknowledged.add(matchedTask.id);
log.warn(`[recovery] orphan-rescue-archived-skip ${matchedTask.id} branch=${branch} tip=${inspection.tipSha.slice(0, 12)} unique=${inspection.uniqueCommitCount}`);
}
continue;
}
if (!matchedTask && !existingBranchTask) {
const summaryLines = [
`Recovered orphaned branch: ${branch}`,
`Tip: ${inspection.tipSha}`,
`Unique commits vs main: ${inspection.uniqueCommitCount}`,
];
if (inspection.uniqueCommitSubjects.length > 0) {
summaryLines.push("Recent commit subjects:");
for (const subject of inspection.uniqueCommitSubjects) {
summaryLines.push(`- ${subject}`);
}
}
const rescueTask = await this.store.createTask({
title: `Recover orphaned branch ${branch}`,
description: summaryLines.join("\n"),
branch,
column: "triage",
try {
execSync(`git branch -d ${shellQuote(branch)}`, {
cwd: this.options.rootDir,
stdio: ["pipe", "pipe", "pipe"],
});
allTasks.push({ ...rescueTask, branch, column: "triage" } as Task);
if (inspection.registeredWorktreePath) {
await this.store.updateTask(rescueTask.id, { worktree: inspection.registeredWorktreePath });
}
await this.store.logEntry(rescueTask.id, `[recovery] orphan-rescue-created ${rescueTask.id} from ${branch} (${inspection.uniqueCommitCount} unique commits)`);
cleaned++;
prunedBranches.push(branch);
try {
const auditor = createRunAuditor(this.store, {
runId: generateSyntheticRunId("self-heal", rescueTask.id),
runId: generateSyntheticRunId("self-heal", "orphan-branch"),
agentId: "self-healing",
taskId: rescueTask.id,
taskLineageId: rescueTask.lineageId,
phase: "orphan-branch-rescue",
phase: "orphan-branch-prune",
});
await auditor.git({
type: "branch:orphan-rescued",
type: "branch:orphan-prune",
target: branch,
metadata: {
phase: "orphan-branch-rescue",
rescueTaskId: rescueTask.id,
phase: "orphan-branch-prune",
tipSha: inspection.tipSha,
uniqueCommitCount: inspection.uniqueCommitCount,
derivedTaskId: derivedTaskId ?? null,
},
});
} catch (auditErr: unknown) {
log.warn(`Failed to write branch:orphan-rescued run-audit event for ${branch}: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
log.warn(`Failed to write branch:orphan-prune run-audit event for ${branch}: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
}
} catch (err: unknown) {
log.warn(`Failed to prune subsumed orphaned branch ${branch}: ${err instanceof Error ? err.message : String(err)} — non-fatal`);
}
}
@@ -7201,7 +7037,8 @@ export class SelfHealingManager {
}
return cleaned;
} catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.error(`Orphaned branch cleanup failed: ${errorMessage}`);
return 0;
}

View File

@@ -730,7 +730,6 @@ export const RemovalReason = {
SelfHealingReclaim: "self-healing-reclaim",
SelfHealingStaleActiveBranch: "self-healing-stale-active-branch",
SelfHealingBranchConflict: "self-healing-branch-conflict",
SelfHealingOrphanRescue: "self-healing-orphan-rescue",
SelfHealingIdleSweep: "self-healing-idle-sweep",
PoolPrune: "pool-prune",
} as const;

View File

@@ -830,25 +830,14 @@ export async function reapOrphanWorktrees(
return removed;
}
/** Columns where the merger handles branch cleanup — skip these during orphan scanning. */
/** Columns where merger/finalization owns branch lifecycle. */
const MERGER_MANAGED_COLUMNS: ReadonlySet<Column> = new Set(["in-review", "done"]);
/**
* Scan for orphaned `fusion/*` branches that are not associated with any
* non-archived, non-merger-managed task.
*
* Lists all local branches matching the `fusion/*` pattern, then compares
* against branches stored on tasks (via `task.branch` or derived as
* canonicalFusionBranchName(taskId)). Branches belonging to tasks in the
* `in-review` or `done` columns are excluded because the merger is
* responsible for cleaning those up.
*
* @param rootDir — Project root directory (git working tree)
* @param store — Task store for listing tasks and their branch assignments
* @returns Array of orphaned branch names
* Return local `fusion/*` branches not associated with any active task.
* Branches tied to merger-managed or archived tasks are excluded.
*/
export async function scanOrphanedBranches(rootDir: string, store: TaskStore): Promise<string[]> {
// List all local branches matching fusion/*
let allBranches: string[];
try {
const result = await execAsync("git branch --list 'fusion/*'", {
@@ -868,23 +857,14 @@ export async function scanOrphanedBranches(rootDir: string, store: TaskStore): P
if (allBranches.length === 0) return [];
// Build set of branches associated with active (non-archived, non-merger-managed) tasks
const tasks = await store.listTasks({ slim: true, includeArchived: false });
const activeBranches = new Set<string>();
for (const task of tasks) {
// Skip tasks in columns where the merger handles branch cleanup
if (MERGER_MANAGED_COLUMNS.has(task.column)) continue;
// Also skip archived tasks
if (task.column === "archived") continue;
// Use stored branch name if available, otherwise derive from task ID
if (task.branch) {
activeBranches.add(task.branch);
}
// Always add the derived name too — the task may not have `branch` set yet
if (task.branch) activeBranches.add(task.branch);
activeBranches.add(canonicalFusionBranchName(task.id));
}
// Return branches not associated with any active task
return allBranches.filter((branch) => !activeBranches.has(branch));
}