Fix same-directory workspace ownership
This commit is contained in:
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user