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
+1 -1
View File
@@ -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. 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 ## The subagents track
+32 -23
View File
@@ -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. Each agent is stored as a separate JSON file, grouped by project directory.
| Field | Type | Description | | Field | Type | Description |
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id` | `string` | UUID, primary key | | `id` | `string` | UUID, primary key |
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) | | `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
| `cwd` | `string` | Working directory the agent operates in | | `cwd` | `string` | Working directory the agent operates in |
| `createdAt` | `string` (ISO 8601) | Creation timestamp | | `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. |
| `updatedAt` | `string` (ISO 8601) | Last update timestamp | | `createdAt` | `string` (ISO 8601) | Creation timestamp |
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp | | `updatedAt` | `string` (ISO 8601) | Last update timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp | | `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `title` | `string?` | User-visible title | | `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `labels` | `Record<string, string>` | 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) | | `title` | `string?` | User-visible title |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` | | `labels` | `Record<string, string>` | 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) |
| `lastModeId` | `string?` | Last active mode ID | | `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `config` | `SerializableConfig?` | Agent session configuration (see below) | | `lastModeId` | `string?` | Last active mode ID |
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) | | `config` | `SerializableConfig?` | Agent session configuration (see below) |
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) | | `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions | | `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
| `lastError` | `string?` (nullable) | Last error message, if any | | `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
| `requiresAttention` | `boolean?` | Whether the agent needs user attention | | `lastError` | `string?` (nullable) | Last error message, if any |
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed | | `requiresAttention` | `boolean?` | Whether the agent needs user attention |
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged | | `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) | | `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp | | `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
### Nested: SerializableConfig ### 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 ## 2. Daemon Configuration
**Path:** `$PASEO_HOME/config.json` **Path:** `$PASEO_HOME/config.json`
+2 -1
View File
@@ -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. - **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. - **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). - **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`). - **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. - **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. - **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
+1 -1
View File
@@ -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. `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 ### Transitions carry their own history
@@ -1,16 +1,26 @@
import { test, expect } from "./fixtures"; import { test, expect } from "./fixtures";
import { import { expectComposerEditable, expectComposerVisible, submitMessage } from "./helpers/composer";
expectComposerEditable,
expectComposerVisible,
fillComposerDraft,
} from "./helpers/composer";
import { clickNewChat, gotoWorkspace } from "./helpers/launcher"; import { clickNewChat, gotoWorkspace } from "./helpers/launcher";
import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace"; import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace";
import { captureWsSessionFrames } from "./helpers/rename";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs"; import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
type NewWorkspaceDaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>; type NewWorkspaceDaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
interface CreateAgentFrame extends Record<string, unknown> {
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", () => { test.describe("Workspace model regressions", () => {
let client: NewWorkspaceDaemonClient; let client: NewWorkspaceDaemonClient;
@@ -64,6 +74,21 @@ test.describe("Workspace model regressions", () => {
const seeded: SeededWorkspace = await seedWorkspace({ const seeded: SeededWorkspace = await seedWorkspace({
repoPrefix: "workspace-new-agent-model-", repoPrefix: "workspace-new-agent-model-",
}); });
const createFrames = captureWsSessionFrames<CreateAgentFrame>(
page,
"create_agent_request",
(inner) => {
const config = (inner.config ?? {}) as Record<string, unknown>;
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 { try {
const secondWorkspace = await seeded.client.createWorkspace({ const secondWorkspace = await seeded.client.createWorkspace({
@@ -78,9 +103,9 @@ test.describe("Workspace model regressions", () => {
localStorage.setItem( localStorage.setItem(
"@paseo:create-agent-preferences", "@paseo:create-agent-preferences",
JSON.stringify({ JSON.stringify({
provider: "codex", provider: "mock",
providerPreferences: { providerPreferences: {
codex: { mode: "full-access" }, mock: { mode: "load-test" },
}, },
}), }),
); );
@@ -90,10 +115,23 @@ test.describe("Workspace model regressions", () => {
await expectComposerVisible(page); await expectComposerVisible(page);
await expectComposerEditable(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( await expect(page.getByText("No model is available for the selected provider")).toHaveCount(
0, 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 { } finally {
await seeded.cleanup(); await seeded.cleanup();
} }
@@ -215,6 +215,8 @@ describe("useAgentInputDraft live contract", () => {
provider: "codex", provider: "codex",
cwd: "/repo", cwd: "/repo",
modeId: "auto", modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
}); });
await act(async () => { await act(async () => {
@@ -52,6 +52,22 @@ describe("resolveEffectiveComposerModelId", () => {
}), }),
).toBe(""); ).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", () => { describe("resolveEffectiveComposerThinkingOptionId", () => {
@@ -229,7 +229,15 @@ export function filterAndRankModelRows(
} }
export function resolveEffectiveComposerModelId(selection: ProviderSelectionState): string { 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( export function resolveEffectiveComposerThinkingOptionId(
+63 -6
View File
@@ -252,6 +252,10 @@ import {
type CreatePaseoWorktreeInput, type CreatePaseoWorktreeInput,
type CreatePaseoWorktreeResult, type CreatePaseoWorktreeResult,
} from "./paseo-worktree-service.js"; } from "./paseo-worktree-service.js";
import {
migrateLegacyAgentWorkspaceOwnership,
resolveLegacyWorkspaceOwnerForCwd,
} from "./workspace-registry-bootstrap.js";
import { import {
generateBranchNameFromFirstAgentContext, generateBranchNameFromFirstAgentContext,
type GeneratedWorkspaceName, type GeneratedWorkspaceName,
@@ -3282,6 +3286,10 @@ export class Session {
const createAgentConfig: AgentSessionConfig = createdWorktree const createAgentConfig: AgentSessionConfig = createdWorktree
? { ...config, cwd: createdWorktree.worktree.worktreePath } ? { ...config, cwd: createdWorktree.worktree.worktreePath }
: config; : config;
const workspaceId =
createdWorktree?.workspace.workspaceId ??
msg.workspaceId ??
(await this.resolveLegacyWorkspaceOwnerForAgentCreate(createAgentConfig.cwd));
const { snapshot, liveSnapshot } = await createAgentCommand( const { snapshot, liveSnapshot } = await createAgentCommand(
{ {
@@ -3297,7 +3305,7 @@ export class Session {
{ {
kind: "session", kind: "session",
config: createAgentConfig, config: createAgentConfig,
workspaceId: createdWorktree ? createdWorktree.workspace.workspaceId : msg.workspaceId, workspaceId,
worktreeName, worktreeName,
initialPrompt, initialPrompt,
clientMessageId, clientMessageId,
@@ -3321,7 +3329,13 @@ export class Session {
agentId: snapshot.id, agentId: snapshot.id,
createdWorktree, createdWorktree,
}); });
if (!createdWorktree && trimmedPrompt) {
await this.scheduleAutoNameLocalWorkspaceTitleForFirstAgent({
workspaceId,
cwd: createAgentConfig.cwd,
firstAgentContext,
});
}
if (requestId) { if (requestId) {
const agentPayload = await this.buildAgentPayload(liveSnapshot); const agentPayload = await this.buildAgentPayload(liveSnapshot);
this.emit({ this.emit({
@@ -3801,6 +3815,26 @@ export class Session {
await this.emitWorkspaceUpdateForCwd(input.cwd); 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( private emitProviderDisabledResponse(
kind: "models" | "modes", kind: "models" | "modes",
provider: AgentProvider, provider: AgentProvider,
@@ -7296,12 +7330,22 @@ export class Session {
{ workspaceId: event.workspaceId, cwd: event.cwd }, { workspaceId: event.workspaceId, cwd: event.cwd },
workspaces, workspaces,
); );
if (workspaceId) { const owningWorkspace = workspaceId
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { ? workspaces.find(
skipReconcile: true, (workspace) => workspace.workspaceId === workspaceId && !workspace.archivedAt,
}); )
: null;
if (!owningWorkspace) {
return; return;
} }
const workspaceIds = this.workspaceDirectory.resolveRegisteredWorkspaceIdsForCwd(
owningWorkspace.cwd,
workspaces,
);
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds, {
skipReconcile: true,
});
return;
} }
await this.emitWorkspaceUpdateForCwd(event.cwd, { skipReconcile: true }); await this.emitWorkspaceUpdateForCwd(event.cwd, { skipReconcile: true });
} }
@@ -7614,6 +7658,13 @@ export class Session {
return; return;
} }
await migrateLegacyAgentWorkspaceOwnership({
agentStorage: this.agentStorage,
workspaceRegistry: this.workspaceRegistry,
logger: this.sessionLogger,
cwds: [cwd],
});
const workspace = await createLocalCheckoutWorkspace( const workspace = await createLocalCheckoutWorkspace(
{ cwd, title: request.title ?? null }, { 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 // Schedules a background workspace-naming write off the request path and
// records the resulting promise so tests can await completion deterministically // 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. // (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 session = createSessionForWorkspaceTests();
const project = createPersistedProjectRecord({ const project = createPersistedProjectRecord({
projectId: "proj-same-cwd-status", 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 }); const runningDescriptors = await session.buildWorkspaceDescriptorMap({ includeGitData: false });
expect(runningDescriptors.get(workspaceA.workspaceId)?.status).toBe("running"); 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 () => [ session.listAgentPayloads = async () => [
makeAgent({ makeAgent({
@@ -4773,7 +4773,7 @@ test("same-cwd workspace descriptors scope agent status by workspace id", async
}), }),
]; ];
const attentionDescriptors = await session.buildWorkspaceDescriptorMap({ includeGitData: false }); 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"); 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 emitted: SessionOutboundMessage[] = [];
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-same-cwd-")); const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-same-cwd-"));
const workspaceA = createPersistedWorkspaceRecord({ const workspaceA = createPersistedWorkspaceRecord({
@@ -5816,21 +5816,22 @@ test("same-cwd workspace attribution targets the terminal's workspace", async ()
message.payload.kind === "upsert" && message.payload.kind === "upsert" &&
message.payload.workspace.id === workspaceB.workspaceId && message.payload.workspace.id === workspaceB.workspaceId &&
message.payload.workspace.status === "running", 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 updates = filterByType(emitted, "workspace_update");
const upserts = updates.filter((update) => update.payload.kind === "upsert"); const upserts = updates.filter((update) => update.payload.kind === "upsert");
const targetIds = new Set(upserts.map((update) => update.payload.workspace.id)); const targetIds = new Set(upserts.map((update) => update.payload.workspace.id));
expect(targetIds.has(workspaceB.workspaceId)).toBe(true); expect(targetIds.has(workspaceB.workspaceId)).toBe(true);
expect(targetIds.has(workspaceA.workspaceId)).toBe(false); expect(targetIds.has(workspaceA.workspaceId)).toBe(true);
expect(upserts[upserts.length - 1]?.payload).toMatchObject({
kind: "upsert",
workspace: {
id: workspaceB.workspaceId,
status: "running",
},
});
}); });
test("nested worktree attribution targets the deepest active workspace", async () => { 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 // 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 { hasStampedRootAgent(input: AgentState & { workspaceId: string }): void {
this.agents.push( this.agents.push(
createAgent({ ...input, cwd: this.workspace.cwd, workspaceId: input.workspaceId }), 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 // A working terminal owned by a specific same-cwd workspace. Ownership follows
// follow workspaceId, not the deterministic-oldest cwd fallback. // workspaceId; aggregate status intentionally fans out to every workspace for
// that cwd.
hasStampedWorkingTerminal(input: { workspaceId: string; changedAt: number }): void { hasStampedWorkingTerminal(input: { workspaceId: string; changedAt: number }): void {
this.terminals.push({ this.terminals.push({
cwd: this.workspace.cwd, cwd: this.workspace.cwd,
@@ -278,13 +280,10 @@ describe("WorkspaceDirectory", () => {
await expect(workspace.workspaceStatus()).resolves.toBe("running"); 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(); const workspace = new WorkspaceStatus();
workspace.hasSiblingWorkspaceSameCwd(); 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({ workspace.hasStampedRootAgent({
id: "agent-a", id: "agent-a",
status: "running", status: "running",
@@ -297,23 +296,49 @@ describe("WorkspaceDirectory", () => {
}); });
await expect(workspace.workspaceStatuses()).resolves.toEqual({ await expect(workspace.workspaceStatuses()).resolves.toEqual({
"workspace-1": "done", "workspace-1": "running",
"workspace-1-sibling": "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 workspace = new WorkspaceStatus();
const changedAt = new Date(NOW).getTime(); const changedAt = new Date(NOW).getTime();
workspace.hasSiblingWorkspaceSameCwd(); 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 }); workspace.hasStampedWorkingTerminal({ workspaceId: "workspace-1-sibling", changedAt });
await expect(workspace.workspaceStatuses()).resolves.toEqual({ await expect(workspace.workspaceStatuses()).resolves.toEqual({
"workspace-1": "done", "workspace-1": "running",
"workspace-1-sibling": "running", "workspace-1-sibling": "running",
}); });
}); });
@@ -338,19 +338,21 @@ export class WorkspaceDirectory {
}); });
} }
const workspaceId = resolveWorkspaceIdForRecord(workspaceAgent, activeRecords); const workspaceIds = resolveWorkspaceIdsForStatusContribution(workspaceAgent, activeRecords);
if (workspaceId === null || !activeWorkspaceIds.has(workspaceId)) { for (const workspaceId of workspaceIds) {
continue; if (!activeWorkspaceIds.has(workspaceId)) {
} continue;
const existing = descriptorsByWorkspaceId.get(workspaceId); }
if (!existing) { const existing = descriptorsByWorkspaceId.get(workspaceId);
continue; if (!existing) {
} continue;
}
if ( if (
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status) getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
) { ) {
existing.status = bucket; existing.status = bucket;
}
} }
} }
} }
@@ -377,24 +379,29 @@ export class WorkspaceDirectory {
} }
const bucket = deriveTerminalActivityStatusBucket(activity); const bucket = deriveTerminalActivityStatusBucket(activity);
if (!bucket) continue; if (!bucket) continue;
const workspaceId = const resolvedWorkspaceId = contributedWorkspaceId
resolveWorkspaceIdForRecord({ workspaceId: contributedWorkspaceId, cwd }, activeRecords) ?? ? resolveWorkspaceIdForRecord({ workspaceId: contributedWorkspaceId, cwd }, activeRecords)
resolveWorkspaceIdForCwd(cwd); : resolveWorkspaceIdForCwd(cwd);
if (workspaceId == null) { const workspaceIds = resolvedWorkspaceId
continue; ? 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; return terminalEntriesByWorkspaceId;
} }
@@ -649,17 +656,44 @@ function groupAgentsByWorkspaceId(
): Map<string, AgentSnapshotPayload[]> { ): Map<string, AgentSnapshotPayload[]> {
const byWorkspaceId = new Map<string, AgentSnapshotPayload[]>(); const byWorkspaceId = new Map<string, AgentSnapshotPayload[]>();
for (const agent of agents) { for (const agent of agents) {
const workspaceId = resolveWorkspaceIdForRecord(agent, activeRecords); const workspaceIds = resolveWorkspaceIdsForStatusContribution(agent, activeRecords);
if (workspaceId === null || !activeWorkspaceIds.has(workspaceId)) { for (const workspaceId of workspaceIds) {
continue; 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; 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( function resolveDelegationRootAgent(
agent: AgentSnapshotPayload, agent: AgentSnapshotPayload,
activeAgentsById: ReadonlyMap<string, AgentSnapshotPayload>, activeAgentsById: ReadonlyMap<string, AgentSnapshotPayload>,
@@ -175,6 +175,146 @@ describe("bootstrapWorkspaceRegistries", () => {
expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("ws-existing"); 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 () => { test("preserves existing workspace IDs when only the projects file is missing", async () => {
await workspaceRegistry.initialize(); await workspaceRegistry.initialize();
await workspaceRegistry.upsert({ await workspaceRegistry.upsert({
@@ -12,6 +12,7 @@ import type { WorkspaceGitService } from "./workspace-git-service.js";
import { import {
createPersistedProjectRecord, createPersistedProjectRecord,
createPersistedWorkspaceRecord, createPersistedWorkspaceRecord,
type PersistedWorkspaceRecord,
type ProjectRegistry, type ProjectRegistry,
type WorkspaceRegistry, type WorkspaceRegistry,
} from "./workspace-registry.js"; } from "./workspace-registry.js";
@@ -44,6 +45,71 @@ function resolveAgentUpdatedAt(record: StoredAgentRecord): string {
return record.lastActivityAt || record.updatedAt || record.createdAt || new Date(0).toISOString(); 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: { export async function bootstrapWorkspaceRegistries(options: {
paseoHome: string; paseoHome: string;
agentStorage: AgentStorage; agentStorage: AgentStorage;
@@ -60,6 +126,7 @@ export async function bootstrapWorkspaceRegistries(options: {
await Promise.all([options.projectRegistry.initialize(), options.workspaceRegistry.initialize()]); await Promise.all([options.projectRegistry.initialize(), options.workspaceRegistry.initialize()]);
if (projectsExists && workspacesExists) { if (projectsExists && workspacesExists) {
await migrateLegacyAgentWorkspaceOwnership(options);
return; return;
} }
@@ -168,6 +235,8 @@ export async function bootstrapWorkspaceRegistries(options: {
), ),
); );
await migrateLegacyAgentWorkspaceOwnership(options);
options.logger.info( options.logger.info(
{ {
projectsFile: path.join(options.paseoHome, "projects", "projects.json"), projectsFile: path.join(options.paseoHome, "projects", "projects.json"),
@@ -178,3 +247,9 @@ export async function bootstrapWorkspaceRegistries(options: {
"Workspace registries bootstrapped from existing agent storage", "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"); 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 = [ const workspaces = [
createWorkspaceRecord("/tmp/repo", "ws-newer", { createdAt: "2026-03-02T00:00:00.000Z" }), createWorkspaceRecord("/tmp/repo", "ws-newer", { createdAt: "2026-03-02T00:00:00.000Z" }),
createWorkspaceRecord("/tmp/repo", "ws-older", { createdAt: "2026-03-01T00: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); 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", () => { test("returns null for a legacy record with no cwd match", () => {
@@ -278,7 +278,7 @@ describe("resolveWorkspaceIdForRecord", () => {
expect(resolved).toBeNull(); 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 = [ const workspaces = [
createWorkspaceRecord("/tmp/repo", "ws-archived", { createWorkspaceRecord("/tmp/repo", "ws-archived", {
archivedAt: "2026-03-05T00:00:00.000Z", archivedAt: "2026-03-05T00:00:00.000Z",
@@ -291,6 +291,6 @@ describe("resolveWorkspaceIdForRecord", () => {
workspaces, workspaces,
); );
expect(resolved).toBe("ws-live"); expect(resolved).toBeNull();
}); });
}); });
@@ -34,12 +34,12 @@ export function generateWorkspaceId(): string {
return `wks_${randomBytes(8).toString("hex")}`; 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 // Resolves the owning workspace for a record (agent/terminal) that may predate
// workspaceId stamping. New records always carry workspaceId and hit the first // workspaceId stamping. New records always carry workspaceId and hit the first
// branch; legacy records fall back to cwd matching. When several active // branch; legacy records fall back only when cwd has a single active owner.
// workspaces share the same cwd, duplicate-cwd disambiguation is impossible from // Duplicate-cwd records must be stamped by migration before runtime projection;
// cwd alone, so we pick the deterministic oldest one. // otherwise cwd-only membership leaks into every same-directory workspace.
export function resolveWorkspaceIdForRecord( export function resolveWorkspaceIdForRecord(
record: { workspaceId?: string; cwd: string }, record: { workspaceId?: string; cwd: string },
activeWorkspaces: Iterable<PersistedWorkspaceRecord>, activeWorkspaces: Iterable<PersistedWorkspaceRecord>,
@@ -52,6 +52,7 @@ export function resolveWorkspaceIdForRecord(
if (exact) { if (exact) {
return exact.workspaceId; return exact.workspaceId;
} }
return null;
} }
const resolvedCwd = resolve(record.cwd); const resolvedCwd = resolve(record.cwd);
@@ -61,11 +62,6 @@ export function resolveWorkspaceIdForRecord(
if (cwdMatches.length === 1) { if (cwdMatches.length === 1) {
return cwdMatches[0].workspaceId; return cwdMatches[0].workspaceId;
} }
if (cwdMatches.length > 1) {
return cwdMatches.reduce((oldest, candidate) =>
candidate.createdAt < oldest.createdAt ? candidate : oldest,
).workspaceId;
}
return null; return null;
} }
@@ -14,9 +14,9 @@ import {
const WORKSPACE_A = "wks_same_cwd_a"; const WORKSPACE_A = "wks_same_cwd_a";
const WORKSPACE_B = "wks_same_cwd_b"; const WORKSPACE_B = "wks_same_cwd_b";
// Seed two active workspaces that share one cwd, so we can prove Phase 1 // Seed two active workspaces that share one cwd, so we can prove ownership
// attributes agents, terminals, and status by workspaceId rather than by // stays workspaceId-scoped while aggregate status is cwd-scoped. Both
// directory. Both registry files must exist on disk before the daemon starts: // registry files must exist on disk before the daemon starts:
// bootstrapWorkspaceRegistries skips materialization when both files are // bootstrapWorkspaceRegistries skips materialization when both files are
// present, leaving these seeded records untouched. // present, leaving these seeded records untouched.
function seedSameCwdWorkspaces(): { paseoHomeRoot: string; cwd: string } { 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])); 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 { paseoHomeRoot, cwd } = seedSameCwdWorkspaces();
const daemon = await createTestPaseoDaemon({ paseoHomeRoot }); const daemon = await createTestPaseoDaemon({ paseoHomeRoot });
const client = new DaemonClient({ 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 // 1. Agent created in workspace A carries workspaceId A. Ask mode + a
// status. Ask mode + a write parks the agent on a pending permission, // write parks the agent on a pending permission, which contributes the
// which is the deterministic "needs_input" signal for A. // deterministic "needs_input" aggregate signal to all same-cwd
// workspaces.
const agentA = await client.createAgent({ const agentA = await client.createAgent({
...getAskModeConfig("codex"), ...getAskModeConfig("codex"),
cwd, cwd,
@@ -111,7 +112,7 @@ test("two workspaces sharing one cwd stay isolated by workspaceId", async () =>
expect(await statusByWorkspaceId(client)).toEqual( expect(await statusByWorkspaceId(client)).toEqual(
new Map([ new Map([
[WORKSPACE_A, "needs_input"], [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 }); const listForB = await client.listTerminals(cwd, undefined, { workspaceId: WORKSPACE_B });
expect(listForB.terminals.some((terminal) => terminal.id === terminalId)).toBe(false); expect(listForB.terminals.some((terminal) => terminal.id === terminalId)).toBe(false);
// 3. Agent created in workspace B carries workspaceId B and moves only B's // 3. Agent created in workspace B carries workspaceId B while the aggregate
// status. A keeps its own pending-permission state independently. // needs_input status stays shared across both same-cwd workspace rows.
const agentB = await client.createAgent({ const agentB = await client.createAgent({
...getAskModeConfig("codex"), ...getAskModeConfig("codex"),
cwd, cwd,
@@ -110,6 +110,20 @@ it("lists subdirectory terminals when querying the workspace root", async () =>
expect(rootTerminals.map((terminal) => terminal.id)).toEqual([created.id]); 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 () => { it("creates additional terminal with auto-incrementing name", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
const cwd = realpathSync(tmpdir()); const cwd = realpathSync(tmpdir());
@@ -296,14 +296,10 @@ export function createTerminalManager(
} }
// When the query carries a workspaceId, two workspaces sharing a cwd must // When the query carries a workspaceId, two workspaces sharing a cwd must
// not see each other's terminals. Exclude sessions owned by a different // not see each other's terminals. A missing owner is not workspace
// workspace; keep sessions without an owner (COMPAT: created by clients // membership; unscoped callers can still list those legacy terminals.
// that predate terminal workspace ownership).
if (options?.workspaceId !== undefined) { if (options?.workspaceId !== undefined) {
return sessions.filter( return sessions.filter((session) => session.workspaceId === options.workspaceId);
(session) =>
session.workspaceId === undefined || session.workspaceId === options.workspaceId,
);
} }
return sessions; 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); 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 () => { it("rejects non-absolute cwd in getTerminals", async () => {
const worker = new FakeTerminalWorker(); const worker = new FakeTerminalWorker();
manager = createWorkerTerminalManager({ manager = createWorkerTerminalManager({
@@ -653,14 +653,10 @@ export function createWorkerTerminalManager(
} }
// When the query carries a workspaceId, two workspaces sharing a cwd must // When the query carries a workspaceId, two workspaces sharing a cwd must
// not see each other's terminals. Exclude sessions owned by a different // not see each other's terminals. A missing owner is not workspace
// workspace; keep sessions without an owner (COMPAT: created by clients // membership; unscoped callers can still list those legacy terminals.
// that predate terminal workspace ownership).
if (options?.workspaceId !== undefined) { if (options?.workspaceId !== undefined) {
return sessions.filter( return sessions.filter((session) => session.workspaceId === options.workspaceId);
(session) =>
session.workspaceId === undefined || session.workspaceId === options.workspaceId,
);
} }
return sessions; return sessions;
}, },