diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 1d0b29b018..45c54ca18b 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -136,6 +136,12 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a normal root agent immediately, and closing its still-open tab archives it. +Agent-scoped workspace creation records the caller's workspace as `parentWorkspaceId`. +This is informational ancestry for future workspace grouping. It does not change placement, +visibility, status, or archive behavior. Archiving a parent workspace preserves its children's +records and parentage. Unscoped creation and existing workspaces have no parent; ancestry is +never inferred from directories, projects, or Git branches. + Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested. Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility. diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 0ea262f7cb..5c90ffa5e1 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -321,6 +321,26 @@ describe("ReplicaCache", () => { expect(restored?.range).toBeNull(); }); + it("preserves workspace parentage and its checkpoint across cache reopen", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const baseline = directory({ workspaces: { generation: "g", afterSeq: 8 } }); + const workspace = normalizeWorkspaceDescriptor({ + ...workspacePayload(), + parentWorkspaceId: "parent", + }); + baseline.workspaces.set(workspace.id, workspace); + commitDirectory(writer, SERVER_ID, baseline); + await writer.flush(); + const reader = createCache(storage); + const restored = await reader.readDirectory(SERVER_ID); + expect(restored.checkpoint).toEqual(baseline.checkpoint); + expect(restored.workspaces.get(workspace.id)?.parentWorkspaceId).toBe("parent"); + expect((await reader.readWorkspace(SERVER_ID, workspace.id))?.workspace.parentWorkspaceId).toBe( + "parent", + ); + }); + it("preserves workspace sorting timestamps across cache reopen", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 1032a663bf..91a41e4186 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -299,6 +299,7 @@ const WorkspaceGitRuntimeSchema = z .optional(); const StoredWorkspaceSchema = z.strictObject({ + parentWorkspaceId: z.string().optional(), // Require both keys: a cursor without these values would preserve the wrong sort on reconnect. createdAt: IsoDateSchema.nullable(), lastUserMessageAt: IsoDateSchema.nullable(), @@ -707,6 +708,7 @@ function restoreWorkspace(stored: StoredWorkspace): WorkspaceDescriptor { function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace { return { + parentWorkspaceId: workspace.parentWorkspaceId, createdAt: workspace.createdAt?.toISOString() ?? null, lastUserMessageAt: workspace.lastUserMessageAt?.toISOString() ?? null, id: workspace.id, diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index fb38605220..1b9052c4c1 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -557,6 +557,33 @@ describe("normalizeWorkspaceDescriptor", () => { expect(blank.workspaceDirectory).toBe(""); }); + it("preserves informational workspace parentage", () => { + const payload = { + id: "child", + parentWorkspaceId: "parent", + projectId: "1", + projectDisplayName: "Project 1", + projectRootPath: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "checkout", + name: "main", + archivingAt: null, + status: "done", + statusEnteredAt: null, + activityAt: null, + diffStat: null, + scripts: [], + } as WorkspaceDescriptorPayload; + + const workspace = normalizeWorkspaceDescriptor(payload); + + expect(workspace.parentWorkspaceId).toBe("parent"); + expect( + normalizeWorkspaceDescriptor({ ...payload, parentWorkspaceId: undefined }).parentWorkspaceId, + ).toBeUndefined(); + }); + it("defaults missing scripts to an empty array", () => { const payload = { id: "1", diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index b1312860a9..0f8a9f2476 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -105,6 +105,7 @@ export interface Agent { } export interface WorkspaceDescriptor { + parentWorkspaceId?: string; createdAt: Date | null; lastUserMessageAt: Date | null; id: string; @@ -162,6 +163,7 @@ export function normalizeWorkspaceDescriptor( projectKind: payload.projectKind, workspaceKind: payload.workspaceKind, background: payload.background ?? false, + parentWorkspaceId: payload.parentWorkspaceId, name: payload.name, title: payload.title ?? null, pinnedAt: payload.pinnedAt ?? null, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 64f37064e2..5436e1737d 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -4189,6 +4189,8 @@ export const WorkspaceGitHubRuntimePayloadSchema = z export const WorkspaceDescriptorPayloadSchema = z .object({ + // Informational creation ancestry; independent of placement and lifecycle. + parentWorkspaceId: z.string().optional(), id: z.string(), projectId: z.string(), projectDisplayName: z.string(), diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index 3cb9b0d5bd..8a5bd13224 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -634,6 +634,26 @@ describe("workspace message schemas", () => { ]); }); + test("preserves informational workspace parentage in directory updates", () => { + const workspace = { + id: "child", + parentWorkspaceId: "parent", + projectId: "project", + projectDisplayName: "repo", + projectRootPath: "/repo", + projectKind: "git", + workspaceKind: "worktree", + name: "child", + status: "done", + activityAt: null, + }; + expect(WorkspaceDescriptorPayloadSchema.parse(workspace).parentWorkspaceId).toBe("parent"); + expect( + WorkspaceDescriptorPayloadSchema.parse({ ...workspace, parentWorkspaceId: undefined }) + .parentWorkspaceId, + ).toBeUndefined(); + }); + test("parses legacy workspace descriptors without workspaceDirectory", () => { const parsed = SessionOutboundMessageSchema.parse({ type: "workspace_update", diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index 80d6da20a7..d4b1b76af1 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -1171,7 +1171,7 @@ describe("Suite E: Workspace Tools", () => { .callTool({ name: "archive_workspace", args: { workspaceId } }) .catch(() => undefined); } - }); + }, 30_000); test("agent-scoped workspace creation inherits visibility and permits an explicit false override", async () => { const parentWorkspace = await callToolStructured(topLevelClient, "create_workspace", { @@ -1197,12 +1197,43 @@ describe("Suite E: Workspace Tools", () => { }); ownedWorkspaces.push(str(inherited.workspaceId)); expect(inherited.background).toBe(true); + expect(inherited.parentWorkspaceId).toBe(parentWorkspaceId); const visible = await callToolStructured(scoped, "create_workspace", { isolation: "local", background: false, }); ownedWorkspaces.push(str(visible.workspaceId)); expect(visible.background).toBe(false); + expect(visible.parentWorkspaceId).toBe(parentWorkspaceId); + const worktree = await callToolStructured(scoped, "create_workspace", { + isolation: "worktree", + branchName: "parentage-child", + baseBranch: "main", + background: false, + }); + ownedWorkspaces.push(str(worktree.workspaceId)); + expect(worktree.parentWorkspaceId).toBe(parentWorkspaceId); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemonHandle.port}/ws` }); + try { + await client.connect(); + const created = await client.createWorkspace({ + source: { kind: "directory", path: worktreeRepoCwd }, + callerAgentId: str(parent.agentId), + background: false, + }); + const workspaceId = str(created.workspace?.id); + ownedWorkspaces.push(workspaceId); + expect(created.workspace?.parentWorkspaceId).toBe(parentWorkspaceId); + const listed = await client.fetchWorkspaces(); + expect(listed.entries).toContainEqual( + expect.objectContaining({ + id: workspaceId, + parentWorkspaceId: parentWorkspaceId, + }), + ); + } finally { + await client.close(); + } } finally { await scoped?.close(); for (const workspaceId of ownedWorkspaces.toReversed()) @@ -1210,7 +1241,7 @@ describe("Suite E: Workspace Tools", () => { .callTool({ name: "archive_workspace", args: { workspaceId } }) .catch(() => undefined); } - }); + }, 30_000); test("a background agent's children and workspaces are background unless it overrides", async () => { const visibleAgentId = await createTopLevelAgent(); diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 7b33d2e250..42b6ddec4c 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -188,6 +188,7 @@ interface ProviderSummary { } const WorkspaceAutomationSummarySchema = z.object({ + parentWorkspaceId: z.string().optional(), workspaceId: z.string(), projectId: z.string(), cwd: z.string(), @@ -206,6 +207,7 @@ function toWorkspaceAutomationSummary(workspace: PersistedWorkspaceRecord) { kind: workspace.kind, title: workspace.title, background: workspace.background, + parentWorkspaceId: workspace.parentWorkspaceId, }; } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 3dec72f45f..339a7c17fb 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -5900,6 +5900,7 @@ export class Session { pinnedAt: workspace.pinnedAt, ...(workspace.labels && workspace.labels.length > 0 ? { labels: workspace.labels } : {}), background: workspace.background, + parentWorkspaceId: workspace.parentWorkspaceId, archivingAt: null, status: "done", statusEnteredAt: null, @@ -5988,6 +5989,7 @@ export class Session { projectKind: projectRecord?.kind ?? "git", workspaceKind: result.workspace.kind, background: result.workspace.background, + parentWorkspaceId: result.workspace.parentWorkspaceId, name: resolveWorkspaceName({ title: result.workspace.title, derivedDisplayName: result.worktree.branchName || result.workspace.displayName, diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index 012ad0d490..706f199114 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -205,6 +205,82 @@ test("fresh non-git directory creates a directory workspace at the exact path", expect(workspace.cwd).toBe(dir); }); +test("workspace parentage survives reload and parent archive without changing placement", async () => { + const cwd = path.join(tmpDir, "plain"); + const parent = await provisioning.createWorkspaceForDirectory(cwd); + const child = await provisioning.createWorkspaceForDirectory(cwd, null, undefined, { + callerWorkspaceId: parent.workspaceId, + background: false, + }); + const grandchild = await provisioning.createWorkspaceForDirectory( + path.join(tmpDir, "other"), + null, + undefined, + { + callerWorkspaceId: child.workspaceId, + }, + ); + expect(parent.parentWorkspaceId).toBeUndefined(); + expect(child).toMatchObject({ parentWorkspaceId: parent.workspaceId, cwd }); + expect(grandchild).toMatchObject({ parentWorkspaceId: child.workspaceId }); + await workspaceRegistry.archive(parent.workspaceId, ARCHIVED_AT); + const reloaded = new FileBackedWorkspaceRegistry( + path.join(tmpDir, "projects", "workspaces.json"), + logger, + ); + await reloaded.initialize(); + expect(await reloaded.get(child.workspaceId)).toMatchObject({ + parentWorkspaceId: parent.workspaceId, + archivedAt: null, + }); + expect(await reloaded.get(grandchild.workspaceId)).toMatchObject({ + parentWorkspaceId: child.workspaceId, + archivedAt: null, + }); +}); + +test("worktree workspace creation records its caller independently of its source project", async () => { + const sourceCwd = path.join(tmpDir, "repo"); + gitRoots.add(sourceCwd); + const parent = await provisioning.createWorkspaceForDirectory(path.join(tmpDir, "other")); + const child = await provisioning.createWorkspaceForWorktree({ + sourceCwd, + repoRoot: sourceCwd, + cwd: path.join(tmpDir, "worktree"), + worktreeRoot: path.join(tmpDir, "worktree"), + branch: "child", + baseBranch: "main", + title: null, + callerWorkspaceId: parent.workspaceId, + }); + expect(child.parentWorkspaceId).toBe(parent.workspaceId); + expect(child.projectId).not.toBe(parent.projectId); + expect(await workspaceRegistry.get(child.workspaceId)).toMatchObject({ + parentWorkspaceId: parent.workspaceId, + }); +}); + +test("workspace creation rejects an unknown caller even with explicit visibility", async () => { + await expect( + provisioning.createWorkspaceForDirectory(path.join(tmpDir, "plain"), null, undefined, { + callerWorkspaceId: "missing", + background: false, + }), + ).rejects.toThrow("Caller workspace missing not found"); +}); + +test("workspace creation cannot overwrite its caller with a self relationship", async () => { + const cwd = path.join(tmpDir, "plain"); + const parent = await provisioning.createWorkspaceForDirectory(cwd); + await expect( + provisioning.createWorkspaceForDirectory(cwd, null, undefined, { + callerWorkspaceId: parent.workspaceId, + workspaceId: parent.workspaceId, + }), + ).rejects.toThrow("A workspace cannot be its own parent"); + expect(await workspaceRegistry.get(parent.workspaceId)).toEqual(parent); +}); + test("background workspace creation preserves hooks, inheritance and durable visibility", async () => { const dir = path.join(tmpDir, "plain"); const events: string[] = []; diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 3f2a7b423c..5744c2ddeb 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -227,15 +227,21 @@ export function createWorkspaceProvisioningService(deps: { return project; } - async function resolveBackground(input?: { + async function resolveCreationMetadata(input?: { + workspaceId?: string; background?: boolean; callerWorkspaceId?: string; - }): Promise { - if (input?.background !== undefined) return input.background; - if (!input?.callerWorkspaceId) return false; + }): Promise<{ background: boolean; parentWorkspaceId?: string }> { + if (!input?.callerWorkspaceId) return { background: input?.background ?? false }; + if (input.workspaceId === input.callerWorkspaceId) { + throw new Error("A workspace cannot be its own parent"); + } const callerWorkspace = await workspaceRegistry.get(input.callerWorkspaceId); if (!callerWorkspace) throw new Error(`Caller workspace ${input.callerWorkspaceId} not found`); - return callerWorkspace.background; + return { + background: input.background ?? callerWorkspace.background, + parentWorkspaceId: callerWorkspace.workspaceId, + }; } async function createWorkspaceForDirectory( @@ -269,7 +275,7 @@ export function createWorkspaceProvisioningService(deps: { title: title?.trim() || null, createdAt: timestamp, updatedAt: timestamp, - background: await resolveBackground(context), + ...(await resolveCreationMetadata(context)), }); await workspaceRegistry.upsert(workspace, { expectsInitialAgent: context?.expectsInitialAgent, @@ -310,7 +316,7 @@ export function createWorkspaceProvisioningService(deps: { createdAt: timestamp, updatedAt: timestamp, ...(input.untrustedSource ? { untrustedSource: input.untrustedSource } : {}), - background: await resolveBackground(input), + ...(await resolveCreationMetadata(input)), }); await workspaceRegistry.upsert(workspace, { expectsInitialAgent: input.expectsInitialAgent, diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 351ba043e4..b337543c7e 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -49,6 +49,7 @@ const PersistedProjectRecordSchema = z.object({ }); const PersistedWorkspaceRecordSchema = z.object({ + parentWorkspaceId: z.string().optional(), workspaceId: z.string(), projectId: z.string(), cwd: z.string(), @@ -679,6 +680,7 @@ export function resolveProjectDisplayName(record: PersistedProjectRecord): strin } export function createPersistedWorkspaceRecord(input: { + parentWorkspaceId?: string; workspaceId: string; projectId: string; cwd: string;