fix(server): deduplicate workspaces by git worktree root (#190)

* fix(server): deduplicate workspaces by resolving to git worktree root

Agents running in subdirectories of the same git repo were creating
separate workspace entries in the sidebar. Now workspace IDs resolve
to the git worktree root (via the already-computed `worktreeRoot` from
`inspectCheckoutContext`), so all agents in the same checkout share a
single workspace. Stale subdirectory workspace records are archived
during reconciliation.

* fix(server): fix 3 broken test files in server unit suite

- relay-reconnect: move speech mock to correct constructor position and
  rename getSpeechReadiness→getReadiness after SpeechService refactor
- commands-poc: add credential/CLI availability guards matching
  claude-agent.integration.test.ts pattern
- claude-sdk-behavior: pass pathToClaudeCodeExecutable via findExecutable
  and add credential guards matching real provider configuration

* fix(server): fix race condition in loop-service test mock

Replace setTimeout(..., 0) with queueMicrotask() in ScriptedAgentSession
so turn_completed events fire before the verify check runs. Fixes flaky
CI failure where the file write hadn't completed before verification.

* fix(server): use /bin/sh for loop verify checks instead of /bin/zsh

More portable across CI environments. Also use queueMicrotask in test
mock to fix race condition between event emission and waiter setup.

* fix(server): correct import paths for isCommandAvailable and findExecutable

Import from utils/executable.js (where they're exported) instead of
provider-launch-config.js (which only imports them internally).
This commit is contained in:
Mohamed Boudra
2026-04-04 14:35:41 +07:00
committed by GitHub
parent 3d3e327378
commit 9154f8fc4d
19 changed files with 369 additions and 45 deletions

View File

@@ -157,6 +157,7 @@ function makeFetchAgentsEntry(input: {
isGit: false, isGit: false,
currentBranch: null, currentBranch: null,
remoteUrl: null, remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
mainRepoRoot: null, mainRepoRoot: null,
}, },

View File

@@ -39,8 +39,9 @@ describe("resolveNewAgentWorkingDir", () => {
it("returns the main repo root for paseo-owned worktrees", () => { it("returns the main repo root for paseo-owned worktrees", () => {
const checkout = { const checkout = {
isPaseoOwnedWorktree: true, isPaseoOwnedWorktree: true,
worktreeRoot: "/repo/.paseo/worktrees/feature",
mainRepoRoot: "/repo/main", mainRepoRoot: "/repo/main",
} as CheckoutStatusPayload; } as unknown as CheckoutStatusPayload;
expect(resolveNewAgentWorkingDir("/repo/.paseo/worktrees/feature", checkout)).toBe( expect(resolveNewAgentWorkingDir("/repo/.paseo/worktrees/feature", checkout)).toBe(
"/repo/main", "/repo/main",

View File

@@ -28,6 +28,7 @@ describe("project-placement", () => {
isGit: true as const, isGit: true as const,
currentBranch: "main", currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git", remoteUrl: "https://github.com/acme/repo.git",
worktreeRoot: "/Users/test/repo",
isPaseoOwnedWorktree: false as const, isPaseoOwnedWorktree: false as const,
mainRepoRoot: null, mainRepoRoot: null,
}, },

View File

@@ -18,6 +18,7 @@ export function deriveProjectPlacementFromCwd(cwd: string): ProjectPlacementPayl
isGit: false, isGit: false,
currentBranch: null, currentBranch: null,
remoteUrl: null, remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
mainRepoRoot: null, mainRepoRoot: null,
}, },

View File

@@ -15,13 +15,16 @@
* This pattern is used in claude-agent.ts listModels(). * This pattern is used in claude-agent.ts listModels().
*/ */
import { describe, it, expect } from "vitest"; import { describe, expect, test } from "vitest";
import { import {
query, query,
type Query,
type SlashCommand,
type SDKUserMessage, type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk"; } from "@anthropic-ai/claude-agent-sdk";
import { isCommandAvailable } from "../utils/executable.js";
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials;
// Pattern from claude-agent.ts listModels(): // Pattern from claude-agent.ts listModels():
// Use an empty async generator when you just need control methods // Use an empty async generator when you just need control methods
@@ -31,7 +34,7 @@ function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
describe("Claude Agent SDK Commands POC", () => { describe("Claude Agent SDK Commands POC", () => {
describe("supportedCommands() API", () => { describe("supportedCommands() API", () => {
it("should return an array of SlashCommand objects", async () => { test.runIf(canRunClaudeIntegration)("should return an array of SlashCommand objects", async () => {
// Use the pattern from claude-agent.ts: // Use the pattern from claude-agent.ts:
// Create a query with empty prompt generator for control methods // Create a query with empty prompt generator for control methods
const emptyPrompt = createEmptyPrompt(); const emptyPrompt = createEmptyPrompt();
@@ -72,7 +75,7 @@ describe("Claude Agent SDK Commands POC", () => {
} }
}, 30000); }, 30000);
it("should have valid SlashCommand structure for all commands", async () => { test.runIf(canRunClaudeIntegration)("should have valid SlashCommand structure for all commands", async () => {
const emptyPrompt = createEmptyPrompt(); const emptyPrompt = createEmptyPrompt();
const claudeQuery = query({ const claudeQuery = query({
@@ -107,7 +110,7 @@ describe("Claude Agent SDK Commands POC", () => {
}); });
describe("Command Execution", () => { describe("Command Execution", () => {
it("should explain that commands are prompts with / prefix", () => { test("should explain that commands are prompts with / prefix", () => {
// This is a documentation test - commands ARE just prompts with / prefix // This is a documentation test - commands ARE just prompts with / prefix
// To execute a command: // To execute a command:
// 1. Create a user message with content: "/{commandName}" // 1. Create a user message with content: "/{commandName}"

View File

@@ -4,8 +4,9 @@
import { mkdtempSync, rmSync, realpathSync } from "node:fs"; import { mkdtempSync, rmSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, it, expect } from "vitest"; import { beforeAll, describe, expect, test } from "vitest";
import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
class Pushable<T> implements AsyncIterable<T> { class Pushable<T> implements AsyncIterable<T> {
private queue: T[] = []; private queue: T[] = [];
@@ -52,10 +53,22 @@ function tmpCwd(): string {
} }
} }
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
describe("Claude SDK direct behavior", () => { describe("Claude SDK direct behavior", () => {
it("shows what happens after interrupt()", async () => { const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials;
beforeAll(() => {
if (canRunClaudeIntegration) {
expect(isCommandAvailable("claude")).toBe(true);
}
});
test.runIf(canRunClaudeIntegration)("shows what happens after interrupt()", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const input = new Pushable<SDKUserMessage>(); const input = new Pushable<SDKUserMessage>();
const claudeBinary = findExecutable("claude");
// Use same options as claude-agent.ts // Use same options as claude-agent.ts
const q = query({ const q = query({
@@ -64,6 +77,7 @@ describe("Claude SDK direct behavior", () => {
cwd, cwd,
includePartialMessages: true, includePartialMessages: true,
permissionMode: "bypassPermissions", permissionMode: "bypassPermissions",
...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}),
systemPrompt: { systemPrompt: {
type: "preset", type: "preset",
preset: "claude_code", preset: "claude_code",

View File

@@ -106,9 +106,9 @@ class ScriptedAgentSession implements AgentSession {
const promptText = typeof prompt === "string" ? prompt : JSON.stringify(prompt); const promptText = typeof prompt === "string" ? prompt : JSON.stringify(prompt);
const turnId = `turn-${++this.turnCount}`; const turnId = `turn-${++this.turnCount}`;
this.interrupted = false; this.interrupted = false;
setTimeout(() => { queueMicrotask(() => {
void this.runScript(promptText, turnId); void this.runScript(promptText, turnId);
}, 0); });
return { turnId }; return { turnId };
} }

View File

@@ -256,7 +256,7 @@ async function runVerifyCheck(options: {
}): Promise<LoopVerifyCheckResult> { }): Promise<LoopVerifyCheckResult> {
const startedAt = nowIso(); const startedAt = nowIso();
try { try {
const result = await execFileAsync("/bin/zsh", ["-lc", options.command], { const result = await execFileAsync("/bin/sh", ["-lc", options.command], {
cwd: options.cwd, cwd: options.cwd,
maxBuffer: MAX_VERIFY_OUTPUT_BYTES, maxBuffer: MAX_VERIFY_OUTPUT_BYTES,
}); });

View File

@@ -109,6 +109,7 @@ import {
detectStaleWorkspaces, detectStaleWorkspaces,
deriveProjectKind, deriveProjectKind,
deriveProjectRootPath, deriveProjectRootPath,
deriveWorkspaceId,
deriveWorkspaceDisplayName, deriveWorkspaceDisplayName,
deriveWorkspaceKind, deriveWorkspaceKind,
normalizeWorkspaceId as normalizePersistedWorkspaceId, normalizeWorkspaceId as normalizePersistedWorkspaceId,
@@ -1298,11 +1299,15 @@ export class Session {
private async reconcileWorkspaceRecord(workspaceId: string): Promise<{ private async reconcileWorkspaceRecord(workspaceId: string): Promise<{
workspace: PersistedWorkspaceRecord; workspace: PersistedWorkspaceRecord;
changed: boolean; changed: boolean;
removedWorkspaceId: string | null;
}> { }> {
const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId); const normalizedCwd = normalizePersistedWorkspaceId(workspaceId);
const existing = await this.workspaceRegistry.get(normalizedWorkspaceId); const placement = await this.buildProjectPlacement(normalizedCwd);
const placement = await this.buildProjectPlacement(normalizedWorkspaceId); const resolvedWorkspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
await this.syncWorkspaceGitWatchTarget(normalizedWorkspaceId, { const staleWorkspace =
resolvedWorkspaceId === normalizedCwd ? null : await this.workspaceRegistry.get(normalizedCwd);
const existing = (await this.workspaceRegistry.get(resolvedWorkspaceId)) ?? staleWorkspace;
await this.syncWorkspaceGitWatchTarget(resolvedWorkspaceId, {
isGit: placement.checkout.isGit, isGit: placement.checkout.isGit,
}); });
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -1310,13 +1315,13 @@ export class Session {
const nextWorkspaceCreatedAt = existing?.createdAt ?? now; const nextWorkspaceCreatedAt = existing?.createdAt ?? now;
const currentProjectRecord = await this.projectRegistry.get(placement.projectKey); const currentProjectRecord = await this.projectRegistry.get(placement.projectKey);
const nextProjectRecord = this.buildPersistedProjectRecord({ const nextProjectRecord = this.buildPersistedProjectRecord({
workspaceId: normalizedWorkspaceId, workspaceId: resolvedWorkspaceId,
placement, placement,
createdAt: currentProjectRecord?.createdAt ?? nextProjectCreatedAt, createdAt: currentProjectRecord?.createdAt ?? nextProjectCreatedAt,
updatedAt: now, updatedAt: now,
}); });
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({ const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
workspaceId: normalizedWorkspaceId, workspaceId: resolvedWorkspaceId,
placement, placement,
createdAt: nextWorkspaceCreatedAt, createdAt: nextWorkspaceCreatedAt,
updatedAt: now, updatedAt: now,
@@ -1335,16 +1340,33 @@ export class Session {
currentProjectRecord.rootPath !== nextProjectRecord.rootPath || currentProjectRecord.rootPath !== nextProjectRecord.rootPath ||
currentProjectRecord.kind !== nextProjectRecord.kind || currentProjectRecord.kind !== nextProjectRecord.kind ||
currentProjectRecord.displayName !== nextProjectRecord.displayName; currentProjectRecord.displayName !== nextProjectRecord.displayName;
const needsStaleWorkspaceCleanup =
!!staleWorkspace &&
!staleWorkspace.archivedAt &&
staleWorkspace.workspaceId !== resolvedWorkspaceId;
if (!needsWorkspaceUpdate && !needsProjectUpdate) { let removedWorkspaceId: string | null = null;
if (needsStaleWorkspaceCleanup) {
await this.workspaceRegistry.archive(staleWorkspace.workspaceId, now);
this.removeWorkspaceGitWatchTarget(staleWorkspace.workspaceId);
removedWorkspaceId = staleWorkspace.workspaceId;
}
if (!needsWorkspaceUpdate && !needsProjectUpdate && !needsStaleWorkspaceCleanup) {
return { return {
workspace: existing!, workspace: existing!,
changed: false, changed: false,
removedWorkspaceId: null,
}; };
} }
await this.projectRegistry.upsert(nextProjectRecord); await this.projectRegistry.upsert(nextProjectRecord);
await this.workspaceRegistry.upsert(nextWorkspaceRecord); await this.workspaceRegistry.upsert(nextWorkspaceRecord);
if (existing && existing.workspaceId !== resolvedWorkspaceId) {
await this.workspaceRegistry.archive(existing.workspaceId, now);
this.removeWorkspaceGitWatchTarget(existing.workspaceId);
removedWorkspaceId ??= existing.workspaceId;
}
if (existing && !existing.archivedAt && existing.projectId !== nextWorkspaceRecord.projectId) { if (existing && !existing.archivedAt && existing.projectId !== nextWorkspaceRecord.projectId) {
await this.archiveProjectRecordIfEmpty(existing.projectId, now); await this.archiveProjectRecordIfEmpty(existing.projectId, now);
@@ -1353,6 +1375,7 @@ export class Session {
return { return {
workspace: nextWorkspaceRecord, workspace: nextWorkspaceRecord,
changed: true, changed: true,
removedWorkspaceId,
}; };
} }
@@ -1386,6 +1409,9 @@ export class Session {
const result = await this.reconcileWorkspaceRecord(workspace.workspaceId); const result = await this.reconcileWorkspaceRecord(workspace.workspaceId);
if (result.changed) { if (result.changed) {
changedWorkspaceIds.add(result.workspace.workspaceId); changedWorkspaceIds.add(result.workspace.workspaceId);
if (result.removedWorkspaceId) {
changedWorkspaceIds.add(result.removedWorkspaceId);
}
} }
} }
@@ -5131,7 +5157,7 @@ export class Session {
continue; continue;
} }
const workspaceId = normalizePersistedWorkspaceId(agent.cwd); const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(agent.cwd, activeRecords);
const existing = descriptorsByWorkspaceId.get(workspaceId); const existing = descriptorsByWorkspaceId.get(workspaceId);
if (!existing) { if (!existing) {
continue; continue;
@@ -5147,6 +5173,32 @@ export class Session {
return Array.from(descriptorsByWorkspaceId.values()); return Array.from(descriptorsByWorkspaceId.values());
} }
private resolveRegisteredWorkspaceIdForCwd(
cwd: string,
workspaces: PersistedWorkspaceRecord[],
): string {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const exact = workspaces.find((workspace) => workspace.workspaceId === normalizedCwd);
if (exact) {
return exact.workspaceId;
}
let bestMatch: PersistedWorkspaceRecord | null = null;
for (const workspace of workspaces) {
const prefix = workspace.workspaceId.endsWith(sep)
? workspace.workspaceId
: `${workspace.workspaceId}${sep}`;
if (!normalizedCwd.startsWith(prefix)) {
continue;
}
if (!bestMatch || workspace.workspaceId.length > bestMatch.workspaceId.length) {
bestMatch = workspace;
}
}
return bestMatch?.workspaceId ?? normalizedCwd;
}
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> { private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
await this.reconcileActiveWorkspaceRecords(); await this.reconcileActiveWorkspaceRecords();
return this.listWorkspaceDescriptorsSnapshot(); return this.listWorkspaceDescriptorsSnapshot();
@@ -5435,8 +5487,7 @@ export class Session {
} }
private async ensureWorkspaceRegistered(cwd: string): Promise<PersistedWorkspaceRecord> { private async ensureWorkspaceRegistered(cwd: string): Promise<PersistedWorkspaceRecord> {
const workspaceId = normalizePersistedWorkspaceId(cwd); return (await this.reconcileWorkspaceRecord(cwd)).workspace;
return (await this.reconcileWorkspaceRecord(workspaceId)).workspace;
} }
private async registerPendingWorktreeWorkspace(options: { private async registerPendingWorktreeWorkspace(options: {
@@ -5488,11 +5539,17 @@ export class Session {
return; return;
} }
const workspaceId = normalizePersistedWorkspaceId(cwd);
const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords(); const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords();
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => !workspace.archivedAt,
);
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces);
const all = await this.listWorkspaceDescriptorsSnapshot(); const all = await this.listWorkspaceDescriptorsSnapshot();
const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const)); const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const));
const workspaceIdsToEmit = new Set<string>([workspaceId, ...changedWorkspaceIds]); const workspaceIdsToEmit = new Set<string>([
workspaceId,
...changedWorkspaceIds,
]);
for (const nextWorkspaceId of workspaceIdsToEmit) { for (const nextWorkspaceId of workspaceIdsToEmit) {
const workspace = descriptorsByWorkspaceId.get(nextWorkspaceId); const workspace = descriptorsByWorkspaceId.get(nextWorkspaceId);
@@ -5529,13 +5586,12 @@ export class Session {
} }
const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords(); const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords();
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => !workspace.archivedAt,
);
const uniqueWorkspaceCwds = new Set<string>(changedWorkspaceIds); const uniqueWorkspaceCwds = new Set<string>(changedWorkspaceIds);
for (const cwd of cwds) { for (const cwd of cwds) {
const normalized = normalizePersistedWorkspaceId(cwd); uniqueWorkspaceCwds.add(this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces));
if (!normalized) {
continue;
}
uniqueWorkspaceCwds.add(normalized);
} }
const subscription = this.workspaceUpdatesSubscription; const subscription = this.workspaceUpdatesSubscription;

View File

@@ -181,6 +181,7 @@ describe("workspace git watch targets", () => {
isGit: true, isGit: true,
currentBranch: "main", currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git", remoteUrl: "https://github.com/acme/repo.git",
worktreeRoot: cwd,
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
mainRepoRoot: null, mainRepoRoot: null,
}, },
@@ -267,6 +268,7 @@ describe("workspace git watch targets", () => {
isGit: true, isGit: true,
currentBranch: "main", currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git", remoteUrl: "https://github.com/acme/repo.git",
worktreeRoot: cwd,
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
mainRepoRoot: null, mainRepoRoot: null,
}, },

View File

@@ -251,6 +251,7 @@ describe("workspace aggregation", () => {
isGit: false, isGit: false,
currentBranch: null, currentBranch: null,
remoteUrl: null, remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
mainRepoRoot: null, mainRepoRoot: null,
}, },
@@ -400,6 +401,7 @@ describe("workspace aggregation", () => {
isGit: false, isGit: false,
currentBranch: null, currentBranch: null,
remoteUrl: null, remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
mainRepoRoot: null, mainRepoRoot: null,
}, },
@@ -564,6 +566,7 @@ describe("workspace aggregation", () => {
isGit: false, isGit: false,
currentBranch: null, currentBranch: null,
remoteUrl: null, remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
mainRepoRoot: null, mainRepoRoot: null,
}, },
@@ -699,6 +702,7 @@ describe("workspace aggregation", () => {
isGit: false, isGit: false,
currentBranch: null, currentBranch: null,
remoteUrl: null, remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
mainRepoRoot: null, mainRepoRoot: null,
}, },
@@ -792,6 +796,7 @@ describe("workspace aggregation", () => {
isGit: true, isGit: true,
currentBranch: "feature/name-from-server", currentBranch: "feature/name-from-server",
remoteUrl: "https://github.com/acme/repo-branch.git", remoteUrl: "https://github.com/acme/repo-branch.git",
worktreeRoot: cwd,
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
mainRepoRoot: null, mainRepoRoot: null,
}, },
@@ -849,6 +854,41 @@ describe("workspace aggregation", () => {
expect(result.entries[0]?.status).toBe("needs_input"); expect(result.entries[0]?.status).toBe("needs_input");
}); });
test("subdirectory agents map to an existing parent workspace descriptor", async () => {
const session = createSessionForWorkspaceTests() as any;
session.workspaceRegistry.list = async () => [
createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo",
projectId: "/tmp/repo",
cwd: "/tmp/repo",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
}),
];
session.listAgentPayloads = async () => [
makeAgent({
id: "a1",
cwd: "/tmp/repo/packages/app",
status: "running",
updatedAt: "2026-03-01T12:03:00.000Z",
}),
];
const result = await session.listFetchWorkspacesEntries({
type: "fetch_workspaces_request",
requestId: "req-subdir-agent",
});
expect(result.entries).toHaveLength(1);
expect(result.entries[0]).toMatchObject({
id: "/tmp/repo",
status: "running",
activityAt: "2026-03-01T12:03:00.000Z",
});
});
test("workspace update stream keeps persisted workspace visible after agents stop", async () => { test("workspace update stream keeps persisted workspace visible after agents stop", async () => {
const emitted: Array<{ type: string; payload: unknown }> = []; const emitted: Array<{ type: string; payload: unknown }> = [];
const logger = { const logger = {
@@ -1114,6 +1154,7 @@ describe("workspace aggregation", () => {
isGit: false, isGit: false,
currentBranch: null, currentBranch: null,
remoteUrl: null, remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
mainRepoRoot: null, mainRepoRoot: null,
}, },
@@ -1131,6 +1172,57 @@ describe("workspace aggregation", () => {
expect(response?.payload.workspace?.id).toBe("/tmp/repo"); expect(response?.payload.workspace?.id).toBe("/tmp/repo");
}); });
test("open_project_request collapses a git subdirectory onto the repo root workspace", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests() as any;
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const repoRoot = "/tmp/repo";
const subdir = "/tmp/repo/packages/app";
session.emit = (message: any) => emitted.push(message);
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
session.projectRegistry.upsert = async (
record: ReturnType<typeof createPersistedProjectRecord>,
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
workspaces.set(record.workspaceId, record);
};
session.projectRegistry.list = async () => Array.from(projects.values());
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.buildProjectPlacement = async (cwd: string) => ({
projectKey: repoRoot,
projectName: "repo",
checkout: {
cwd,
isGit: true,
currentBranch: "main",
remoteUrl: null,
worktreeRoot: repoRoot,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
await session.handleMessage({
type: "open_project_request",
cwd: subdir,
requestId: "req-open-subdir",
});
expect(workspaces.get(repoRoot)).toBeTruthy();
expect(workspaces.has(subdir)).toBe(false);
const response = emitted.find((message) => message.type === "open_project_response") as any;
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe(repoRoot);
});
test("archive_workspace_request hides non-destructive workspace records", async () => { test("archive_workspace_request hides non-destructive workspace records", async () => {
const emitted: Array<{ type: string; payload: unknown }> = []; const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests() as any; const session = createSessionForWorkspaceTests() as any;
@@ -1239,6 +1331,7 @@ describe("workspace aggregation", () => {
isGit: true, isGit: true,
currentBranch: cwd === mainWorkspaceId ? "main" : "feature-a", currentBranch: cwd === mainWorkspaceId ? "main" : "feature-a",
remoteUrl: "https://github.com/zimakki/inkwell.git", remoteUrl: "https://github.com/zimakki/inkwell.git",
worktreeRoot: cwd,
isPaseoOwnedWorktree: cwd !== mainWorkspaceId, isPaseoOwnedWorktree: cwd !== mainWorkspaceId,
mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId, mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId,
}, },
@@ -1345,6 +1438,7 @@ describe("workspace aggregation", () => {
isGit: true, isGit: true,
currentBranch: cwd === mainWorkspaceId ? "main" : "feature-a", currentBranch: cwd === mainWorkspaceId ? "main" : "feature-a",
remoteUrl: "https://github.com/new-owner/inkwell.git", remoteUrl: "https://github.com/new-owner/inkwell.git",
worktreeRoot: cwd,
isPaseoOwnedWorktree: cwd !== mainWorkspaceId, isPaseoOwnedWorktree: cwd !== mainWorkspaceId,
mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId, mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId,
}, },
@@ -1367,4 +1461,99 @@ describe("workspace aggregation", () => {
rmSync(tempDir, { recursive: true, force: true }); rmSync(tempDir, { recursive: true, force: true });
} }
}); });
test("reconcile archives stale subdirectory workspace records when collapsing to the repo root", async () => {
const session = createSessionForWorkspaceTests() as any;
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-collapse-")));
const repoRoot = path.join(tempDir, "repo");
const subdirWorkspaceId = path.join(repoRoot, "packages", "app");
const projectId = "remote:github.com/acme/repo";
execSync(`mkdir -p ${JSON.stringify(subdirWorkspaceId)}`);
projects.set(
projectId,
createPersistedProjectRecord({
projectId,
rootPath: repoRoot,
kind: "git",
displayName: "acme/repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
}),
);
workspaces.set(
repoRoot,
createPersistedWorkspaceRecord({
workspaceId: repoRoot,
projectId,
cwd: repoRoot,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
}),
);
workspaces.set(
subdirWorkspaceId,
createPersistedWorkspaceRecord({
workspaceId: subdirWorkspaceId,
projectId,
cwd: subdirWorkspaceId,
kind: "directory",
displayName: "app",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
}),
);
session.projectRegistry.get = async (nextProjectId: string) => projects.get(nextProjectId) ?? null;
session.projectRegistry.list = async () => Array.from(projects.values());
session.projectRegistry.upsert = async (
record: ReturnType<typeof createPersistedProjectRecord>,
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
workspaces.set(record.workspaceId, record);
};
session.workspaceRegistry.archive = async (workspaceId: string, archivedAt: string) => {
const existing = workspaces.get(workspaceId);
if (!existing) return;
workspaces.set(workspaceId, { ...existing, archivedAt, updatedAt: archivedAt });
};
session.buildProjectPlacement = async (cwd: string) => ({
projectKey: projectId,
projectName: "acme/repo",
checkout: {
cwd,
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
worktreeRoot: repoRoot,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
try {
const result = await session.reconcileWorkspaceRecord(subdirWorkspaceId);
expect(result.changed).toBe(true);
expect(result.workspace.workspaceId).toBe(repoRoot);
expect(result.removedWorkspaceId).toBe(subdirWorkspaceId);
expect(workspaces.get(repoRoot)?.archivedAt).toBeNull();
expect(workspaces.get(subdirWorkspaceId)?.archivedAt).toBeTruthy();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
}); });

View File

@@ -167,15 +167,16 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
"/tmp/paseo-test", "/tmp/paseo-test",
async () => ({}) as any, async () => ({}) as any,
{ allowedOrigins: new Set() }, { allowedOrigins: new Set() },
undefined,
undefined,
undefined,
speechReadiness speechReadiness
? { ? {
getSpeechReadiness: () => speechReadiness, getReadiness: () => speechReadiness,
onReadinessChange: vi.fn(() => () => {}),
} }
: undefined, : undefined,
undefined, undefined,
undefined,
undefined,
undefined,
TEST_DAEMON_VERSION, TEST_DAEMON_VERSION,
undefined, undefined,
undefined, undefined,

View File

@@ -6,6 +6,7 @@ import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentStorage } from "./agent/agent-storage.js"; import type { AgentStorage } from "./agent/agent-storage.js";
import { import {
buildProjectPlacementForCwd, buildProjectPlacementForCwd,
deriveWorkspaceId,
deriveProjectKind, deriveProjectKind,
deriveProjectRootPath, deriveProjectRootPath,
deriveWorkspaceDisplayName, deriveWorkspaceDisplayName,
@@ -67,22 +68,26 @@ export async function bootstrapWorkspaceRegistries(options: {
const records = await options.agentStorage.list(); const records = await options.agentStorage.list();
const activeRecords = records.filter((record) => !record.archivedAt); const activeRecords = records.filter((record) => !record.archivedAt);
const recordsByWorkspaceId = new Map<string, StoredAgentRecord[]>(); const recordsByWorkspaceId = new Map<
string,
{ placement: Awaited<ReturnType<typeof buildProjectPlacementForCwd>>; records: StoredAgentRecord[] }
>();
for (const record of activeRecords) { for (const record of activeRecords) {
const workspaceId = normalizeWorkspaceId(record.cwd); const normalizedCwd = normalizeWorkspaceId(record.cwd);
const existing = recordsByWorkspaceId.get(workspaceId) ?? []; const placement = await buildProjectPlacementForCwd({
existing.push(record); cwd: normalizedCwd,
paseoHome: options.paseoHome,
});
const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
const existing = recordsByWorkspaceId.get(workspaceId) ?? { placement, records: [] };
existing.records.push(record);
recordsByWorkspaceId.set(workspaceId, existing); recordsByWorkspaceId.set(workspaceId, existing);
} }
const projectRanges = new Map<string, { createdAt: string | null; updatedAt: string | null }>(); const projectRanges = new Map<string, { createdAt: string | null; updatedAt: string | null }>();
for (const [workspaceId, workspaceRecords] of recordsByWorkspaceId.entries()) { for (const [workspaceId, entry] of recordsByWorkspaceId.entries()) {
const placement = await buildProjectPlacementForCwd({ const { placement, records: workspaceRecords } = entry;
cwd: workspaceId,
paseoHome: options.paseoHome,
});
let workspaceCreatedAt: string | null = null; let workspaceCreatedAt: string | null = null;
let workspaceUpdatedAt: string | null = null; let workspaceUpdatedAt: string | null = null;
for (const record of workspaceRecords) { for (const record of workspaceRecords) {

View File

@@ -1,6 +1,6 @@
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { detectStaleWorkspaces } from "./workspace-registry-model.js"; import { deriveWorkspaceId, detectStaleWorkspaces } from "./workspace-registry-model.js";
import { createPersistedWorkspaceRecord } from "./workspace-registry.js"; import { createPersistedWorkspaceRecord } from "./workspace-registry.js";
function createWorkspaceRecord(workspaceId: string) { function createWorkspaceRecord(workspaceId: string) {
@@ -52,3 +52,33 @@ describe("detectStaleWorkspaces", () => {
expect(Array.from(staleWorkspaceIds)).toEqual([]); expect(Array.from(staleWorkspaceIds)).toEqual([]);
}); });
}); });
describe("deriveWorkspaceId", () => {
test("uses git worktree root when available", () => {
expect(
deriveWorkspaceId("/tmp/repo/packages/app", {
cwd: "/tmp/repo/packages/app",
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
worktreeRoot: "/tmp/repo",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
}),
).toBe("/tmp/repo");
});
test("falls back to normalized cwd for non-git directories", () => {
expect(
deriveWorkspaceId("/tmp/repo/../repo/scratch", {
cwd: "/tmp/repo/../repo/scratch",
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
}),
).toBe("/tmp/repo/scratch");
});
});

View File

@@ -19,6 +19,10 @@ export function normalizeWorkspaceId(cwd: string): string {
return resolve(trimmed); return resolve(trimmed);
} }
export function deriveWorkspaceId(cwd: string, checkout: ProjectCheckoutLitePayload): string {
return checkout.worktreeRoot ?? normalizeWorkspaceId(cwd);
}
function deriveRemoteProjectKey(remoteUrl: string | null): string | null { function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
if (!remoteUrl) { if (!remoteUrl) {
return null; return null;
@@ -161,6 +165,7 @@ export async function buildProjectPlacementForCwd(input: {
isGit: false, isGit: false,
currentBranch: null, currentBranch: null,
remoteUrl: null, remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
mainRepoRoot: null, mainRepoRoot: null,
}; };
@@ -172,6 +177,7 @@ export async function buildProjectPlacementForCwd(input: {
isGit: true, isGit: true,
currentBranch: status.currentBranch, currentBranch: status.currentBranch,
remoteUrl: status.remoteUrl, remoteUrl: status.remoteUrl,
worktreeRoot: status.worktreeRoot,
isPaseoOwnedWorktree: true, isPaseoOwnedWorktree: true,
mainRepoRoot: status.mainRepoRoot, mainRepoRoot: status.mainRepoRoot,
}; };
@@ -182,6 +188,7 @@ export async function buildProjectPlacementForCwd(input: {
isGit: true, isGit: true,
currentBranch: status.currentBranch, currentBranch: status.currentBranch,
remoteUrl: status.remoteUrl, remoteUrl: status.remoteUrl,
worktreeRoot: status.worktreeRoot,
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
mainRepoRoot: null, mainRepoRoot: null,
}; };
@@ -192,13 +199,14 @@ export async function buildProjectPlacementForCwd(input: {
isGit: false, isGit: false,
currentBranch: null, currentBranch: null,
remoteUrl: null, remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
mainRepoRoot: null, mainRepoRoot: null,
}), }),
); );
const projectKey = deriveProjectGroupingKey({ const projectKey = deriveProjectGroupingKey({
cwd: normalizedCwd, cwd: checkout.worktreeRoot ?? normalizedCwd,
remoteUrl: checkout.remoteUrl, remoteUrl: checkout.remoteUrl,
isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree, isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree,
mainRepoRoot: checkout.mainRepoRoot, mainRepoRoot: checkout.mainRepoRoot,

View File

@@ -518,6 +518,7 @@ export async function registerPendingWorktreeWorkspace(
isGit: true, isGit: true,
currentBranch: options.branchName, currentBranch: options.branchName,
remoteUrl: basePlacement.checkout.remoteUrl, remoteUrl: basePlacement.checkout.remoteUrl,
worktreeRoot: options.worktreePath,
isPaseoOwnedWorktree: true, isPaseoOwnedWorktree: true,
mainRepoRoot: options.repoRoot, mainRepoRoot: options.repoRoot,
}, },

View File

@@ -1598,6 +1598,7 @@ export const ProjectCheckoutLiteNotGitPayloadSchema = z.object({
isGit: z.literal(false), isGit: z.literal(false),
currentBranch: z.null(), currentBranch: z.null(),
remoteUrl: z.null(), remoteUrl: z.null(),
worktreeRoot: z.null(),
isPaseoOwnedWorktree: z.literal(false), isPaseoOwnedWorktree: z.literal(false),
mainRepoRoot: z.null(), mainRepoRoot: z.null(),
}); });
@@ -1607,6 +1608,7 @@ export const ProjectCheckoutLiteGitNonPaseoPayloadSchema = z.object({
isGit: z.literal(true), isGit: z.literal(true),
currentBranch: z.string().nullable(), currentBranch: z.string().nullable(),
remoteUrl: z.string().nullable(), remoteUrl: z.string().nullable(),
worktreeRoot: z.string(),
isPaseoOwnedWorktree: z.literal(false), isPaseoOwnedWorktree: z.literal(false),
mainRepoRoot: z.null(), mainRepoRoot: z.null(),
}); });
@@ -1616,6 +1618,7 @@ export const ProjectCheckoutLiteGitPaseoPayloadSchema = z.object({
isGit: z.literal(true), isGit: z.literal(true),
currentBranch: z.string().nullable(), currentBranch: z.string().nullable(),
remoteUrl: z.string().nullable(), remoteUrl: z.string().nullable(),
worktreeRoot: z.string(),
isPaseoOwnedWorktree: z.literal(true), isPaseoOwnedWorktree: z.literal(true),
mainRepoRoot: z.string(), mainRepoRoot: z.string(),
}); });

View File

@@ -151,6 +151,7 @@ const x = 1;
const status = await getCheckoutStatusLite(repoDir); const status = await getCheckoutStatusLite(repoDir);
expect(status.isGit).toBe(true); expect(status.isGit).toBe(true);
expect(status.currentBranch).toBe("main"); expect(status.currentBranch).toBe("main");
expect(status.worktreeRoot).toBe(repoDir);
expect(status.isPaseoOwnedWorktree).toBe(false); expect(status.isPaseoOwnedWorktree).toBe(false);
expect(status.mainRepoRoot).toBeNull(); expect(status.mainRepoRoot).toBeNull();
}); });
@@ -373,6 +374,7 @@ const x = 1;
const status = await getCheckoutStatusLite(result.worktreePath, { paseoHome }); const status = await getCheckoutStatusLite(result.worktreePath, { paseoHome });
expect(status.isGit).toBe(true); expect(status.isGit).toBe(true);
expect(status.worktreeRoot).toBe(result.worktreePath);
expect(status.isPaseoOwnedWorktree).toBe(true); expect(status.isPaseoOwnedWorktree).toBe(true);
expect(status.mainRepoRoot).toBe(repoDir); expect(status.mainRepoRoot).toBe(repoDir);
}); });

View File

@@ -542,6 +542,7 @@ export type CheckoutStatusLiteNotGit = {
isGit: false; isGit: false;
currentBranch: null; currentBranch: null;
remoteUrl: null; remoteUrl: null;
worktreeRoot: null;
isPaseoOwnedWorktree: false; isPaseoOwnedWorktree: false;
mainRepoRoot: null; mainRepoRoot: null;
}; };
@@ -550,6 +551,7 @@ export type CheckoutStatusLiteGitNonPaseo = {
isGit: true; isGit: true;
currentBranch: string | null; currentBranch: string | null;
remoteUrl: string | null; remoteUrl: string | null;
worktreeRoot: string;
isPaseoOwnedWorktree: false; isPaseoOwnedWorktree: false;
mainRepoRoot: null; mainRepoRoot: null;
}; };
@@ -558,6 +560,7 @@ export type CheckoutStatusLiteGitPaseo = {
isGit: true; isGit: true;
currentBranch: string | null; currentBranch: string | null;
remoteUrl: string | null; remoteUrl: string | null;
worktreeRoot: string;
isPaseoOwnedWorktree: true; isPaseoOwnedWorktree: true;
mainRepoRoot: string; mainRepoRoot: string;
}; };
@@ -1159,6 +1162,7 @@ export async function getCheckoutStatusLite(
isGit: false, isGit: false,
currentBranch: null, currentBranch: null,
remoteUrl: null, remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
mainRepoRoot: null, mainRepoRoot: null,
}; };
@@ -1169,6 +1173,7 @@ export async function getCheckoutStatusLite(
isGit: true, isGit: true,
currentBranch: inspected.currentBranch, currentBranch: inspected.currentBranch,
remoteUrl: inspected.remoteUrl, remoteUrl: inspected.remoteUrl,
worktreeRoot: inspected.worktreeRoot,
isPaseoOwnedWorktree: true, isPaseoOwnedWorktree: true,
mainRepoRoot: await getMainRepoRoot(cwd), mainRepoRoot: await getMainRepoRoot(cwd),
}; };
@@ -1178,6 +1183,7 @@ export async function getCheckoutStatusLite(
isGit: true, isGit: true,
currentBranch: inspected.currentBranch, currentBranch: inspected.currentBranch,
remoteUrl: inspected.remoteUrl, remoteUrl: inspected.remoteUrl,
worktreeRoot: inspected.worktreeRoot,
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
mainRepoRoot: null, mainRepoRoot: null,
}; };