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:
@@ -1,6 +1,11 @@
|
||||
import { create } from "zustand";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
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;
|
||||
|
||||
@@ -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 inFlight = new Map<string, Promise<unknown>>();
|
||||
|
||||
@@ -320,6 +338,7 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
}
|
||||
removeWorktreeFromCachedLists({ serverId, worktreePath });
|
||||
invalidateWorktreeList();
|
||||
purgeArchivedWorkspaceState({ serverId, worktreePath });
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -99,6 +99,7 @@ interface WorkspaceLayoutStore {
|
||||
unpinAgent: (workspaceKey: string, agentId: string) => void;
|
||||
hideAgent: (workspaceKey: string, agentId: string) => void;
|
||||
unhideAgent: (workspaceKey: string, agentId: string) => void;
|
||||
purgeWorkspace: (workspaceKey: string) => void;
|
||||
}
|
||||
|
||||
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",
|
||||
|
||||
@@ -93,6 +93,7 @@ type WorkspaceTabsState = {
|
||||
}) => string | null;
|
||||
reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void;
|
||||
getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[];
|
||||
purgeWorkspace: (input: { serverId: string; workspaceId: string }) => void;
|
||||
};
|
||||
|
||||
export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
@@ -326,6 +327,31 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
}
|
||||
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",
|
||||
|
||||
@@ -15,7 +15,9 @@ import type { SessionOutboundMessage } from "./messages.js";
|
||||
import { ScriptRouteStore } from "./script-proxy.js";
|
||||
import * as worktreeBootstrap from "./worktree-bootstrap.js";
|
||||
import {
|
||||
archivePaseoWorktree,
|
||||
buildAgentSessionConfig,
|
||||
handlePaseoWorktreeArchiveRequest,
|
||||
runWorktreeSetupInBackground,
|
||||
handleCreatePaseoWorktreeRequest,
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,6 +96,7 @@ type ArchivePaseoWorktreeDependencies = {
|
||||
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
|
||||
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
|
||||
sessionLogger?: Logger;
|
||||
};
|
||||
|
||||
type RegisterPendingWorktreeWorkspaceDependencies = {
|
||||
@@ -169,6 +170,7 @@ type HandleCreatePaseoWorktreeRequestDependencies = {
|
||||
type KillTerminalsUnderPathDependencies = {
|
||||
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||
killTrackedTerminal: (terminalId: string, options?: { emitExit: boolean }) => void;
|
||||
detachTerminalStream?: (terminalId: string, options: { emitExit: boolean }) => void;
|
||||
sessionLogger: Logger;
|
||||
terminalManager: TerminalManager | null;
|
||||
};
|
||||
@@ -487,7 +489,8 @@ export async function archivePaseoWorktree(
|
||||
dependencies: ArchivePaseoWorktreeDependencies,
|
||||
options: {
|
||||
targetPath: string;
|
||||
repoRoot: string;
|
||||
repoRoot: string | null;
|
||||
worktreesRoot?: string;
|
||||
requestId: string;
|
||||
},
|
||||
): Promise<string[]> {
|
||||
@@ -502,53 +505,73 @@ export async function archivePaseoWorktree(
|
||||
const removedAgents = new Set<string>();
|
||||
const affectedWorkspaceCwds = new Set<string>([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);
|
||||
affectedWorkspaceCwds.add(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();
|
||||
for (const record of registryRecords) {
|
||||
if (!dependencies.isPathWithinRoot(targetPath, record.cwd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const storedRecords = await dependencies.agentStorage.list().catch(() => []);
|
||||
const matchingStoredRecords = storedRecords.filter((record) =>
|
||||
dependencies.isPathWithinRoot(targetPath, record.cwd),
|
||||
);
|
||||
for (const record of matchingStoredRecords) {
|
||||
removedAgents.add(record.id);
|
||||
affectedWorkspaceCwds.add(record.cwd);
|
||||
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd));
|
||||
try {
|
||||
await dependencies.agentStorage.remove(record.id);
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
|
||||
const agentIdsToRemoveFromStorage = new Set<string>([
|
||||
...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({
|
||||
cwd: options.repoRoot,
|
||||
worktreePath: targetPath,
|
||||
worktreesRoot: options.worktreesRoot,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
|
||||
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) {
|
||||
@@ -612,14 +635,15 @@ export async function handlePaseoWorktreeArchiveRequest(
|
||||
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;
|
||||
if (!repoRoot) {
|
||||
throw new Error("Unable to resolve repo root for worktree");
|
||||
}
|
||||
|
||||
const removedAgents = await archivePaseoWorktree(dependencies, {
|
||||
targetPath,
|
||||
repoRoot,
|
||||
worktreesRoot: ownership.worktreeRoot,
|
||||
requestId,
|
||||
});
|
||||
|
||||
@@ -953,34 +977,48 @@ export async function killTerminalsUnderPath(
|
||||
dependencies: KillTerminalsUnderPathDependencies,
|
||||
rootPath: string,
|
||||
): Promise<void> {
|
||||
if (!dependencies.terminalManager) {
|
||||
const terminalManager = dependencies.terminalManager;
|
||||
if (!terminalManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanupErrors: Array<{ cwd: string; message: string }> = [];
|
||||
const terminalDirectories = [...dependencies.terminalManager.listDirectories()];
|
||||
const terminalIds: string[] = [];
|
||||
const terminalDirectories = [...terminalManager.listDirectories()];
|
||||
for (const terminalCwd of terminalDirectories) {
|
||||
if (!dependencies.isPathWithinRoot(rootPath, terminalCwd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const terminals = await dependencies.terminalManager.getTerminals(terminalCwd);
|
||||
for (const terminal of [...terminals]) {
|
||||
dependencies.killTrackedTerminal(terminal.id, { emitExit: true });
|
||||
const terminals = await terminalManager.getTerminals(terminalCwd);
|
||||
for (const terminal of terminals) {
|
||||
terminalIds.push(terminal.id);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
cleanupErrors.push({ cwd: terminalCwd, message });
|
||||
dependencies.sessionLogger.warn(
|
||||
{ err: error, cwd: terminalCwd },
|
||||
"Failed to clean up worktree terminals during archive",
|
||||
"Failed to enumerate worktree terminals during archive",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanupErrors.length > 0) {
|
||||
const details = cleanupErrors.map((entry) => `${entry.cwd}: ${entry.message}`).join("; ");
|
||||
throw new Error(`Failed to clean up worktree terminals during archive (${details})`);
|
||||
if (terminalIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
getTerminal(id: string): TerminalSession | undefined;
|
||||
killTerminal(id: string): void;
|
||||
killTerminalAndWait(
|
||||
id: string,
|
||||
options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number },
|
||||
): Promise<void>;
|
||||
listDirectories(): string[];
|
||||
killAll(): void;
|
||||
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void;
|
||||
@@ -200,6 +204,21 @@ export function createTerminalManager(): TerminalManager {
|
||||
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[] {
|
||||
return Array.from(terminalsByCwd.keys());
|
||||
},
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface TerminalSession {
|
||||
getTitle(): string | undefined;
|
||||
getExitInfo(): TerminalExitInfo | null;
|
||||
kill(): void;
|
||||
killAndWait(options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number }): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CreateTerminalOptions {
|
||||
@@ -527,6 +528,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
let killed = false;
|
||||
let disposed = false;
|
||||
let exitEmitted = false;
|
||||
let processExited = false;
|
||||
const processExitWaiters = new Set<() => void>();
|
||||
let exitInfo: TerminalExitInfo | null = null;
|
||||
let recentOutputText = "";
|
||||
let title: string | undefined;
|
||||
@@ -670,6 +673,15 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
|
||||
ptyProcess.onExit((event) => {
|
||||
killed = true;
|
||||
processExited = true;
|
||||
for (const waiter of Array.from(processExitWaiters)) {
|
||||
try {
|
||||
waiter();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
processExitWaiters.clear();
|
||||
emitExit(
|
||||
buildExitInfo({
|
||||
exitCode: event.exitCode,
|
||||
@@ -783,6 +795,55 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
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
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
@@ -799,5 +860,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
getTitle,
|
||||
getExitInfo,
|
||||
kill,
|
||||
killAndWait,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -879,6 +879,31 @@ describe("paseo worktree manager", () => {
|
||||
deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }),
|
||||
).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", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { execFile, spawn } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs";
|
||||
import { rm, stat } from "fs/promises";
|
||||
import { join, basename, dirname, resolve, sep } from "path";
|
||||
import net from "node:net";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -1027,20 +1028,33 @@ export async function deletePaseoWorktree({
|
||||
cwd,
|
||||
worktreePath,
|
||||
worktreeSlug,
|
||||
worktreesRoot,
|
||||
paseoHome,
|
||||
}: {
|
||||
cwd: string;
|
||||
cwd: string | null;
|
||||
worktreePath?: string;
|
||||
worktreeSlug?: string;
|
||||
worktreesRoot?: string;
|
||||
paseoHome?: string;
|
||||
}): Promise<void> {
|
||||
if (!worktreePath && !worktreeSlug) {
|
||||
throw new Error("worktreePath or worktreeSlug is required");
|
||||
}
|
||||
|
||||
const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome);
|
||||
const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep;
|
||||
const requestedPath = worktreePath ?? join(worktreesRoot, worktreeSlug!);
|
||||
// Resolve the worktrees-root. With a repo cwd we hash it the normal way; if
|
||||
// git has forgotten about the worktree we expect the caller to hand us the
|
||||
// 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 resolvedWorktree =
|
||||
(await resolvePaseoWorktreeRootForCwd(requestedPath, { paseoHome }))?.worktreePath ??
|
||||
@@ -1050,32 +1064,72 @@ export async function deletePaseoWorktree({
|
||||
throw new Error("Refusing to delete non-Paseo worktree");
|
||||
}
|
||||
|
||||
if (existsSync(resolvedWorktree)) {
|
||||
if (await pathExists(resolvedWorktree)) {
|
||||
await runWorktreeTeardownCommands({
|
||||
worktreePath: resolvedWorktree,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await runGitCommand(["worktree", "remove", resolvedWorktree, "--force"], {
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
} catch {
|
||||
// `git worktree remove` fails if the admin dir is already gone (e.g. a prior
|
||||
// archive attempt removed it before the working tree could be fully cleaned
|
||||
// up). Fall through to the rmSync fallback below so the retry is idempotent.
|
||||
if (cwd) {
|
||||
try {
|
||||
await runGitCommand(["worktree", "remove", resolvedWorktree, "--force"], {
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
} catch {
|
||||
// `git worktree remove` fails if the admin dir is already gone (e.g. a
|
||||
// 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)) {
|
||||
rmSync(resolvedWorktree, { recursive: true, force: true });
|
||||
await removeDirectoryWithRetries(resolvedWorktree);
|
||||
|
||||
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.
|
||||
try {
|
||||
await runGitCommand(["worktree", "prune"], { cwd, timeout: 30_000 });
|
||||
} catch {
|
||||
// not critical; git will prune lazily
|
||||
const delaysMs = [0, 100, 300, 700, 1500];
|
||||
let lastError: unknown = null;
|
||||
for (const delay of delaysMs) {
|
||||
if (delay > 0) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user