fix(engine): recoverable worktree failures + prevent nested/gitlink worktrees

Fixes two classes of task failures found while investigating stuck in-review
tasks FN-2165 (worktree base ref missing) and FN-2152 (stray .tmp-fn-2152
gitlink accidentally committed via merger amend).

FN-2165 — stale baseBranch:
- resolveWorktreeStartPoint now returns null instead of throwing
  NonRetryableWorktreeError when the stored baseBranch is gone. Caller clears
  task.baseBranch and falls back to branching from the default base (HEAD) so
  the task self-heals instead of failing permanently.
- New TaskStore.clearStaleBaseBranchReferences() nulls baseBranch on any
  dependent task when its upstream branch is deleted. Wired into
  cleanupBranchForTask (archive/delete), merger branch cleanup, self-healing
  orphan-branch sweep, executor dep-abort and conflict-cleanup paths, and
  stale-branch recovery.

Nested worktrees:
- assertWorktreePathNotNested guard in tryCreateWorktree refuses to create a
  worktree inside another registered worktree (previously produced pathological
  paths like .worktrees/green-finch/.worktrees/amber-panda when rootDir pointed
  at a worktree instead of the main repo).

Context-overflow recovery (FN-2182 class):
- Reduced-prompt retry budget raised from 1 → 3 within the same session.
- Adds a fresh-session requeue path when same-session retries still overflow:
  task moves back to todo with worktree retained, bounded by
  computeRecoveryDecision / MAX_RECOVERY_RETRIES. Prevents late-step context
  exhaustion from becoming terminal.

Gitlink prevention (FN-2152 class):
- .gitignore now excludes .tmp-fn-* and .tmp-kb-* so stray worktrees at the
  repo root cannot be captured by git add -A.
- Merger amend flow now scans staged entries for 160000 gitlinks and unstages
  them with a loud warning; the project uses no submodules, so any such entry
  is a bug (this is how f8f90f26 landed in HEAD as .tmp-fn-2152).

