diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index afc63e782..2acb25c74 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -652,12 +652,19 @@ describe("create_agent MCP tool", () => { const workspaceGitService = { getSnapshot: vi.fn(async () => null), }; + const archiveWorkspaceRecord = vi.fn(async () => undefined); + const emitWorkspaceUpdatesForCwds = vi.fn(async () => undefined); + const emitSessionMessage = vi.fn(); const server = await createAgentMcpServer({ agentManager, agentStorage, paseoHome, createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts: [] }), workspaceGitService: workspaceGitService as any, + archiveWorkspaceRecord, + emitWorkspaceUpdatesForCwds, + emitSessionMessage, + github: createGitHubServiceStub(), logger, }); const createTool = (server as any)._registeredTools["create_worktree"]; @@ -678,6 +685,10 @@ describe("create_agent MCP tool", () => { force: true, reason: "archive-worktree", }); + expect(archiveWorkspaceRecord).toHaveBeenCalledWith(created.structuredContent.worktreePath); + expect(Array.from(emitWorkspaceUpdatesForCwds.mock.calls[0]?.[0] ?? [])).toEqual([ + created.structuredContent.worktreePath, + ]); } finally { await rm(tempDir, { recursive: true, force: true }); } diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 00edd3597..49f9c1269 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -26,7 +26,12 @@ import { appendTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown, } from "./timeline-append.js"; -import { deletePaseoWorktree, type WorktreeConfig } from "../../utils/worktree.js"; +import { getPaseoWorktreesRoot, type WorktreeConfig } from "../../utils/worktree.js"; +import { + archivePaseoWorktree, + killTerminalsUnderPath, + type ArchivePaseoWorktreeDependencies, +} from "../paseo-worktree-archive-service.js"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js"; @@ -61,6 +66,7 @@ import type { CreatePaseoWorktreeResult, } from "../paseo-worktree-service.js"; import { toWorktreeRequestError } from "../worktree-errors.js"; +import { join } from "node:path"; export interface AgentMcpServerOptions { agentManager: AgentManager; @@ -71,6 +77,9 @@ export interface AgentMcpServerOptions { providerRegistry?: Record | null; github?: GitHubService; workspaceGitService?: Pick; + archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"]; + emitWorkspaceUpdatesForCwds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForCwds"]; + emitSessionMessage?: ArchivePaseoWorktreeDependencies["emit"]; createPaseoWorktree?: CreatePaseoWorktreeFn; paseoHome?: string; /** @@ -1733,24 +1742,58 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, async ({ cwd, worktreePath, worktreeSlug }) => { const repoRoot = resolveScopedCwd(cwd, { required: true }); - await deletePaseoWorktree({ - cwd: repoRoot, - worktreePath, - worktreeSlug, - paseoHome: options.paseoHome, - }); - options.github?.invalidate({ cwd: repoRoot }); - try { - await options.workspaceGitService?.getSnapshot(repoRoot, { - force: true, - reason: "archive-worktree", - }); - } catch (error) { - options.logger.warn( - { err: error, cwd: repoRoot }, - "Failed to force-refresh workspace git snapshot after archiving worktree", - ); + if (!worktreePath && !worktreeSlug) { + throw new Error("worktreePath or worktreeSlug is required"); } + if (!options.github) { + throw new Error("GitHub service is required to archive worktrees"); + } + if (!options.workspaceGitService) { + throw new Error("WorkspaceGitService is required to archive worktrees"); + } + if (!options.archiveWorkspaceRecord) { + throw new Error("Workspace registry archiver is required to archive worktrees"); + } + if (!options.emitWorkspaceUpdatesForCwds) { + throw new Error("Workspace update emitter is required to archive worktrees"); + } + if (!options.emitSessionMessage) { + throw new Error("Session message emitter is required to archive worktrees"); + } + + const targetPath = + worktreePath ?? + join(await getPaseoWorktreesRoot(repoRoot, options.paseoHome), worktreeSlug!); + + await archivePaseoWorktree( + { + paseoHome: options.paseoHome, + github: options.github, + workspaceGitService: options.workspaceGitService, + agentManager, + agentStorage, + archiveWorkspaceRecord: options.archiveWorkspaceRecord, + emit: options.emitSessionMessage, + emitWorkspaceUpdatesForCwds: options.emitWorkspaceUpdatesForCwds, + isPathWithinRoot: isSameOrDescendantPath, + killTerminalsUnderPath: (rootPath) => + killTerminalsUnderPath( + { + terminalManager: terminalManager ?? null, + isPathWithinRoot: isSameOrDescendantPath, + killTrackedTerminal: () => {}, + sessionLogger: childLogger, + }, + rootPath, + ), + sessionLogger: childLogger, + }, + { + targetPath, + repoRoot, + requestId: "mcp:archive_worktree", + }, + ); return { content: [], diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 05da8e8da..b40c1d4f8 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -112,6 +112,8 @@ import { LoopService } from "./loop-service.js"; import { ScheduleService } from "./schedule/service.js"; import { DaemonConfigStore } from "./daemon-config-store.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; +import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js"; +import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js"; import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js"; import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js"; import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; @@ -493,6 +495,34 @@ export async function createPaseoDaemon( if (mcpEnabled) { const agentMcpRoute = "/mcp/agents"; const agentMcpTransports: AgentMcpTransportMap = new Map(); + const archiveWorkspaceRecordForMcp = async (workspaceId: string) => { + const sessions = wsServer?.listActiveSessions() ?? []; + if (sessions.length > 0) { + await Promise.all( + sessions.map((session) => + session.archiveWorkspaceRecordForExternalMutation(workspaceId), + ), + ); + return; + } + + await archivePersistedWorkspaceRecord({ + workspaceId, + workspaceRegistry, + projectRegistry, + }); + }; + const emitWorkspaceUpdatesForMcpArchive = async (cwds: Iterable) => { + const cwdList = Array.from(cwds); + await Promise.all( + (wsServer?.listActiveSessions() ?? []).map((session) => + session.emitWorkspaceUpdatesForExternalCwds(cwdList), + ), + ); + }; + const emitMcpArchiveSessionMessage = (message: SessionOutboundMessage) => { + wsServer?.broadcast(wrapSessionMessage(message)); + }; const createAgentMcpTransport = async (callerAgentId?: string) => { const agentMcpServer = await createAgentMcpServer({ @@ -505,6 +535,9 @@ export async function createPaseoDaemon( providerRegistry, github, workspaceGitService, + archiveWorkspaceRecord: archiveWorkspaceRecordForMcp, + emitWorkspaceUpdatesForCwds: emitWorkspaceUpdatesForMcpArchive, + emitSessionMessage: emitMcpArchiveSessionMessage, createPaseoWorktree: async (input, serviceOptions) => { const coreDeps = createWorktreeCoreDeps(github); const result = await createPaseoWorktree(input, { diff --git a/packages/server/src/server/paseo-worktree-archive-service.ts b/packages/server/src/server/paseo-worktree-archive-service.ts new file mode 100644 index 000000000..914b79fb3 --- /dev/null +++ b/packages/server/src/server/paseo-worktree-archive-service.ts @@ -0,0 +1,220 @@ +import type { Logger } from "pino"; + +import type { AgentManager } from "./agent/agent-manager.js"; +import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; +import type { SessionOutboundMessage } from "./messages.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; +import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js"; +import type { GitHubService } from "../services/github-service.js"; +import { deletePaseoWorktree, resolvePaseoWorktreeRootForCwd } from "../utils/worktree.js"; +import type { TerminalManager } from "../terminal/terminal-manager.js"; + +type EmitSessionMessage = (message: SessionOutboundMessage) => void; + +export type ArchivePaseoWorktreeDependencies = { + paseoHome?: string; + github: GitHubService; + workspaceGitService: Pick; + agentManager: Pick; + agentStorage: Pick; + archiveWorkspaceRecord: (workspaceId: string) => Promise; + emit: EmitSessionMessage; + emitWorkspaceUpdatesForCwds: (cwds: Iterable) => Promise; + isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; + killTerminalsUnderPath: (rootPath: string) => Promise; + sessionLogger?: Logger; +}; + +export 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; +}; + +export async function archivePaseoWorktree( + dependencies: ArchivePaseoWorktreeDependencies, + options: { + targetPath: string; + repoRoot: string | null; + worktreesRoot?: string; + requestId: string; + }, +): Promise { + let targetPath = options.targetPath; + const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, { + paseoHome: dependencies.paseoHome, + }); + if (resolvedWorktree) { + targetPath = resolvedWorktree.worktreePath; + } + + const removedAgents = new Set(); + const affectedWorkspaceCwds = new Set([targetPath]); + const affectedWorkspaceIds = new Set([normalizePersistedWorkspaceId(targetPath)]); + + 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)); + } + + let storedRecords: StoredAgentRecord[] = []; + try { + storedRecords = await dependencies.agentStorage.list(); + } catch (error) { + dependencies.sessionLogger?.warn( + { err: error, targetPath }, + "Failed to list stored agents during worktree archive; continuing", + ); + } + 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)); + } + + const agentIdsToRemoveFromStorage = new Set([ + ...liveAgents.map((agent) => agent.id), + ...matchingStoredRecords.map((record) => record.id), + ]); + + 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", + ); + } + } + + const agentIdsToRemove = Array.from(agentIdsToRemoveFromStorage); + const storageRemovalResults = await Promise.allSettled( + agentIdsToRemove.map((agentId) => dependencies.agentStorage.remove(agentId)), + ); + storageRemovalResults.forEach((result, index) => { + if (result.status === "fulfilled") { + return; + } + dependencies.sessionLogger?.warn( + { + err: result.reason, + agentId: agentIdsToRemove[index], + targetPath, + }, + "Failed to remove archived worktree agent from storage; continuing", + ); + }); + + await deletePaseoWorktree({ + cwd: options.repoRoot, + worktreePath: targetPath, + worktreesRoot: options.worktreesRoot, + paseoHome: dependencies.paseoHome, + }); + + if (options.repoRoot) { + try { + await dependencies.workspaceGitService.getSnapshot(options.repoRoot, { + force: true, + reason: "archive-worktree", + }); + } catch (error) { + dependencies.sessionLogger?.warn( + { err: error, cwd: options.repoRoot }, + "Failed to force-refresh workspace git snapshot after archiving worktree", + ); + } + } + + for (const cwd of affectedWorkspaceCwds) { + dependencies.github.invalidate({ cwd }); + } + + for (const workspaceId of affectedWorkspaceIds) { + 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) { + dependencies.emit({ + type: "agent_deleted", + payload: { + agentId, + requestId: options.requestId, + }, + }); + } + + await dependencies.emitWorkspaceUpdatesForCwds(affectedWorkspaceCwds); + + return Array.from(removedAgents); +} + +export async function killTerminalsUnderPath( + dependencies: KillTerminalsUnderPathDependencies, + rootPath: string, +): Promise { + const terminalManager = dependencies.terminalManager; + if (!terminalManager) { + return; + } + + const terminalIds: string[] = []; + const terminalDirectories = [...terminalManager.listDirectories()]; + for (const terminalCwd of terminalDirectories) { + if (!dependencies.isPathWithinRoot(rootPath, terminalCwd)) { + continue; + } + try { + const terminals = await terminalManager.getTerminals(terminalCwd); + for (const terminal of terminals) { + terminalIds.push(terminal.id); + } + } catch (error) { + dependencies.sessionLogger.warn( + { err: error, cwd: terminalCwd }, + "Failed to enumerate worktree terminals during archive", + ); + } + } + + 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", + ); + } + }), + ); +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 291199aaa..d104ded25 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -165,6 +165,7 @@ import { DownloadTokenStore } from "./file-download/token-store.js"; import { PushTokenStore } from "./push/token-store.js"; import { type WorktreeConfig } from "../utils/worktree.js"; import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js"; +import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js"; import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; import type { ScriptRouteStore } from "./script-proxy.js"; import { @@ -211,8 +212,8 @@ import { handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest, handlePaseoWorktreeListRequest as handleWorktreeListRequest, handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage, - killTerminalsUnderPath as killWorktreeTerminalsUnderPath, } from "./worktree-session.js"; +import { killTerminalsUnderPath as killWorktreeTerminalsUnderPath } from "./paseo-worktree-archive-service.js"; import { toWorktreeWireError } from "./worktree-errors.js"; const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS); @@ -907,6 +908,14 @@ export class Session { await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true }); } + async archiveWorkspaceRecordForExternalMutation(workspaceId: string): Promise { + await this.archiveWorkspaceRecord(workspaceId); + } + + async emitWorkspaceUpdatesForExternalCwds(cwds: Iterable): Promise { + await this.emitWorkspaceUpdatesForCwds(cwds); + } + async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise { await this.primeWorkspaceGitWatchFingerprintForWorkspace(workspace); await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); @@ -6403,24 +6412,20 @@ export class Session { } private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise { - const existingWorkspace = await this.workspaceRegistry.get(workspaceId); - if (!existingWorkspace || existingWorkspace.archivedAt) { + const existingWorkspace = await archivePersistedWorkspaceRecord({ + workspaceId, + archivedAt, + workspaceRegistry: this.workspaceRegistry, + projectRegistry: this.projectRegistry, + }); + if (!existingWorkspace) { this.removeWorkspaceGitSubscription(workspaceId); return; } - const nextArchivedAt = archivedAt ?? new Date().toISOString(); - await this.workspaceRegistry.archive(workspaceId, nextArchivedAt); await this.removeWorkspaceGitWatchTarget(existingWorkspace.cwd); this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.cwd); this.removeWorkspaceGitSubscription(workspaceId); - - const siblingWorkspaces = (await this.workspaceRegistry.list()).filter( - (workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt, - ); - if (siblingWorkspaces.length === 0) { - await this.projectRegistry.archive(existingWorkspace.projectId, nextArchivedAt); - } } private async reconcileAndEmitWorkspaceUpdates(): Promise { diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts new file mode 100644 index 000000000..487b162f3 --- /dev/null +++ b/packages/server/src/server/workspace-archive-service.ts @@ -0,0 +1,32 @@ +import type { + PersistedWorkspaceRecord, + ProjectRegistry, + WorkspaceRegistry, +} from "./workspace-registry.js"; + +export async function archivePersistedWorkspaceRecord(input: { + workspaceId: string; + workspaceRegistry: Pick; + projectRegistry: Pick; + archivedAt?: string; +}): Promise { + const existingWorkspace = await input.workspaceRegistry.get(input.workspaceId); + if (!existingWorkspace) { + return null; + } + + if (existingWorkspace.archivedAt) { + return existingWorkspace; + } + + const archivedAt = input.archivedAt ?? new Date().toISOString(); + await input.workspaceRegistry.archive(input.workspaceId, archivedAt); + const activeSiblings = (await input.workspaceRegistry.list()).filter( + (workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt, + ); + if (activeSiblings.length === 0) { + await input.projectRegistry.archive(existingWorkspace.projectId, archivedAt); + } + + return existingWorkspace; +} diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index c4295d758..9caa59e23 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -14,8 +14,8 @@ import pino, { type Logger } from "pino"; import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messages.js"; import { ScriptRouteStore } from "./script-proxy.js"; +import { archivePaseoWorktree } from "./paseo-worktree-archive-service.js"; import { - archivePaseoWorktree, buildAgentSessionConfig, handlePaseoWorktreeArchiveRequest, handlePaseoWorktreeListRequest, diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 4a28ef6f7..9dfd8687a 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -2,8 +2,6 @@ import type { Logger } from "pino"; import { basename } from "node:path"; import type { AgentSessionConfig } from "./agent/agent-sdk-types.js"; -import type { AgentManager } from "./agent/agent-manager.js"; -import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; import { type AgentAttachment, type GitSetupOptions, @@ -14,7 +12,6 @@ import { } from "./messages.js"; import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; -import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js"; import { applyWorktreeSetupProgressEvent, buildWorktreeSetupDetail, @@ -28,10 +25,8 @@ import type { GitHubService } from "../services/github-service.js"; import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; import { - deletePaseoWorktree, getWorktreeSetupCommands, isPaseoOwnedWorktreeCwd, - resolvePaseoWorktreeRootForCwd, resolveWorktreeRuntimeEnv, runWorktreeSetupCommands, slugify, @@ -45,6 +40,10 @@ import type { CreatePaseoWorktreeInput, CreatePaseoWorktreeResult, } from "./paseo-worktree-service.js"; +import { + archivePaseoWorktree, + type ArchivePaseoWorktreeDependencies, +} from "./paseo-worktree-archive-service.js"; import { toWorktreeWireError } from "./worktree-errors.js"; const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/; @@ -82,20 +81,6 @@ type BuildAgentSessionConfigDependencies = { github?: Pick; }; -type ArchivePaseoWorktreeDependencies = { - paseoHome?: string; - github: GitHubService; - workspaceGitService: WorkspaceGitService; - agentManager: Pick; - agentStorage: Pick; - archiveWorkspaceRecord: (workspaceId: string) => Promise; - emit: EmitSessionMessage; - emitWorkspaceUpdatesForCwds: (cwds: Iterable) => Promise; - isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; - killTerminalsUnderPath: (rootPath: string) => Promise; - sessionLogger?: Logger; -}; - type CreatePaseoWorktreeInBackgroundDependencies = { paseoHome?: string; emitWorkspaceUpdateForCwd: (cwd: string, options?: { dedupeGitState?: boolean }) => Promise; @@ -136,14 +121,6 @@ type HandleCreatePaseoWorktreeRequestDependencies = { }) => Promise; }; -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; -}; - export async function buildAgentSessionConfig( dependencies: BuildAgentSessionConfigDependencies, config: AgentSessionConfig, @@ -368,151 +345,13 @@ export async function handlePaseoWorktreeListRequest( } } -export async function archivePaseoWorktree( - dependencies: ArchivePaseoWorktreeDependencies, - options: { - targetPath: string; - repoRoot: string | null; - worktreesRoot?: string; - requestId: string; - }, -): Promise { - let targetPath = options.targetPath; - const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, { - paseoHome: dependencies.paseoHome, - }); - if (resolvedWorktree) { - targetPath = resolvedWorktree.worktreePath; - } - - const removedAgents = new Set(); - const affectedWorkspaceCwds = new Set([targetPath]); - const affectedWorkspaceIds = new Set([normalizePersistedWorkspaceId(targetPath)]); - - 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)); - } - - let storedRecords: StoredAgentRecord[] = []; - try { - storedRecords = await dependencies.agentStorage.list(); - } catch (error) { - dependencies.sessionLogger?.warn( - { err: error, targetPath }, - "Failed to list stored agents during worktree archive; continuing", - ); - } - 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)); - } - - const agentIdsToRemoveFromStorage = new Set([ - ...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", - ); - } - } - - // 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. - const agentIdsToRemove = Array.from(agentIdsToRemoveFromStorage); - const storageRemovalResults = await Promise.allSettled( - agentIdsToRemove.map((agentId) => dependencies.agentStorage.remove(agentId)), - ); - storageRemovalResults.forEach((result, index) => { - if (result.status === "fulfilled") { - return; - } - dependencies.sessionLogger?.warn( - { - err: result.reason, - agentId: agentIdsToRemove[index], - targetPath, - }, - "Failed to remove archived worktree agent from storage; continuing", - ); - }); - - await deletePaseoWorktree({ - cwd: options.repoRoot, - worktreePath: targetPath, - worktreesRoot: options.worktreesRoot, - paseoHome: dependencies.paseoHome, - }); - - if (options.repoRoot) { - try { - await dependencies.workspaceGitService.getSnapshot(options.repoRoot, { - force: true, - reason: "archive-worktree", - }); - } catch (error) { - dependencies.sessionLogger?.warn( - { err: error, cwd: options.repoRoot }, - "Failed to force-refresh workspace git snapshot after archiving worktree", - ); - } - } - - for (const cwd of affectedWorkspaceCwds) { - dependencies.github.invalidate({ cwd }); - } - - for (const workspaceId of affectedWorkspaceIds) { - 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) { - dependencies.emit({ - type: "agent_deleted", - payload: { - agentId, - requestId: options.requestId, - }, - }); - } - - await dependencies.emitWorkspaceUpdatesForCwds(affectedWorkspaceCwds); - - return Array.from(removedAgents); -} - export async function handlePaseoWorktreeArchiveRequest( - dependencies: Omit & { + dependencies: Omit< + ArchivePaseoWorktreeDependencies, + "emitWorkspaceUpdatesForCwds" | "workspaceGitService" + > & { emit: EmitSessionMessage; + workspaceGitService: Pick; emitWorkspaceUpdatesForCwds: (cwds: Iterable) => Promise; }, msg: Extract, @@ -533,6 +372,9 @@ export async function handlePaseoWorktreeArchiveRequest( } targetPath = match.path; } + if (!targetPath) { + throw new Error("worktreePath could not be resolved"); + } const ownership = await isPaseoOwnedWorktreeCwd(targetPath, { paseoHome: dependencies.paseoHome, @@ -779,53 +621,3 @@ export async function runWorktreeSetupInBackground( await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath); } } - -export async function killTerminalsUnderPath( - dependencies: KillTerminalsUnderPathDependencies, - rootPath: string, -): Promise { - const terminalManager = dependencies.terminalManager; - if (!terminalManager) { - return; - } - - const terminalIds: string[] = []; - const terminalDirectories = [...terminalManager.listDirectories()]; - for (const terminalCwd of terminalDirectories) { - if (!dependencies.isPathWithinRoot(rootPath, terminalCwd)) { - continue; - } - try { - const terminals = await terminalManager.getTerminals(terminalCwd); - for (const terminal of terminals) { - terminalIds.push(terminal.id); - } - } catch (error) { - dependencies.sessionLogger.warn( - { err: error, cwd: terminalCwd }, - "Failed to enumerate worktree terminals during archive", - ); - } - } - - 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", - ); - } - }), - ); -}