fix(server): harden worktree archive with parallel teardown

Fan out agent close + terminal kill concurrently with Promise.allSettled
so one failure never leaves the worktree half-dead. Await real PTY exit
with SIGHUP→SIGKILL escalation before rm to avoid EBUSY races. Tolerate
missing repoRoot and retry directory removal with backoff. Purge stale
client workspace layout/tab state after successful archive.
This commit is contained in:
Mohamed Boudra
2026-04-17 15:50:48 +07:00
parent ed2dc08443
commit d6585f7c3a
9 changed files with 537 additions and 65 deletions
@@ -1,6 +1,11 @@
import { create } from "zustand"; import { create } from "zustand";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { queryClient } from "@/query/query-client"; import { queryClient } from "@/query/query-client";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
const SUCCESS_DISPLAY_MS = 1000; const SUCCESS_DISPLAY_MS = 1000;
@@ -123,6 +128,19 @@ function removeWorktreeFromCachedLists(input: { serverId: string; worktreePath:
); );
} }
function purgeArchivedWorkspaceState(input: { serverId: string; worktreePath: string }): void {
const serverId = input.serverId.trim();
const workspaceId = input.worktreePath.trim();
if (!serverId || !workspaceId) {
return;
}
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
if (workspaceKey) {
useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey);
}
useWorkspaceTabsStore.getState().purgeWorkspace({ serverId, workspaceId });
}
const successTimers = new Map<string, ReturnType<typeof setTimeout>>(); const successTimers = new Map<string, ReturnType<typeof setTimeout>>();
const inFlight = new Map<string, Promise<unknown>>(); const inFlight = new Map<string, Promise<unknown>>();
@@ -320,6 +338,7 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
} }
removeWorktreeFromCachedLists({ serverId, worktreePath }); removeWorktreeFromCachedLists({ serverId, worktreePath });
invalidateWorktreeList(); invalidateWorktreeList();
purgeArchivedWorkspaceState({ serverId, worktreePath });
}, },
}); });
}, },
@@ -99,6 +99,7 @@ interface WorkspaceLayoutStore {
unpinAgent: (workspaceKey: string, agentId: string) => void; unpinAgent: (workspaceKey: string, agentId: string) => void;
hideAgent: (workspaceKey: string, agentId: string) => void; hideAgent: (workspaceKey: string, agentId: string) => void;
unhideAgent: (workspaceKey: string, agentId: string) => void; unhideAgent: (workspaceKey: string, agentId: string) => void;
purgeWorkspace: (workspaceKey: string) => void;
} }
const MAX_TREE_DEPTH = 4; const MAX_TREE_DEPTH = 4;
@@ -669,6 +670,37 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
}; };
}); });
}, },
purgeWorkspace: (workspaceKey) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) {
return;
}
set((state) => {
const hasAny =
normalizedWorkspaceKey in state.layoutByWorkspace ||
normalizedWorkspaceKey in state.splitSizesByWorkspace ||
normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace ||
normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace;
if (!hasAny) {
return state;
}
const { [normalizedWorkspaceKey]: _layout, ...layoutByWorkspace } =
state.layoutByWorkspace;
const { [normalizedWorkspaceKey]: _splits, ...splitSizesByWorkspace } =
state.splitSizesByWorkspace;
const { [normalizedWorkspaceKey]: _pinned, ...pinnedAgentIdsByWorkspace } =
state.pinnedAgentIdsByWorkspace;
const { [normalizedWorkspaceKey]: _hidden, ...hiddenAgentIdsByWorkspace } =
state.hiddenAgentIdsByWorkspace;
return {
layoutByWorkspace,
splitSizesByWorkspace,
pinnedAgentIdsByWorkspace,
hiddenAgentIdsByWorkspace,
};
});
},
}), }),
{ {
name: "workspace-layout-state", name: "workspace-layout-state",
@@ -93,6 +93,7 @@ type WorkspaceTabsState = {
}) => string | null; }) => string | null;
reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void; reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void;
getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[]; getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[];
purgeWorkspace: (input: { serverId: string; workspaceId: string }) => void;
}; };
export const useWorkspaceTabsStore = create<WorkspaceTabsState>()( export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
@@ -326,6 +327,31 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
} }
return get().uiTabsByWorkspace[key] ?? []; return get().uiTabsByWorkspace[key] ?? [];
}, },
purgeWorkspace: ({ serverId, workspaceId }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
if (!key) {
return;
}
set((state) => {
if (
!(key in state.uiTabsByWorkspace) &&
!(key in state.tabOrderByWorkspace) &&
!(key in state.focusedTabIdByWorkspace)
) {
return state;
}
const { [key]: _tabs, ...remainingUiTabsByWorkspace } = state.uiTabsByWorkspace;
const { [key]: _order, ...remainingTabOrderByWorkspace } = state.tabOrderByWorkspace;
const { [key]: _focused, ...remainingFocusedTabIdByWorkspace } =
state.focusedTabIdByWorkspace;
return {
...state,
uiTabsByWorkspace: remainingUiTabsByWorkspace,
tabOrderByWorkspace: remainingTabOrderByWorkspace,
focusedTabIdByWorkspace: remainingFocusedTabIdByWorkspace,
};
});
},
}), }),
{ {
name: "workspace-tabs-state", name: "workspace-tabs-state",
@@ -15,7 +15,9 @@ import type { SessionOutboundMessage } from "./messages.js";
import { ScriptRouteStore } from "./script-proxy.js"; import { ScriptRouteStore } from "./script-proxy.js";
import * as worktreeBootstrap from "./worktree-bootstrap.js"; import * as worktreeBootstrap from "./worktree-bootstrap.js";
import { import {
archivePaseoWorktree,
buildAgentSessionConfig, buildAgentSessionConfig,
handlePaseoWorktreeArchiveRequest,
runWorktreeSetupInBackground, runWorktreeSetupInBackground,
handleCreatePaseoWorktreeRequest, handleCreatePaseoWorktreeRequest,
handleWorkspaceSetupStatusRequest, handleWorkspaceSetupStatusRequest,
@@ -1010,3 +1012,198 @@ describe("handleCreatePaseoWorktreeRequest", () => {
} }
}); });
}); });
describe("archivePaseoWorktree", () => {
const cleanupPaths: string[] = [];
afterEach(() => {
for (const target of cleanupPaths.splice(0)) {
rmSync(target, { recursive: true, force: true });
}
});
function createIsPathWithinRoot() {
return (rootPath: string, candidatePath: string) => {
const normalizedRoot = path.resolve(rootPath);
const normalizedCandidate = path.resolve(candidatePath);
return (
normalizedCandidate === normalizedRoot ||
normalizedCandidate.startsWith(normalizedRoot + path.sep)
);
};
}
test("runs agent close and terminal teardown concurrently and removes the worktree", async () => {
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const created = await createWorktree({
branchName: "archive-parallel",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "archive-parallel",
runSetup: false,
paseoHome,
});
const teardownStartTimes: Record<string, number> = {};
const teardownEndTimes: Record<string, number> = {};
const closeAgentSpy = vi.fn(async (agentId: string) => {
teardownStartTimes[agentId] = Date.now();
await new Promise((resolve) => setTimeout(resolve, 100));
teardownEndTimes[agentId] = Date.now();
});
const killTerminalsUnderPath = vi.fn(async () => {
teardownStartTimes.__terminals = Date.now();
await new Promise((resolve) => setTimeout(resolve, 100));
teardownEndTimes.__terminals = Date.now();
});
const emitted: SessionOutboundMessage[] = [];
const removedAgents = await archivePaseoWorktree(
{
paseoHome,
agentManager: {
listAgents: () => [
{ id: "agent-1", cwd: created.worktreePath } as any,
{ id: "agent-2", cwd: created.worktreePath } as any,
],
closeAgent: closeAgentSpy,
},
agentStorage: {
list: async () => [],
remove: vi.fn(async () => {}),
} as any,
archiveWorkspaceRecord: vi.fn(async () => {}),
emit: (msg) => emitted.push(msg),
emitWorkspaceUpdatesForCwds: vi.fn(async () => {}),
isPathWithinRoot: createIsPathWithinRoot(),
killTerminalsUnderPath,
sessionLogger: createLogger(),
},
{
targetPath: created.worktreePath,
repoRoot: repoDir,
requestId: "req-archive-parallel",
},
);
expect(removedAgents).toEqual(expect.arrayContaining(["agent-1", "agent-2"]));
expect(existsSync(created.worktreePath)).toBe(false);
expect(closeAgentSpy).toHaveBeenCalledTimes(2);
expect(killTerminalsUnderPath).toHaveBeenCalledWith(created.worktreePath);
// All teardown work must overlap — sequential would take ~300ms, parallel ~100ms.
const starts = Object.values(teardownStartTimes);
const ends = Object.values(teardownEndTimes);
const maxEnd = Math.max(...ends);
const minStart = Math.min(...starts);
expect(maxEnd - minStart).toBeLessThan(220);
});
test("proceeds to FS delete even when terminal teardown rejects", async () => {
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const created = await createWorktree({
branchName: "archive-terminal-throws",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "archive-terminal-throws",
runSetup: false,
paseoHome,
});
const killTerminalsUnderPath = vi.fn(async () => {
throw new Error("simulated terminal teardown failure");
});
await archivePaseoWorktree(
{
paseoHome,
agentManager: {
listAgents: () => [],
closeAgent: vi.fn(async () => {}),
},
agentStorage: {
list: async () => [],
remove: vi.fn(async () => {}),
} as any,
archiveWorkspaceRecord: vi.fn(async () => {}),
emit: vi.fn(),
emitWorkspaceUpdatesForCwds: vi.fn(async () => {}),
isPathWithinRoot: createIsPathWithinRoot(),
killTerminalsUnderPath,
sessionLogger: createLogger(),
},
{
targetPath: created.worktreePath,
repoRoot: repoDir,
requestId: "req-archive-terminal-throws",
},
);
expect(killTerminalsUnderPath).toHaveBeenCalledTimes(1);
expect(existsSync(created.worktreePath)).toBe(false);
});
test("succeeds when git has forgotten about the worktree (no repoRoot)", async () => {
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const created = await createWorktree({
branchName: "archive-orphan",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "archive-orphan",
runSetup: false,
paseoHome,
});
// Simulate a prior failed archive that stripped git's admin dir.
rmSync(path.join(repoDir, ".git", "worktrees", "archive-orphan"), {
recursive: true,
force: true,
});
expect(existsSync(created.worktreePath)).toBe(true);
const emitted: SessionOutboundMessage[] = [];
await handlePaseoWorktreeArchiveRequest(
{
paseoHome,
agentManager: {
listAgents: () => [],
closeAgent: vi.fn(async () => {}),
},
agentStorage: {
list: async () => [],
remove: vi.fn(async () => {}),
} as any,
archiveWorkspaceRecord: vi.fn(async () => {}),
emit: (msg) => emitted.push(msg),
emitWorkspaceUpdatesForCwds: vi.fn(async () => {}),
isPathWithinRoot: createIsPathWithinRoot(),
killTerminalsUnderPath: vi.fn(async () => {}),
sessionLogger: createLogger(),
},
{
type: "paseo_worktree_archive_request",
requestId: "req-archive-orphan",
worktreePath: created.worktreePath,
},
);
const response = emitted.find(
(
message,
): message is Extract<SessionOutboundMessage, { type: "paseo_worktree_archive_response" }> =>
message.type === "paseo_worktree_archive_response",
);
expect(response?.payload.success).toBe(true);
expect(response?.payload.error).toBeNull();
expect(existsSync(created.worktreePath)).toBe(false);
});
});
+82 -44
View File
@@ -96,6 +96,7 @@ type ArchivePaseoWorktreeDependencies = {
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>; emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
killTerminalsUnderPath: (rootPath: string) => Promise<void>; killTerminalsUnderPath: (rootPath: string) => Promise<void>;
sessionLogger?: Logger;
}; };
type RegisterPendingWorktreeWorkspaceDependencies = { type RegisterPendingWorktreeWorkspaceDependencies = {
@@ -169,6 +170,7 @@ type HandleCreatePaseoWorktreeRequestDependencies = {
type KillTerminalsUnderPathDependencies = { type KillTerminalsUnderPathDependencies = {
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
killTrackedTerminal: (terminalId: string, options?: { emitExit: boolean }) => void; killTrackedTerminal: (terminalId: string, options?: { emitExit: boolean }) => void;
detachTerminalStream?: (terminalId: string, options: { emitExit: boolean }) => void;
sessionLogger: Logger; sessionLogger: Logger;
terminalManager: TerminalManager | null; terminalManager: TerminalManager | null;
}; };
@@ -487,7 +489,8 @@ export async function archivePaseoWorktree(
dependencies: ArchivePaseoWorktreeDependencies, dependencies: ArchivePaseoWorktreeDependencies,
options: { options: {
targetPath: string; targetPath: string;
repoRoot: string; repoRoot: string | null;
worktreesRoot?: string;
requestId: string; requestId: string;
}, },
): Promise<string[]> { ): Promise<string[]> {
@@ -502,53 +505,73 @@ export async function archivePaseoWorktree(
const removedAgents = new Set<string>(); const removedAgents = new Set<string>();
const affectedWorkspaceCwds = new Set<string>([targetPath]); const affectedWorkspaceCwds = new Set<string>([targetPath]);
const affectedWorkspaceIds = new Set<string>([normalizePersistedWorkspaceId(targetPath)]); const affectedWorkspaceIds = new Set<string>([normalizePersistedWorkspaceId(targetPath)]);
const agents = dependencies.agentManager.listAgents();
for (const agent of agents) {
if (!dependencies.isPathWithinRoot(targetPath, agent.cwd)) {
continue;
}
const liveAgents = dependencies.agentManager
.listAgents()
.filter((agent) => dependencies.isPathWithinRoot(targetPath, agent.cwd));
for (const agent of liveAgents) {
removedAgents.add(agent.id); removedAgents.add(agent.id);
affectedWorkspaceCwds.add(agent.cwd); affectedWorkspaceCwds.add(agent.cwd);
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd)); affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd));
try {
await dependencies.agentManager.closeAgent(agent.id);
} catch {
// ignore cleanup errors
}
try {
await dependencies.agentStorage.remove(agent.id);
} catch {
// ignore cleanup errors
}
} }
const registryRecords = await dependencies.agentStorage.list(); const storedRecords = await dependencies.agentStorage.list().catch(() => []);
for (const record of registryRecords) { const matchingStoredRecords = storedRecords.filter((record) =>
if (!dependencies.isPathWithinRoot(targetPath, record.cwd)) { dependencies.isPathWithinRoot(targetPath, record.cwd),
continue; );
} for (const record of matchingStoredRecords) {
removedAgents.add(record.id); removedAgents.add(record.id);
affectedWorkspaceCwds.add(record.cwd); affectedWorkspaceCwds.add(record.cwd);
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd)); affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd));
try { }
await dependencies.agentStorage.remove(record.id);
} catch { const agentIdsToRemoveFromStorage = new Set<string>([
// ignore cleanup errors ...liveAgents.map((agent) => agent.id),
...matchingStoredRecords.map((record) => record.id),
]);
// Fan out agent close + terminal teardown concurrently. We never let a
// per-item failure abort the archive — the FS delete must still run so the
// worktree doesn't get stuck half-dead.
const teardownResults = await Promise.allSettled([
...liveAgents.map((agent) => dependencies.agentManager.closeAgent(agent.id)),
dependencies.killTerminalsUnderPath(targetPath),
]);
for (const result of teardownResults) {
if (result.status === "rejected") {
dependencies.sessionLogger?.warn(
{ err: result.reason, targetPath },
"Worktree teardown step failed during archive; continuing",
);
} }
} }
await dependencies.killTerminalsUnderPath(targetPath); // Agent storage removal runs after closeAgent so file handles on the agent
// state file are released; still allSettled so a single bad record can't
// derail the rest.
await Promise.allSettled(
Array.from(agentIdsToRemoveFromStorage).map((agentId) =>
dependencies.agentStorage.remove(agentId),
),
);
await deletePaseoWorktree({ await deletePaseoWorktree({
cwd: options.repoRoot, cwd: options.repoRoot,
worktreePath: targetPath, worktreePath: targetPath,
worktreesRoot: options.worktreesRoot,
paseoHome: dependencies.paseoHome, paseoHome: dependencies.paseoHome,
}); });
for (const workspaceId of affectedWorkspaceIds) { for (const workspaceId of affectedWorkspaceIds) {
await dependencies.archiveWorkspaceRecord(workspaceId); try {
await dependencies.archiveWorkspaceRecord(workspaceId);
} catch (error) {
dependencies.sessionLogger?.warn(
{ err: error, workspaceId },
"Failed to archive workspace record; worktree FS already removed",
);
}
} }
for (const agentId of removedAgents) { for (const agentId of removedAgents) {
@@ -612,14 +635,15 @@ export async function handlePaseoWorktreeArchiveRequest(
return; return;
} }
// repoRoot is best-effort: if git has forgotten about the worktree we
// still proceed using the path-derived worktreesRoot, since the ownership
// check already proved the path lives under $PASEO_HOME/worktrees.
repoRoot = ownership.repoRoot ?? repoRoot ?? null; repoRoot = ownership.repoRoot ?? repoRoot ?? null;
if (!repoRoot) {
throw new Error("Unable to resolve repo root for worktree");
}
const removedAgents = await archivePaseoWorktree(dependencies, { const removedAgents = await archivePaseoWorktree(dependencies, {
targetPath, targetPath,
repoRoot, repoRoot,
worktreesRoot: ownership.worktreeRoot,
requestId, requestId,
}); });
@@ -953,34 +977,48 @@ export async function killTerminalsUnderPath(
dependencies: KillTerminalsUnderPathDependencies, dependencies: KillTerminalsUnderPathDependencies,
rootPath: string, rootPath: string,
): Promise<void> { ): Promise<void> {
if (!dependencies.terminalManager) { const terminalManager = dependencies.terminalManager;
if (!terminalManager) {
return; return;
} }
const cleanupErrors: Array<{ cwd: string; message: string }> = []; const terminalIds: string[] = [];
const terminalDirectories = [...dependencies.terminalManager.listDirectories()]; const terminalDirectories = [...terminalManager.listDirectories()];
for (const terminalCwd of terminalDirectories) { for (const terminalCwd of terminalDirectories) {
if (!dependencies.isPathWithinRoot(rootPath, terminalCwd)) { if (!dependencies.isPathWithinRoot(rootPath, terminalCwd)) {
continue; continue;
} }
try { try {
const terminals = await dependencies.terminalManager.getTerminals(terminalCwd); const terminals = await terminalManager.getTerminals(terminalCwd);
for (const terminal of [...terminals]) { for (const terminal of terminals) {
dependencies.killTrackedTerminal(terminal.id, { emitExit: true }); terminalIds.push(terminal.id);
} }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error);
cleanupErrors.push({ cwd: terminalCwd, message });
dependencies.sessionLogger.warn( dependencies.sessionLogger.warn(
{ err: error, cwd: terminalCwd }, { err: error, cwd: terminalCwd },
"Failed to clean up worktree terminals during archive", "Failed to enumerate worktree terminals during archive",
); );
} }
} }
if (cleanupErrors.length > 0) { if (terminalIds.length === 0) {
const details = cleanupErrors.map((entry) => `${entry.cwd}: ${entry.message}`).join("; "); return;
throw new Error(`Failed to clean up worktree terminals during archive (${details})`);
} }
await Promise.allSettled(
terminalIds.map(async (terminalId) => {
try {
dependencies.detachTerminalStream?.(terminalId, { emitExit: true });
await terminalManager.killTerminalAndWait(terminalId, {
gracefulTimeoutMs: 2000,
forceTimeoutMs: 1500,
});
} catch (error) {
dependencies.sessionLogger.warn(
{ err: error, terminalId },
"Terminal kill escalation failed during archive; proceeding anyway",
);
}
}),
);
} }
@@ -28,6 +28,10 @@ export interface TerminalManager {
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void; registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
getTerminal(id: string): TerminalSession | undefined; getTerminal(id: string): TerminalSession | undefined;
killTerminal(id: string): void; killTerminal(id: string): void;
killTerminalAndWait(
id: string,
options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number },
): Promise<void>;
listDirectories(): string[]; listDirectories(): string[];
killAll(): void; killAll(): void;
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void; subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void;
@@ -200,6 +204,21 @@ export function createTerminalManager(): TerminalManager {
removeSessionById(id, { kill: true }); removeSessionById(id, { kill: true });
}, },
async killTerminalAndWait(
id: string,
options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number },
): Promise<void> {
const session = terminalsById.get(id);
if (!session) {
return;
}
try {
await session.killAndWait(options);
} finally {
removeSessionById(id, { kill: false });
}
},
listDirectories(): string[] { listDirectories(): string[] {
return Array.from(terminalsByCwd.keys()); return Array.from(terminalsByCwd.keys());
}, },
+62
View File
@@ -44,6 +44,7 @@ export interface TerminalSession {
getTitle(): string | undefined; getTitle(): string | undefined;
getExitInfo(): TerminalExitInfo | null; getExitInfo(): TerminalExitInfo | null;
kill(): void; kill(): void;
killAndWait(options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number }): Promise<void>;
} }
export interface CreateTerminalOptions { export interface CreateTerminalOptions {
@@ -527,6 +528,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
let killed = false; let killed = false;
let disposed = false; let disposed = false;
let exitEmitted = false; let exitEmitted = false;
let processExited = false;
const processExitWaiters = new Set<() => void>();
let exitInfo: TerminalExitInfo | null = null; let exitInfo: TerminalExitInfo | null = null;
let recentOutputText = ""; let recentOutputText = "";
let title: string | undefined; let title: string | undefined;
@@ -670,6 +673,15 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
ptyProcess.onExit((event) => { ptyProcess.onExit((event) => {
killed = true; killed = true;
processExited = true;
for (const waiter of Array.from(processExitWaiters)) {
try {
waiter();
} catch {
// no-op
}
}
processExitWaiters.clear();
emitExit( emitExit(
buildExitInfo({ buildExitInfo({
exitCode: event.exitCode, exitCode: event.exitCode,
@@ -783,6 +795,55 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
disposeResources(); disposeResources();
} }
function waitForProcessExit(timeoutMs: number): Promise<boolean> {
if (processExited) {
return Promise.resolve(true);
}
return new Promise((resolve) => {
const waiter = (): void => {
clearTimeout(timer);
resolve(true);
};
const timer = setTimeout(() => {
processExitWaiters.delete(waiter);
resolve(false);
}, timeoutMs);
processExitWaiters.add(waiter);
});
}
async function killAndWait(options?: {
gracefulTimeoutMs?: number;
forceTimeoutMs?: number;
}): Promise<void> {
const gracefulTimeoutMs = options?.gracefulTimeoutMs ?? 2000;
const forceTimeoutMs = options?.forceTimeoutMs ?? 1000;
if (processExited) {
kill();
return;
}
try {
ptyProcess.kill();
} catch {
// process may already be gone
}
const exitedGracefully = await waitForProcessExit(gracefulTimeoutMs);
if (!exitedGracefully) {
try {
ptyProcess.kill("SIGKILL");
} catch {
// process may already be gone
}
await waitForProcessExit(forceTimeoutMs);
}
// Finalize bookkeeping (idempotent if ptyProcess.onExit already fired).
kill();
}
// Small delay to let shell initialize // Small delay to let shell initialize
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
@@ -799,5 +860,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
getTitle, getTitle,
getExitInfo, getExitInfo,
kill, kill,
killAndWait,
}; };
} }
@@ -879,6 +879,31 @@ describe("paseo worktree manager", () => {
deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }), deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
}); });
it("deletes a worktree when the parent repo root is not available", async () => {
const created = await createWorktree({
branchName: "no-cwd-branch",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "no-cwd",
paseoHome,
});
const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome });
expect(ownership.allowed).toBe(true);
expect(ownership.worktreeRoot).toBeTruthy();
// Simulate the handler path when git has forgotten about the worktree:
// caller forwards the path-derived worktreesRoot from the ownership check.
await deletePaseoWorktree({
cwd: null,
worktreePath: created.worktreePath,
worktreesRoot: ownership.worktreeRoot,
paseoHome,
});
expect(existsSync(created.worktreePath)).toBe(false);
});
}); });
describe("slugify", () => { describe("slugify", () => {
+75 -21
View File
@@ -1,6 +1,7 @@
import { execFile, spawn } from "child_process"; import { execFile, spawn } from "child_process";
import { promisify } from "util"; import { promisify } from "util";
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs"; import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs";
import { rm, stat } from "fs/promises";
import { join, basename, dirname, resolve, sep } from "path"; import { join, basename, dirname, resolve, sep } from "path";
import net from "node:net"; import net from "node:net";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
@@ -1027,20 +1028,33 @@ export async function deletePaseoWorktree({
cwd, cwd,
worktreePath, worktreePath,
worktreeSlug, worktreeSlug,
worktreesRoot,
paseoHome, paseoHome,
}: { }: {
cwd: string; cwd: string | null;
worktreePath?: string; worktreePath?: string;
worktreeSlug?: string; worktreeSlug?: string;
worktreesRoot?: string;
paseoHome?: string; paseoHome?: string;
}): Promise<void> { }): Promise<void> {
if (!worktreePath && !worktreeSlug) { if (!worktreePath && !worktreeSlug) {
throw new Error("worktreePath or worktreeSlug is required"); throw new Error("worktreePath or worktreeSlug is required");
} }
const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome); // Resolve the worktrees-root. With a repo cwd we hash it the normal way; if
const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep; // git has forgotten about the worktree we expect the caller to hand us the
const requestedPath = worktreePath ?? join(worktreesRoot, worktreeSlug!); // path-derived worktreesRoot from the ownership check.
let resolvedWorktreesRoot: string;
if (worktreesRoot) {
resolvedWorktreesRoot = worktreesRoot;
} else if (cwd) {
resolvedWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome);
} else {
throw new Error("cwd or worktreesRoot is required to delete a Paseo worktree");
}
const resolvedRoot = normalizePathForOwnership(resolvedWorktreesRoot) + sep;
const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!);
const resolvedRequested = normalizePathForOwnership(requestedPath); const resolvedRequested = normalizePathForOwnership(requestedPath);
const resolvedWorktree = const resolvedWorktree =
(await resolvePaseoWorktreeRootForCwd(requestedPath, { paseoHome }))?.worktreePath ?? (await resolvePaseoWorktreeRootForCwd(requestedPath, { paseoHome }))?.worktreePath ??
@@ -1050,32 +1064,72 @@ export async function deletePaseoWorktree({
throw new Error("Refusing to delete non-Paseo worktree"); throw new Error("Refusing to delete non-Paseo worktree");
} }
if (existsSync(resolvedWorktree)) { if (await pathExists(resolvedWorktree)) {
await runWorktreeTeardownCommands({ await runWorktreeTeardownCommands({
worktreePath: resolvedWorktree, worktreePath: resolvedWorktree,
}); });
} }
try { if (cwd) {
await runGitCommand(["worktree", "remove", resolvedWorktree, "--force"], { try {
cwd, await runGitCommand(["worktree", "remove", resolvedWorktree, "--force"], {
timeout: 120_000, cwd,
}); timeout: 120_000,
} catch { });
// `git worktree remove` fails if the admin dir is already gone (e.g. a prior } catch {
// archive attempt removed it before the working tree could be fully cleaned // `git worktree remove` fails if the admin dir is already gone (e.g. a
// up). Fall through to the rmSync fallback below so the retry is idempotent. // prior archive attempt removed it before the working tree could be
// fully cleaned up), or if the repo root has moved. Fall through to the
// rm retry loop below so the operation stays idempotent.
}
} }
if (existsSync(resolvedWorktree)) { await removeDirectoryWithRetries(resolvedWorktree);
rmSync(resolvedWorktree, { recursive: true, force: true });
if (cwd) {
try {
await runGitCommand(["worktree", "prune"], { cwd, timeout: 30_000 });
} catch {
// not critical; git will prune lazily
}
}
}
async function pathExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}
async function removeDirectoryWithRetries(path: string): Promise<void> {
if (!(await pathExists(path))) {
return;
} }
// Clean up any stale admin entries git may still have cached for this path. const delaysMs = [0, 100, 300, 700, 1500];
try { let lastError: unknown = null;
await runGitCommand(["worktree", "prune"], { cwd, timeout: 30_000 }); for (const delay of delaysMs) {
} catch { if (delay > 0) {
// not critical; git will prune lazily await new Promise((resolve) => setTimeout(resolve, delay));
}
try {
await rm(path, { recursive: true, force: true });
if (!(await pathExists(path))) {
return;
}
lastError = new Error(`Directory still present after rm: ${path}`);
} catch (error) {
lastError = error;
}
}
if (await pathExists(path)) {
throw lastError instanceof Error
? lastError
: new Error(`Failed to remove worktree directory: ${path}`);
} }
} }