fix(engine): harden worktree lifecycle and seed frontend UX criteria
Atomic worktree creation: on `git worktree add` failure, remove the partial directory so retries see a clean slate (executor.ts, step-session-executor.ts). Add `reapOrphanWorktrees()` sweep on engine startup to delete `.worktrees/*` dirs that have no `.git` file and are not registered with git. Force readonly workflow steps to run pre-merge so review personas reuse the coding worktree instead of allocating a fresh post-merge one. Inject a Frontend UX Criteria checklist into TRIAGE_SYSTEM_PROMPT when a task touches dashboard UI, so the first coding pass can meet UX reviewer expectations. Motivated by FN-2185 post-mortem: an incomplete `.worktrees/pale-raven` dir blocked retries, and three Step 6 revision cycles over cosmetic UX issues consumed ~13 hours. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3320,8 +3320,9 @@ ${failureFeedback}
|
||||
// Normalize legacy steps: undefined phase → "pre-merge"
|
||||
const stepPhase = ws.phase || "pre-merge";
|
||||
|
||||
// Skip post-merge steps — those run in the merger after merge
|
||||
if (stepPhase === "post-merge") continue;
|
||||
// readonly review steps always run pre-merge to reuse the coding worktree — see FN-2185 post-mortem.
|
||||
// Skip non-readonly post-merge steps — those run in the merger after merge.
|
||||
if (stepPhase === "post-merge" && ws.toolMode !== "readonly") continue;
|
||||
|
||||
// Normalize legacy steps without mode to prompt-mode
|
||||
const stepMode: "prompt" | "script" = ws.mode || "prompt";
|
||||
@@ -3794,11 +3795,35 @@ and show an appropriate message to the user.\`
|
||||
const cmd = startPoint
|
||||
? `git worktree add -b "${branchToCreate}" "${path}" "${startPoint}"`
|
||||
: `git worktree add -b "${branchToCreate}" "${path}"`;
|
||||
await execAsync(cmd, { cwd: this.rootDir });
|
||||
try {
|
||||
await execAsync(cmd, { cwd: this.rootDir });
|
||||
} catch (err) {
|
||||
// Remove any partial directory left behind so the invariant holds:
|
||||
// "if .worktrees/<slug> exists on disk, it is a fully registered git worktree."
|
||||
try {
|
||||
await execAsync(`rm -rf "${path}"`, { cwd: this.rootDir });
|
||||
} catch {
|
||||
// best-effort cleanup; log but don't mask the original error
|
||||
executorLog.log(`Warning: failed to remove partial worktree directory after creation failure: ${path}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const createFromExistingBranch = async () => {
|
||||
await execAsync(`git worktree add "${path}" "${branch}"`, { cwd: this.rootDir });
|
||||
try {
|
||||
await execAsync(`git worktree add "${path}" "${branch}"`, { cwd: this.rootDir });
|
||||
} catch (err) {
|
||||
// Remove any partial directory left behind so the invariant holds:
|
||||
// "if .worktrees/<slug> exists on disk, it is a fully registered git worktree."
|
||||
try {
|
||||
await execAsync(`rm -rf "${path}"`, { cwd: this.rootDir });
|
||||
} catch {
|
||||
// best-effort cleanup; log but don't mask the original error
|
||||
executorLog.log(`Warning: failed to remove partial worktree directory after creation failure: ${path}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -32,7 +32,7 @@ export {
|
||||
resolveAgentInstructions,
|
||||
buildSystemPromptWithInstructions,
|
||||
} from "./agent-instructions.js";
|
||||
export { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees } from "./worktree-pool.js";
|
||||
export { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees, reapOrphanWorktrees } from "./worktree-pool.js";
|
||||
export { createLogger, type Logger } from "./logger.js";
|
||||
export { isUsageLimitError, UsageLimitPauser } from "./usage-limit-detector.js";
|
||||
export { withRateLimitRetry } from "./rate-limit-retry.js";
|
||||
|
||||
@@ -3004,7 +3004,8 @@ async function hasEnabledPostMergeWorkflowSteps(
|
||||
const ws = await store.getWorkflowStep(wsId);
|
||||
if (!ws) continue;
|
||||
const stepPhase = ws.phase || "pre-merge";
|
||||
if (stepPhase === "post-merge") {
|
||||
// readonly review steps always run pre-merge to reuse the coding worktree — see FN-2185 post-mortem.
|
||||
if (stepPhase === "post-merge" && ws.toolMode !== "readonly") {
|
||||
return true;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
@@ -3045,8 +3046,9 @@ async function runPostMergeWorkflowSteps(
|
||||
// Normalize legacy steps: undefined phase → "pre-merge"
|
||||
const stepPhase = ws.phase || "pre-merge";
|
||||
|
||||
// Only run post-merge steps here
|
||||
if (stepPhase !== "post-merge") continue;
|
||||
// Only run post-merge steps here.
|
||||
// readonly review steps always run pre-merge to reuse the coding worktree — see FN-2185 post-mortem.
|
||||
if (stepPhase !== "post-merge" || ws.toolMode === "readonly") continue;
|
||||
|
||||
// Normalize legacy steps without mode to prompt-mode
|
||||
const stepMode: "prompt" | "script" = ws.mode || "prompt";
|
||||
|
||||
@@ -171,10 +171,28 @@ export class InProcessRuntime
|
||||
runtimeLog.log(`PluginRunner initialized`);
|
||||
|
||||
// 3. Initialize WorktreePool
|
||||
|
||||
// Reap half-initialized orphan worktree directories before doing anything
|
||||
// else with the pool. These are directories under .worktrees/ that exist
|
||||
// on disk but were never fully registered with git (e.g. the process was
|
||||
// killed between `mkdir` and `git worktree add`). Removing them here
|
||||
// ensures scanIdleWorktrees / rehydrate never sees broken entries, and
|
||||
// prevents assertValidWorktreeSession from permanently blocking retries.
|
||||
const { reapOrphanWorktrees, scanIdleWorktrees } = await import("../worktree-pool.js");
|
||||
try {
|
||||
const reaped = await reapOrphanWorktrees(this.config.workingDirectory);
|
||||
if (reaped > 0) {
|
||||
runtimeLog.log(`Reaped ${reaped} half-initialized orphan worktree(s) on startup`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// Non-fatal — log and continue; a missed orphan is better than a failed start.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
runtimeLog.warn(`reapOrphanWorktrees failed (continuing): ${msg}`);
|
||||
}
|
||||
|
||||
this.worktreePool = new WorktreePool();
|
||||
|
||||
// Rehydrate pool from disk state (idle worktrees)
|
||||
const { scanIdleWorktrees } = await import("../worktree-pool.js");
|
||||
const idleWorktrees = await scanIdleWorktrees(
|
||||
this.config.workingDirectory,
|
||||
this.taskStore
|
||||
|
||||
@@ -1139,10 +1139,22 @@ export class StepSessionExecutor {
|
||||
|
||||
stepExecLog.log(`Creating worktree for step ${stepIndex}: ${worktreePath} (branch: ${branchName})`);
|
||||
|
||||
await execAsync(
|
||||
`git worktree add -b "${branchName}" "${worktreePath}" HEAD`,
|
||||
{ cwd: this.options.worktreePath },
|
||||
);
|
||||
try {
|
||||
await execAsync(
|
||||
`git worktree add -b "${branchName}" "${worktreePath}" HEAD`,
|
||||
{ cwd: this.options.worktreePath },
|
||||
);
|
||||
} catch (err) {
|
||||
// Remove any partial directory left behind so the invariant holds:
|
||||
// "if .worktrees/<slug> exists on disk, it is a fully registered git worktree."
|
||||
try {
|
||||
await execAsync(`rm -rf "${worktreePath}"`, { cwd: rootDir });
|
||||
} catch {
|
||||
// best-effort cleanup; log but don't mask the original error
|
||||
stepExecLog.log(`Warning: failed to remove partial worktree directory after creation failure: ${worktreePath}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
this.parallelWorktrees.set(stepIndex, worktreePath);
|
||||
this.parallelBranches.set(stepIndex, branchName);
|
||||
|
||||
@@ -246,7 +246,36 @@ After writing the PROMPT.md, call \`review_spec()\` to get an independent qualit
|
||||
You MUST call \`review_spec()\` after writing the PROMPT.md. Do not finish without getting an APPROVE verdict.
|
||||
|
||||
## Output
|
||||
Write the PROMPT.md directly using the write tool, then call \`review_spec()\` for review.`;
|
||||
Write the PROMPT.md directly using the write tool, then call \`review_spec()\` for review.
|
||||
|
||||
## Frontend UX Criteria Injection
|
||||
|
||||
<!-- UX criteria mirror the "frontend-ux-design" reviewer persona in packages/core/src/types.ts — keep them aligned. -->
|
||||
|
||||
If the derived **File Scope** touches any of the following paths:
|
||||
- \`packages/dashboard/**\`
|
||||
- \`packages/*/app/components/**\`
|
||||
- \`packages/*/app/hooks/**\`
|
||||
- Any \`*.css\` or \`*.tsx\` file inside a dashboard-like package
|
||||
|
||||
…then **PREPEND** a \`## Frontend UX Criteria\` section to the generated PROMPT.md, placed immediately after the \`## Mission\` section.
|
||||
|
||||
Use this exact checklist (keep it verbatim — do not expand or reorder):
|
||||
|
||||
\`\`\`markdown
|
||||
## Frontend UX Criteria
|
||||
|
||||
- [ ] **Design tokens only** — no hardcoded \`px\` values except \`0\`, no hardcoded hex/rgb colors; use CSS custom properties (\`--color-*\`, \`--spacing-*\`, etc.)
|
||||
- [ ] **Icon sizing** — match the surrounding component's icon size convention (default lucide size unless the local pattern already uses an explicit \`size={N}\`)
|
||||
- [ ] **Semantic color tokens for status** — use \`--color-error\` for stderr/error states, \`--color-warning\` for starting/pending states; never hardcode status colors
|
||||
- [ ] **Component reuse** — reach for existing classes (\`.btn\`, \`.btn-icon\`, \`.card\`, \`.input\`) before writing one-off styles
|
||||
- [ ] **Responsive scaffolding** — add \`@media (max-width: 768px)\` overrides for any new layout; verify mobile usability
|
||||
- [ ] **Single canonical nav destination** — each route must appear in exactly one of: Header primary nav, Header overflow menu, or MobileNavBar More; no duplicates across all three
|
||||
- [ ] **Status-indicator dot convention** — use the existing \`.status-dot\` pattern (size, border, animation) rather than custom dot styling
|
||||
- [ ] **Visual hierarchy preserved** — new elements must not disrupt heading levels, content flow, or information architecture established in the surrounding page
|
||||
\`\`\`
|
||||
|
||||
Only inject this section when the task genuinely touches frontend UI. Omit it for backend-only, config-only, or documentation-only tasks.`;
|
||||
|
||||
export interface TriageProcessorOptions {
|
||||
pollIntervalMs?: number;
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock("node:child_process", async () => {
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
lstatSync: vi.fn().mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false }),
|
||||
readdirSync: vi.fn().mockReturnValue([]),
|
||||
rmSync: vi.fn(),
|
||||
}));
|
||||
@@ -49,14 +50,16 @@ import {
|
||||
getRegisteredWorktreePaths,
|
||||
scanIdleWorktrees,
|
||||
cleanupOrphanedWorktrees,
|
||||
reapOrphanWorktrees,
|
||||
scanOrphanedBranches,
|
||||
} from "./worktree-pool.js";
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, readdirSync, rmSync } from "node:fs";
|
||||
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
|
||||
import type { Task, Column } from "@fusion/core";
|
||||
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
const mockedExistsSync = vi.mocked(existsSync);
|
||||
const mockedLstatSync = vi.mocked(lstatSync);
|
||||
const mockedReaddirSync = vi.mocked(readdirSync);
|
||||
const mockedRmSync = vi.mocked(rmSync);
|
||||
|
||||
@@ -878,3 +881,203 @@ describe("scanOrphanedBranches", () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { existsSync, readdirSync, rmSync } from "node:fs";
|
||||
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
|
||||
import { join, relative, resolve, isAbsolute } from "node:path";
|
||||
import type { Column, TaskStore } from "@fusion/core";
|
||||
import { worktreePoolLog } from "./logger.js";
|
||||
@@ -355,6 +355,101 @@ export async function cleanupOrphanedWorktrees(rootDir: string, store: TaskStore
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove "half-initialized" worktree directories — directories that exist under
|
||||
* `<projectRoot>/.worktrees/` on disk but were never fully registered with git
|
||||
* (i.e., `git worktree add` never completed successfully for them).
|
||||
*
|
||||
* This is the housekeeping path; it runs once at engine startup and is safe to
|
||||
* call repeatedly. The hot path (`assertValidWorktreeSession`) is deliberately
|
||||
* left untouched.
|
||||
*
|
||||
* Safety invariants enforced before any removal:
|
||||
* - Only removes direct children of `<projectRoot>/.worktrees/` — never the
|
||||
* project root itself, a parent, or an arbitrary path.
|
||||
* - Skips symlinks (only removes real directories).
|
||||
* - Never removes a directory that is a registered git worktree.
|
||||
* - Never removes a directory that has a valid `.git` file pointing to an
|
||||
* existing gitdir (belt-and-suspenders: git would list it anyway, but guards
|
||||
* against stale porcelain output on broken repos).
|
||||
*
|
||||
* @param projectRoot - Absolute path to the project root (parent of `.worktrees/`)
|
||||
* @returns Number of orphan directories removed
|
||||
*/
|
||||
export async function reapOrphanWorktrees(projectRoot: string): Promise<number> {
|
||||
const worktreesDir = join(projectRoot, ".worktrees");
|
||||
|
||||
if (!existsSync(worktreesDir)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// List direct children of .worktrees/
|
||||
let entries: { name: string; fullPath: string }[];
|
||||
try {
|
||||
entries = readdirSync(worktreesDir, { withFileTypes: true })
|
||||
.filter((e) => {
|
||||
// Only real directories — never symlinks
|
||||
if (!e.isDirectory()) return false;
|
||||
try {
|
||||
return lstatSync(join(worktreesDir, e.name)).isDirectory() && !lstatSync(join(worktreesDir, e.name)).isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.map((e) => ({ name: e.name, fullPath: join(worktreesDir, e.name) }));
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
worktreePoolLog.warn(`reapOrphanWorktrees: failed to read .worktrees/ — ${msg}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (entries.length === 0) return 0;
|
||||
|
||||
// Get the set of paths registered with git
|
||||
const registered = await getRegisteredWorktreePaths(projectRoot);
|
||||
|
||||
let removed = 0;
|
||||
for (const { name, fullPath } of entries) {
|
||||
const resolvedFull = resolve(fullPath);
|
||||
|
||||
// Safety: only operate on paths directly under .worktrees/
|
||||
const rel = relative(resolve(worktreesDir), resolvedFull);
|
||||
if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
|
||||
worktreePoolLog.warn(`reapOrphanWorktrees: skipping out-of-bounds path ${fullPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip registered worktrees — those are managed by the normal lifecycle
|
||||
if (registered.has(resolvedFull)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: skip if a .git file exists AND points to an existing gitdir.
|
||||
// This guards against races where git registered the worktree between our list
|
||||
// call and now, or against a broken repo whose porcelain is unreliable.
|
||||
const dotGit = join(resolvedFull, ".git");
|
||||
if (existsSync(dotGit)) {
|
||||
// If there's a .git file/dir, don't touch it — assertValidWorktreeSession
|
||||
// will handle it on the next agent start.
|
||||
worktreePoolLog.log(`reapOrphanWorktrees: skipping ${name} (has .git entry but not in registered list — may be partially registered)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// This directory is on disk but has no .git entry and is not a registered
|
||||
// worktree — it is a half-initialized orphan. Remove it.
|
||||
try {
|
||||
rmSync(resolvedFull, { recursive: true, force: true });
|
||||
worktreePoolLog.log(`reapOrphanWorktrees: removed half-initialized orphan ${name}`);
|
||||
removed++;
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
worktreePoolLog.warn(`reapOrphanWorktrees: failed to remove ${name} — ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Columns where the merger handles branch cleanup — skip these during orphan scanning. */
|
||||
const MERGER_MANAGED_COLUMNS: ReadonlySet<Column> = new Set(["in-review", "done"]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user