Fix same-directory workspace ownership

This commit is contained in:
Mohamed Boudra
2026-06-15 22:22:31 +07:00
parent a4d4d42873
commit ba9f7740a8
21 changed files with 599 additions and 139 deletions
+63 -6
View File
@@ -252,6 +252,10 @@ import {
type CreatePaseoWorktreeInput,
type CreatePaseoWorktreeResult,
} from "./paseo-worktree-service.js";
import {
migrateLegacyAgentWorkspaceOwnership,
resolveLegacyWorkspaceOwnerForCwd,
} from "./workspace-registry-bootstrap.js";
import {
generateBranchNameFromFirstAgentContext,
type GeneratedWorkspaceName,
@@ -3282,6 +3286,10 @@ export class Session {
const createAgentConfig: AgentSessionConfig = createdWorktree
? { ...config, cwd: createdWorktree.worktree.worktreePath }
: config;
const workspaceId =
createdWorktree?.workspace.workspaceId ??
msg.workspaceId ??
(await this.resolveLegacyWorkspaceOwnerForAgentCreate(createAgentConfig.cwd));
const { snapshot, liveSnapshot } = await createAgentCommand(
{
@@ -3297,7 +3305,7 @@ export class Session {
{
kind: "session",
config: createAgentConfig,
workspaceId: createdWorktree ? createdWorktree.workspace.workspaceId : msg.workspaceId,
workspaceId,
worktreeName,
initialPrompt,
clientMessageId,
@@ -3321,7 +3329,13 @@ export class Session {
agentId: snapshot.id,
createdWorktree,
});
if (!createdWorktree && trimmedPrompt) {
await this.scheduleAutoNameLocalWorkspaceTitleForFirstAgent({
workspaceId,
cwd: createAgentConfig.cwd,
firstAgentContext,
});
}
if (requestId) {
const agentPayload = await this.buildAgentPayload(liveSnapshot);
this.emit({
@@ -3801,6 +3815,26 @@ export class Session {
await this.emitWorkspaceUpdateForCwd(input.cwd);
}
private async scheduleAutoNameLocalWorkspaceTitleForFirstAgent(input: {
workspaceId?: string;
cwd: string;
firstAgentContext: FirstAgentContext;
}): Promise<void> {
const workspaceId = input.workspaceId ?? (await this.resolveWorkspaceIdForCwd(input.cwd));
if (!workspaceId) {
return;
}
this.scheduleWorkspaceNaming(
() =>
this.maybeAutoNameDirectoryWorkspaceTitle({
workspaceId,
cwd: input.cwd,
firstAgentContext: input.firstAgentContext,
}),
{ cwd: input.cwd, message: "Failed to auto-name local workspace title" },
);
}
private emitProviderDisabledResponse(
kind: "models" | "modes",
provider: AgentProvider,
@@ -7296,12 +7330,22 @@ export class Session {
{ workspaceId: event.workspaceId, cwd: event.cwd },
workspaces,
);
if (workspaceId) {
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], {
skipReconcile: true,
});
const owningWorkspace = workspaceId
? workspaces.find(
(workspace) => workspace.workspaceId === workspaceId && !workspace.archivedAt,
)
: null;
if (!owningWorkspace) {
return;
}
const workspaceIds = this.workspaceDirectory.resolveRegisteredWorkspaceIdsForCwd(
owningWorkspace.cwd,
workspaces,
);
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds, {
skipReconcile: true,
});
return;
}
await this.emitWorkspaceUpdateForCwd(event.cwd, { skipReconcile: true });
}
@@ -7614,6 +7658,13 @@ export class Session {
return;
}
await migrateLegacyAgentWorkspaceOwnership({
agentStorage: this.agentStorage,
workspaceRegistry: this.workspaceRegistry,
logger: this.sessionLogger,
cwds: [cwd],
});
const workspace = await createLocalCheckoutWorkspace(
{ cwd, title: request.title ?? null },
{
@@ -7656,6 +7707,12 @@ export class Session {
}
}
private async resolveLegacyWorkspaceOwnerForAgentCreate(
cwd: string,
): Promise<string | undefined> {
return resolveLegacyWorkspaceOwnerForCwd(cwd, await this.workspaceRegistry.list()) ?? undefined;
}
// Schedules a background workspace-naming write off the request path and
// records the resulting promise so tests can await completion deterministically
// (no wall-clock sleeps). The setTimeout(0) keeps the LLM call off the hot path.
@@ -4716,7 +4716,7 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi
}
});
test("same-cwd workspace descriptors scope agent status by workspace id", async () => {
test("same-cwd workspace descriptors share agent status by cwd", async () => {
const session = createSessionForWorkspaceTests();
const project = createPersistedProjectRecord({
projectId: "proj-same-cwd-status",
@@ -4758,7 +4758,7 @@ test("same-cwd workspace descriptors scope agent status by workspace id", async
];
const runningDescriptors = await session.buildWorkspaceDescriptorMap({ includeGitData: false });
expect(runningDescriptors.get(workspaceA.workspaceId)?.status).toBe("running");
expect(runningDescriptors.get(workspaceB.workspaceId)?.status).toBe("done");
expect(runningDescriptors.get(workspaceB.workspaceId)?.status).toBe("running");
session.listAgentPayloads = async () => [
makeAgent({
@@ -4773,7 +4773,7 @@ test("same-cwd workspace descriptors scope agent status by workspace id", async
}),
];
const attentionDescriptors = await session.buildWorkspaceDescriptorMap({ includeGitData: false });
expect(attentionDescriptors.get(workspaceA.workspaceId)?.status).toBe("done");
expect(attentionDescriptors.get(workspaceA.workspaceId)?.status).toBe("attention");
expect(attentionDescriptors.get(workspaceB.workspaceId)?.status).toBe("attention");
});
@@ -5770,7 +5770,7 @@ test("terminal activity contribution change updates the correct workspace", asyn
});
});
test("same-cwd workspace attribution targets the terminal's workspace", async () => {
test("same-cwd terminal activity updates every workspace status bucket for that cwd", async () => {
const emitted: SessionOutboundMessage[] = [];
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-same-cwd-"));
const workspaceA = createPersistedWorkspaceRecord({
@@ -5816,21 +5816,22 @@ test("same-cwd workspace attribution targets the terminal's workspace", async ()
message.payload.kind === "upsert" &&
message.payload.workspace.id === workspaceB.workspaceId &&
message.payload.workspace.status === "running",
"same-cwd terminal activity targets the stamped workspace",
"same-cwd terminal activity updates the stamped workspace",
);
await waitForWorkspaceUpdate(
emitted,
(message) =>
message.payload.kind === "upsert" &&
message.payload.workspace.id === workspaceA.workspaceId &&
message.payload.workspace.status === "running",
"same-cwd terminal activity updates the sibling workspace status bucket",
);
const updates = filterByType(emitted, "workspace_update");
const upserts = updates.filter((update) => update.payload.kind === "upsert");
const targetIds = new Set(upserts.map((update) => update.payload.workspace.id));
expect(targetIds.has(workspaceB.workspaceId)).toBe(true);
expect(targetIds.has(workspaceA.workspaceId)).toBe(false);
expect(upserts[upserts.length - 1]?.payload).toMatchObject({
kind: "upsert",
workspace: {
id: workspaceB.workspaceId,
status: "running",
},
});
expect(targetIds.has(workspaceA.workspaceId)).toBe(true);
});
test("nested worktree attribution targets the deepest active workspace", async () => {
@@ -99,7 +99,8 @@ class WorkspaceStatus {
}
// A root agent owned by a specific workspace, even though both same-cwd
// workspaces share the directory. Attribution must follow workspaceId.
// workspaces share the directory. Ownership follows workspaceId; aggregate
// status intentionally fans out to every workspace for that cwd.
hasStampedRootAgent(input: AgentState & { workspaceId: string }): void {
this.agents.push(
createAgent({ ...input, cwd: this.workspace.cwd, workspaceId: input.workspaceId }),
@@ -153,8 +154,9 @@ class WorkspaceStatus {
});
}
// A working terminal owned by a specific same-cwd workspace. Attribution must
// follow workspaceId, not the deterministic-oldest cwd fallback.
// A working terminal owned by a specific same-cwd workspace. Ownership follows
// workspaceId; aggregate status intentionally fans out to every workspace for
// that cwd.
hasStampedWorkingTerminal(input: { workspaceId: string; changedAt: number }): void {
this.terminals.push({
cwd: this.workspace.cwd,
@@ -278,13 +280,10 @@ describe("WorkspaceDirectory", () => {
await expect(workspace.workspaceStatus()).resolves.toBe("running");
});
test("two same-cwd workspaces keep independent status, attributed by workspaceId", async () => {
test("same-cwd workspaces share agent status buckets", async () => {
const workspace = new WorkspaceStatus();
workspace.hasSiblingWorkspaceSameCwd();
// The running agent is stamped to the SIBLING. A cwd-only fallback would
// attribute it to the older `workspace-1` and leave the sibling "done", so
// this asserts the running status follows workspaceId, not directory.
workspace.hasStampedRootAgent({
id: "agent-a",
status: "running",
@@ -297,23 +296,49 @@ describe("WorkspaceDirectory", () => {
});
await expect(workspace.workspaceStatuses()).resolves.toEqual({
"workspace-1": "done",
"workspace-1": "running",
"workspace-1-sibling": "running",
});
});
test("two same-cwd workspaces keep independent terminal status, attributed by workspaceId", async () => {
test("same-cwd workspaces share agent attention buckets", async () => {
const workspace = new WorkspaceStatus();
workspace.hasSiblingWorkspaceSameCwd();
workspace.hasStampedRootAgent({
id: "agent-a",
status: "idle",
pendingPermissionCount: 1,
workspaceId: "workspace-1-sibling",
});
await expect(workspace.workspaceStatuses()).resolves.toEqual({
"workspace-1": "needs_input",
"workspace-1-sibling": "needs_input",
});
});
test("same-cwd workspaces share legacy unstamped agent status buckets", async () => {
const workspace = new WorkspaceStatus();
workspace.hasSiblingWorkspaceSameCwd();
workspace.hasRootAgent({ id: "legacy-agent", status: "running" });
await expect(workspace.workspaceStatuses()).resolves.toEqual({
"workspace-1": "running",
"workspace-1-sibling": "running",
});
});
test("same-cwd workspaces share terminal status buckets", async () => {
const workspace = new WorkspaceStatus();
const changedAt = new Date(NOW).getTime();
workspace.hasSiblingWorkspaceSameCwd();
// The working terminal is owned by the SIBLING. A cwd-only resolver would
// attribute it to the older `workspace-1`, so this asserts the running
// status follows the terminal's workspaceId, not the directory.
workspace.hasStampedWorkingTerminal({ workspaceId: "workspace-1-sibling", changedAt });
await expect(workspace.workspaceStatuses()).resolves.toEqual({
"workspace-1": "done",
"workspace-1": "running",
"workspace-1-sibling": "running",
});
});
@@ -338,19 +338,21 @@ export class WorkspaceDirectory {
});
}
const workspaceId = resolveWorkspaceIdForRecord(workspaceAgent, activeRecords);
if (workspaceId === null || !activeWorkspaceIds.has(workspaceId)) {
continue;
}
const existing = descriptorsByWorkspaceId.get(workspaceId);
if (!existing) {
continue;
}
const workspaceIds = resolveWorkspaceIdsForStatusContribution(workspaceAgent, activeRecords);
for (const workspaceId of workspaceIds) {
if (!activeWorkspaceIds.has(workspaceId)) {
continue;
}
const existing = descriptorsByWorkspaceId.get(workspaceId);
if (!existing) {
continue;
}
if (
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
) {
existing.status = bucket;
if (
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
) {
existing.status = bucket;
}
}
}
}
@@ -377,24 +379,29 @@ export class WorkspaceDirectory {
}
const bucket = deriveTerminalActivityStatusBucket(activity);
if (!bucket) continue;
const workspaceId =
resolveWorkspaceIdForRecord({ workspaceId: contributedWorkspaceId, cwd }, activeRecords) ??
resolveWorkspaceIdForCwd(cwd);
if (workspaceId == null) {
continue;
const resolvedWorkspaceId = contributedWorkspaceId
? resolveWorkspaceIdForRecord({ workspaceId: contributedWorkspaceId, cwd }, activeRecords)
: resolveWorkspaceIdForCwd(cwd);
const workspaceIds = resolvedWorkspaceId
? resolveWorkspaceIdsForStatusContribution(
{ workspaceId: resolvedWorkspaceId, cwd },
activeRecords,
)
: resolveWorkspaceIdsForStatusContribution({ cwd }, activeRecords);
for (const workspaceId of workspaceIds) {
const existing = descriptorsByWorkspaceId.get(workspaceId);
if (!existing) {
continue;
}
if (
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
) {
existing.status = bucket;
}
const entries = terminalEntriesByWorkspaceId.get(workspaceId) ?? [];
entries.push({ bucket, changedAtIso: new Date(activity.changedAt).toISOString() });
terminalEntriesByWorkspaceId.set(workspaceId, entries);
}
const existing = descriptorsByWorkspaceId.get(workspaceId);
if (!existing) {
continue;
}
if (
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
) {
existing.status = bucket;
}
const entries = terminalEntriesByWorkspaceId.get(workspaceId) ?? [];
entries.push({ bucket, changedAtIso: new Date(activity.changedAt).toISOString() });
terminalEntriesByWorkspaceId.set(workspaceId, entries);
}
return terminalEntriesByWorkspaceId;
}
@@ -649,17 +656,44 @@ function groupAgentsByWorkspaceId(
): Map<string, AgentSnapshotPayload[]> {
const byWorkspaceId = new Map<string, AgentSnapshotPayload[]>();
for (const agent of agents) {
const workspaceId = resolveWorkspaceIdForRecord(agent, activeRecords);
if (workspaceId === null || !activeWorkspaceIds.has(workspaceId)) {
continue;
const workspaceIds = resolveWorkspaceIdsForStatusContribution(agent, activeRecords);
for (const workspaceId of workspaceIds) {
if (!activeWorkspaceIds.has(workspaceId)) {
continue;
}
const entries = byWorkspaceId.get(workspaceId) ?? [];
entries.push(agent);
byWorkspaceId.set(workspaceId, entries);
}
const entries = byWorkspaceId.get(workspaceId) ?? [];
entries.push(agent);
byWorkspaceId.set(workspaceId, entries);
}
return byWorkspaceId;
}
function resolveWorkspaceIdsForStatusContribution(
record: { workspaceId?: string; cwd: string },
activeWorkspaces: PersistedWorkspaceRecord[],
): string[] {
const owningWorkspaceId = resolveWorkspaceIdForRecord(record, activeWorkspaces);
if (!owningWorkspaceId) {
const recordCwd = resolve(record.cwd);
return activeWorkspaces
.filter((workspace) => !workspace.archivedAt && resolve(workspace.cwd) === recordCwd)
.map((workspace) => workspace.workspaceId);
}
const owningWorkspace = activeWorkspaces.find(
(workspace) => workspace.workspaceId === owningWorkspaceId && !workspace.archivedAt,
);
if (!owningWorkspace) {
return [];
}
const owningCwd = resolve(owningWorkspace.cwd);
return activeWorkspaces
.filter((workspace) => !workspace.archivedAt && resolve(workspace.cwd) === owningCwd)
.map((workspace) => workspace.workspaceId);
}
function resolveDelegationRootAgent(
agent: AgentSnapshotPayload,
activeAgentsById: ReadonlyMap<string, AgentSnapshotPayload>,
@@ -175,6 +175,146 @@ describe("bootstrapWorkspaceRegistries", () => {
expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("ws-existing");
});
test("migrates cwd-only agents to the oldest existing same-cwd workspace", async () => {
await projectRegistry.initialize();
await workspaceRegistry.initialize();
await projectRegistry.upsert({
projectId: NON_GIT_PROJECT,
rootPath: NON_GIT_PROJECT,
kind: "non_git",
displayName: "non-git-project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
await workspaceRegistry.upsert({
workspaceId: "ws-newer",
projectId: NON_GIT_PROJECT,
cwd: NON_GIT_PROJECT,
kind: "directory",
displayName: "newer",
createdAt: "2026-03-02T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
});
await workspaceRegistry.upsert({
workspaceId: "ws-older",
projectId: NON_GIT_PROJECT,
cwd: NON_GIT_PROJECT,
kind: "directory",
displayName: "older",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
await agentStorage.initialize();
await agentStorage.upsert({
id: "legacy-agent",
provider: "codex",
cwd: NON_GIT_PROJECT,
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
lastActivityAt: "2026-03-01T12:00:00.000Z",
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "idle",
lastModeId: null,
config: null,
runtimeInfo: { provider: "codex", sessionId: null },
persistence: null,
archivedAt: null,
});
await bootstrapWorkspaceRegistries({
paseoHome,
agentStorage,
projectRegistry,
workspaceRegistry,
workspaceGitService,
logger,
});
expect((await agentStorage.get("legacy-agent"))?.workspaceId).toBe("ws-older");
expect(await workspaceRegistry.list()).toHaveLength(2);
});
test("migrated legacy agents stay owned by the deterministic workspace when a same-cwd workspace is added later", async () => {
await projectRegistry.initialize();
await workspaceRegistry.initialize();
await projectRegistry.upsert({
projectId: NON_GIT_PROJECT,
rootPath: NON_GIT_PROJECT,
kind: "non_git",
displayName: "non-git-project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
await workspaceRegistry.upsert({
workspaceId: "ws-original-owner",
projectId: NON_GIT_PROJECT,
cwd: NON_GIT_PROJECT,
kind: "directory",
displayName: "original",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
await agentStorage.initialize();
await agentStorage.upsert({
id: "legacy-agent",
provider: "codex",
cwd: NON_GIT_PROJECT,
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
lastActivityAt: "2026-03-01T12:00:00.000Z",
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "idle",
lastModeId: null,
config: null,
runtimeInfo: { provider: "codex", sessionId: null },
persistence: null,
archivedAt: null,
});
await bootstrapWorkspaceRegistries({
paseoHome,
agentStorage,
projectRegistry,
workspaceRegistry,
workspaceGitService,
logger,
});
await workspaceRegistry.upsert({
workspaceId: "ws-created-later",
projectId: NON_GIT_PROJECT,
cwd: NON_GIT_PROJECT,
kind: "directory",
displayName: "created later",
createdAt: "2026-03-04T00:00:00.000Z",
updatedAt: "2026-03-04T00:00:00.000Z",
archivedAt: null,
});
await bootstrapWorkspaceRegistries({
paseoHome,
agentStorage,
projectRegistry,
workspaceRegistry,
workspaceGitService,
logger,
});
expect((await agentStorage.get("legacy-agent"))?.workspaceId).toBe("ws-original-owner");
expect(await workspaceRegistry.get("ws-created-later")).toMatchObject({
cwd: NON_GIT_PROJECT,
});
});
test("preserves existing workspace IDs when only the projects file is missing", async () => {
await workspaceRegistry.initialize();
await workspaceRegistry.upsert({
@@ -12,6 +12,7 @@ import type { WorkspaceGitService } from "./workspace-git-service.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
type PersistedWorkspaceRecord,
type ProjectRegistry,
type WorkspaceRegistry,
} from "./workspace-registry.js";
@@ -44,6 +45,71 @@ function resolveAgentUpdatedAt(record: StoredAgentRecord): string {
return record.lastActivityAt || record.updatedAt || record.createdAt || new Date(0).toISOString();
}
export function resolveLegacyWorkspaceOwnerForCwd(
cwd: string,
workspaces: Iterable<PersistedWorkspaceRecord>,
): string | null {
const normalizedCwd = path.resolve(cwd);
const activeWorkspaces = Array.from(workspaces).filter((workspace) => !workspace.archivedAt);
const exactMatches = activeWorkspaces.filter(
(workspace) => path.resolve(workspace.cwd) === normalizedCwd,
);
if (exactMatches.length > 0) {
return oldestWorkspace(exactMatches).workspaceId;
}
const prefixMatches = activeWorkspaces.filter((workspace) => {
const workspaceCwd = path.resolve(workspace.cwd);
return normalizedCwd === workspaceCwd || normalizedCwd.startsWith(`${workspaceCwd}${path.sep}`);
});
if (prefixMatches.length === 0) {
return null;
}
const longestPrefixLength = Math.max(
...prefixMatches.map((workspace) => path.resolve(workspace.cwd).length),
);
return oldestWorkspace(
prefixMatches.filter((workspace) => path.resolve(workspace.cwd).length === longestPrefixLength),
).workspaceId;
}
export async function migrateLegacyAgentWorkspaceOwnership(options: {
agentStorage: AgentStorage;
workspaceRegistry: WorkspaceRegistry;
logger: Logger;
cwds?: Iterable<string>;
}): Promise<number> {
const workspaceRecords = await options.workspaceRegistry.list();
const cwdFilter = options.cwds
? new Set(Array.from(options.cwds, (cwd) => path.resolve(cwd)))
: null;
const records = await options.agentStorage.list();
let migrated = 0;
for (const record of records) {
if (record.workspaceId) {
continue;
}
if (cwdFilter && !cwdFilter.has(path.resolve(record.cwd))) {
continue;
}
const workspaceId = resolveLegacyWorkspaceOwnerForCwd(record.cwd, workspaceRecords);
if (!workspaceId) {
continue;
}
await options.agentStorage.upsert({ ...record, workspaceId });
migrated += 1;
}
if (migrated > 0) {
options.logger.info({ migrated }, "Migrated legacy agent workspace ownership");
}
return migrated;
}
export async function bootstrapWorkspaceRegistries(options: {
paseoHome: string;
agentStorage: AgentStorage;
@@ -60,6 +126,7 @@ export async function bootstrapWorkspaceRegistries(options: {
await Promise.all([options.projectRegistry.initialize(), options.workspaceRegistry.initialize()]);
if (projectsExists && workspacesExists) {
await migrateLegacyAgentWorkspaceOwnership(options);
return;
}
@@ -168,6 +235,8 @@ export async function bootstrapWorkspaceRegistries(options: {
),
);
await migrateLegacyAgentWorkspaceOwnership(options);
options.logger.info(
{
projectsFile: path.join(options.paseoHome, "projects", "projects.json"),
@@ -178,3 +247,9 @@ export async function bootstrapWorkspaceRegistries(options: {
"Workspace registries bootstrapped from existing agent storage",
);
}
function oldestWorkspace(workspaces: PersistedWorkspaceRecord[]): PersistedWorkspaceRecord {
return workspaces.reduce((oldest, candidate) =>
candidate.createdAt < oldest.createdAt ? candidate : oldest,
);
}
@@ -259,7 +259,7 @@ describe("resolveWorkspaceIdForRecord", () => {
expect(resolved).toBe("ws-only");
});
test("resolves a legacy record with multiple cwd matches to the deterministic oldest", () => {
test("does not resolve an unstamped legacy record with multiple cwd matches", () => {
const workspaces = [
createWorkspaceRecord("/tmp/repo", "ws-newer", { createdAt: "2026-03-02T00:00:00.000Z" }),
createWorkspaceRecord("/tmp/repo", "ws-older", { createdAt: "2026-03-01T00:00:00.000Z" }),
@@ -267,7 +267,7 @@ describe("resolveWorkspaceIdForRecord", () => {
const resolved = resolveWorkspaceIdForRecord({ cwd: "/tmp/repo" }, workspaces);
expect(resolved).toBe("ws-older");
expect(resolved).toBeNull();
});
test("returns null for a legacy record with no cwd match", () => {
@@ -278,7 +278,7 @@ describe("resolveWorkspaceIdForRecord", () => {
expect(resolved).toBeNull();
});
test("falls back to cwd when the stamped workspaceId points at an archived workspace", () => {
test("does not move a stamped record to another workspace when its owner is archived", () => {
const workspaces = [
createWorkspaceRecord("/tmp/repo", "ws-archived", {
archivedAt: "2026-03-05T00:00:00.000Z",
@@ -291,6 +291,6 @@ describe("resolveWorkspaceIdForRecord", () => {
workspaces,
);
expect(resolved).toBe("ws-live");
expect(resolved).toBeNull();
});
});
@@ -34,12 +34,12 @@ export function generateWorkspaceId(): string {
return `wks_${randomBytes(8).toString("hex")}`;
}
// COMPAT(workspaceOwnership): added in v0.1.97, drop the gate when floor >= v0.1.97.
// COMPAT(workspaceOwnership): added in v0.1.97, drop after 2026-12-15 once floor >= v0.1.97.
// Resolves the owning workspace for a record (agent/terminal) that may predate
// workspaceId stamping. New records always carry workspaceId and hit the first
// branch; legacy records fall back to cwd matching. When several active
// workspaces share the same cwd, duplicate-cwd disambiguation is impossible from
// cwd alone, so we pick the deterministic oldest one.
// branch; legacy records fall back only when cwd has a single active owner.
// Duplicate-cwd records must be stamped by migration before runtime projection;
// otherwise cwd-only membership leaks into every same-directory workspace.
export function resolveWorkspaceIdForRecord(
record: { workspaceId?: string; cwd: string },
activeWorkspaces: Iterable<PersistedWorkspaceRecord>,
@@ -52,6 +52,7 @@ export function resolveWorkspaceIdForRecord(
if (exact) {
return exact.workspaceId;
}
return null;
}
const resolvedCwd = resolve(record.cwd);
@@ -61,11 +62,6 @@ export function resolveWorkspaceIdForRecord(
if (cwdMatches.length === 1) {
return cwdMatches[0].workspaceId;
}
if (cwdMatches.length > 1) {
return cwdMatches.reduce((oldest, candidate) =>
candidate.createdAt < oldest.createdAt ? candidate : oldest,
).workspaceId;
}
return null;
}
@@ -14,9 +14,9 @@ import {
const WORKSPACE_A = "wks_same_cwd_a";
const WORKSPACE_B = "wks_same_cwd_b";
// Seed two active workspaces that share one cwd, so we can prove Phase 1
// attributes agents, terminals, and status by workspaceId rather than by
// directory. Both registry files must exist on disk before the daemon starts:
// Seed two active workspaces that share one cwd, so we can prove ownership
// stays workspaceId-scoped while aggregate status is cwd-scoped. Both
// registry files must exist on disk before the daemon starts:
// bootstrapWorkspaceRegistries skips materialization when both files are
// present, leaving these seeded records untouched.
function seedSameCwdWorkspaces(): { paseoHomeRoot: string; cwd: string } {
@@ -68,7 +68,7 @@ async function statusByWorkspaceId(client: DaemonClient): Promise<Map<string, st
return new Map(workspaces.entries.map((entry) => [entry.id, entry.status]));
}
test("two workspaces sharing one cwd stay isolated by workspaceId", async () => {
test("two workspaces sharing one cwd share agent status without leaking ownership", async () => {
const { paseoHomeRoot, cwd } = seedSameCwdWorkspaces();
const daemon = await createTestPaseoDaemon({ paseoHomeRoot });
const client = new DaemonClient({
@@ -87,9 +87,10 @@ test("two workspaces sharing one cwd stay isolated by workspaceId", async () =>
]),
);
// 1. Agent created in workspace A carries workspaceId A and only moves A's
// status. Ask mode + a write parks the agent on a pending permission,
// which is the deterministic "needs_input" signal for A.
// 1. Agent created in workspace A carries workspaceId A. Ask mode + a
// write parks the agent on a pending permission, which contributes the
// deterministic "needs_input" aggregate signal to all same-cwd
// workspaces.
const agentA = await client.createAgent({
...getAskModeConfig("codex"),
cwd,
@@ -111,7 +112,7 @@ test("two workspaces sharing one cwd stay isolated by workspaceId", async () =>
expect(await statusByWorkspaceId(client)).toEqual(
new Map([
[WORKSPACE_A, "needs_input"],
[WORKSPACE_B, "done"],
[WORKSPACE_B, "needs_input"],
]),
);
@@ -159,8 +160,8 @@ test("two workspaces sharing one cwd stay isolated by workspaceId", async () =>
const listForB = await client.listTerminals(cwd, undefined, { workspaceId: WORKSPACE_B });
expect(listForB.terminals.some((terminal) => terminal.id === terminalId)).toBe(false);
// 3. Agent created in workspace B carries workspaceId B and moves only B's
// status. A keeps its own pending-permission state independently.
// 3. Agent created in workspace B carries workspaceId B while the aggregate
// needs_input status stays shared across both same-cwd workspace rows.
const agentB = await client.createAgent({
...getAskModeConfig("codex"),
cwd,
@@ -110,6 +110,20 @@ it("lists subdirectory terminals when querying the workspace root", async () =>
expect(rootTerminals.map((terminal) => terminal.id)).toEqual([created.id]);
});
it("includes only stamped terminals in workspace-scoped queries", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const legacy = await manager.createTerminal({ cwd });
const owned = await manager.createTerminal({ cwd, workspaceId: "ws-owned" });
const sibling = await manager.createTerminal({ cwd, workspaceId: "ws-sibling" });
const scoped = await manager.getTerminals(cwd, { workspaceId: "ws-owned" });
const unscoped = await manager.getTerminals(cwd);
expect(scoped.map((terminal) => terminal.id)).toEqual([owned.id]);
expect(unscoped.map((terminal) => terminal.id)).toEqual([legacy.id, owned.id, sibling.id]);
});
it("creates additional terminal with auto-incrementing name", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
@@ -296,14 +296,10 @@ export function createTerminalManager(
}
// When the query carries a workspaceId, two workspaces sharing a cwd must
// not see each other's terminals. Exclude sessions owned by a different
// workspace; keep sessions without an owner (COMPAT: created by clients
// that predate terminal workspace ownership).
// not see each other's terminals. A missing owner is not workspace
// membership; unscoped callers can still list those legacy terminals.
if (options?.workspaceId !== undefined) {
return sessions.filter(
(session) =>
session.workspaceId === undefined || session.workspaceId === options.workspaceId,
);
return sessions.filter((session) => session.workspaceId === options.workspaceId);
}
return sessions;
},
@@ -566,6 +566,57 @@ it("lists terminals locally without waiting on the worker", async () => {
expect(worker.sentMessages.some((message) => message.type === "getTerminals")).toBe(false);
});
it("includes only stamped terminals in workspace-scoped local reads", async () => {
const worker = new FakeTerminalWorker();
manager = createWorkerTerminalManager({
requestTimeoutMs: 5,
forkWorker: () => worker,
});
worker.emitWorkerMessage({
type: "terminalCreated",
terminal: {
id: "terminal-legacy",
name: "Legacy",
cwd: "/workspace",
activity: null,
},
state: createTerminalState(),
});
worker.emitWorkerMessage({
type: "terminalCreated",
terminal: {
id: "terminal-owned",
name: "Owned",
cwd: "/workspace",
workspaceId: "ws-owned",
activity: null,
},
state: createTerminalState(),
});
worker.emitWorkerMessage({
type: "terminalCreated",
terminal: {
id: "terminal-sibling",
name: "Sibling",
cwd: "/workspace",
workspaceId: "ws-sibling",
activity: null,
},
state: createTerminalState(),
});
const scoped = await manager.getTerminals("/workspace", { workspaceId: "ws-owned" });
const unscoped = await manager.getTerminals("/workspace");
expect(scoped.map((terminal) => terminal.id)).toEqual(["terminal-owned"]);
expect(unscoped.map((terminal) => terminal.id)).toEqual([
"terminal-legacy",
"terminal-owned",
"terminal-sibling",
]);
});
it("rejects non-absolute cwd in getTerminals", async () => {
const worker = new FakeTerminalWorker();
manager = createWorkerTerminalManager({
@@ -653,14 +653,10 @@ export function createWorkerTerminalManager(
}
// When the query carries a workspaceId, two workspaces sharing a cwd must
// not see each other's terminals. Exclude sessions owned by a different
// workspace; keep sessions without an owner (COMPAT: created by clients
// that predate terminal workspace ownership).
// not see each other's terminals. A missing owner is not workspace
// membership; unscoped callers can still list those legacy terminals.
if (options?.workspaceId !== undefined) {
return sessions.filter(
(session) =>
session.workspaceId === undefined || session.workspaceId === options.workspaceId,
);
return sessions.filter((session) => session.workspaceId === options.workspaceId);
}
return sessions;
},