From ba9f7740a8c9b58588a7bbd7537bb824962b0157 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 15 Jun 2026 22:22:31 +0700 Subject: [PATCH] Fix same-directory workspace ownership --- docs/agent-lifecycle.md | 2 +- docs/data-model.md | 55 ++++--- docs/glossary.md | 3 +- docs/terminal-activity.md | 2 +- .../e2e/workspace-model-regressions.spec.ts | 54 ++++++- .../composer/draft/input-draft.live.test.tsx | 2 + .../src/composer/draft/input-draft.test.ts | 16 ++ .../provider-selection/provider-selection.ts | 10 +- packages/server/src/server/session.ts | 69 ++++++++- .../src/server/session.workspaces.test.ts | 27 ++-- .../src/server/workspace-directory.test.ts | 51 +++++-- .../server/src/server/workspace-directory.ts | 104 ++++++++----- .../workspace-registry-bootstrap.test.ts | 140 ++++++++++++++++++ .../server/workspace-registry-bootstrap.ts | 75 ++++++++++ .../server/workspace-registry-model.test.ts | 8 +- .../src/server/workspace-registry-model.ts | 14 +- .../workspace-same-cwd-isolation.e2e.test.ts | 21 +-- .../src/terminal/terminal-manager.test.ts | 14 ++ .../server/src/terminal/terminal-manager.ts | 10 +- .../terminal/worker-terminal-manager.test.ts | 51 +++++++ .../src/terminal/worker-terminal-manager.ts | 10 +- 21 files changed, 599 insertions(+), 139 deletions(-) diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index a72247fc1..f847914c4 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -58,7 +58,7 @@ The asymmetry is intentional: a subagent's home is the parent's track, not the t Agent lifecycle status stays literal: a parent agent is `idle` when its own turn is idle, even if a child is running. -Workspace status is an aggregate activity signal. Root agents contribute their normal state bucket to their own workspace. Running subagents contribute `running` to their root parent's workspace, not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket. +Workspace status is an aggregate activity signal. Root agents contribute their normal state bucket to every active workspace with the same `cwd` as their owning workspace; tab and agent visibility still stays scoped to the agent's `workspaceId`. Running subagents contribute `running` to workspaces with the same `cwd` as their root parent's owning workspace, not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket. ## The subagents track diff --git a/docs/data-model.md b/docs/data-model.md index ea7916074..c3fd69695 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -40,29 +40,30 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b Each agent is stored as a separate JSON file, grouped by project directory. -| Field | Type | Description | -| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | `string` | UUID, primary key | -| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) | -| `cwd` | `string` | Working directory the agent operates in | -| `createdAt` | `string` (ISO 8601) | Creation timestamp | -| `updatedAt` | `string` (ISO 8601) | Last update timestamp | -| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp | -| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp | -| `title` | `string?` | User-visible title | -| `labels` | `Record` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) | -| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` | -| `lastModeId` | `string?` | Last active mode ID | -| `config` | `SerializableConfig?` | Agent session configuration (see below) | -| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) | -| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) | -| `persistence` | `PersistenceHandle?` | Handle for resuming sessions | -| `lastError` | `string?` (nullable) | Last error message, if any | -| `requiresAttention` | `boolean?` | Whether the agent needs user attention | -| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed | -| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged | -| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) | -| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp | +| Field | Type | Description | +| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | `string` | UUID, primary key | +| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) | +| `cwd` | `string` | Working directory the agent operates in | +| `workspaceId` | `string?` | Owning workspace id. Legacy cwd-only records are migrated to a stable existing workspace owner; runtime workspace membership should not infer ownership from cwd when same-CWD workspaces exist. | +| `createdAt` | `string` (ISO 8601) | Creation timestamp | +| `updatedAt` | `string` (ISO 8601) | Last update timestamp | +| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp | +| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp | +| `title` | `string?` | User-visible title | +| `labels` | `Record` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) | +| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` | +| `lastModeId` | `string?` | Last active mode ID | +| `config` | `SerializableConfig?` | Agent session configuration (see below) | +| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) | +| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) | +| `persistence` | `PersistenceHandle?` | Handle for resuming sessions | +| `lastError` | `string?` (nullable) | Last error message, if any | +| `requiresAttention` | `boolean?` | Whether the agent needs user attention | +| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed | +| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged | +| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) | +| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp | ### Nested: SerializableConfig @@ -126,6 +127,14 @@ Each agent is stored as a separate JSON file, grouped by project directory. --- +## Runtime-only Terminal Sessions + +Terminals are live daemon state, not persisted JSON records. A terminal may carry a `workspaceId` while it is running; workspace-scoped terminal lists include only terminals with the matching `workspaceId`. Legacy live terminals without an owner remain visible to unscoped terminal reads but are not migrated or inferred into same-`cwd` workspace lists. + +Terminal activity still contributes to the aggregate workspace status bucket. When an owned terminal contributes activity, every active workspace with the owning workspace's `cwd` receives the same status bucket; terminal visibility itself stays `workspaceId`-scoped. + +--- + ## 2. Daemon Configuration **Path:** `$PASEO_HOME/config.json` diff --git a/docs/glossary.md b/docs/glossary.md index 1bdae66eb..6a9513e8d 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -14,7 +14,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym. - **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label. - **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, GitHub PR info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). -- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, status, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). +- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). +- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`. - **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). - **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log. - **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. diff --git a/docs/terminal-activity.md b/docs/terminal-activity.md index 933ae23be..9f0d8cddc 100644 --- a/docs/terminal-activity.md +++ b/docs/terminal-activity.md @@ -24,7 +24,7 @@ TerminalSession `TerminalActivityTracker` is the single stateful object per session. It holds `{ state, changedAt }`, starts at unknown (`null`), and fires `onChange` only when the state actually changes. -Terminal directory snapshots (`terminalsChanged`) and workspace contribution changes are separate concerns. A title-only change produces a terminal list snapshot but never touches workspace descriptors. A transition that changes the derived workspace bucket (e.g. idle → working, working → idle, attention cleared) emits both a terminal list snapshot and a server-internal `TerminalWorkspaceContributionChanged` event, which Session consumes to invalidate only the affected workspace. +Terminal directory snapshots (`terminalsChanged`) and workspace contribution changes are separate concerns. A title-only change produces a terminal list snapshot but never touches workspace descriptors. A transition that changes the derived workspace bucket (e.g. idle -> working, working -> idle, attention cleared) emits both a terminal list snapshot and a server-internal `TerminalWorkspaceContributionChanged` event, which Session consumes to invalidate every active workspace sharing the owning workspace's `cwd`. ### Transitions carry their own history diff --git a/packages/app/e2e/workspace-model-regressions.spec.ts b/packages/app/e2e/workspace-model-regressions.spec.ts index 0fe1dca6b..4269155ae 100644 --- a/packages/app/e2e/workspace-model-regressions.spec.ts +++ b/packages/app/e2e/workspace-model-regressions.spec.ts @@ -1,16 +1,26 @@ import { test, expect } from "./fixtures"; -import { - expectComposerEditable, - expectComposerVisible, - fillComposerDraft, -} from "./helpers/composer"; +import { expectComposerEditable, expectComposerVisible, submitMessage } from "./helpers/composer"; import { clickNewChat, gotoWorkspace } from "./helpers/launcher"; import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace"; +import { captureWsSessionFrames } from "./helpers/rename"; import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs"; type NewWorkspaceDaemonClient = Awaited>; +interface CreateAgentFrame extends Record { + initialPrompt: string | null; + workspaceId: string | null; + provider: string | null; + cwd: string | null; + modeId: string | null; + model: string | null; +} + +function createFrameForPrompt(frames: CreateAgentFrame[], prompt: string): CreateAgentFrame | null { + return frames.find((frame) => frame.initialPrompt === prompt) ?? null; +} + test.describe("Workspace model regressions", () => { let client: NewWorkspaceDaemonClient; @@ -64,6 +74,21 @@ test.describe("Workspace model regressions", () => { const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "workspace-new-agent-model-", }); + const createFrames = captureWsSessionFrames( + page, + "create_agent_request", + (inner) => { + const config = (inner.config ?? {}) as Record; + return { + initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null, + workspaceId: typeof inner.workspaceId === "string" ? inner.workspaceId : null, + provider: typeof config.provider === "string" ? config.provider : null, + cwd: typeof config.cwd === "string" ? config.cwd : null, + modeId: typeof config.modeId === "string" ? config.modeId : null, + model: typeof config.model === "string" ? config.model : null, + }; + }, + ); try { const secondWorkspace = await seeded.client.createWorkspace({ @@ -78,9 +103,9 @@ test.describe("Workspace model regressions", () => { localStorage.setItem( "@paseo:create-agent-preferences", JSON.stringify({ - provider: "codex", + provider: "mock", providerPreferences: { - codex: { mode: "full-access" }, + mock: { mode: "load-test" }, }, }), ); @@ -90,10 +115,23 @@ test.describe("Workspace model regressions", () => { await expectComposerVisible(page); await expectComposerEditable(page); - await fillComposerDraft(page, "d"); + const prompt = `Create agent default model ${Date.now()}`; + await submitMessage(page, prompt); await expect(page.getByText("No model is available for the selected provider")).toHaveCount( 0, ); + await expect + .poll(() => createFrameForPrompt(createFrames, prompt), { + timeout: 10_000, + }) + .toEqual({ + initialPrompt: prompt, + workspaceId: secondWorkspace.workspace.id, + provider: "mock", + cwd: seeded.repoPath, + modeId: "load-test", + model: "five-minute-stream", + }); } finally { await seeded.cleanup(); } diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index c4d554ee7..64b684ea0 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -215,6 +215,8 @@ describe("useAgentInputDraft live contract", () => { provider: "codex", cwd: "/repo", modeId: "auto", + model: "gpt-5.4", + thinkingOptionId: "high", }); await act(async () => { diff --git a/packages/app/src/composer/draft/input-draft.test.ts b/packages/app/src/composer/draft/input-draft.test.ts index 31af4f468..7ca166e3b 100644 --- a/packages/app/src/composer/draft/input-draft.test.ts +++ b/packages/app/src/composer/draft/input-draft.test.ts @@ -52,6 +52,22 @@ describe("resolveEffectiveComposerModelId", () => { }), ).toBe(""); }); + + it("falls back to the provider default model when no model is selected", () => { + expect( + resolveEffectiveComposerModelId({ + provider: "codex", + modelId: "", + modeId: "", + thinkingOptionId: "", + availableModels: [ + { provider: "codex", id: "gpt-5.4-mini", label: "gpt-5.4-mini" }, + { provider: "codex", id: "gpt-5.4", label: "gpt-5.4", isDefault: true }, + ], + modeOptions: [], + }), + ).toBe("gpt-5.4"); + }); }); describe("resolveEffectiveComposerThinkingOptionId", () => { diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index bad65c7db..e11bfffca 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -229,7 +229,15 @@ export function filterAndRankModelRows( } export function resolveEffectiveComposerModelId(selection: ProviderSelectionState): string { - return selection.modelId.trim(); + const selectedModelId = selection.modelId.trim(); + if (selectedModelId) { + return selectedModelId; + } + return ( + selection.availableModels.find((model) => model.isDefault)?.id ?? + selection.availableModels[0]?.id ?? + "" + ); } export function resolveEffectiveComposerThinkingOptionId( diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 32060de35..e2a6f7773 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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 { + 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 { + 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. diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index a740dd8b4..1efec395b 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -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 () => { diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index 9d9d3a02d..e9b40c0ef 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -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", }); }); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index a7b31c58d..4998b772b 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -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 { const byWorkspaceId = new Map(); 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, diff --git a/packages/server/src/server/workspace-registry-bootstrap.test.ts b/packages/server/src/server/workspace-registry-bootstrap.test.ts index 0aabadf8c..e97b83112 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.test.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.test.ts @@ -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({ diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts index bbe54f4ec..d1b39bcc5 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -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, +): 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; +}): Promise { + 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, + ); +} diff --git a/packages/server/src/server/workspace-registry-model.test.ts b/packages/server/src/server/workspace-registry-model.test.ts index a47d2a4ef..e4f07f258 100644 --- a/packages/server/src/server/workspace-registry-model.test.ts +++ b/packages/server/src/server/workspace-registry-model.test.ts @@ -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(); }); }); diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index ef1084223..46455a934 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -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, @@ -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; } diff --git a/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts b/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts index 709420a11..9f405b9b0 100644 --- a/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts +++ b/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts @@ -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 [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, diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index 193821c70..fbee8a0f4 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -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()); diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index c5966b363..4505a642b 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -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; }, diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index f26b306a4..211005d32 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -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({ diff --git a/packages/server/src/terminal/worker-terminal-manager.ts b/packages/server/src/terminal/worker-terminal-manager.ts index 4272e0985..988118253 100644 --- a/packages/server/src/terminal/worker-terminal-manager.ts +++ b/packages/server/src/terminal/worker-terminal-manager.ts @@ -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; },