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,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},

View File

@@ -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",

View File

@@ -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,
},

View File

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

View File

@@ -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<SDKUserMessage, void, undefined> {
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}"

View File

@@ -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<T> implements AsyncIterable<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", () => {
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<SDKUserMessage>();
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",

View File

@@ -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 };
}

View File

@@ -256,7 +256,7 @@ async function runVerifyCheck(options: {
}): Promise<LoopVerifyCheckResult> {
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,
});

View File

@@ -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<WorkspaceDescriptorPayload[]> {
await this.reconcileActiveWorkspaceRecords();
return this.listWorkspaceDescriptorsSnapshot();
@@ -5435,8 +5487,7 @@ export class Session {
}
private async ensureWorkspaceRegistered(cwd: string): Promise<PersistedWorkspaceRecord> {
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<string>([workspaceId, ...changedWorkspaceIds]);
const workspaceIdsToEmit = new Set<string>([
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<string>(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;

View File

@@ -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,
},

View File

@@ -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<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 () => {
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<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",
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,

View File

@@ -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<string, StoredAgentRecord[]>();
const recordsByWorkspaceId = new Map<
string,
{ placement: Awaited<ReturnType<typeof buildProjectPlacementForCwd>>; 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<string, { createdAt: string | null; updatedAt: string | null }>();
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) {

View File

@@ -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");
});
});

View File

@@ -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,

View File

@@ -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,
},

View File

@@ -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(),
});

View File

@@ -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);
});

View File

@@ -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,
};