fix(workspaces): stabilize runtime CI paths

This commit is contained in:
Mohamed Boudra
2026-08-14 16:49:05 +02:00
parent d35c10a8b5
commit 51193e7a92
27 changed files with 219 additions and 43 deletions
@@ -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<void> {
+6 -1
View File
@@ -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 <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />;
}
export function WorkspaceActions(props: WorkspaceActionsProps) {
return props.isWorkspaceGitBound ? <BoundWorkspaceActions {...props} /> : null;
}
@@ -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<WorkspaceFetchResult> {
async fetchWorkspaces(options?: unknown): Promise<WorkspaceFetchResult> {
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);
@@ -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<void> {
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 });
@@ -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,
+1 -2
View File
@@ -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({
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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",
@@ -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";
@@ -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" },
@@ -4707,15 +4707,7 @@ export class AgentManager {
}
private async requireAvailableClient(options: { provider: AgentProvider }): Promise<AgentClient> {
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;
}
@@ -29,6 +29,7 @@ interface CreateAgentLifecycleDispatchDependencies {
findWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
destroyWorkspace: (workspaceId: string) => Promise<void>;
emit: (message: SessionOutboundMessage) => void;
emitAgentRemove: (agentId: string) => Promise<void>;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
@@ -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,
@@ -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<void> {
await new Promise((resolve) => setTimeout(resolve, 25));
}
const mcpWorktreeRuntimes = new Map<
string,
{
service: ReturnType<typeof createWorkspaceRuntimeService>;
workspaces: Map<string, PersistedWorkspaceRecord>;
}
>();
async function removeTempDir(path: string): Promise<void> {
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 {
@@ -5625,11 +5625,11 @@ async function readClaudeSidechainHistoryFromWorkspace(
historyPath: string,
workspace: ProviderWorkspace,
): Promise<ClaudeSidechainHistory> {
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: [],
@@ -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<string>(),
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);
+1
View File
@@ -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,
@@ -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();
@@ -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 () => {
@@ -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));
@@ -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<ProviderSnapshotManager, "resolveCreat
},
};
class AgentManager extends BaseAgentManager {
constructor(options: AgentManagerOptions) {
super({
...options,
resolveProviderWorkspace: options.resolveProviderWorkspace ?? resolveHostProviderWorkspace,
});
}
}
const TEST_CLAUDE_PROVIDER_DEFINITION = {
enabled: true,
validateOptions: (options: AgentSessionConfig["providerOptions"]) =>
+6
View File
@@ -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) =>
@@ -0,0 +1,5 @@
import type { AgentManagerOptions } from "../agent/agent-manager.js";
export const resolveHostProviderWorkspace: NonNullable<
AgentManagerOptions["resolveProviderWorkspace"]
> = async () => null;
@@ -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<WorkspaceRuntimeService>({}),
);
}
@@ -41,8 +41,8 @@ export interface ArchiveDependencies {
getWorkspace?: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
// 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<ActiveWorkspaceRef[]>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
@@ -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<void>((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({
@@ -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(
@@ -2063,6 +2063,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
requestId: "req-default-scope-sibling",
worktreePath: sharedCwd,
repoRoot: repoDir,
workspaceId: workspaceA,
},
);