fix(server): unify worktree archive paths

This commit is contained in:
Mohamed Boudra
2026-04-22 19:59:00 +07:00
parent d80c524e0e
commit 8e63274ce6
8 changed files with 387 additions and 251 deletions
@@ -652,12 +652,19 @@ describe("create_agent MCP tool", () => {
const workspaceGitService = { const workspaceGitService = {
getSnapshot: vi.fn(async () => null), 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({ const server = await createAgentMcpServer({
agentManager, agentManager,
agentStorage, agentStorage,
paseoHome, paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts: [] }), createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts: [] }),
workspaceGitService: workspaceGitService as any, workspaceGitService: workspaceGitService as any,
archiveWorkspaceRecord,
emitWorkspaceUpdatesForCwds,
emitSessionMessage,
github: createGitHubServiceStub(),
logger, logger,
}); });
const createTool = (server as any)._registeredTools["create_worktree"]; const createTool = (server as any)._registeredTools["create_worktree"];
@@ -678,6 +685,10 @@ describe("create_agent MCP tool", () => {
force: true, force: true,
reason: "archive-worktree", reason: "archive-worktree",
}); });
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(created.structuredContent.worktreePath);
expect(Array.from(emitWorkspaceUpdatesForCwds.mock.calls[0]?.[0] ?? [])).toEqual([
created.structuredContent.worktreePath,
]);
} finally { } finally {
await rm(tempDir, { recursive: true, force: true }); await rm(tempDir, { recursive: true, force: true });
} }
+61 -18
View File
@@ -26,7 +26,12 @@ import {
appendTimelineItemIfAgentKnown, appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown,
} from "./timeline-append.js"; } 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 { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
@@ -61,6 +66,7 @@ import type {
CreatePaseoWorktreeResult, CreatePaseoWorktreeResult,
} from "../paseo-worktree-service.js"; } from "../paseo-worktree-service.js";
import { toWorktreeRequestError } from "../worktree-errors.js"; import { toWorktreeRequestError } from "../worktree-errors.js";
import { join } from "node:path";
export interface AgentMcpServerOptions { export interface AgentMcpServerOptions {
agentManager: AgentManager; agentManager: AgentManager;
@@ -71,6 +77,9 @@ export interface AgentMcpServerOptions {
providerRegistry?: Record<AgentProvider, ProviderDefinition> | null; providerRegistry?: Record<AgentProvider, ProviderDefinition> | null;
github?: GitHubService; github?: GitHubService;
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">; workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"];
emitWorkspaceUpdatesForCwds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForCwds"];
emitSessionMessage?: ArchivePaseoWorktreeDependencies["emit"];
createPaseoWorktree?: CreatePaseoWorktreeFn; createPaseoWorktree?: CreatePaseoWorktreeFn;
paseoHome?: string; paseoHome?: string;
/** /**
@@ -1733,24 +1742,58 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
}, },
async ({ cwd, worktreePath, worktreeSlug }) => { async ({ cwd, worktreePath, worktreeSlug }) => {
const repoRoot = resolveScopedCwd(cwd, { required: true }); const repoRoot = resolveScopedCwd(cwd, { required: true });
await deletePaseoWorktree({ if (!worktreePath && !worktreeSlug) {
cwd: repoRoot, throw new Error("worktreePath or worktreeSlug is required");
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 (!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 { return {
content: [], content: [],
+33
View File
@@ -112,6 +112,8 @@ import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js"; import { ScheduleService } from "./schedule/service.js";
import { DaemonConfigStore } from "./daemon-config-store.js"; import { DaemonConfigStore } from "./daemon-config-store.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.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 { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js"; import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
@@ -493,6 +495,34 @@ export async function createPaseoDaemon(
if (mcpEnabled) { if (mcpEnabled) {
const agentMcpRoute = "/mcp/agents"; const agentMcpRoute = "/mcp/agents";
const agentMcpTransports: AgentMcpTransportMap = new Map(); 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<string>) => {
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 createAgentMcpTransport = async (callerAgentId?: string) => {
const agentMcpServer = await createAgentMcpServer({ const agentMcpServer = await createAgentMcpServer({
@@ -505,6 +535,9 @@ export async function createPaseoDaemon(
providerRegistry, providerRegistry,
github, github,
workspaceGitService, workspaceGitService,
archiveWorkspaceRecord: archiveWorkspaceRecordForMcp,
emitWorkspaceUpdatesForCwds: emitWorkspaceUpdatesForMcpArchive,
emitSessionMessage: emitMcpArchiveSessionMessage,
createPaseoWorktree: async (input, serviceOptions) => { createPaseoWorktree: async (input, serviceOptions) => {
const coreDeps = createWorktreeCoreDeps(github); const coreDeps = createWorktreeCoreDeps(github);
const result = await createPaseoWorktree(input, { const result = await createPaseoWorktree(input, {
@@ -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<WorkspaceGitService, "getSnapshot">;
agentManager: Pick<AgentManager, "listAgents" | "closeAgent">;
agentStorage: Pick<AgentStorage, "list" | "remove">;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emit: EmitSessionMessage;
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
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<string[]> {
let targetPath = options.targetPath;
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
paseoHome: dependencies.paseoHome,
});
if (resolvedWorktree) {
targetPath = resolvedWorktree.worktreePath;
}
const removedAgents = new Set<string>();
const affectedWorkspaceCwds = new Set<string>([targetPath]);
const affectedWorkspaceIds = new Set<string>([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<string>([
...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<void> {
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",
);
}
}),
);
}
+17 -12
View File
@@ -165,6 +165,7 @@ import { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js"; import { PushTokenStore } from "./push/token-store.js";
import { type WorktreeConfig } from "../utils/worktree.js"; import { type WorktreeConfig } from "../utils/worktree.js";
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js"; import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import type { ScriptRouteStore } from "./script-proxy.js"; import type { ScriptRouteStore } from "./script-proxy.js";
import { import {
@@ -211,8 +212,8 @@ import {
handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest, handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest,
handlePaseoWorktreeListRequest as handleWorktreeListRequest, handlePaseoWorktreeListRequest as handleWorktreeListRequest,
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage, handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
killTerminalsUnderPath as killWorktreeTerminalsUnderPath,
} from "./worktree-session.js"; } from "./worktree-session.js";
import { killTerminalsUnderPath as killWorktreeTerminalsUnderPath } from "./paseo-worktree-archive-service.js";
import { toWorktreeWireError } from "./worktree-errors.js"; import { toWorktreeWireError } from "./worktree-errors.js";
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS); 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 }); await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true });
} }
async archiveWorkspaceRecordForExternalMutation(workspaceId: string): Promise<void> {
await this.archiveWorkspaceRecord(workspaceId);
}
async emitWorkspaceUpdatesForExternalCwds(cwds: Iterable<string>): Promise<void> {
await this.emitWorkspaceUpdatesForCwds(cwds);
}
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> { async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
await this.primeWorkspaceGitWatchFingerprintForWorkspace(workspace); await this.primeWorkspaceGitWatchFingerprintForWorkspace(workspace);
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
@@ -6403,24 +6412,20 @@ export class Session {
} }
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> { private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
const existingWorkspace = await this.workspaceRegistry.get(workspaceId); const existingWorkspace = await archivePersistedWorkspaceRecord({
if (!existingWorkspace || existingWorkspace.archivedAt) { workspaceId,
archivedAt,
workspaceRegistry: this.workspaceRegistry,
projectRegistry: this.projectRegistry,
});
if (!existingWorkspace) {
this.removeWorkspaceGitSubscription(workspaceId); this.removeWorkspaceGitSubscription(workspaceId);
return; return;
} }
const nextArchivedAt = archivedAt ?? new Date().toISOString();
await this.workspaceRegistry.archive(workspaceId, nextArchivedAt);
await this.removeWorkspaceGitWatchTarget(existingWorkspace.cwd); await this.removeWorkspaceGitWatchTarget(existingWorkspace.cwd);
this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.cwd); this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.cwd);
this.removeWorkspaceGitSubscription(workspaceId); 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<void> { private async reconcileAndEmitWorkspaceUpdates(): Promise<void> {
@@ -0,0 +1,32 @@
import type {
PersistedWorkspaceRecord,
ProjectRegistry,
WorkspaceRegistry,
} from "./workspace-registry.js";
export async function archivePersistedWorkspaceRecord(input: {
workspaceId: string;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list" | "archive">;
projectRegistry: Pick<ProjectRegistry, "archive">;
archivedAt?: string;
}): Promise<PersistedWorkspaceRecord | null> {
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;
}
@@ -14,8 +14,8 @@ import pino, { type Logger } from "pino";
import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messages.js"; import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messages.js";
import { ScriptRouteStore } from "./script-proxy.js"; import { ScriptRouteStore } from "./script-proxy.js";
import { archivePaseoWorktree } from "./paseo-worktree-archive-service.js";
import { import {
archivePaseoWorktree,
buildAgentSessionConfig, buildAgentSessionConfig,
handlePaseoWorktreeArchiveRequest, handlePaseoWorktreeArchiveRequest,
handlePaseoWorktreeListRequest, handlePaseoWorktreeListRequest,
+12 -220
View File
@@ -2,8 +2,6 @@ import type { Logger } from "pino";
import { basename } from "node:path"; import { basename } from "node:path";
import type { AgentSessionConfig } from "./agent/agent-sdk-types.js"; 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 { import {
type AgentAttachment, type AgentAttachment,
type GitSetupOptions, type GitSetupOptions,
@@ -14,7 +12,6 @@ import {
} from "./messages.js"; } from "./messages.js";
import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
import type { WorkspaceGitService } from "./workspace-git-service.js"; import type { WorkspaceGitService } from "./workspace-git-service.js";
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
import { import {
applyWorktreeSetupProgressEvent, applyWorktreeSetupProgressEvent,
buildWorktreeSetupDetail, buildWorktreeSetupDetail,
@@ -28,10 +25,8 @@ import type { GitHubService } from "../services/github-service.js";
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js"; import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js"; import { expandTilde } from "../utils/path.js";
import { import {
deletePaseoWorktree,
getWorktreeSetupCommands, getWorktreeSetupCommands,
isPaseoOwnedWorktreeCwd, isPaseoOwnedWorktreeCwd,
resolvePaseoWorktreeRootForCwd,
resolveWorktreeRuntimeEnv, resolveWorktreeRuntimeEnv,
runWorktreeSetupCommands, runWorktreeSetupCommands,
slugify, slugify,
@@ -45,6 +40,10 @@ import type {
CreatePaseoWorktreeInput, CreatePaseoWorktreeInput,
CreatePaseoWorktreeResult, CreatePaseoWorktreeResult,
} from "./paseo-worktree-service.js"; } from "./paseo-worktree-service.js";
import {
archivePaseoWorktree,
type ArchivePaseoWorktreeDependencies,
} from "./paseo-worktree-archive-service.js";
import { toWorktreeWireError } from "./worktree-errors.js"; import { toWorktreeWireError } from "./worktree-errors.js";
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/; const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
@@ -82,20 +81,6 @@ type BuildAgentSessionConfigDependencies = {
github?: Pick<GitHubService, "invalidate">; github?: Pick<GitHubService, "invalidate">;
}; };
type ArchivePaseoWorktreeDependencies = {
paseoHome?: string;
github: GitHubService;
workspaceGitService: WorkspaceGitService;
agentManager: Pick<AgentManager, "listAgents" | "closeAgent">;
agentStorage: Pick<AgentStorage, "list" | "remove">;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emit: EmitSessionMessage;
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
sessionLogger?: Logger;
};
type CreatePaseoWorktreeInBackgroundDependencies = { type CreatePaseoWorktreeInBackgroundDependencies = {
paseoHome?: string; paseoHome?: string;
emitWorkspaceUpdateForCwd: (cwd: string, options?: { dedupeGitState?: boolean }) => Promise<void>; emitWorkspaceUpdateForCwd: (cwd: string, options?: { dedupeGitState?: boolean }) => Promise<void>;
@@ -136,14 +121,6 @@ type HandleCreatePaseoWorktreeRequestDependencies = {
}) => Promise<void>; }) => Promise<void>;
}; };
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( export async function buildAgentSessionConfig(
dependencies: BuildAgentSessionConfigDependencies, dependencies: BuildAgentSessionConfigDependencies,
config: AgentSessionConfig, 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<string[]> {
let targetPath = options.targetPath;
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
paseoHome: dependencies.paseoHome,
});
if (resolvedWorktree) {
targetPath = resolvedWorktree.worktreePath;
}
const removedAgents = new Set<string>();
const affectedWorkspaceCwds = new Set<string>([targetPath]);
const affectedWorkspaceIds = new Set<string>([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<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",
);
}
}
// 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( export async function handlePaseoWorktreeArchiveRequest(
dependencies: Omit<ArchivePaseoWorktreeDependencies, "emitWorkspaceUpdatesForCwds"> & { dependencies: Omit<
ArchivePaseoWorktreeDependencies,
"emitWorkspaceUpdatesForCwds" | "workspaceGitService"
> & {
emit: EmitSessionMessage; emit: EmitSessionMessage;
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>; emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
}, },
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>, msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
@@ -533,6 +372,9 @@ export async function handlePaseoWorktreeArchiveRequest(
} }
targetPath = match.path; targetPath = match.path;
} }
if (!targetPath) {
throw new Error("worktreePath could not be resolved");
}
const ownership = await isPaseoOwnedWorktreeCwd(targetPath, { const ownership = await isPaseoOwnedWorktreeCwd(targetPath, {
paseoHome: dependencies.paseoHome, paseoHome: dependencies.paseoHome,
@@ -779,53 +621,3 @@ export async function runWorktreeSetupInBackground(
await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath); await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath);
} }
} }
export async function killTerminalsUnderPath(
dependencies: KillTerminalsUnderPathDependencies,
rootPath: string,
): Promise<void> {
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",
);
}
}),
);
}