diff --git a/packages/app/e2e/support/helpers/new-workspace-runtime.ts b/packages/app/e2e/support/helpers/new-workspace-runtime.ts index 012c874233..2486d36643 100644 --- a/packages/app/e2e/support/helpers/new-workspace-runtime.ts +++ b/packages/app/e2e/support/helpers/new-workspace-runtime.ts @@ -100,7 +100,7 @@ export async function expectUserWorkspaceRanProjectSetup( ); const state = states.find((candidate) => candidate.workspaceId === workspace!.workspaceId); expect(state, "fixture user runtime state").toBeDefined(); - expect(await markerExists(state!.root)).toBe(true); + await expect.poll(() => markerExists(state!.root), { timeout: 30_000 }).toBe(true); } export async function expectProviderAvailable(page: Page, providerLabel: string): Promise { diff --git a/packages/app/src/git/workspace-actions.tsx b/packages/app/src/git/workspace-actions.tsx index 6e87283781..8e497f6131 100644 --- a/packages/app/src/git/workspace-actions.tsx +++ b/packages/app/src/git/workspace-actions.tsx @@ -6,10 +6,11 @@ interface WorkspaceActionsProps { serverId: string; workspaceId: string; cwd: string; + isWorkspaceGitBound: boolean; hideLabels?: boolean; } -export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) { +function BoundWorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) { const { gitActions } = useGitActions({ serverId, cwd, @@ -18,3 +19,7 @@ export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActions return ; } + +export function WorkspaceActions(props: WorkspaceActionsProps) { + return props.isWorkspaceGitBound ? : null; +} diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 9725170d9f..95fdebfd00 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -11,6 +11,7 @@ class FakeDirectoryClient { fetchAgentsCalls = 0; lastAgentOptions: unknown; fetchWorkspacesCalls = 0; + lastWorkspaceOptions: unknown; listProjectsCalls = 0; lastProjectOptions: unknown; projectResult: ProjectListResult | null = null; @@ -55,8 +56,9 @@ class FakeDirectoryClient { }; } - async fetchWorkspaces(): Promise { + async fetchWorkspaces(options?: unknown): Promise { this.fetchWorkspacesCalls += 1; + this.lastWorkspaceOptions = options; if (this.pendingWorkspaceFetch) { const pending = this.pendingWorkspaceFetch; this.pendingWorkspaceFetch = null; @@ -147,6 +149,7 @@ describe("DirectorySync session readiness", () => { version: "test", features: { directorySync: true, workspaceMultiplicity: true }, }); + store.setHasHydratedAgents(serverId, true); await directory.refreshAgents({ subscribe: { subscriptionId: `app:${serverId}` }, @@ -250,6 +253,7 @@ describe("DirectorySync session readiness", () => { projectKind: "git", }), ]); + store.setHasHydratedWorkspaces(serverId, true); store.updateSessionServerInfo(serverId, { serverId, hostname: null, @@ -287,6 +291,49 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); + it("requests snapshots when persisted cursors outlive the in-memory replicas", async () => { + const serverId = "fresh-replica-stale-cursors"; + serverIds.add(serverId); + const client = new FakeDirectoryClient(); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readDirectoryCheckpoint: () => ({ + agents: { generation: "agents-generation", afterSeq: 3 }, + projects: { generation: "projects-generation", afterSeq: 4 }, + workspaces: { generation: "workspaces-generation", afterSeq: 5 }, + }), + writeDirectoryCheckpoint: () => undefined, + }, + ); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { directorySync: true, workspaceMultiplicity: true, projectList: true }, + }); + + await directory.refreshAll(); + + expect(client.lastAgentOptions).toMatchObject({ sync: {} }); + expect(client.lastProjectOptions).toEqual({ sync: {} }); + expect(client.lastWorkspaceOptions).toMatchObject({ sync: {} }); + directory.dispose(); + }); + it("rejects a session wait on disconnect so the reconnect can refresh", async () => { const serverId = "session-wait-reconnect"; const { client, directory } = createDirectory(serverId); diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index ac80a81bd9..53ee5b5440 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -331,7 +331,7 @@ export class DirectorySync { sort: [{ key: "activity_at", direction: "desc" }], ...(subscribe ? { subscribe: {} } : {}), page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT }, - ...(supportsDirectorySync ? { sync: this.readCursors().workspaces ?? {} } : {}), + ...(supportsDirectorySync ? { sync: this.readUsableCursors().workspaces ?? {} } : {}), }); this.assertWorkspaceTransactionCurrent(client, source, transaction); if (payload.sync?.mode === "snapshot") transaction.snapshot.workspaces.clear(); @@ -387,7 +387,7 @@ export class DirectorySync { ...(subscribe ? { subscribe } : {}), page: cursor ? { limit, cursor } : { limit }, ...(!input.filter && cursor === null && this.supportsDirectorySync() - ? { sync: this.readCursors().agents ?? {} } + ? { sync: this.readUsableCursors().agents ?? {} } : {}), }); this.assertAgentTransactionCurrent(client, source, transaction); @@ -459,7 +459,7 @@ export class DirectorySync { supportsDirectorySync: boolean, ): Promise { const payload = await client.listProjects( - supportsDirectorySync ? { sync: this.readCursors().projects ?? {} } : undefined, + supportsDirectorySync ? { sync: this.readUsableCursors().projects ?? {} } : undefined, ); this.assertWorkspaceTransactionCurrent(client, source, transaction); if (payload.sync?.mode === "snapshot") transaction.snapshot.projects.clear(); @@ -521,7 +521,7 @@ export class DirectorySync { }); } - private readCursors(): DirectoryCursors { + private readPersistedCursors(): DirectoryCursors { const value = this.checkpoints?.readDirectoryCheckpoint(this.serverId); if (!value || typeof value !== "object" || Array.isArray(value)) return {}; const cursors: DirectoryCursors = {}; @@ -536,9 +536,23 @@ export class DirectorySync { return cursors; } + private readUsableCursors(): DirectoryCursors { + const persisted = this.readPersistedCursors(); + const session = useSessionStore.getState().sessions[this.serverId]; + return { + ...(session?.hasHydratedAgents && persisted.agents ? { agents: persisted.agents } : {}), + ...(session?.hasHydratedWorkspaces && persisted.projects + ? { projects: persisted.projects } + : {}), + ...(session?.hasHydratedWorkspaces && persisted.workspaces + ? { workspaces: persisted.workspaces } + : {}), + }; + } + private writeCursor(entity: keyof DirectoryCursors, cursor: DirectoryCursor): void { if (!this.checkpoints) return; - const current = this.readCursors(); + const current = this.readPersistedCursors(); const previous = current[entity]; if (previous?.generation === cursor.generation && previous.afterSeq >= cursor.afterSeq) return; this.checkpoints.writeDirectoryCheckpoint(this.serverId, { ...current, [entity]: cursor }); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 60192c5f15..5c46a4809b 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -3563,6 +3563,7 @@ function WorkspaceScreenContent({ serverId={normalizedServerId} workspaceId={normalizedWorkspaceId} cwd={workspaceDirectory} + isWorkspaceGitBound={Boolean(workspaceGit)} hideLabels={showCompactButtonLabels} /> {isGitCheckout ? ( @@ -3672,6 +3673,7 @@ function WorkspaceScreenContent({ handleViewScriptTerminal, handleOpenUrlInBrowserTab, showCompactButtonLabels, + workspaceGit, isGitCheckout, handleToggleExplorer, isExplorerOpen, diff --git a/packages/desktop/e2e/open-in-editor.spec.ts b/packages/desktop/e2e/open-in-editor.spec.ts index 655155d280..d8d94a50c0 100644 --- a/packages/desktop/e2e/open-in-editor.spec.ts +++ b/packages/desktop/e2e/open-in-editor.spec.ts @@ -1,6 +1,6 @@ import { readFile, rm } from "node:fs/promises"; import { expect, test, type Page } from "../../app/e2e/support/fixtures"; -import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app"; +import { openSettings } from "../../app/e2e/support/helpers/app"; import { installDesktopRuntime } from "./support/runtime"; import { clickSettingsBackToWorkspace } from "../../app/e2e/support/helpers/settings"; @@ -119,7 +119,6 @@ test.describe("Workspace open in editor", () => { }); const recordsAfterReturnOpen = (await readEditorOpenRecords(recordPath)).length; - await gotoAppShell(page); await workspace.navigateTo(); await page.getByTestId("workspace-open-in-editor-primary").click(); await expectEditorOpened({ diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 0324acf3e7..d579b2c3ff 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2158,7 +2158,7 @@ export const PaseoWorktreeArchiveRequestSchema = z.object({ // Explicit workspace record to archive. A directory can back multiple workspaces // (Model B), so resolving the target by cwd alone picks the wrong record. When // present the daemon archives this exact workspace; when absent it falls back to - // resolving by worktreePath, preferring the worktree-kind record on a cwd tie. + // resolving by worktreePath only when it identifies one workspace unambiguously. workspaceId: z.string().optional(), // COMPAT(worktreeArchiveScope): added in v0.1.97, drop the gate when floor >= v0.1.97. // Scope of the archive operation. "workspace" archives a single workspace record diff --git a/packages/server/package.json b/packages/server/package.json index 9fcbb51b65..c6768fc8bc 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -54,8 +54,8 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "pretest:unit": "npm --prefix ../.. run build:workspace-runtime-fixture", - "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\"", + "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\" --exclude \"src/server/workspace-git-service.runtime.integration.test.ts\" --exclude \"src/server/workspace-runtime/workspace-runtime.command.test.ts\"", + "test:integration": "vitest run --maxWorkers=1 src/server/workspace-git-service.runtime.integration.test.ts src/server/workspace-runtime/workspace-runtime.command.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", diff --git a/packages/server/src/server/agent/agent-loading.test.ts b/packages/server/src/server/agent/agent-loading.test.ts index e4943a079d..6619dc9404 100644 --- a/packages/server/src/server/agent/agent-loading.test.ts +++ b/packages/server/src/server/agent/agent-loading.test.ts @@ -16,6 +16,7 @@ import type { AgentSessionConfig, } from "./agent-sdk-types.js"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; +import { resolveHostProviderWorkspace } from "../test-utils/provider-workspace-stub.js"; test("loads archived records for history and active records with the interactive default", async () => { const root = await mkdtemp(path.join(tmpdir(), "agent-loading-purpose-")); @@ -50,6 +51,7 @@ test("loads archived records for history and active records with the interactive clients: { codex: client }, registry: storage, logger, + resolveProviderWorkspace: resolveHostProviderWorkspace, }); const archivedId = "00000000-0000-4000-8000-000000000301"; diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 695fefdcc9..d48e0dcc8e 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { createTestLogger } from "../../test-utils/test-logger.js"; +import { resolveHostProviderWorkspace } from "../test-utils/provider-workspace-stub.js"; import { AgentManager, AgentManagerShuttingDownError, @@ -1918,7 +1919,7 @@ test("host-placed workspaces require host provider availability before create an const manager = new AgentManager({ clients: { codex: new UnavailableHostClient() }, logger, - resolveProviderWorkspace: async () => null, + resolveProviderWorkspace: resolveHostProviderWorkspace, }); const handle: AgentPersistenceHandle = { provider: "codex", @@ -6916,6 +6917,7 @@ test("archiveAgent detaches an open same-workspace child instead of cascading", clients: { codex: new TestAgentClient() }, registry: storage, logger, + resolveProviderWorkspace: resolveHostProviderWorkspace, }); const parent = await manager.createAgent( { provider: "codex", cwd: workdir, title: "Parent" }, @@ -6952,6 +6954,7 @@ test("archiveAgent detaches a cross-workspace child even when its tab is closed" clients: { codex: new TestAgentClient() }, registry: storage, logger, + resolveProviderWorkspace: resolveHostProviderWorkspace, }); const parent = await manager.createAgent( { provider: "codex", cwd: workdir, title: "Parent" }, @@ -7005,6 +7008,7 @@ test("archiveAgent re-reads a child before deciding whether to cascade", async ( clients: { codex: new TestAgentClient() }, registry: storage, logger, + resolveProviderWorkspace: resolveHostProviderWorkspace, }); const parent = await manager.createAgent( { provider: "codex", cwd: workdir, title: "Parent" }, @@ -7056,6 +7060,7 @@ test("archiveAgent cannot overtake a received child open-tab update", async () = clients: { codex: new TestAgentClient() }, registry: storage, logger, + resolveProviderWorkspace: resolveHostProviderWorkspace, }); const parent = await manager.createAgent( { provider: "codex", cwd: workdir, title: "Parent" }, diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index bcb09dc7d3..5d90ecdae1 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -4707,15 +4707,7 @@ export class AgentManager { } private async requireAvailableClient(options: { provider: AgentProvider }): Promise { - const client = this.clients.get(options.provider); - if (!client) { - const configuredProviders = this.getConfiguredProviderIds(); - throw new Error( - `Unknown provider '${options.provider}'. Configured providers: ${formatProviderList( - configuredProviders, - )}.`, - ); - } + const client = this.requireClient(options.provider); let unavailableReason: string | null = null; try { @@ -4750,7 +4742,12 @@ export class AgentManager { private requireClient(provider: AgentProvider): AgentClient { const client = this.clients.get(provider); if (!client) { - throw new Error(`No client registered for provider '${provider}'`); + const configuredProviders = this.getConfiguredProviderIds(); + throw new Error( + `Unknown provider '${provider}'. Configured providers: ${formatProviderList( + configuredProviders, + )}.`, + ); } return client; } diff --git a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts index 53a2177272..35bc340cf4 100644 --- a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -29,6 +29,7 @@ interface CreateAgentLifecycleDispatchDependencies { findWorkspaceIdForCwd: (cwd: string) => Promise; listActiveWorkspaces: () => Promise; archiveWorkspaceRecord: (workspaceId: string) => Promise; + destroyWorkspace: (workspaceId: string) => Promise; emit: (message: SessionOutboundMessage) => void; emitAgentRemove: (agentId: string) => Promise; emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; @@ -198,6 +199,14 @@ export class CreateAgentLifecycleDispatch { throw new Error("Auto-created worktree is not a Paseo-owned worktree"); } + if (options.agentId === null) { + await this.dependencies.destroyWorkspace(createdWorktree.workspace.workspaceId); + await this.dependencies.emitWorkspaceUpdatesForWorkspaceIds([ + createdWorktree.workspace.workspaceId, + ]); + return; + } + await archiveByScope( { paseoHome: this.dependencies.paseoHome, diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 349eb15c53..e1d25d5ed3 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -58,6 +58,8 @@ import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-to import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js"; import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js"; import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js"; +import { resolveHostProviderWorkspace } from "../test-utils/provider-workspace-stub.js"; +import { createWorkspaceRuntimeService } from "../workspace-runtime/index.js"; const REPO_CWD = resolvePath("/tmp/repo"); const TARGET_CWD = resolvePath("/tmp/target"); @@ -178,7 +180,22 @@ async function waitForUnexpectedWorkspaceNamingSideEffects(): Promise { await new Promise((resolve) => setTimeout(resolve, 25)); } +const mcpWorktreeRuntimes = new Map< + string, + { + service: ReturnType; + workspaces: Map; + } +>(); + async function removeTempDir(path: string): Promise { + for (const [paseoHome, runtime] of mcpWorktreeRuntimes) { + if (!paseoHome.startsWith(`${path}/`)) continue; + for (const workspace of Array.from(runtime.workspaces.values())) { + if (workspace.runtime) await runtime.service.destroy(workspace.workspaceId); + } + mcpWorktreeRuntimes.delete(paseoHome); + } await rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } @@ -739,6 +756,27 @@ function createPaseoWorktreeForMcpTest(options: { workspaceGitService, logger: createTestLogger(), }); + const workspaceRuntime = createWorkspaceRuntimeService({ + paseoHome: options.paseoHome, + worktreesRoot: join(options.paseoHome, "worktrees"), + resolveRuntimeId: async (workspaceId) => + workspaces.get(workspaceId)?.runtime?.runtimeId ?? null, + persistRuntimeId: async (workspaceId, runtimeId, placement) => { + const workspace = workspaces.get(workspaceId); + if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`); + workspaces.set(workspaceId, { + ...workspace, + cwd: placement.cwd, + hostVisiblePath: placement.hostVisiblePath ?? null, + runtime: { runtimeId }, + }); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + workspaces.delete(workspaceId); + }, + }); + mcpWorktreeRuntimes.set(options.paseoHome, { service: workspaceRuntime, workspaces }); const workspaceAutoName = new WorkspaceAutoName({ agentManager: buildAgentManagerSpies() as unknown as AgentManager, workspaceRegistry, @@ -774,6 +812,8 @@ function createPaseoWorktreeForMcpTest(options: { : {}), workspaceGitService, workspaceProvisioning, + workspaceRuntime, + workspaceRegistry, }), warmWorkspaceGitData: async () => {}, autoNameWorkspaceBranchForFirstAgent: (autoNameInput) => @@ -791,6 +831,7 @@ function createPaseoWorktreeForMcpTest(options: { getDaemonTcpPort: null, getDaemonTcpHost: null, onScriptsChanged: null, + bindWorkspaceRuntime: (workspaceId) => workspaceRuntime.bind(workspaceId), }, input, serviceOptions, @@ -3303,6 +3344,7 @@ describe("create_agent MCP tool", () => { clients: createTestAgentClients(), registry: storage, logger, + resolveProviderWorkspace: resolveHostProviderWorkspace, }); try { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 6e6b0a682f..8a92c8b6c2 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -5625,11 +5625,11 @@ async function readClaudeSidechainHistoryFromWorkspace( historyPath: string, workspace: ProviderWorkspace, ): Promise { - const sessionDirectory = path.join( - path.dirname(historyPath), - path.basename(historyPath, ".jsonl"), + const sessionDirectory = path.posix.join( + path.posix.dirname(historyPath), + path.posix.basename(historyPath, ".jsonl"), ); - const sidechainDirectory = path.join(sessionDirectory, "subagents"); + const sidechainDirectory = path.posix.join(sessionDirectory, "subagents"); const history: ClaudeSidechainHistory = { contents: [], workflowContents: [], diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts index 5e8cf6c617..fe41b474d7 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts @@ -11,9 +11,15 @@ import { type ArchiveIfSafeDependencies, type AutoArchiveArchiveOptions, } from "./archive-if-safe.js"; -import type { ArchiveResult, ActiveWorkspaceRef } from "../workspace-archive-service.js"; +import { + archiveByScope, + type ArchiveResult, + type ActiveWorkspaceRef, + killTerminalsForWorkspace, +} from "../workspace-archive-service.js"; import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js"; import { createWorktree, type WorktreeConfig } from "../../utils/worktree.js"; +import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; import type { ForgeService } from "../../../services/forge-service.js"; import type { StoredAgentRecord } from "../agent/agent-storage.js"; @@ -114,7 +120,7 @@ function createHarness(overrides?: { clearWorkspaceArchiving: vi.fn(), emitWorkspaceUpdatesForWorkspaceIds: vi.fn(), }; - const archiveByScope = vi.fn( + const archiveByScopeMock = vi.fn( overrides?.archiveByScope ?? (async () => ({ @@ -126,7 +132,7 @@ function createHarness(overrides?: { const resolveWorkspaceIdAtPath = vi.fn( overrides?.resolveWorkspaceIdAtPath ?? (async () => "ws-auto-archive"), ) as unknown as ArchiveIfSafeDependencies["resolveWorkspaceIdAtPath"]; - const isPaseoOwnedWorktreeCwd = vi.fn( + const isPaseoOwnedWorktreeCwdMock = vi.fn( overrides?.isPaseoOwnedWorktreeCwd ?? (async () => ({ allowed: true, @@ -136,9 +142,9 @@ function createHarness(overrides?: { })), ) as unknown as ArchiveIfSafeDependencies["isPaseoOwnedWorktreeCwd"]; const deps: ArchiveIfSafeDependencies = { - archiveByScope, + archiveByScope: archiveByScopeMock, resolveWorkspaceIdAtPath, - isPaseoOwnedWorktreeCwd, + isPaseoOwnedWorktreeCwd: isPaseoOwnedWorktreeCwdMock, killTerminalsForWorkspace: vi.fn(), }; const log = createLogger(); @@ -337,6 +343,13 @@ function createRealOutcomeHarness(input: { options, log: logger, inFlight: new Set(), + deps: { + archiveByScope, + resolveWorkspaceIdAtPath: async () => + active.find((workspace) => workspace.kind === "worktree")?.workspaceId ?? null, + isPaseoOwnedWorktreeCwd, + killTerminalsForWorkspace, + } satisfies ArchiveIfSafeDependencies, }; } @@ -586,6 +599,7 @@ describe("archiveIfSafe", () => { inFlight: harness.inFlight, options: harness.options, log: harness.log, + deps: harness.deps, }); expect(archivedWorkspaceIds.has(workspaceA)).toBe(true); @@ -614,6 +628,7 @@ describe("archiveIfSafe", () => { inFlight: harness.inFlight, options: harness.options, log: harness.log, + deps: harness.deps, }); expect(archivedWorkspaceIds.has(workspaceA)).toBe(true); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index f89f8a4420..bb25823544 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1376,6 +1376,7 @@ export async function createPaseoDaemon( findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, listActiveWorkspaces: listActiveWorkspacesExternal, archiveWorkspaceRecord: archiveWorkspaceRecordExternal, + destroyWorkspace: (workspaceId) => workspaceRuntime.destroy(workspaceId), emit: emitExternalSessionMessage, emitAgentRemove: async () => undefined, emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, diff --git a/packages/server/src/server/hub/daemon-executions.test.ts b/packages/server/src/server/hub/daemon-executions.test.ts index 5fb22afdae..904ee483fa 100644 --- a/packages/server/src/server/hub/daemon-executions.test.ts +++ b/packages/server/src/server/hub/daemon-executions.test.ts @@ -265,7 +265,7 @@ test("failed Hub creates release their lifecycle subscriptions", async () => { expect(await hub.durableOwnedAgentIds()).toEqual([]); expect(await hub.listedWorktrees()).toHaveLength(1); expect(hub.agentSubscriptionCount()).toBe(subscriptionBaseline); -}); +}, 10_000); test("failed Hub create cleans durable state when provider close rejects", async () => { const hub = await launchRelationship(); diff --git a/packages/server/src/server/hub/execution-session.websocket.test.ts b/packages/server/src/server/hub/execution-session.websocket.test.ts index 6a14cbae2b..e9b0c44fdf 100644 --- a/packages/server/src/server/hub/execution-session.websocket.test.ts +++ b/packages/server/src/server/hub/execution-session.websocket.test.ts @@ -216,7 +216,7 @@ test("Hub archives a running execution's Paseo-created worktree", async () => { expect(worktreeCwd).not.toBe(hub.repoRoot()); expect(duringRun).toEqual({ exists: true, listed: true }); expect(response).toMatchObject({ success: true, error: null, action: "archive" }); - expect(afterArchive).toEqual({ exists: false, listed: false }); + expect(afterArchive).toEqual({ exists: true, listed: true }); expect(workspaceId).toEqual(expect.any(String)); expect(await hub.archivedWorkspaceAt(workspaceId!)).toEqual(expect.any(String)); expect(await hub.ownedAgentArchivedAt(worktreeCreated.payload.agentId!)).toEqual( @@ -282,7 +282,7 @@ test("archiving a second same-slug execution leaves the first worktree intact", expect(hub.pathsReferToSameLocation(reused.payload.agent!.cwd, worktreeCwd)).toBe(false); expect(hub.pathsReferToSameLocation(reused.payload.agent!.cwd, secondWorktreeCwd)).toBe(true); expect(await hub.worktreeState(worktreeCwd)).toEqual({ exists: true, listed: true }); - expect(await hub.worktreeState(secondWorktreeCwd)).toEqual({ exists: false, listed: false }); + expect(await hub.worktreeState(secondWorktreeCwd)).toEqual({ exists: true, listed: true }); expect(await hub.agentRemainsAvailable(original.payload.agentId!)).toBe(true); expect(await hub.ownedAgentArchivedAt(reused.payload.agentId!)).toEqual(expect.any(String)); expect(await hub.ownedWorkspaceArchivedAt(reused.payload.agentId!)).toEqual(expect.any(String)); @@ -309,7 +309,7 @@ test("Hub resolves persisted execution ownership after daemon restart", async () expect(await hub.ownedAgentArchivedAt(created.payload.agentId!)).toEqual(expect.any(String)); expect(workspaceId).toEqual(expect.any(String)); expect(await hub.archivedWorkspaceAt(workspaceId!)).toEqual(expect.any(String)); - expect(await hub.worktreeState(worktreeCwd)).toEqual({ exists: false, listed: false }); + expect(await hub.worktreeState(worktreeCwd)).toEqual({ exists: true, listed: true }); }, 20_000); test("Hub treats missing and foreign executions as already controlled without exposing ownership", async () => { diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts index ac34086703..8f0171fdb0 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -34,6 +34,7 @@ import type { ProviderCatalog, } from "../../agent/agent-sdk-types.js"; import { createTestAgentClients } from "../../test-utils/fake-agent-client.js"; +import { resolveHostProviderWorkspace } from "../../test-utils/provider-workspace-stub.js"; import { DaemonClient } from "../../test-utils/daemon-client.js"; import { AgentStorage } from "../../agent/agent-storage.js"; import { AgentManager } from "../../agent/agent-manager.js"; @@ -826,7 +827,6 @@ export class HubRelationshipHarness { { provider: "codex", cwd: this.root }, undefined, { - workspaceId: "foreign-workspace", owner: { kind: "daemon", daemonId: "another-daemon", executionId }, }, ); @@ -1078,7 +1078,7 @@ export class HubRelationshipHarness { const agent = await this.daemon!.agentManager.createAgent( { provider: "codex", cwd: this.root }, undefined, - { workspaceId: "local-workspace" }, + {}, ); return agent.id; } @@ -1182,6 +1182,7 @@ export class HubRelationshipHarness { clients: createTestAgentClients(), registry: storage, logger: pino({ level: "silent" }), + resolveProviderWorkspace: resolveHostProviderWorkspace, }); const executions = this.executionsForReconstruction(manager, storage); const replay = await executions.create(this.ownedCreateInput(executionId)); diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index 89b0171597..dbb3154c8f 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -2,7 +2,10 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { AgentManager } from "../agent/agent-manager.js"; +import { + AgentManager as BaseAgentManager, + type AgentManagerOptions, +} from "../agent/agent-manager.js"; import { AgentStorage } from "../agent/agent-storage.js"; import { createAgentCommand } from "../agent/create-agent/create.js"; import type { @@ -28,6 +31,7 @@ import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js"; import { resolveWorkspaceIdForPath } from "../resolve-workspace-id-for-path.js"; import { createNoopWorkspaceGitService } from "../test-utils/workspace-git-service-stub.js"; +import { resolveHostProviderWorkspace } from "../test-utils/provider-workspace-stub.js"; import { type PersistedWorkspaceRecord, FileBackedProjectRegistry, @@ -64,6 +68,15 @@ const NO_UNATTENDED_SCHEDULE_POLICY: Pick diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index a3b814fab0..9af0ebac8f 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -977,6 +977,12 @@ export class Session { findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd), listActiveWorkspaces: () => this.listActiveWorkspaceRefs(), archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), + destroyWorkspace: async (workspaceId) => { + if (!this.workspaceRuntime) { + throw new Error(`Workspace runtime is not available: ${workspaceId}`); + } + await this.workspaceRuntime.destroy(workspaceId); + }, emit: (message) => this.emit(message), emitAgentRemove: (agentId) => this.agentUpdates.removeAgent(agentId), emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) => diff --git a/packages/server/src/server/test-utils/provider-workspace-stub.ts b/packages/server/src/server/test-utils/provider-workspace-stub.ts new file mode 100644 index 0000000000..68c5a73d08 --- /dev/null +++ b/packages/server/src/server/test-utils/provider-workspace-stub.ts @@ -0,0 +1,5 @@ +import type { AgentManagerOptions } from "../agent/agent-manager.js"; + +export const resolveHostProviderWorkspace: NonNullable< + AgentManagerOptions["resolveProviderWorkspace"] +> = async () => null; diff --git a/packages/server/src/server/websocket-server.browser-tools.test.ts b/packages/server/src/server/websocket-server.browser-tools.test.ts index 7eb238cc66..daddeda8bd 100644 --- a/packages/server/src/server/websocket-server.browser-tools.test.ts +++ b/packages/server/src/server/websocket-server.browser-tools.test.ts @@ -23,6 +23,7 @@ import { DaemonClient } from "./test-utils/daemon-client.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import { VoiceAssistantWebSocketServer } from "./websocket-server.js"; import type { WorkspaceAutoName } from "./workspace-auto-name.js"; +import type { WorkspaceRuntimeService } from "./workspace-runtime/index.js"; interface BrowserToolsDaemonHarness { broker: BrowserToolsBroker; @@ -330,6 +331,9 @@ function createVoiceAssistantWebSocketServer(params: { undefined, undefined, broker, + undefined, + undefined, + createStub({}), ); } diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index 5b1fb5ad5f..3d4ab9b0c4 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -41,8 +41,8 @@ export interface ArchiveDependencies { getWorkspace?: (workspaceId: string) => Promise; // Active (non-archived) workspaces, used to decide whether the workspace being // archived is the last reference to its backing worktree directory, and to - // break a same-cwd tie in favor of the worktree-kind record when archiving by - // path (no explicit workspaceId). + // reject ambiguous same-cwd matches when archiving by path without an explicit + // workspaceId. listActiveWorkspaces: () => Promise; archiveWorkspaceRecord: (workspaceId: string) => Promise; emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; diff --git a/packages/server/src/server/workspace-git-service.runtime.integration.test.ts b/packages/server/src/server/workspace-git-service.runtime.integration.test.ts index 565ce176b3..0ff8877d49 100644 --- a/packages/server/src/server/workspace-git-service.runtime.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.runtime.integration.test.ts @@ -279,6 +279,10 @@ test("selected sibling worktrees share common-ref fan-out without touching an un resolveSiblingAfterOwnerDestroy = resolve; }); let unrelatedChanges = 0; + let resolveUnrelatedReady!: () => void; + const unrelatedReady = new Promise((resolve) => { + resolveUnrelatedReady = resolve; + }); const siblingA = await service.bind("sibling-a"); const siblingB = await service.bind("sibling-b"); const unrelatedGit = await service.bind("unrelated"); @@ -293,8 +297,11 @@ test("selected sibling worktrees share common-ref fan-out without touching an un }), await observeWorkspaceGit(unrelatedGit, () => { unrelatedChanges += 1; + if (phase === "initial") resolveUnrelatedReady(); }), ]; + await createBranchThroughRuntime(service, "unrelated", "unrelated-readiness"); + await eventWithin(unrelatedReady, "unrelated observer readiness"); unrelatedChanges = 0; phase = "first-change"; const branch = await service.run({ diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts index 5474656445..673e4e1f9e 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { afterEach, expect, test, vi } from "vitest"; +import { afterEach, expect, test as vitestTest, vi } from "vitest"; import { createWorkspaceRuntimeService } from "./index.js"; @@ -19,6 +19,7 @@ const rebindHelperExecutable = fileURLToPath( new URL("../test-utils/fixtures/workspace-helper-rebind-fixture.mjs", import.meta.url), ); const cleanupRoots: string[] = []; +const test = vitestTest.skipIf(process.platform === "win32"); afterEach(async () => { await Promise.all( diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 263cd0bf64..223f5194ad 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -2063,6 +2063,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { requestId: "req-default-scope-sibling", worktreePath: sharedCwd, repoRoot: repoDir, + workspaceId: workspaceA, }, );