From 64b2949f083ff77c697e7d3171dc6d598c1cd78d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 16 Jun 2026 14:58:33 +0700 Subject: [PATCH] Key agent placement by workspace id, not directory Agent project-placement and the fetch/list grouping still resolved a workspace from the agent's directory, even though every agent now carries a workspaceId. Walk workspaceId -> workspace -> project instead: an agent whose cwd is shared with or nested under another workspace now groups under its own workspace, not a directory-prefix match. The fetch path's placement map is keyed by workspace id, closing the same-directory collision. resolveWorkspaceIdForPath survives only at the archive-by-path boundary (auto-archive-on-merge, the MCP archive tool), where a client passes a bare worktree path with no id; renamed to match that role. --- packages/server/src/server/bootstrap.ts | 2 +- ... => resolve-workspace-id-for-path.test.ts} | 7 +- ...ip.ts => resolve-workspace-id-for-path.ts} | 12 +- packages/server/src/server/session.ts | 143 ++++++++---------- ...on.workspace-resolution-invariants.test.ts | 11 +- .../src/server/session.workspaces.test.ts | 57 ++++--- 6 files changed, 115 insertions(+), 117 deletions(-) rename packages/server/src/server/{workspace-ownership.test.ts => resolve-workspace-id-for-path.test.ts} (89%) rename packages/server/src/server/{workspace-ownership.ts => resolve-workspace-id-for-path.ts} (75%) diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 6648ad80b..4ff3f97db 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -113,7 +113,7 @@ 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 { resolveWorkspaceIdForPath } from "./workspace-ownership.js"; +import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js"; import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js"; import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js"; import type { ActiveWorkspaceRef } from "./paseo-worktree-archive-service.js"; diff --git a/packages/server/src/server/workspace-ownership.test.ts b/packages/server/src/server/resolve-workspace-id-for-path.test.ts similarity index 89% rename from packages/server/src/server/workspace-ownership.test.ts rename to packages/server/src/server/resolve-workspace-id-for-path.test.ts index 5226825d0..162fdf1c5 100644 --- a/packages/server/src/server/workspace-ownership.test.ts +++ b/packages/server/src/server/resolve-workspace-id-for-path.test.ts @@ -3,7 +3,7 @@ import { basename } from "node:path"; import { describe, expect, test } from "vitest"; import { createPersistedWorkspaceRecord } from "./workspace-registry.js"; -import { resolveWorkspaceIdForPath } from "./workspace-ownership.js"; +import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js"; function createWorkspaceRecord( cwd: string, @@ -23,9 +23,8 @@ function createWorkspaceRecord( } // resolveWorkspaceIdForPath is the external path→workspace adapter for -// archive-by-path and agent project-placement display. It is NOT ownership: a -// record's owner is its workspaceId. The per-id status law is exercised in -// workspace-directory.test.ts. +// archive-by-path. It is NOT ownership: a record's owner is its workspaceId. +// The per-id status law is exercised in workspace-directory.test.ts. describe("resolveWorkspaceIdForPath", () => { test("returns a single id when multiple workspaces share the exact cwd", () => { diff --git a/packages/server/src/server/workspace-ownership.ts b/packages/server/src/server/resolve-workspace-id-for-path.ts similarity index 75% rename from packages/server/src/server/workspace-ownership.ts rename to packages/server/src/server/resolve-workspace-id-for-path.ts index 382e1a465..58b7572cf 100644 --- a/packages/server/src/server/workspace-ownership.ts +++ b/packages/server/src/server/resolve-workspace-id-for-path.ts @@ -5,12 +5,12 @@ import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; // external path→workspace adapter, not ownership. // -// Resolves a raw filesystem path to a single workspace id for the two flows that -// receive a path rather than an id: archive-by-path (an old client / CLI passes a -// worktree path) and agent project-placement display (an agent's cwd, which may -// be a subdirectory of its workspace). This is NEVER used to attribute a record's -// status to a workspace — status is per `workspaceId`, and git facts derive from a -// workspace's OWN cwd (id → cwd). +// Resolves a raw filesystem path to a single workspace id ONLY at the boundary +// where a client hands the daemon a bare worktree path with no id: +// archive-by-path (old client / CLI), auto-archive-after-merge, and the MCP +// `archive_worktree` tool. It is NEVER used to attribute agent status or place +// agents under a workspace — those are keyed by `workspaceId`, and git facts +// derive from a workspace's OWN cwd (id → cwd). // // Resolution: an exact directory match wins; otherwise the deepest enclosing // workspace directory, never the home directory; null when nothing encloses it. diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index b49eebbc2..88d083cf2 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -159,11 +159,10 @@ import { import { checkoutLiteFromGitSnapshot, classifyDirectoryForProjectMembership, - deriveProjectGroupingName, deriveWorkspaceDisplayName, generateWorkspaceId, } from "./workspace-registry-model.js"; -import { resolveWorkspaceIdForPath } from "./workspace-ownership.js"; +import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, @@ -1648,19 +1647,6 @@ export class Session { } } - private async findWorkspaceByDirectory( - cwd: string, - options?: { refreshGit?: boolean }, - ): Promise { - const normalizedCwd = await this.resolveWorkspaceDirectory(cwd, options); - const workspaces = await this.workspaceRegistry.list(); - const workspaceId = resolveWorkspaceIdForPath(normalizedCwd, workspaces); - if (!workspaceId) { - return null; - } - return workspaces.find((workspace) => workspace.workspaceId === workspaceId) ?? null; - } - private async findExactWorkspaceByDirectory( cwd: string, options?: { refreshGit?: boolean }, @@ -1702,37 +1688,11 @@ export class Session { }; } - private async buildProjectPlacementForCwd( - cwd: string, - options?: { refreshGit?: boolean; fallback?: boolean }, + private async buildProjectPlacementForWorkspaceId( + workspaceId: string, ): Promise { - const workspace = await this.findWorkspaceByDirectory(cwd, { - refreshGit: options?.refreshGit, - }); - if (!workspace) { - if (!options?.fallback) { - return null; - } - - // An agent can run in a directory that has no registered workspace yet - // (e.g. a fresh non-git folder). Synthesize a directory-scoped placement so - // the agent still emits updates. projectKey/cwd are paths here — a project - // grouping key, not a workspace id — so this stays opaque-id-safe. - const resolvedCwd = resolve(cwd); - return { - projectKey: resolvedCwd, - projectName: deriveProjectGroupingName(resolvedCwd), - checkout: { - cwd: resolvedCwd, - isGit: false, - currentBranch: null, - remoteUrl: null, - worktreeRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }; - } + const workspace = await this.workspaceRegistry.get(workspaceId); + if (!workspace) return null; return this.buildProjectPlacementForWorkspace(workspace); } @@ -1741,30 +1701,33 @@ export class Session { const subscription = this.agentUpdatesSubscription; const payload = await this.buildAgentPayload(agent); if (subscription) { - const project = await this.buildProjectPlacementForCwd(payload.cwd, { - refreshGit: false, - fallback: true, - }); + const project = payload.workspaceId + ? await this.buildProjectPlacementForWorkspaceId(payload.workspaceId) + : null; if (!project) { - throw new Error(`Workspace not found for agent ${payload.id}`); - } - const matches = this.matchesAgentFilter({ - agent: payload, - project, - filter: subscription.filter, - }); - - if (matches) { - this.bufferOrEmitAgentUpdate(subscription, { - kind: "upsert", - agent: payload, - project, - }); - } else { this.bufferOrEmitAgentUpdate(subscription, { kind: "remove", agentId: payload.id, }); + } else { + const matches = this.matchesAgentFilter({ + agent: payload, + project, + filter: subscription.filter, + }); + + if (matches) { + this.bufferOrEmitAgentUpdate(subscription, { + kind: "upsert", + agent: payload, + project, + }); + } else { + this.bufferOrEmitAgentUpdate(subscription, { + kind: "remove", + agentId: payload.id, + }); + } } } @@ -2474,7 +2437,9 @@ export class Session { if (this.agentUpdatesSubscription) { const payload = this.buildStoredAgentPayload(archivedRecord); - const project = await this.buildProjectPlacementForCwd(payload.cwd); + const project = payload.workspaceId + ? await this.buildProjectPlacementForWorkspaceId(payload.workspaceId) + : null; if (project) { const matches = this.matchesAgentFilter({ agent: payload, @@ -6450,7 +6415,7 @@ export class Session { return this.isProviderVisibleToClient(payload.provider) ? payload : null; } - private async buildActiveProjectPlacementsByWorkspaceCwd(): Promise< + private async buildActiveProjectPlacementsByWorkspaceId(): Promise< Map > { const [persistedWorkspaces, persistedProjects] = await Promise.all([ @@ -6462,7 +6427,7 @@ export class Session { .filter((project) => !project.archivedAt) .map((project) => [project.projectId, project] as const), ); - const placementsByCwd = new Map(); + const placementsByWorkspaceId = new Map(); const pairs = persistedWorkspaces.flatMap((workspace) => { if (workspace.archivedAt) return []; @@ -6476,16 +6441,16 @@ export class Session { ), ); for (let i = 0; i < pairs.length; i += 1) { - placementsByCwd.set(resolve(pairs[i].workspace.cwd), placements[i]); + placementsByWorkspaceId.set(pairs[i].workspace.workspaceId, placements[i]); } - return placementsByCwd; + return placementsByWorkspaceId; } private async collectFetchAgentsEntries(params: { candidates: AgentSnapshotPayload[]; limit: number; - getPlacement: (cwd: string) => Promise; + getPlacement: (workspaceId: string | undefined) => Promise; filter: AgentUpdatesFilter | undefined; }): Promise { const { candidates, limit, getPlacement, filter } = params; @@ -6499,7 +6464,7 @@ export class Session { const batch = candidates.slice(start, start + batchSize); const batchEntries = await Promise.all( batch.map(async (agent) => { - const project = await getPlacement(agent.cwd); + const project = await getPlacement(agent.workspaceId); return project ? { agent, project } : null; }), ); @@ -6542,25 +6507,33 @@ export class Session { includeArchived: filter?.includeArchived, includeUnavailablePersisted: request.type === "fetch_agent_history_request", }); - const activePlacementsByCwd = - scope === "active" ? await this.buildActiveProjectPlacementsByWorkspaceCwd() : null; - if (activePlacementsByCwd) { + const activePlacementsByWorkspaceId = + scope === "active" ? await this.buildActiveProjectPlacementsByWorkspaceId() : null; + if (activePlacementsByWorkspaceId) { agents = agents.filter( - (agent) => !agent.archivedAt && activePlacementsByCwd.has(resolve(agent.cwd)), + (agent) => + !agent.archivedAt && + agent.workspaceId != null && + activePlacementsByWorkspaceId.has(agent.workspaceId), ); } - const placementByCwd = new Map>(); - const getPlacement = (cwd: string): Promise => { - if (activePlacementsByCwd) { - return Promise.resolve(activePlacementsByCwd.get(resolve(cwd)) ?? null); + const placementByWorkspaceId = new Map>(); + const getPlacement = ( + workspaceId: string | undefined, + ): Promise => { + if (!workspaceId) { + return Promise.resolve(null); } - const existing = placementByCwd.get(cwd); + if (activePlacementsByWorkspaceId) { + return Promise.resolve(activePlacementsByWorkspaceId.get(workspaceId) ?? null); + } + const existing = placementByWorkspaceId.get(workspaceId); if (existing) { return existing; } - const placementPromise = this.buildProjectPlacementForCwd(cwd); - placementByCwd.set(cwd, placementPromise); + const placementPromise = this.buildProjectPlacementForWorkspaceId(workspaceId); + placementByWorkspaceId.set(workspaceId, placementPromise); return placementPromise; }; @@ -8197,7 +8170,7 @@ export class Session { }; await this.agentStorage.upsert(nextRecord); const agent = this.buildStoredAgentPayload(nextRecord); - const project = await this.buildProjectPlacementForCwd(agent.cwd); + const project = await this.buildProjectPlacementForWorkspace(workspace); this.emit({ type: "agent_update", payload: { @@ -8276,7 +8249,9 @@ export class Session { return; } - const project = await this.buildProjectPlacementForCwd(agent.cwd); + const project = agent.workspaceId + ? await this.buildProjectPlacementForWorkspaceId(agent.workspaceId) + : null; this.emit({ type: "fetch_agent_response", payload: { requestId, agent, project, error: null }, diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index 2baa31d52..551634e42 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -19,6 +19,7 @@ import { type PersistedProjectRecord, type PersistedWorkspaceRecord, } from "./workspace-registry.js"; +import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js"; interface Harness { session: Session; @@ -456,17 +457,17 @@ test("S11: re-opening an archived project by exact path unarchives project + wor // ───────────────────────────────────────────────────────────────────────────── // S12. Prefix-fallback resolver must not surface an archived ancestor: looking // up a child cwd whose only matching record is an archived parent should -// return null (not the archived parent). +// return null (not the archived parent). The behavior lives in +// `resolveWorkspaceIdForPath` now; this invariant keeps archive-by-path +// from accidentally resolving into an archived workspace subtree. // ───────────────────────────────────────────────────────────────────────────── -test("S12: findWorkspaceByDirectory does not return archived ancestor via prefix fallback", async () => { +test("S12: resolveWorkspaceIdForPath does not return archived ancestor via prefix fallback", async () => { const archivedAt = "2026-04-22T13:08:05.400Z"; const h = createHarness({ workspaces: [dirWorkspace(PARENT, archivedAt)], projects: [dirProject(PARENT, archivedAt)], }); - const found = await asInternals<{ - findWorkspaceByDirectory(cwd: string): Promise; - }>(h.session).findWorkspaceByDirectory(PARENT_CHILD); + const found = resolveWorkspaceIdForPath(PARENT_CHILD, Array.from(h.workspaces.values())); expect(found).toBeNull(); }); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 689f3cf1e..a839b58c3 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -117,7 +117,7 @@ interface SessionTestAccess { listFetchAgentsEntries(params: unknown): Promise; resolveAgentIdentifier(identifier: string): Promise; getAgentPayloadById(agentId: string): Promise; - buildProjectPlacementForCwd(cwd: string): Promise; + buildProjectPlacementForWorkspaceId(workspaceId: string): Promise; buildProjectPlacement(cwd: string): Promise; buildWorkspaceDescriptorMap(...args: unknown[]): Promise>; describeWorkspaceRecord(...args: unknown[]): Promise; @@ -250,6 +250,7 @@ function makeStoredAgent(input: { function makeManagedAgent(input: { id: string; cwd: string; + workspaceId?: string; lifecycle: AgentSnapshotPayload["status"]; updatedAt: string; }) { @@ -257,6 +258,7 @@ function makeManagedAgent(input: { const snapshot = makeAgent({ id: input.id, cwd: input.cwd, + workspaceId: input.workspaceId, status: input.lifecycle, updatedAt: input.updatedAt, }); @@ -765,9 +767,11 @@ test("create_agent_request keeps requested child cwd when grouped under an exist const [createdAgent] = agentManager.listAgents(); expect(createdAgent?.cwd).toBe(child); - await expect(session.buildProjectPlacementForCwd(createdAgent.cwd)).resolves.toMatchObject({ - projectKey: "proj-parent", - checkout: { cwd: parent }, + await expect( + session.buildProjectPlacementForWorkspaceId(createdAgent!.workspaceId!), + ).resolves.toMatchObject({ + projectKey: parent, + checkout: { cwd: child }, }); expect(findByType(emitted, "status")?.payload).toMatchObject({ status: "agent_created", @@ -930,6 +934,8 @@ test("agent_update placement does not refresh git snapshots", async () => { session.projectRegistry.get = async (id: string) => (id === project.projectId ? project : null); session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.get = async (id: string) => + id === workspace.workspaceId ? workspace : null; session.agentUpdatesSubscription = { subscriptionId: "sub-agents", filter: {}, @@ -941,6 +947,7 @@ test("agent_update placement does not refresh git snapshots", async () => { makeManagedAgent({ id: "agent-1", cwd: REPO_CWD, + workspaceId: workspace.workspaceId, lifecycle: "running", updatedAt: "2026-03-30T15:00:00.000Z", }), @@ -960,7 +967,7 @@ test("agent_update placement does not refresh git snapshots", async () => { }); }); -test("agent_update emits a directory-scoped placement when no workspace is registered", async () => { +test("agent_update emits remove when the agent has no workspaceId", async () => { const emitted: SessionOutboundMessage[] = []; const getSnapshot = vi.fn(async () => { throw new Error("getSnapshot should not be called for unregistered agent_update placement"); @@ -993,15 +1000,11 @@ test("agent_update emits a directory-scoped placement when no workspace is regis ); expect(getSnapshot).not.toHaveBeenCalled(); - // An agent in a directory with no registered workspace must still emit an - // upsert (live model/thinking switches depend on this). It gets a directory- - // scoped placement keyed by the path — a project key, not a workspace id. const update = emitted.find((message) => message.type === "agent_update"); - if (update?.type !== "agent_update" || update.payload.kind !== "upsert") { - throw new Error("expected an agent_update upsert"); - } - expect(update.payload.agent.id).toBe("agent-1"); - expect(update.payload.project?.checkout.isGit).toBe(false); + expect(update?.payload).toMatchObject({ + kind: "remove", + agentId: "agent-1", + }); }); test("archive emits an authoritative agent_update upsert for subscribed clients", async () => { @@ -1010,6 +1013,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" id: "agent-1", provider: "codex", cwd: REPO_CWD, + workspaceId: "ws-1", createdAt: "2026-03-30T15:00:00.000Z", updatedAt: "2026-03-30T15:00:00.000Z", lastActivityAt: "2026-03-30T15:00:00.000Z", @@ -1379,6 +1383,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy id: "agent-1", provider: "codex", cwd: REPO_CWD, + workspaceId: "ws-close", model: null, thinkingOptionId: null, effectiveThinkingOptionId: null, @@ -1738,6 +1743,7 @@ test("close_items_request continues after an archive failure", async () => { ...makeAgent({ id: "agent-good", cwd: REPO_CWD, + workspaceId: "ws-err", status: "idle", updatedAt: "2026-03-01T12:00:00.000Z", }), @@ -1912,7 +1918,7 @@ test("non-git workspace uses deterministic directory name and no unknown branch expect(result.entries[0]?.name).not.toBe("Unknown branch"); }); -test("active-scoped fetch_agents includes only unarchived agents in active exact workspaces", async () => { +test("active-scoped fetch_agents includes only unarchived agents in active workspaces", async () => { const session = createSessionForWorkspaceTests(); const archivedAt = "2026-03-02T12:00:00.000Z"; const activeProject = createPersistedProjectRecord({ @@ -1973,24 +1979,28 @@ test("active-scoped fetch_agents includes only unarchived agents in active exact makeAgent({ id: "agent-active", cwd: "/tmp/active", + workspaceId: "ws-active", status: "idle", updatedAt: "2026-03-01T12:04:00.000Z", }), makeAgent({ id: "agent-subdir", cwd: "/tmp/active/packages/app", + workspaceId: "ws-active", status: "idle", updatedAt: "2026-03-01T12:03:00.000Z", }), makeAgent({ id: "agent-archived-workspace", cwd: "/tmp/archived-workspace", + workspaceId: "ws-archived", status: "idle", updatedAt: "2026-03-01T12:02:00.000Z", }), makeAgent({ id: "agent-archived-project", cwd: "/tmp/archived-project", + workspaceId: "ws-archived-project", status: "idle", updatedAt: "2026-03-01T12:01:00.000Z", }), @@ -1998,6 +2008,7 @@ test("active-scoped fetch_agents includes only unarchived agents in active exact ...makeAgent({ id: "agent-archived", cwd: "/tmp/active", + workspaceId: "ws-active", status: "idle", updatedAt: "2026-03-01T12:00:00.000Z", }), @@ -2012,7 +2023,7 @@ test("active-scoped fetch_agents includes only unarchived agents in active exact filter: { includeArchived: true }, }); - expect(agentIdsFromEntries(result.entries)).toEqual(["agent-active"]); + expect(agentIdsFromEntries(result.entries)).toEqual(["agent-active", "agent-subdir"]); expect(result.pageInfo.hasMore).toBe(false); }); @@ -2062,18 +2073,21 @@ test("active-scoped fetch_agents pages within active scope instead of global his makeAgent({ id: "active-one", cwd: "/tmp/pages/one", + workspaceId: "ws-active-one", status: "idle", updatedAt: "2026-03-01T12:03:00.000Z", }), makeAgent({ id: "stale-between", cwd: "/tmp/pages/stale", + workspaceId: "ws-stale", status: "idle", updatedAt: "2026-03-01T12:02:00.000Z", }), makeAgent({ id: "active-two", cwd: "/tmp/pages/two", + workspaceId: "ws-active-two", status: "idle", updatedAt: "2026-03-01T12:01:00.000Z", }), @@ -2133,16 +2147,22 @@ test("legacy unscoped fetch_agents keeps global workspace behavior", async () => session.projectRegistry.get = async () => project; session.workspaceRegistry.list = async () => [activeWorkspace, archivedWorkspace]; + session.workspaceRegistry.get = async (workspaceId: string) => + [activeWorkspace, archivedWorkspace].find( + (workspace) => workspace.workspaceId === workspaceId, + ) ?? null; session.listAgentPayloads = async () => [ makeAgent({ id: "legacy-active", cwd: activeCwd, + workspaceId: "ws-legacy-active", status: "idle", updatedAt: "2026-03-01T12:01:00.000Z", }), makeAgent({ id: "legacy-archived-workspace", cwd: archivedCwd, + workspaceId: "ws-legacy-archived", status: "idle", updatedAt: "2026-03-01T12:00:00.000Z", }), @@ -2187,11 +2207,13 @@ test("fetch_agent_history_request pages archived historical rows separately", as }; session.projectRegistry.get = async () => project; session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.get = async () => workspace; session.listAgentPayloads = async () => [ { ...makeAgent({ id: "history-archived", cwd: historyCwd, + workspaceId: "ws-history", status: "idle", updatedAt: "2026-03-01T12:00:00.000Z", }), @@ -2467,6 +2489,7 @@ test("fetch_agent_request still resolves archived historical agents", async () = ...makeAgent({ id: "archived-history-agent", cwd: path.resolve("/tmp/history-detail"), + workspaceId: "ws-history-detail", status: "idle", updatedAt: "2026-03-01T12:00:00.000Z", }), @@ -2481,11 +2504,11 @@ test("fetch_agent_request still resolves archived historical agents", async () = ? { ok: true, agentId: agent.id } : { ok: false, error: `Agent not found: ${identifier}` }; session.getAgentPayloadById = async (agentId: string) => (agentId === agent.id ? agent : null); - session.buildProjectPlacementForCwd = async (cwd: string) => ({ + session.buildProjectPlacementForWorkspaceId = async () => ({ projectKey: "proj-history-detail", projectName: "history detail", checkout: { - cwd, + cwd: agent.cwd, isGit: false, currentBranch: null, remoteUrl: null,