Record workspace creation parentage (#8890)

* feat(workspaces): record workspace creation parentage

Retain the caller workspace as informational ancestry for future sidebar nesting, independently of workspace placement and lifecycle.

* fix(app): preserve workspace parentage in replica cache
This commit is contained in:
Mohamed Boudra
2026-11-06 10:55:56 +07:00
committed by GitHub
parent 6355b339c8
commit 5abfdf7107
13 changed files with 207 additions and 9 deletions
+6
View File
@@ -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.
@@ -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);
@@ -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,
@@ -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",
+2
View File
@@ -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,
+2
View File
@@ -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(),
@@ -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",
@@ -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();
@@ -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,
};
}
+2
View File
@@ -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,
@@ -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[] = [];
@@ -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<boolean> {
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,
@@ -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;