Key workspace status and ownership by ID, not directory

Multiple workspaces can share one checkout directory. Agent and terminal
status, sidebar tabs, and ownership now attribute strictly by workspace ID,
so a running or blocked agent in one workspace no longer shows up on its
same-directory siblings.

A one-time startup migration backfills workspace IDs onto legacy agents, and
every agent and terminal is created with one thereafter. Directory lookups
remain only for genuinely folder-derived git facts (branch, diff) and the
archive-by-path adapter.
This commit is contained in:
Mohamed Boudra
2026-06-16 13:37:58 +07:00
parent ba9f7740a8
commit 9966e49329
102 changed files with 3222 additions and 1932 deletions
@@ -57,16 +57,19 @@ function makeAgent(input: {
};
}
const WORKSPACE_ID = "ws-1";
describe("workspace agent visibility", () => {
it("keeps subagents active and known while excluding them from auto-open", () => {
const workspaceDirectory = "/repo/worktree";
const parent = makeAgent({
id: "parent-agent",
cwd: workspaceDirectory,
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
});
const child = makeAgent({
id: "child-agent",
cwd: workspaceDirectory,
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
parentAgentId: "parent-agent",
});
@@ -75,7 +78,7 @@ describe("workspace agent visibility", () => {
[parent.id, parent],
[child.id, child],
]),
workspaceDirectory,
workspaceId: WORKSPACE_ID,
});
expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"]));
@@ -84,17 +87,17 @@ describe("workspace agent visibility", () => {
});
it("keeps archived subagents known but excludes them from active and auto-open", () => {
const workspaceDirectory = "/repo/worktree";
const archivedChild = makeAgent({
id: "archived-child",
cwd: workspaceDirectory,
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
parentAgentId: "parent-agent",
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
});
const result = deriveWorkspaceAgentVisibility({
sessionAgents: new Map<string, Agent>([[archivedChild.id, archivedChild]]),
workspaceDirectory,
workspaceId: WORKSPACE_ID,
});
expect(result.activeAgentIds).toEqual(new Set<string>());
@@ -103,15 +106,16 @@ describe("workspace agent visibility", () => {
});
it("excludes a child from auto-open even when its snapshot arrives before the parent", () => {
const workspaceDirectory = "/repo/worktree";
const child = makeAgent({
id: "child-agent",
cwd: workspaceDirectory,
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
parentAgentId: "parent-agent",
});
const parent = makeAgent({
id: "parent-agent",
cwd: workspaceDirectory,
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
});
const result = deriveWorkspaceAgentVisibility({
@@ -119,7 +123,7 @@ describe("workspace agent visibility", () => {
[child.id, child],
[parent.id, parent],
]),
workspaceDirectory,
workspaceId: WORKSPACE_ID,
});
expect(result.activeAgentIds).toEqual(new Set(["child-agent", "parent-agent"]));
@@ -128,21 +132,23 @@ describe("workspace agent visibility", () => {
});
it("keeps archived agents out of activeAgentIds but present in knownAgentIds", () => {
const workspaceDirectory = "/repo/worktree";
const visible = makeAgent({
id: "visible-agent",
cwd: workspaceDirectory,
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
createdAt: new Date("2026-03-04T00:00:00.000Z"),
});
const archived = makeAgent({
id: "archived-agent",
cwd: workspaceDirectory,
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
createdAt: new Date("2026-03-04T00:01:00.000Z"),
});
const otherWorkspace = makeAgent({
id: "other-workspace-agent",
cwd: "/repo/other",
workspaceId: "ws-other",
});
const sessionAgents = new Map<string, Agent>([
@@ -153,7 +159,7 @@ describe("workspace agent visibility", () => {
const result = deriveWorkspaceAgentVisibility({
sessionAgents,
workspaceDirectory,
workspaceId: WORKSPACE_ID,
});
expect(result.activeAgentIds).toEqual(new Set(["visible-agent"]));
@@ -164,18 +170,22 @@ describe("workspace agent visibility", () => {
});
it("treats lazy historical details as known without making them active", () => {
const workspaceDirectory = "/repo/worktree";
const active = makeAgent({ id: "active-agent", cwd: workspaceDirectory });
const active = makeAgent({
id: "active-agent",
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
});
const historicalDetail = makeAgent({
id: "historical-agent",
cwd: workspaceDirectory,
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
});
const result = deriveWorkspaceAgentVisibility({
sessionAgents: new Map([[active.id, active]]),
agentDetails: new Map([[historicalDetail.id, historicalDetail]]),
workspaceDirectory,
workspaceId: WORKSPACE_ID,
});
expect(result.activeAgentIds).toEqual(new Set(["active-agent"]));
@@ -226,49 +236,6 @@ describe("workspace agent visibility", () => {
).toBe(true);
});
it("matches workspace agents when cwd and route workspace differ only by trailing slash", () => {
const sessionAgents = new Map<string, Agent>([
[
"slash-agent",
makeAgent({
id: "slash-agent",
cwd: "/Users/moboudra/dev/paseo/.dev/paseo-home/worktrees/1luy0po7/normal-squid/",
}),
],
]);
const result = deriveWorkspaceAgentVisibility({
sessionAgents,
workspaceDirectory:
"/Users/moboudra/dev/paseo/.dev/paseo-home/worktrees/1luy0po7/normal-squid",
});
expect(result.activeAgentIds).toEqual(new Set(["slash-agent"]));
expect(result.autoOpenAgentIds).toEqual(new Set(["slash-agent"]));
expect(result.knownAgentIds.has("slash-agent")).toBe(true);
});
it("matches workspace agents using the workspace directory even when the route uses a numeric workspace id", () => {
const sessionAgents = new Map<string, Agent>([
[
"recent-agent",
makeAgent({
id: "recent-agent",
cwd: "/tmp/workspace-lifecycle-main",
}),
],
]);
const result = deriveWorkspaceAgentVisibility({
sessionAgents,
workspaceDirectory: "/tmp/workspace-lifecycle-main",
});
expect(result.activeAgentIds).toEqual(new Set(["recent-agent"]));
expect(result.autoOpenAgentIds).toEqual(new Set(["recent-agent"]));
expect(result.knownAgentIds).toEqual(new Set(["recent-agent"]));
});
it("matches agents by workspaceId regardless of cwd", () => {
const sessionAgents = new Map<string, Agent>([
[
@@ -284,7 +251,6 @@ describe("workspace agent visibility", () => {
const result = deriveWorkspaceAgentVisibility({
sessionAgents,
workspaceId: "ws-1",
workspaceDirectory: "/repo/worktree",
});
expect(result.activeAgentIds).toEqual(new Set(["stamped-agent"]));
@@ -306,26 +272,24 @@ describe("workspace agent visibility", () => {
const result = deriveWorkspaceAgentVisibility({
sessionAgents,
workspaceId: "ws-1",
workspaceDirectory: "/repo/worktree",
});
expect(result.activeAgentIds).toEqual(new Set<string>());
expect(result.knownAgentIds).toEqual(new Set<string>());
});
it("falls back to cwd matching for legacy agents without a workspaceId", () => {
it("excludes agents without a workspaceId", () => {
const sessionAgents = new Map<string, Agent>([
["legacy-agent", makeAgent({ id: "legacy-agent", cwd: "/repo/worktree" })],
["ownerless-agent", makeAgent({ id: "ownerless-agent", cwd: "/repo/worktree" })],
]);
const result = deriveWorkspaceAgentVisibility({
sessionAgents,
workspaceId: "ws-1",
workspaceDirectory: "/repo/worktree",
});
expect(result.activeAgentIds).toEqual(new Set(["legacy-agent"]));
expect(result.knownAgentIds).toEqual(new Set(["legacy-agent"]));
expect(result.activeAgentIds).toEqual(new Set<string>());
expect(result.knownAgentIds).toEqual(new Set<string>());
});
it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => {
@@ -1,11 +1,7 @@
import type { Agent } from "@/stores/session-store";
import type { WorkspaceTabSnapshot } from "@/stores/workspace-layout-actions";
import { shouldAutoOpenAgentTab } from "@/subagents/policies";
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
function normalizeWorkspaceDirectory(value: string | null | undefined): string {
return normalizeWorkspacePath(value) ?? "";
}
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
export interface WorkspaceAgentVisibility {
activeAgentIds: Set<string>;
@@ -13,31 +9,18 @@ export interface WorkspaceAgentVisibility {
knownAgentIds: Set<string>;
}
function agentBelongsToWorkspace(input: {
agent: Agent;
workspaceId: string | null;
normalizedWorkspaceDirectory: string;
}): boolean {
const agentWorkspaceId = normalizeWorkspaceOpaqueId(input.agent.workspaceId);
if (agentWorkspaceId) {
return input.workspaceId !== null && agentWorkspaceId === input.workspaceId;
}
// COMPAT(workspaceOwnership): legacy agents predate workspaceId stamping. Fall
// back to the single approved cwd→id inference by comparing the agent's cwd to
// this workspace's directory. Drop when the daemon floor always stamps workspaceId.
return normalizeWorkspaceDirectory(input.agent.cwd) === input.normalizedWorkspaceDirectory;
function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean {
return normalizeWorkspaceOpaqueId(agent.workspaceId) === workspaceId;
}
export function deriveWorkspaceAgentVisibility(input: {
sessionAgents: Map<string, Agent> | undefined;
agentDetails?: Map<string, Agent> | undefined;
workspaceId?: string | null | undefined;
workspaceDirectory: string | null | undefined;
workspaceId: string | null | undefined;
}): WorkspaceAgentVisibility {
const { sessionAgents, agentDetails, workspaceDirectory } = input;
const { sessionAgents, agentDetails } = input;
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
const normalizedWorkspaceDirectory = normalizeWorkspaceDirectory(workspaceDirectory);
if ((!sessionAgents && !agentDetails) || (!workspaceId && !normalizedWorkspaceDirectory)) {
if ((!sessionAgents && !agentDetails) || !workspaceId) {
return {
activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(),
@@ -49,7 +32,7 @@ export function deriveWorkspaceAgentVisibility(input: {
const autoOpenAgentIds = new Set<string>();
const knownAgentIds = new Set<string>();
for (const agent of sessionAgents?.values() ?? []) {
if (!agentBelongsToWorkspace({ agent, workspaceId, normalizedWorkspaceDirectory })) {
if (!agentBelongsToWorkspace(agent, workspaceId)) {
continue;
}
knownAgentIds.add(agent.id);
@@ -61,7 +44,7 @@ export function deriveWorkspaceAgentVisibility(input: {
}
}
for (const agent of agentDetails?.values() ?? []) {
if (!agentBelongsToWorkspace({ agent, workspaceId, normalizedWorkspaceDirectory })) {
if (!agentBelongsToWorkspace(agent, workspaceId)) {
continue;
}
knownAgentIds.add(agent.id);