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.
This commit is contained in:
Mohamed Boudra
2026-06-16 14:58:33 +07:00
parent 36a136d811
commit 64b2949f08
6 changed files with 115 additions and 117 deletions
+1 -1
View File
@@ -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";
@@ -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", () => {
@@ -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.
+59 -84
View File
@@ -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<PersistedWorkspaceRecord | null> {
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<ProjectPlacementPayload | null> {
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<string, ProjectPlacementPayload>
> {
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<string, ProjectPlacementPayload>();
const placementsByWorkspaceId = new Map<string, ProjectPlacementPayload>();
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<ProjectPlacementPayload | null>;
getPlacement: (workspaceId: string | undefined) => Promise<ProjectPlacementPayload | null>;
filter: AgentUpdatesFilter | undefined;
}): Promise<FetchAgentsResponseEntry[]> {
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<string, Promise<ProjectPlacementPayload | null>>();
const getPlacement = (cwd: string): Promise<ProjectPlacementPayload | null> => {
if (activePlacementsByCwd) {
return Promise.resolve(activePlacementsByCwd.get(resolve(cwd)) ?? null);
const placementByWorkspaceId = new Map<string, Promise<ProjectPlacementPayload | null>>();
const getPlacement = (
workspaceId: string | undefined,
): Promise<ProjectPlacementPayload | null> => {
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 },
@@ -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<unknown>;
}>(h.session).findWorkspaceByDirectory(PARENT_CHILD);
const found = resolveWorkspaceIdForPath(PARENT_CHILD, Array.from(h.workspaces.values()));
expect(found).toBeNull();
});
@@ -117,7 +117,7 @@ interface SessionTestAccess {
listFetchAgentsEntries(params: unknown): Promise<ListFetchResult>;
resolveAgentIdentifier(identifier: string): Promise<unknown>;
getAgentPayloadById(agentId: string): Promise<unknown>;
buildProjectPlacementForCwd(cwd: string): Promise<unknown>;
buildProjectPlacementForWorkspaceId(workspaceId: string): Promise<unknown>;
buildProjectPlacement(cwd: string): Promise<unknown>;
buildWorkspaceDescriptorMap(...args: unknown[]): Promise<Map<string, unknown>>;
describeWorkspaceRecord(...args: unknown[]): Promise<unknown>;
@@ -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,