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 { 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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());
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user