From 9154f8fc4d45959c7988d39b66bf5965120387bc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 4 Apr 2026 14:35:41 +0700 Subject: [PATCH] fix(server): deduplicate workspaces by git worktree root (#190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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). --- packages/app/src/runtime/host-runtime.test.ts | 1 + .../app/src/utils/new-agent-routing.test.ts | 3 +- .../app/src/utils/project-placement.test.ts | 1 + packages/app/src/utils/project-placement.ts | 1 + .../src/poc-commands/commands-poc.test.ts | 15 +- .../providers/claude-sdk-behavior.test.ts | 18 +- .../server/src/server/loop-service.test.ts | 4 +- packages/server/src/server/loop-service.ts | 2 +- packages/server/src/server/session.ts | 90 +++++++-- .../session.workspace-git-watch.test.ts | 2 + .../src/server/session.workspaces.test.ts | 189 ++++++++++++++++++ .../websocket-server.relay-reconnect.test.ts | 9 +- .../server/workspace-registry-bootstrap.ts | 25 ++- .../server/workspace-registry-model.test.ts | 32 ++- .../src/server/workspace-registry-model.ts | 10 +- .../server/src/server/worktree-session.ts | 1 + packages/server/src/shared/messages.ts | 3 + .../server/src/utils/checkout-git.test.ts | 2 + packages/server/src/utils/checkout-git.ts | 6 + 19 files changed, 369 insertions(+), 45 deletions(-) diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index b91bef121..dcebc6118 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -157,6 +157,7 @@ function makeFetchAgentsEntry(input: { isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, diff --git a/packages/app/src/utils/new-agent-routing.test.ts b/packages/app/src/utils/new-agent-routing.test.ts index cbc3e4f23..b4d0cfa42 100644 --- a/packages/app/src/utils/new-agent-routing.test.ts +++ b/packages/app/src/utils/new-agent-routing.test.ts @@ -39,8 +39,9 @@ describe("resolveNewAgentWorkingDir", () => { it("returns the main repo root for paseo-owned worktrees", () => { const checkout = { isPaseoOwnedWorktree: true, + worktreeRoot: "/repo/.paseo/worktrees/feature", mainRepoRoot: "/repo/main", - } as CheckoutStatusPayload; + } as unknown as CheckoutStatusPayload; expect(resolveNewAgentWorkingDir("/repo/.paseo/worktrees/feature", checkout)).toBe( "/repo/main", diff --git a/packages/app/src/utils/project-placement.test.ts b/packages/app/src/utils/project-placement.test.ts index 27d31f484..28083855f 100644 --- a/packages/app/src/utils/project-placement.test.ts +++ b/packages/app/src/utils/project-placement.test.ts @@ -28,6 +28,7 @@ describe("project-placement", () => { isGit: true as const, currentBranch: "main", remoteUrl: "https://github.com/acme/repo.git", + worktreeRoot: "/Users/test/repo", isPaseoOwnedWorktree: false as const, mainRepoRoot: null, }, diff --git a/packages/app/src/utils/project-placement.ts b/packages/app/src/utils/project-placement.ts index 61e01420e..08c0fe30b 100644 --- a/packages/app/src/utils/project-placement.ts +++ b/packages/app/src/utils/project-placement.ts @@ -18,6 +18,7 @@ export function deriveProjectPlacementFromCwd(cwd: string): ProjectPlacementPayl isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, diff --git a/packages/server/src/poc-commands/commands-poc.test.ts b/packages/server/src/poc-commands/commands-poc.test.ts index f78c713f2..7ca97cef4 100644 --- a/packages/server/src/poc-commands/commands-poc.test.ts +++ b/packages/server/src/poc-commands/commands-poc.test.ts @@ -15,13 +15,16 @@ * This pattern is used in claude-agent.ts listModels(). */ -import { describe, it, expect } from "vitest"; +import { describe, expect, test } from "vitest"; import { query, - type Query, - type SlashCommand, type SDKUserMessage, } 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(): // Use an empty async generator when you just need control methods @@ -31,7 +34,7 @@ function createEmptyPrompt(): AsyncGenerator { describe("Claude Agent SDK Commands POC", () => { 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: // Create a query with empty prompt generator for control methods const emptyPrompt = createEmptyPrompt(); @@ -72,7 +75,7 @@ describe("Claude Agent SDK Commands POC", () => { } }, 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 claudeQuery = query({ @@ -107,7 +110,7 @@ describe("Claude Agent SDK Commands POC", () => { }); 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 // To execute a command: // 1. Create a user message with content: "/{commandName}" diff --git a/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts b/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts index 76105eed2..2b330830b 100644 --- a/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts +++ b/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts @@ -4,8 +4,9 @@ import { mkdtempSync, rmSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; 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 { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; class Pushable implements AsyncIterable { 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", () => { - 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 input = new Pushable(); + const claudeBinary = findExecutable("claude"); // Use same options as claude-agent.ts const q = query({ @@ -64,6 +77,7 @@ describe("Claude SDK direct behavior", () => { cwd, includePartialMessages: true, permissionMode: "bypassPermissions", + ...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}), systemPrompt: { type: "preset", preset: "claude_code", diff --git a/packages/server/src/server/loop-service.test.ts b/packages/server/src/server/loop-service.test.ts index 2f9a09e7c..3385584f5 100644 --- a/packages/server/src/server/loop-service.test.ts +++ b/packages/server/src/server/loop-service.test.ts @@ -106,9 +106,9 @@ class ScriptedAgentSession implements AgentSession { const promptText = typeof prompt === "string" ? prompt : JSON.stringify(prompt); const turnId = `turn-${++this.turnCount}`; this.interrupted = false; - setTimeout(() => { + queueMicrotask(() => { void this.runScript(promptText, turnId); - }, 0); + }); return { turnId }; } diff --git a/packages/server/src/server/loop-service.ts b/packages/server/src/server/loop-service.ts index 1d3eccf3d..ead9e2bf0 100644 --- a/packages/server/src/server/loop-service.ts +++ b/packages/server/src/server/loop-service.ts @@ -256,7 +256,7 @@ async function runVerifyCheck(options: { }): Promise { const startedAt = nowIso(); try { - const result = await execFileAsync("/bin/zsh", ["-lc", options.command], { + const result = await execFileAsync("/bin/sh", ["-lc", options.command], { cwd: options.cwd, maxBuffer: MAX_VERIFY_OUTPUT_BYTES, }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index d8dd0338d..6d2320935 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -109,6 +109,7 @@ import { detectStaleWorkspaces, deriveProjectKind, deriveProjectRootPath, + deriveWorkspaceId, deriveWorkspaceDisplayName, deriveWorkspaceKind, normalizeWorkspaceId as normalizePersistedWorkspaceId, @@ -1298,11 +1299,15 @@ export class Session { private async reconcileWorkspaceRecord(workspaceId: string): Promise<{ workspace: PersistedWorkspaceRecord; changed: boolean; + removedWorkspaceId: string | null; }> { - const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId); - const existing = await this.workspaceRegistry.get(normalizedWorkspaceId); - const placement = await this.buildProjectPlacement(normalizedWorkspaceId); - await this.syncWorkspaceGitWatchTarget(normalizedWorkspaceId, { + const normalizedCwd = normalizePersistedWorkspaceId(workspaceId); + const placement = await this.buildProjectPlacement(normalizedCwd); + const resolvedWorkspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout); + 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, }); const now = new Date().toISOString(); @@ -1310,13 +1315,13 @@ export class Session { const nextWorkspaceCreatedAt = existing?.createdAt ?? now; const currentProjectRecord = await this.projectRegistry.get(placement.projectKey); const nextProjectRecord = this.buildPersistedProjectRecord({ - workspaceId: normalizedWorkspaceId, + workspaceId: resolvedWorkspaceId, placement, createdAt: currentProjectRecord?.createdAt ?? nextProjectCreatedAt, updatedAt: now, }); const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({ - workspaceId: normalizedWorkspaceId, + workspaceId: resolvedWorkspaceId, placement, createdAt: nextWorkspaceCreatedAt, updatedAt: now, @@ -1335,16 +1340,33 @@ export class Session { currentProjectRecord.rootPath !== nextProjectRecord.rootPath || currentProjectRecord.kind !== nextProjectRecord.kind || 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 { workspace: existing!, changed: false, + removedWorkspaceId: null, }; } await this.projectRegistry.upsert(nextProjectRecord); 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) { await this.archiveProjectRecordIfEmpty(existing.projectId, now); @@ -1353,6 +1375,7 @@ export class Session { return { workspace: nextWorkspaceRecord, changed: true, + removedWorkspaceId, }; } @@ -1386,6 +1409,9 @@ export class Session { const result = await this.reconcileWorkspaceRecord(workspace.workspaceId); if (result.changed) { changedWorkspaceIds.add(result.workspace.workspaceId); + if (result.removedWorkspaceId) { + changedWorkspaceIds.add(result.removedWorkspaceId); + } } } @@ -5131,7 +5157,7 @@ export class Session { continue; } - const workspaceId = normalizePersistedWorkspaceId(agent.cwd); + const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(agent.cwd, activeRecords); const existing = descriptorsByWorkspaceId.get(workspaceId); if (!existing) { continue; @@ -5147,6 +5173,32 @@ export class Session { 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 { await this.reconcileActiveWorkspaceRecords(); return this.listWorkspaceDescriptorsSnapshot(); @@ -5435,8 +5487,7 @@ export class Session { } private async ensureWorkspaceRegistered(cwd: string): Promise { - const workspaceId = normalizePersistedWorkspaceId(cwd); - return (await this.reconcileWorkspaceRecord(workspaceId)).workspace; + return (await this.reconcileWorkspaceRecord(cwd)).workspace; } private async registerPendingWorktreeWorkspace(options: { @@ -5488,11 +5539,17 @@ export class Session { return; } - const workspaceId = normalizePersistedWorkspaceId(cwd); 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 descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const)); - const workspaceIdsToEmit = new Set([workspaceId, ...changedWorkspaceIds]); + const workspaceIdsToEmit = new Set([ + workspaceId, + ...changedWorkspaceIds, + ]); for (const nextWorkspaceId of workspaceIdsToEmit) { const workspace = descriptorsByWorkspaceId.get(nextWorkspaceId); @@ -5529,13 +5586,12 @@ export class Session { } const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords(); + const activeWorkspaces = (await this.workspaceRegistry.list()).filter( + (workspace) => !workspace.archivedAt, + ); const uniqueWorkspaceCwds = new Set(changedWorkspaceIds); for (const cwd of cwds) { - const normalized = normalizePersistedWorkspaceId(cwd); - if (!normalized) { - continue; - } - uniqueWorkspaceCwds.add(normalized); + uniqueWorkspaceCwds.add(this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces)); } const subscription = this.workspaceUpdatesSubscription; diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 77818a928..b9b65602d 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -181,6 +181,7 @@ describe("workspace git watch targets", () => { isGit: true, currentBranch: "main", remoteUrl: "https://github.com/acme/repo.git", + worktreeRoot: cwd, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, @@ -267,6 +268,7 @@ describe("workspace git watch targets", () => { isGit: true, currentBranch: "main", remoteUrl: "https://github.com/acme/repo.git", + worktreeRoot: cwd, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 04f3b739d..859192291 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -251,6 +251,7 @@ describe("workspace aggregation", () => { isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, @@ -400,6 +401,7 @@ describe("workspace aggregation", () => { isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, @@ -564,6 +566,7 @@ describe("workspace aggregation", () => { isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, @@ -699,6 +702,7 @@ describe("workspace aggregation", () => { isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, @@ -792,6 +796,7 @@ describe("workspace aggregation", () => { isGit: true, currentBranch: "feature/name-from-server", remoteUrl: "https://github.com/acme/repo-branch.git", + worktreeRoot: cwd, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, @@ -849,6 +854,41 @@ describe("workspace aggregation", () => { 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 () => { const emitted: Array<{ type: string; payload: unknown }> = []; const logger = { @@ -1114,6 +1154,7 @@ describe("workspace aggregation", () => { isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, @@ -1131,6 +1172,57 @@ describe("workspace aggregation", () => { 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>(); + const workspaces = new Map>(); + 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, + ) => { + projects.set(record.projectId, record); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + 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 () => { const emitted: Array<{ type: string; payload: unknown }> = []; const session = createSessionForWorkspaceTests() as any; @@ -1239,6 +1331,7 @@ describe("workspace aggregation", () => { isGit: true, currentBranch: cwd === mainWorkspaceId ? "main" : "feature-a", remoteUrl: "https://github.com/zimakki/inkwell.git", + worktreeRoot: cwd, isPaseoOwnedWorktree: cwd !== mainWorkspaceId, mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId, }, @@ -1345,6 +1438,7 @@ describe("workspace aggregation", () => { isGit: true, currentBranch: cwd === mainWorkspaceId ? "main" : "feature-a", remoteUrl: "https://github.com/new-owner/inkwell.git", + worktreeRoot: cwd, isPaseoOwnedWorktree: cwd !== mainWorkspaceId, mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId, }, @@ -1367,4 +1461,99 @@ describe("workspace aggregation", () => { 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>(); + const workspaces = new Map>(); + + 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, + ) => { + 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, + ) => { + 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 }); + } + }); }); diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index e19dd7d90..fe1b7c53b 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -167,15 +167,16 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu "/tmp/paseo-test", async () => ({}) as any, { allowedOrigins: new Set() }, - undefined, - undefined, - undefined, speechReadiness ? { - getSpeechReadiness: () => speechReadiness, + getReadiness: () => speechReadiness, + onReadinessChange: vi.fn(() => () => {}), } : undefined, undefined, + undefined, + undefined, + undefined, TEST_DAEMON_VERSION, undefined, undefined, diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts index 902741339..ae839246c 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -6,6 +6,7 @@ import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentStorage } from "./agent/agent-storage.js"; import { buildProjectPlacementForCwd, + deriveWorkspaceId, deriveProjectKind, deriveProjectRootPath, deriveWorkspaceDisplayName, @@ -67,22 +68,26 @@ export async function bootstrapWorkspaceRegistries(options: { const records = await options.agentStorage.list(); const activeRecords = records.filter((record) => !record.archivedAt); - const recordsByWorkspaceId = new Map(); + const recordsByWorkspaceId = new Map< + string, + { placement: Awaited>; records: StoredAgentRecord[] } + >(); for (const record of activeRecords) { - const workspaceId = normalizeWorkspaceId(record.cwd); - const existing = recordsByWorkspaceId.get(workspaceId) ?? []; - existing.push(record); + const normalizedCwd = normalizeWorkspaceId(record.cwd); + const placement = await buildProjectPlacementForCwd({ + 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); } const projectRanges = new Map(); - for (const [workspaceId, workspaceRecords] of recordsByWorkspaceId.entries()) { - const placement = await buildProjectPlacementForCwd({ - cwd: workspaceId, - paseoHome: options.paseoHome, - }); - + for (const [workspaceId, entry] of recordsByWorkspaceId.entries()) { + const { placement, records: workspaceRecords } = entry; let workspaceCreatedAt: string | null = null; let workspaceUpdatedAt: string | null = null; for (const record of workspaceRecords) { diff --git a/packages/server/src/server/workspace-registry-model.test.ts b/packages/server/src/server/workspace-registry-model.test.ts index 29610578b..2005ae2b5 100644 --- a/packages/server/src/server/workspace-registry-model.test.ts +++ b/packages/server/src/server/workspace-registry-model.test.ts @@ -1,6 +1,6 @@ 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"; function createWorkspaceRecord(workspaceId: string) { @@ -52,3 +52,33 @@ describe("detectStaleWorkspaces", () => { 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"); + }); +}); diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index 9d1590be0..43dde14fa 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -19,6 +19,10 @@ export function normalizeWorkspaceId(cwd: string): string { return resolve(trimmed); } +export function deriveWorkspaceId(cwd: string, checkout: ProjectCheckoutLitePayload): string { + return checkout.worktreeRoot ?? normalizeWorkspaceId(cwd); +} + function deriveRemoteProjectKey(remoteUrl: string | null): string | null { if (!remoteUrl) { return null; @@ -161,6 +165,7 @@ export async function buildProjectPlacementForCwd(input: { isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }; @@ -172,6 +177,7 @@ export async function buildProjectPlacementForCwd(input: { isGit: true, currentBranch: status.currentBranch, remoteUrl: status.remoteUrl, + worktreeRoot: status.worktreeRoot, isPaseoOwnedWorktree: true, mainRepoRoot: status.mainRepoRoot, }; @@ -182,6 +188,7 @@ export async function buildProjectPlacementForCwd(input: { isGit: true, currentBranch: status.currentBranch, remoteUrl: status.remoteUrl, + worktreeRoot: status.worktreeRoot, isPaseoOwnedWorktree: false, mainRepoRoot: null, }; @@ -192,13 +199,14 @@ export async function buildProjectPlacementForCwd(input: { isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }), ); const projectKey = deriveProjectGroupingKey({ - cwd: normalizedCwd, + cwd: checkout.worktreeRoot ?? normalizedCwd, remoteUrl: checkout.remoteUrl, isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree, mainRepoRoot: checkout.mainRepoRoot, diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 2076c53e9..17d07a54e 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -518,6 +518,7 @@ export async function registerPendingWorktreeWorkspace( isGit: true, currentBranch: options.branchName, remoteUrl: basePlacement.checkout.remoteUrl, + worktreeRoot: options.worktreePath, isPaseoOwnedWorktree: true, mainRepoRoot: options.repoRoot, }, diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 58416e1c3..4ccb0e657 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1598,6 +1598,7 @@ export const ProjectCheckoutLiteNotGitPayloadSchema = z.object({ isGit: z.literal(false), currentBranch: z.null(), remoteUrl: z.null(), + worktreeRoot: z.null(), isPaseoOwnedWorktree: z.literal(false), mainRepoRoot: z.null(), }); @@ -1607,6 +1608,7 @@ export const ProjectCheckoutLiteGitNonPaseoPayloadSchema = z.object({ isGit: z.literal(true), currentBranch: z.string().nullable(), remoteUrl: z.string().nullable(), + worktreeRoot: z.string(), isPaseoOwnedWorktree: z.literal(false), mainRepoRoot: z.null(), }); @@ -1616,6 +1618,7 @@ export const ProjectCheckoutLiteGitPaseoPayloadSchema = z.object({ isGit: z.literal(true), currentBranch: z.string().nullable(), remoteUrl: z.string().nullable(), + worktreeRoot: z.string(), isPaseoOwnedWorktree: z.literal(true), mainRepoRoot: z.string(), }); diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 77e416669..cdd7f8ff0 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -151,6 +151,7 @@ const x = 1; const status = await getCheckoutStatusLite(repoDir); expect(status.isGit).toBe(true); expect(status.currentBranch).toBe("main"); + expect(status.worktreeRoot).toBe(repoDir); expect(status.isPaseoOwnedWorktree).toBe(false); expect(status.mainRepoRoot).toBeNull(); }); @@ -373,6 +374,7 @@ const x = 1; const status = await getCheckoutStatusLite(result.worktreePath, { paseoHome }); expect(status.isGit).toBe(true); + expect(status.worktreeRoot).toBe(result.worktreePath); expect(status.isPaseoOwnedWorktree).toBe(true); expect(status.mainRepoRoot).toBe(repoDir); }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 7324e7607..37812924f 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -542,6 +542,7 @@ export type CheckoutStatusLiteNotGit = { isGit: false; currentBranch: null; remoteUrl: null; + worktreeRoot: null; isPaseoOwnedWorktree: false; mainRepoRoot: null; }; @@ -550,6 +551,7 @@ export type CheckoutStatusLiteGitNonPaseo = { isGit: true; currentBranch: string | null; remoteUrl: string | null; + worktreeRoot: string; isPaseoOwnedWorktree: false; mainRepoRoot: null; }; @@ -558,6 +560,7 @@ export type CheckoutStatusLiteGitPaseo = { isGit: true; currentBranch: string | null; remoteUrl: string | null; + worktreeRoot: string; isPaseoOwnedWorktree: true; mainRepoRoot: string; }; @@ -1159,6 +1162,7 @@ export async function getCheckoutStatusLite( isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }; @@ -1169,6 +1173,7 @@ export async function getCheckoutStatusLite( isGit: true, currentBranch: inspected.currentBranch, remoteUrl: inspected.remoteUrl, + worktreeRoot: inspected.worktreeRoot, isPaseoOwnedWorktree: true, mainRepoRoot: await getMainRepoRoot(cwd), }; @@ -1178,6 +1183,7 @@ export async function getCheckoutStatusLite( isGit: true, currentBranch: inspected.currentBranch, remoteUrl: inspected.remoteUrl, + worktreeRoot: inspected.worktreeRoot, isPaseoOwnedWorktree: false, mainRepoRoot: null, };