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