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

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.
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

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.
| 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<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) |
| `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<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) |
| `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`

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.
- **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.

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.
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

View File

@@ -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<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", () => {
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<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 {
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();
}

View File

@@ -215,6 +215,8 @@ describe("useAgentInputDraft live contract", () => {
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
});
await act(async () => {

View File

@@ -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", () => {

View File

@@ -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(

View File

@@ -252,6 +252,10 @@ import {
type CreatePaseoWorktreeInput,
type CreatePaseoWorktreeResult,
} from "./paseo-worktree-service.js";
import {
migrateLegacyAgentWorkspaceOwnership,
resolveLegacyWorkspaceOwnerForCwd,
} from "./workspace-registry-bootstrap.js";
import {
generateBranchNameFromFirstAgentContext,
type GeneratedWorkspaceName,
@@ -3282,6 +3286,10 @@ export class Session {
const createAgentConfig: AgentSessionConfig = createdWorktree
? { ...config, cwd: createdWorktree.worktree.worktreePath }
: config;
const workspaceId =
createdWorktree?.workspace.workspaceId ??
msg.workspaceId ??
(await this.resolveLegacyWorkspaceOwnerForAgentCreate(createAgentConfig.cwd));
const { snapshot, liveSnapshot } = await createAgentCommand(
{
@@ -3297,7 +3305,7 @@ export class Session {
{
kind: "session",
config: createAgentConfig,
workspaceId: createdWorktree ? createdWorktree.workspace.workspaceId : msg.workspaceId,
workspaceId,
worktreeName,
initialPrompt,
clientMessageId,
@@ -3321,7 +3329,13 @@ export class Session {
agentId: snapshot.id,
createdWorktree,
});
if (!createdWorktree && trimmedPrompt) {
await this.scheduleAutoNameLocalWorkspaceTitleForFirstAgent({
workspaceId,
cwd: createAgentConfig.cwd,
firstAgentContext,
});
}
if (requestId) {
const agentPayload = await this.buildAgentPayload(liveSnapshot);
this.emit({
@@ -3801,6 +3815,26 @@ export class Session {
await this.emitWorkspaceUpdateForCwd(input.cwd);
}
private async scheduleAutoNameLocalWorkspaceTitleForFirstAgent(input: {
workspaceId?: string;
cwd: string;
firstAgentContext: FirstAgentContext;
}): Promise<void> {
const workspaceId = input.workspaceId ?? (await this.resolveWorkspaceIdForCwd(input.cwd));
if (!workspaceId) {
return;
}
this.scheduleWorkspaceNaming(
() =>
this.maybeAutoNameDirectoryWorkspaceTitle({
workspaceId,
cwd: input.cwd,
firstAgentContext: input.firstAgentContext,
}),
{ cwd: input.cwd, message: "Failed to auto-name local workspace title" },
);
}
private emitProviderDisabledResponse(
kind: "models" | "modes",
provider: AgentProvider,
@@ -7296,12 +7330,22 @@ export class Session {
{ workspaceId: event.workspaceId, cwd: event.cwd },
workspaces,
);
if (workspaceId) {
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], {
skipReconcile: true,
});
const owningWorkspace = workspaceId
? workspaces.find(
(workspace) => workspace.workspaceId === workspaceId && !workspace.archivedAt,
)
: null;
if (!owningWorkspace) {
return;
}
const workspaceIds = this.workspaceDirectory.resolveRegisteredWorkspaceIdsForCwd(
owningWorkspace.cwd,
workspaces,
);
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds, {
skipReconcile: true,
});
return;
}
await this.emitWorkspaceUpdateForCwd(event.cwd, { skipReconcile: true });
}
@@ -7614,6 +7658,13 @@ export class Session {
return;
}
await migrateLegacyAgentWorkspaceOwnership({
agentStorage: this.agentStorage,
workspaceRegistry: this.workspaceRegistry,
logger: this.sessionLogger,
cwds: [cwd],
});
const workspace = await createLocalCheckoutWorkspace(
{ cwd, title: request.title ?? null },
{
@@ -7656,6 +7707,12 @@ export class Session {
}
}
private async resolveLegacyWorkspaceOwnerForAgentCreate(
cwd: string,
): Promise<string | undefined> {
return resolveLegacyWorkspaceOwnerForCwd(cwd, await this.workspaceRegistry.list()) ?? undefined;
}
// Schedules a background workspace-naming write off the request path and
// records the resulting promise so tests can await completion deterministically
// (no wall-clock sleeps). The setTimeout(0) keeps the LLM call off the hot path.

View File

@@ -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 () => {

View File

@@ -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",
});
});

View File

@@ -338,19 +338,21 @@ export class WorkspaceDirectory {
});
}
const workspaceId = resolveWorkspaceIdForRecord(workspaceAgent, activeRecords);
if (workspaceId === null || !activeWorkspaceIds.has(workspaceId)) {
continue;
}
const existing = descriptorsByWorkspaceId.get(workspaceId);
if (!existing) {
continue;
}
const workspaceIds = resolveWorkspaceIdsForStatusContribution(workspaceAgent, activeRecords);
for (const workspaceId of workspaceIds) {
if (!activeWorkspaceIds.has(workspaceId)) {
continue;
}
const existing = descriptorsByWorkspaceId.get(workspaceId);
if (!existing) {
continue;
}
if (
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
) {
existing.status = bucket;
if (
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
) {
existing.status = bucket;
}
}
}
}
@@ -377,24 +379,29 @@ export class WorkspaceDirectory {
}
const bucket = deriveTerminalActivityStatusBucket(activity);
if (!bucket) continue;
const workspaceId =
resolveWorkspaceIdForRecord({ workspaceId: contributedWorkspaceId, cwd }, activeRecords) ??
resolveWorkspaceIdForCwd(cwd);
if (workspaceId == null) {
continue;
const resolvedWorkspaceId = contributedWorkspaceId
? resolveWorkspaceIdForRecord({ workspaceId: contributedWorkspaceId, cwd }, activeRecords)
: resolveWorkspaceIdForCwd(cwd);
const workspaceIds = resolvedWorkspaceId
? resolveWorkspaceIdsForStatusContribution(
{ workspaceId: resolvedWorkspaceId, cwd },
activeRecords,
)
: resolveWorkspaceIdsForStatusContribution({ cwd }, activeRecords);
for (const workspaceId of workspaceIds) {
const existing = descriptorsByWorkspaceId.get(workspaceId);
if (!existing) {
continue;
}
if (
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
) {
existing.status = bucket;
}
const entries = terminalEntriesByWorkspaceId.get(workspaceId) ?? [];
entries.push({ bucket, changedAtIso: new Date(activity.changedAt).toISOString() });
terminalEntriesByWorkspaceId.set(workspaceId, entries);
}
const existing = descriptorsByWorkspaceId.get(workspaceId);
if (!existing) {
continue;
}
if (
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
) {
existing.status = bucket;
}
const entries = terminalEntriesByWorkspaceId.get(workspaceId) ?? [];
entries.push({ bucket, changedAtIso: new Date(activity.changedAt).toISOString() });
terminalEntriesByWorkspaceId.set(workspaceId, entries);
}
return terminalEntriesByWorkspaceId;
}
@@ -649,17 +656,44 @@ function groupAgentsByWorkspaceId(
): Map<string, AgentSnapshotPayload[]> {
const byWorkspaceId = new Map<string, AgentSnapshotPayload[]>();
for (const agent of agents) {
const workspaceId = resolveWorkspaceIdForRecord(agent, activeRecords);
if (workspaceId === null || !activeWorkspaceIds.has(workspaceId)) {
continue;
const workspaceIds = resolveWorkspaceIdsForStatusContribution(agent, activeRecords);
for (const workspaceId of workspaceIds) {
if (!activeWorkspaceIds.has(workspaceId)) {
continue;
}
const entries = byWorkspaceId.get(workspaceId) ?? [];
entries.push(agent);
byWorkspaceId.set(workspaceId, entries);
}
const entries = byWorkspaceId.get(workspaceId) ?? [];
entries.push(agent);
byWorkspaceId.set(workspaceId, entries);
}
return byWorkspaceId;
}
function resolveWorkspaceIdsForStatusContribution(
record: { workspaceId?: string; cwd: string },
activeWorkspaces: PersistedWorkspaceRecord[],
): string[] {
const owningWorkspaceId = resolveWorkspaceIdForRecord(record, activeWorkspaces);
if (!owningWorkspaceId) {
const recordCwd = resolve(record.cwd);
return activeWorkspaces
.filter((workspace) => !workspace.archivedAt && resolve(workspace.cwd) === recordCwd)
.map((workspace) => workspace.workspaceId);
}
const owningWorkspace = activeWorkspaces.find(
(workspace) => workspace.workspaceId === owningWorkspaceId && !workspace.archivedAt,
);
if (!owningWorkspace) {
return [];
}
const owningCwd = resolve(owningWorkspace.cwd);
return activeWorkspaces
.filter((workspace) => !workspace.archivedAt && resolve(workspace.cwd) === owningCwd)
.map((workspace) => workspace.workspaceId);
}
function resolveDelegationRootAgent(
agent: AgentSnapshotPayload,
activeAgentsById: ReadonlyMap<string, AgentSnapshotPayload>,

View File

@@ -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({

View File

@@ -12,6 +12,7 @@ import type { WorkspaceGitService } from "./workspace-git-service.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
type PersistedWorkspaceRecord,
type ProjectRegistry,
type WorkspaceRegistry,
} from "./workspace-registry.js";
@@ -44,6 +45,71 @@ function resolveAgentUpdatedAt(record: StoredAgentRecord): string {
return record.lastActivityAt || record.updatedAt || record.createdAt || new Date(0).toISOString();
}
export function resolveLegacyWorkspaceOwnerForCwd(
cwd: string,
workspaces: Iterable<PersistedWorkspaceRecord>,
): string | null {
const normalizedCwd = path.resolve(cwd);
const activeWorkspaces = Array.from(workspaces).filter((workspace) => !workspace.archivedAt);
const exactMatches = activeWorkspaces.filter(
(workspace) => path.resolve(workspace.cwd) === normalizedCwd,
);
if (exactMatches.length > 0) {
return oldestWorkspace(exactMatches).workspaceId;
}
const prefixMatches = activeWorkspaces.filter((workspace) => {
const workspaceCwd = path.resolve(workspace.cwd);
return normalizedCwd === workspaceCwd || normalizedCwd.startsWith(`${workspaceCwd}${path.sep}`);
});
if (prefixMatches.length === 0) {
return null;
}
const longestPrefixLength = Math.max(
...prefixMatches.map((workspace) => path.resolve(workspace.cwd).length),
);
return oldestWorkspace(
prefixMatches.filter((workspace) => path.resolve(workspace.cwd).length === longestPrefixLength),
).workspaceId;
}
export async function migrateLegacyAgentWorkspaceOwnership(options: {
agentStorage: AgentStorage;
workspaceRegistry: WorkspaceRegistry;
logger: Logger;
cwds?: Iterable<string>;
}): Promise<number> {
const workspaceRecords = await options.workspaceRegistry.list();
const cwdFilter = options.cwds
? new Set(Array.from(options.cwds, (cwd) => path.resolve(cwd)))
: null;
const records = await options.agentStorage.list();
let migrated = 0;
for (const record of records) {
if (record.workspaceId) {
continue;
}
if (cwdFilter && !cwdFilter.has(path.resolve(record.cwd))) {
continue;
}
const workspaceId = resolveLegacyWorkspaceOwnerForCwd(record.cwd, workspaceRecords);
if (!workspaceId) {
continue;
}
await options.agentStorage.upsert({ ...record, workspaceId });
migrated += 1;
}
if (migrated > 0) {
options.logger.info({ migrated }, "Migrated legacy agent workspace ownership");
}
return migrated;
}
export async function bootstrapWorkspaceRegistries(options: {
paseoHome: string;
agentStorage: AgentStorage;
@@ -60,6 +126,7 @@ export async function bootstrapWorkspaceRegistries(options: {
await Promise.all([options.projectRegistry.initialize(), options.workspaceRegistry.initialize()]);
if (projectsExists && workspacesExists) {
await migrateLegacyAgentWorkspaceOwnership(options);
return;
}
@@ -168,6 +235,8 @@ export async function bootstrapWorkspaceRegistries(options: {
),
);
await migrateLegacyAgentWorkspaceOwnership(options);
options.logger.info(
{
projectsFile: path.join(options.paseoHome, "projects", "projects.json"),
@@ -178,3 +247,9 @@ export async function bootstrapWorkspaceRegistries(options: {
"Workspace registries bootstrapped from existing agent storage",
);
}
function oldestWorkspace(workspaces: PersistedWorkspaceRecord[]): PersistedWorkspaceRecord {
return workspaces.reduce((oldest, candidate) =>
candidate.createdAt < oldest.createdAt ? candidate : oldest,
);
}

View File

@@ -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();
});
});

View File

@@ -34,12 +34,12 @@ export function generateWorkspaceId(): string {
return `wks_${randomBytes(8).toString("hex")}`;
}
// COMPAT(workspaceOwnership): added in v0.1.97, drop the gate when floor >= v0.1.97.
// COMPAT(workspaceOwnership): added in v0.1.97, drop after 2026-12-15 once floor >= v0.1.97.
// Resolves the owning workspace for a record (agent/terminal) that may predate
// workspaceId stamping. New records always carry workspaceId and hit the first
// branch; legacy records fall back to cwd matching. When several active
// workspaces share the same cwd, duplicate-cwd disambiguation is impossible from
// cwd alone, so we pick the deterministic oldest one.
// branch; legacy records fall back only when cwd has a single active owner.
// Duplicate-cwd records must be stamped by migration before runtime projection;
// otherwise cwd-only membership leaks into every same-directory workspace.
export function resolveWorkspaceIdForRecord(
record: { workspaceId?: string; cwd: string },
activeWorkspaces: Iterable<PersistedWorkspaceRecord>,
@@ -52,6 +52,7 @@ export function resolveWorkspaceIdForRecord(
if (exact) {
return exact.workspaceId;
}
return null;
}
const resolvedCwd = resolve(record.cwd);
@@ -61,11 +62,6 @@ export function resolveWorkspaceIdForRecord(
if (cwdMatches.length === 1) {
return cwdMatches[0].workspaceId;
}
if (cwdMatches.length > 1) {
return cwdMatches.reduce((oldest, candidate) =>
candidate.createdAt < oldest.createdAt ? candidate : oldest,
).workspaceId;
}
return null;
}

View File

@@ -14,9 +14,9 @@ import {
const WORKSPACE_A = "wks_same_cwd_a";
const WORKSPACE_B = "wks_same_cwd_b";
// Seed two active workspaces that share one cwd, so we can prove Phase 1
// attributes agents, terminals, and status by workspaceId rather than by
// directory. Both registry files must exist on disk before the daemon starts:
// Seed two active workspaces that share one cwd, so we can prove ownership
// stays workspaceId-scoped while aggregate status is cwd-scoped. Both
// registry files must exist on disk before the daemon starts:
// bootstrapWorkspaceRegistries skips materialization when both files are
// present, leaving these seeded records untouched.
function seedSameCwdWorkspaces(): { paseoHomeRoot: string; cwd: string } {
@@ -68,7 +68,7 @@ async function statusByWorkspaceId(client: DaemonClient): Promise<Map<string, st
return new Map(workspaces.entries.map((entry) => [entry.id, entry.status]));
}
test("two workspaces sharing one cwd stay isolated by workspaceId", async () => {
test("two workspaces sharing one cwd share agent status without leaking ownership", async () => {
const { paseoHomeRoot, cwd } = seedSameCwdWorkspaces();
const daemon = await createTestPaseoDaemon({ paseoHomeRoot });
const client = new DaemonClient({
@@ -87,9 +87,10 @@ test("two workspaces sharing one cwd stay isolated by workspaceId", async () =>
]),
);
// 1. Agent created in workspace A carries workspaceId A and only moves A's
// status. Ask mode + a write parks the agent on a pending permission,
// which is the deterministic "needs_input" signal for A.
// 1. Agent created in workspace A carries workspaceId A. Ask mode + a
// write parks the agent on a pending permission, which contributes the
// deterministic "needs_input" aggregate signal to all same-cwd
// workspaces.
const agentA = await client.createAgent({
...getAskModeConfig("codex"),
cwd,
@@ -111,7 +112,7 @@ test("two workspaces sharing one cwd stay isolated by workspaceId", async () =>
expect(await statusByWorkspaceId(client)).toEqual(
new Map([
[WORKSPACE_A, "needs_input"],
[WORKSPACE_B, "done"],
[WORKSPACE_B, "needs_input"],
]),
);
@@ -159,8 +160,8 @@ test("two workspaces sharing one cwd stay isolated by workspaceId", async () =>
const listForB = await client.listTerminals(cwd, undefined, { workspaceId: WORKSPACE_B });
expect(listForB.terminals.some((terminal) => terminal.id === terminalId)).toBe(false);
// 3. Agent created in workspace B carries workspaceId B and moves only B's
// status. A keeps its own pending-permission state independently.
// 3. Agent created in workspace B carries workspaceId B while the aggregate
// needs_input status stays shared across both same-cwd workspace rows.
const agentB = await client.createAgent({
...getAskModeConfig("codex"),
cwd,

View File

@@ -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());

View File

@@ -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;
},

View File

@@ -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({

View File

@@ -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;
},