fix(server): unify worktree archive paths
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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<AgentProvider, ProviderDefinition> | null;
|
||||
github?: GitHubService;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
|
||||
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: [],
|
||||
|
||||
@@ -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<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 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, {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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<void> {
|
||||
await this.archiveWorkspaceRecord(workspaceId);
|
||||
}
|
||||
|
||||
async emitWorkspaceUpdatesForExternalCwds(cwds: Iterable<string>): Promise<void> {
|
||||
await this.emitWorkspaceUpdatesForCwds(cwds);
|
||||
}
|
||||
|
||||
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
||||
await this.primeWorkspaceGitWatchFingerprintForWorkspace(workspace);
|
||||
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
|
||||
@@ -6403,24 +6412,20 @@ export class Session {
|
||||
}
|
||||
|
||||
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
|
||||
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<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 { ScriptRouteStore } from "./script-proxy.js";
|
||||
import { archivePaseoWorktree } from "./paseo-worktree-archive-service.js";
|
||||
import {
|
||||
archivePaseoWorktree,
|
||||
buildAgentSessionConfig,
|
||||
handlePaseoWorktreeArchiveRequest,
|
||||
handlePaseoWorktreeListRequest,
|
||||
|
||||
@@ -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<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 = {
|
||||
paseoHome?: string;
|
||||
emitWorkspaceUpdateForCwd: (cwd: string, options?: { dedupeGitState?: boolean }) => Promise<void>;
|
||||
@@ -136,14 +121,6 @@ type HandleCreatePaseoWorktreeRequestDependencies = {
|
||||
}) => 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(
|
||||
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<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(
|
||||
dependencies: Omit<ArchivePaseoWorktreeDependencies, "emitWorkspaceUpdatesForCwds"> & {
|
||||
dependencies: Omit<
|
||||
ArchivePaseoWorktreeDependencies,
|
||||
"emitWorkspaceUpdatesForCwds" | "workspaceGitService"
|
||||
> & {
|
||||
emit: EmitSessionMessage;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
|
||||
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
|
||||
},
|
||||
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
|
||||
@@ -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<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",
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user