Tests: new coverage for baseBranch fallback, nested-worktree guard, and
clearStaleBaseBranchReferences. Full engine + core + dashboard + cli suites
pass (15349 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-20 08:30:01 -07:00
parent a19b5d8057
commit ed235c1edf
9 changed files with 377 additions and 28 deletions

View File

@@ -233,6 +233,7 @@ function createMockStore() {
listWorkflowSteps: vi.fn().mockResolvedValue([]),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"),
clearStaleBaseBranchReferences: vi.fn().mockReturnValue([]),
};
return store as any;
}
@@ -874,12 +875,14 @@ describe("TaskExecutor worktree recovery", () => {
);
});
it("fails fast when the configured base ref is missing", async () => {
it("falls back to default base and clears task.baseBranch when the configured base ref is missing (FN-2165)", async () => {
const store = createMockStore();
mockedExecSync.mockImplementation((cmd: string | string[]) => {
const command = typeof cmd === "string" ? cmd : cmd[0];
if (command.includes("git rev-parse --verify")) {
// The stored baseBranch no longer exists — simulates a dep's branch
// being deleted while this task sat queued/stuck.
const error: any = new Error("fatal: Needed a single revision");
error.stderr = Buffer.from("fatal: Needed a single revision");
throw error;
@@ -891,20 +894,86 @@ describe("TaskExecutor worktree recovery", () => {
const executor = new TaskExecutor(store, "/tmp/test", { onError });
await executor.execute({ ...makeTask(), baseBranch: "fusion/missing-base" });
expect(mockedExecSync).not.toHaveBeenCalledWith(
expect.stringContaining("git worktree add"),
expect.any(Object),
);
// Should log the soft fallback, not a terminal failure
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
"Worktree base ref is missing",
expect.stringContaining("fusion/missing-base"),
expect.stringContaining('Worktree base ref "fusion/missing-base" is missing'),
expect.any(String),
);
// Should clear baseBranch on the task so retries use the default
expect(store.updateTask).toHaveBeenCalledWith(
"FN-050",
expect.objectContaining({ status: "failed" }),
expect.objectContaining({ baseBranch: null }),
);
// Should proceed to create a worktree from HEAD (no startPoint)
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
(c) => typeof c[0] === "string" && c[0].includes("git worktree add"),
);
expect(worktreeAddCalls.length).toBeGreaterThan(0);
// None of the worktree add calls should include the stale base ref
for (const call of worktreeAddCalls) {
expect(String(call[0])).not.toContain("fusion/missing-base");
}
// The task should NOT have been marked failed because of the stale baseBranch
// (downstream errors unrelated to worktree creation may still occur in this
// integration-style test — we only assert that baseBranch-missing is no
// longer a terminal failure).
const worktreeFailureCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls.filter(
(c) => typeof c[1] === "string" && c[1].includes("Worktree creation failed"),
);
expect(worktreeFailureCalls).toHaveLength(0);
// onError may still fire from downstream step execution in this test harness;
// what matters is that the failure reason is NOT "base ref missing".
void onError;
});
it("refuses to create a worktree nested inside another worktree (FN-2165 guard)", async () => {
const store = createMockStore();
// Simulate `git worktree list --porcelain` returning a non-root worktree
// that would be an ancestor of the target path.
mockedExecSync.mockImplementation((cmd: string | string[]) => {
const command = typeof cmd === "string" ? cmd : cmd[0];
if (command === "git worktree list --porcelain") {
return Buffer.from(
[
"worktree /tmp/test",
"HEAD abc123",
"branch refs/heads/main",
"",
"worktree /tmp/test/.worktrees/green-finch",
"HEAD def456",
"branch refs/heads/fusion/fn-007",
"",
].join("\n"),
);
}
return Buffer.from("");
});
const onError = vi.fn();
const executor = new TaskExecutor(store, "/tmp/test", { onError });
// Task has a worktree path nested inside green-finch — must be refused
await executor.execute({
...makeTask(),
worktree: "/tmp/test/.worktrees/green-finch/.worktrees/amber-panda",
});
// Should NEVER attempt a git worktree add for the nested path
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
(c) =>
typeof c[0] === "string" &&
c[0].includes("git worktree add") &&
c[0].includes("green-finch/.worktrees/amber-panda"),
);
expect(worktreeAddCalls).toHaveLength(0);
// Should log the refusal with both the target and ancestor paths
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
"Refusing to create nested worktree",
expect.stringContaining("green-finch"),
);
expect(onError).toHaveBeenCalled();
});
it("fails after 3 unsuccessful attempts with detailed error", async () => {

View File

@@ -2,7 +2,7 @@ import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
import { isAbsolute, join } from "node:path";
import { isAbsolute, join, relative, resolve as resolvePath } from "node:path";
import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import type { TaskStore, Task, TaskDetail, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext } from "@fusion/core";
@@ -15,7 +15,7 @@ import { buildSessionSkillContext } from "./session-skill-context.js";
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
import { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession } from "@mariozechner/pi-coding-agent";
import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js";
import { isRegisteredGitWorktree, isUsableTaskWorktree, type WorktreePool } from "./worktree-pool.js";
import { getRegisteredWorktreePaths, isRegisteredGitWorktree, isUsableTaskWorktree, type WorktreePool } from "./worktree-pool.js";
import { AgentLogger } from "./agent-logger.js";
import { executorLog, reviewerLog } from "./logger.js";
import { TokenCapDetector } from "./token-cap-detector.js";
@@ -2115,16 +2115,24 @@ export class TaskExecutor {
executorLog.log(`${task.id} terminated by stuck task detector — will ${stuckRequeue ? "retry" : "not retry (budget exhausted)"}`);
} else {
// Context-limit error reached the executor after promptWithFallback's auto-compaction
// already attempted to recover. Try reduced-prompt retry as a second-level fallback.
// This is bounded to 1 attempt to prevent infinite retry loops.
// already attempted to recover. Recovery strategy (in order):
// 1. Reduced-prompt retry in the same session (up to MAX_REDUCED_PROMPT_ATTEMPTS)
// 2. Fresh-session requeue — terminate the saturated session and move the task
// back to "todo" so the next dispatch gets a clean session (bounded by
// recoveryRetryCount / MAX_RECOVERY_RETRIES).
// FN-2182 class: Step 7 overflow after earlier compaction used to hit the
// loopAttempts<1 guard and fail permanently; the requeue path below recovers
// by restarting with a fresh session against the already-written step output.
const MAX_REDUCED_PROMPT_ATTEMPTS = 3;
const loopState = this.loopRecoveryState.get(task.id);
const loopAttempts = loopState?.attempts ?? 0;
const isContextError = isContextLimitError(errorMessage);
if (isContextLimitError(errorMessage) && loopAttempts < 1) {
if (isContextError && loopAttempts < MAX_REDUCED_PROMPT_ATTEMPTS) {
const activeEntry = this.activeSessions.get(task.id);
if (activeEntry) {
executorLog.log(`${task.id} context limit error after auto-compaction — attempting reduced-prompt retry`);
await this.store.logEntry(task.id, `Context limit error after auto-compaction — attempting reduced-prompt retry: ${errorMessage}`, undefined, this.currentRunContext);
executorLog.log(`${task.id} context limit error after auto-compaction — attempting reduced-prompt retry (${loopAttempts + 1}/${MAX_REDUCED_PROMPT_ATTEMPTS})`);
await this.store.logEntry(task.id, `Context limit error after auto-compaction — attempting reduced-prompt retry (${loopAttempts + 1}/${MAX_REDUCED_PROMPT_ATTEMPTS}): ${errorMessage}`, undefined, this.currentRunContext);
this.loopRecoveryState.set(task.id, { attempts: loopAttempts + 1, pending: false });
@@ -2151,11 +2159,52 @@ export class TaskExecutor {
return;
} catch (reducedErr: unknown) {
const reducedErrorMessage = reducedErr instanceof Error ? reducedErr.message : String(reducedErr);
executorLog.error(`${task.id} reduced-prompt recovery also failed: ${reducedErrorMessage}`);
await this.store.logEntry(task.id, `Reduced-prompt recovery failed: ${reducedErrorMessage}`, undefined, this.currentRunContext);
// Fall through to mark task as failed
if (!isContextLimitError(reducedErrorMessage)) {
executorLog.error(`${task.id} reduced-prompt recovery also failed: ${reducedErrorMessage}`);
await this.store.logEntry(task.id, `Reduced-prompt recovery failed: ${reducedErrorMessage}`, undefined, this.currentRunContext);
// Non-context failure — fall through to mark task as failed
} else {
// Still a context error — the session is saturated beyond recovery.
// Fall through to the fresh-session requeue path below.
executorLog.warn(`${task.id} session still saturated after reduced-prompt retry — will attempt fresh-session requeue`);
await this.store.logEntry(task.id, `Reduced-prompt retry still over context — will attempt fresh-session requeue`, undefined, this.currentRunContext);
}
}
}
}
// Fresh-session requeue for context-limit errors: the saturated session
// cannot be salvaged, but the task's git state is intact. Move the task
// back to todo so the next scheduling pass creates a new session.
if (isContextError) {
const decision = computeRecoveryDecision({
recoveryRetryCount: task.recoveryRetryCount,
nextRecoveryAt: task.nextRecoveryAt,
});
if (decision.shouldRetry) {
const attempt = decision.nextState.recoveryRetryCount;
const delay = formatDelay(decision.delayMs);
executorLog.warn(`${task.id} context-overflow fresh-session requeue ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}`);
await this.store.logEntry(task.id, `Context-overflow fresh-session requeue (${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}): ${errorMessage}`, undefined, this.currentRunContext);
// Retain the worktree so the fresh session sees prior progress;
// only clear the in-memory session pointer so a new one is built.
await this.store.updateTask(task.id, {
recoveryRetryCount: decision.nextState.recoveryRetryCount,
nextRecoveryAt: decision.nextState.nextRecoveryAt,
});
await this.store.moveTask(task.id, "todo");
return;
}
executorLog.error(`${task.id} context-overflow requeue budget exhausted (${MAX_RECOVERY_RETRIES} attempts): ${errorMessage}`);
await this.store.logEntry(task.id, `Context-overflow requeues exhausted after ${MAX_RECOVERY_RETRIES} attempts: ${errorMessage}`, undefined, this.currentRunContext);
// Reset so downstream failure path can persist cleanly
await this.store.updateTask(task.id, {
recoveryRetryCount: null,
nextRecoveryAt: null,
});
// Fall through to terminal failure marking
} else if (this.options.usageLimitPauser && isUsageLimitError(errorMessage)) {
await this.options.usageLimitPauser.onUsageLimitHit("executor", task.id, errorMessage);
} else if (isTransientError(errorMessage)) {
@@ -2703,12 +2752,18 @@ export class TaskExecutor {
// Delete the branch — use stored branch name if available, fall back to convention
const task = await this.store.getTask(taskId);
const branch = task.branch || `fusion/${taskId.toLowerCase()}`;
let branchDeleted = false;
try {
await execAsync(`git branch -D "${branch}"`, { cwd: this.rootDir });
branchDeleted = true;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
executorLog.warn(`${taskId}: failed to delete branch during dep-abort cleanup (${branch}): ${msg}`);
}
if (branchDeleted) {
// FN-2165 regression guard: null baseBranch on any task that stored this branch
try { this.store.clearStaleBaseBranchReferences([branch], taskId); } catch { /* best-effort */ }
}
// Clear worktree tracking
this.activeWorktrees.delete(taskId);
@@ -3526,9 +3581,18 @@ and show an appropriate message to the user.\`
): Promise<{ path: string; branch: string }> {
// Track the worktree path we're attempting to use (may change during recovery)
const currentPath = path;
const resolvedStartPoint = startPoint
? await this.resolveWorktreeStartPoint(startPoint, taskId)
: undefined;
let resolvedStartPoint: string | undefined;
if (startPoint) {
const resolved = await this.resolveWorktreeStartPoint(startPoint, taskId);
if (resolved === null) {
// Stored baseBranch no longer exists (e.g., upstream dep merged and branch
// deleted while this task sat queued/stuck). Clear it on the task so any
// subsequent retry branches from the default base, and proceed from HEAD.
await this.store.updateTask(taskId, { baseBranch: null });
} else {
resolvedStartPoint = resolved;
}
}
for (let attempt = 0; attempt < this.MAX_WORKTREE_RETRIES; attempt++) {
try {
@@ -3559,7 +3623,15 @@ and show an appropriate message to the user.\`
throw new Error("Unexpected exit from worktree creation retry loop");
}
private async resolveWorktreeStartPoint(startPoint: string, taskId: string): Promise<string> {
/**
* Resolve a stored baseBranch to a concrete commit SHA.
*
* Returns `null` (not throw) when the ref cannot be resolved — typically
* because the upstream dep's branch was merged and deleted while this task
* sat queued/stuck. Callers should treat null as "fall back to default base"
* rather than fail the task permanently.
*/
private async resolveWorktreeStartPoint(startPoint: string, taskId: string): Promise<string | null> {
const command = isAbsolute(startPoint) && existsSync(startPoint)
? `git -C "${startPoint}" rev-parse --verify HEAD^{commit}`
: `git rev-parse --verify "${startPoint}^{commit}"`;
@@ -3571,12 +3643,10 @@ and show an appropriate message to the user.\`
const errorMessage = error instanceof Error ? error.message : String(error);
await this.store.logEntry(
taskId,
`Worktree base ref is missing`,
`${startPoint}: ${errorMessage}`,
);
throw new NonRetryableWorktreeError(
`Cannot create worktree for ${taskId}: base ref "${startPoint}" does not exist or cannot be resolved`,
`Worktree base ref "${startPoint}" is missing — falling back to default base`,
errorMessage,
);
return null;
}
}
@@ -3592,6 +3662,13 @@ and show an appropriate message to the user.\`
attemptNumber = 0,
recoveryDepth = 0,
): Promise<{ path: string; branch: string }> {
// Guard: refuse to create a worktree nested inside another worktree.
// Nested worktrees happen when the executor is launched with rootDir pointed
// at a worktree directory instead of the main repo — produces paths like
// `.worktrees/green-finch/.worktrees/amber-panda` that bloat the filesystem
// and confuse every tool that walks git state.
await this.assertWorktreePathNotNested(path, taskId);
// If directory exists but is not a registered worktree, remove it first
if (existsSync(path)) {
const isRegistered = await this.isRegisteredWorktree(path);
@@ -3786,6 +3863,34 @@ and show an appropriate message to the user.\`
return isRegisteredGitWorktree(this.rootDir, path);
}
/**
* Throw if `path` lies inside an existing registered worktree other than the
* repo root. The repo root itself is a worktree (main branch) and must be
* allowed — we only reject paths strictly *inside* a non-root worktree.
*/
private async assertWorktreePathNotNested(path: string, taskId: string): Promise<void> {
const target = resolvePath(path);
const rootResolved = resolvePath(this.rootDir);
const registered = await getRegisteredWorktreePaths(this.rootDir);
for (const wt of registered) {
if (wt === rootResolved) continue; // root is allowed as ancestor
if (wt === target) continue; // exact match handled later as "already registered"
const rel = relative(wt, target);
if (rel && !rel.startsWith("..") && !isAbsolute(rel)) {
await this.store.logEntry(
taskId,
`Refusing to create nested worktree`,
`target ${target} is inside registered worktree ${wt}`,
);
throw new NonRetryableWorktreeError(
`Refusing to create worktree at ${target}: path is nested inside existing worktree ${wt}. ` +
`This usually means the executor was launched with rootDir pointing at a worktree instead of the main repo.`,
);
}
}
}
/**
* Determine if we should generate a new worktree name instead of cleaning up.
* Returns true if the conflicting worktree is used by an active task.
@@ -3840,6 +3945,8 @@ and show an appropriate message to the user.\`
cwd: this.rootDir,
});
await this.store.logEntry(taskId, `Deleted branch`, branch);
// FN-2165 regression guard: null baseBranch on any task that stored this branch
this.store.clearStaleBaseBranchReferences([branch], taskId);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
executorLog.warn(`${taskId}: failed to delete conflicting branch ${branch}: ${msg}`);
@@ -3885,6 +3992,8 @@ and show an appropriate message to the user.\`
cwd: this.rootDir,
});
await this.store.logEntry(taskId, `Removed stale branch`, branch);
// FN-2165 regression guard: null baseBranch on any task that stored this branch
try { this.store.clearStaleBaseBranchReferences([branch], taskId); } catch { /* best-effort */ }
return true;
} catch (branchDeleteError: unknown) {
const branchDeleteErrorMessage = branchDeleteError instanceof Error ? branchDeleteError.message : String(branchDeleteError);
@@ -3902,6 +4011,8 @@ and show an appropriate message to the user.\`
cwd: this.rootDir,
});
await this.store.logEntry(taskId, `Force-removed stale branch reference via update-ref`, refPath);
// FN-2165 regression guard: null baseBranch on any task that stored this branch
try { this.store.clearStaleBaseBranchReferences([branch], taskId); } catch { /* best-effort */ }
return true;
} catch (updateRefError: unknown) {
const updateRefErrorMessage = updateRefError instanceof Error ? updateRefError.message : String(updateRefError);

View File

@@ -151,6 +151,7 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
getActiveMergingTask: vi.fn().mockReturnValue(null),
emit: vi.fn(),
on: vi.fn(),
clearStaleBaseBranchReferences: vi.fn().mockReturnValue([]),
} as unknown as TaskStore;
}

View File

@@ -761,6 +761,31 @@ async function amendMergeCommitWithFixes(
await execAsync("git add -A", { cwd: rootDir });
}
// FN-2152 regression guard: `git add -A` at the repo root will capture any
// directory with a `.git` file/dir (nested worktree, orphaned checkout) as
// a 160000 gitlink. The project uses no submodules, so any staged gitlink
// is a bug. Unstage such entries before amending so they cannot land in
// HEAD. Loud log so operators can clean up the offending directory.
const { stdout: staged } = await execAsync("git diff --cached --raw", {
cwd: rootDir,
encoding: "utf-8",
});
const gitlinkPaths: string[] = [];
for (const line of staged.split("\n")) {
// raw format: `:<srcMode> <dstMode> <srcSha> <dstSha> <status>\t<path>`
const match = line.match(/^:\d{6} 160000 [^\t]+\t(.+)$/);
if (match) gitlinkPaths.push(match[1]);
}
for (const path of gitlinkPaths) {
mergerLog.warn(`${taskId}: refusing to stage gitlink "${path}" (project uses no submodules — likely a nested worktree). Unstaging.`);
try {
await execAsync(`git reset HEAD -- "${path}"`, { cwd: rootDir });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
mergerLog.warn(`${taskId}: failed to unstage gitlink "${path}": ${msg}`);
}
}
// Check if there are staged changes to amend
const { stdout: finalStaged } = await execAsync("git diff --cached --name-only", {
cwd: rootDir,
@@ -2107,6 +2132,22 @@ export async function aiMergeTask(
} catch { /* non-fatal */ }
}
if (result.branchDeleted) {
// FN-2165 regression guard: if any other task had this branch stored as
// its baseBranch (common when a dependent task was dispatched off a
// conflict-suffixed branch), null it so the dependent task doesn't
// hard-fail at worktree creation once this branch is gone.
try {
const cleared = store.clearStaleBaseBranchReferences([branch], taskId);
if (cleared.length > 0) {
mergerLog.log(`${taskId}: cleared stale baseBranch on ${cleared.length} dependent task(s): ${cleared.join(", ")}`);
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
mergerLog.warn(`${taskId}: failed to clear stale baseBranch references: ${msg}`);
}
}
// 7. Clean up worktree
if (worktreePath && existsSync(worktreePath)) {
const otherUser = await findWorktreeUser(store, worktreePath, taskId);

View File

@@ -119,6 +119,7 @@ function createMockStore(overrides: Record<string, unknown> = {}): TaskStore & E
walCheckpoint: vi.fn().mockReturnValue({ busy: 0, log: 5, checkpointed: 5 }),
listTasks: vi.fn().mockResolvedValue([]),
getRootDir: vi.fn().mockReturnValue("/tmp/test-project"),
clearStaleBaseBranchReferences: vi.fn().mockReturnValue([]),
...overrides,
}) as unknown as TaskStore & EventEmitter;
return store;

View File

@@ -1509,6 +1509,7 @@ export class SelfHealingManager {
if (orphaned.length === 0) return 0;
let cleaned = 0;
const deletedBranches: string[] = [];
for (const branch of orphaned) {
try {
// Try safe delete first (-d requires branch to be merged)
@@ -1518,6 +1519,7 @@ export class SelfHealingManager {
});
log.log(`Deleted branch: ${branch}`);
cleaned++;
deletedBranches.push(branch);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.warn(
@@ -1531,6 +1533,7 @@ export class SelfHealingManager {
});
log.log(`Force-deleted branch: ${branch}`);
cleaned++;
deletedBranches.push(branch);
} catch (forceErr: unknown) {
const forceErrorMessage = forceErr instanceof Error ? forceErr.message : String(forceErr);
log.warn(`Failed to force-delete orphaned branch ${branch}: ${forceErrorMessage} — non-fatal`);
@@ -1539,6 +1542,16 @@ export class SelfHealingManager {
}
}
if (deletedBranches.length > 0) {
// FN-2165 regression guard: if any dependent task stored one of these
// now-gone branches as its baseBranch, null it so the task doesn't
// hard-fail at worktree creation time.
const cleared = this.store.clearStaleBaseBranchReferences(deletedBranches);
if (cleared.length > 0) {
log.log(`Cleared stale baseBranch on ${cleared.length} task(s): ${cleared.join(", ")}`);
}
}
if (cleaned > 0) {
log.log(`Cleaned ${cleaned} orphaned branch(es)`);
}