fix(projects): close project update races

This commit is contained in:
Mohamed Boudra
2026-07-16 12:55:01 +00:00
parent 15580b7d0c
commit 9540c75dca
19 changed files with 377 additions and 98 deletions

View File

@@ -219,15 +219,20 @@ test.describe("Project remove", () => {
const readded = await workspace.client.addProject(workspace.repoPath);
expect(readded.error).toBeNull();
expect(readded.project).not.toBeNull();
const readdedProjectId = readded.project?.projectId ?? "";
expect(readdedProjectId).not.toBe(workspace.projectId);
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
await page.reload();
await waitForSidebarHydration(page);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).toContainText(workspace.projectDisplayName);
await expect(projectRow).not.toContainText(workspace.repoPath);
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`);
await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 });
await expect(readdedProjectRow).toContainText(workspace.projectDisplayName);
await expect(readdedProjectRow).not.toContainText(workspace.repoPath);
await expect(
page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`),
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`),
).toBeVisible({ timeout: 30_000 });
} finally {
await workspace.cleanup();

View File

@@ -208,7 +208,7 @@ test.describe("Projects settings", () => {
page,
gitlabRemoteProject,
}) => {
expect(gitlabRemoteProject.name).toBe("acme/app");
expect(gitlabRemoteProject.name).toBe(path.basename(gitlabRemoteProject.path));
await openProjects(page);
await openProjectSettings(page, gitlabRemoteProject.name);
await editWorktreeSetup(page, updatedSetup);

View File

@@ -46,24 +46,25 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo
}
test.describe("Sidebar workspace list", () => {
test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => {
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
const workspace = await seedWorkspace({
repoPrefix: "sidebar-remote-",
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
});
try {
const projectName = path.basename(workspace.repoPath);
await gotoAppShell(page);
await waitForSidebarProject(page, "test-owner/test-repo");
await waitForSidebarProject(page, projectName);
await waitForSidebarWorkspace(page, workspace.workspaceId);
const projectRow = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: "test-owner/test-repo" })
.filter({ hasText: projectName })
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).not.toContainText(path.basename(workspace.repoPath));
await expect(projectRow).not.toContainText("test-owner/test-repo");
} finally {
await workspace.cleanup();
}
@@ -96,21 +97,24 @@ test.describe("Sidebar workspace list", () => {
}
});
test("workspace header shows correct title and subtitle", async ({ page }) => {
test("workspace header uses the selected folder name instead of its GitHub remote", async ({
page,
}) => {
const workspace = await seedWorkspace({
repoPrefix: "sidebar-header-",
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
});
try {
const projectName = path.basename(workspace.repoPath);
await gotoAppShell(page);
await waitForSidebarProject(page, "test-owner/test-repo");
await waitForSidebarProject(page, projectName);
await waitForSidebarWorkspace(page, workspace.workspaceId);
await openWorkspaceFromSidebar(page, workspace.workspaceId);
await expectWorkspaceHeader(page, {
title: workspace.workspaceName,
subtitle: "test-owner/test-repo",
subtitle: projectName,
});
} finally {
await workspace.cleanup();

View File

@@ -65,6 +65,7 @@ import type { AttachmentMetadata } from "@/attachments/types";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
import { createProjectHydrationBuffer } from "@/contexts/session-workspace-hydration";
import {
clearWorkspaceArchivePending,
shouldSuppressWorkspaceForLocalArchive,
@@ -126,6 +127,8 @@ interface WorkspaceHydrationSnapshot {
emptyProjects: Map<string, EmptyProjectDescriptor>;
}
type ProjectUpdatePayload = Extract<SessionOutboundMessage, { type: "project.update" }>["payload"];
async function fetchWorkspaceHydrationSnapshot(input: {
client: DaemonClient;
serverId: string;
@@ -586,6 +589,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const wasConnectedRef = useRef(isConnected);
const audioOutputBuffersRef = useRef<Map<string, BufferedAudioChunk[]>>(new Map());
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
const projectHydrationBufferRef = useRef(createProjectHydrationBuffer<ProjectUpdatePayload>());
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState) => {
@@ -604,38 +608,74 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
);
}, [sessionAgents]);
const applyProjectUpdate = useCallback(
(update: ProjectUpdatePayload) => {
useSessionStore
.getState()
.applyProjectUpdate(
serverId,
update.kind === "upsert"
? { kind: "upsert", project: normalizeEmptyProjectDescriptor(update.project) }
: update,
);
},
[serverId],
);
const hydrateWorkspaces = useCallback(
async (options?: { subscribe?: boolean; isCancelled?: () => boolean }) => {
if (!client || !isConnected) {
return;
}
const snapshot = await fetchWorkspaceHydrationSnapshot({
client,
serverId,
subscribe: options?.subscribe ?? false,
isCancelled: options?.isCancelled,
});
if (!snapshot || options?.isCancelled?.()) {
return;
}
const hydration = projectHydrationBufferRef.current.begin();
try {
const snapshot = await fetchWorkspaceHydrationSnapshot({
client,
serverId,
subscribe: options?.subscribe ?? false,
isCancelled: options?.isCancelled,
});
if (!snapshot || options?.isCancelled?.()) {
projectHydrationBufferRef.current.cancel(hydration);
return;
}
const didBackfillLegacy = await backfillLegacyDaemonWorkspaceDirectoryIfEmpty({
client,
serverId,
workspaces: snapshot.workspaces,
emptyProjects: snapshot.emptyProjects,
isCancelled: options?.isCancelled,
});
if (didBackfillLegacy) {
return;
}
const didBackfillLegacy = await backfillLegacyDaemonWorkspaceDirectoryIfEmpty({
client,
serverId,
workspaces: snapshot.workspaces,
emptyProjects: snapshot.emptyProjects,
isCancelled: options?.isCancelled,
});
if (didBackfillLegacy) {
projectHydrationBufferRef.current.commit(hydration, () => {}, applyProjectUpdate);
return;
}
setWorkspaces(serverId, snapshot.workspaces);
setEmptyProjects(serverId, snapshot.emptyProjects.values());
setHasHydratedWorkspaces(serverId, true);
projectHydrationBufferRef.current.commit(
hydration,
() => {
setWorkspaces(serverId, snapshot.workspaces);
setEmptyProjects(serverId, snapshot.emptyProjects.values());
setHasHydratedWorkspaces(serverId, true);
},
applyProjectUpdate,
);
} catch (error) {
projectHydrationBufferRef.current.cancel(hydration);
throw error;
}
},
[client, isConnected, serverId, setEmptyProjects, setHasHydratedWorkspaces, setWorkspaces],
[
applyProjectUpdate,
client,
isConnected,
serverId,
setEmptyProjects,
setHasHydratedWorkspaces,
setWorkspaces,
],
);
const applyAuthoritativeAgentSnapshot = useCallback(
@@ -1393,14 +1433,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const unsubProjectUpdate = client.on("project.update", (message) => {
const update = message.payload;
if (update.kind === "remove") {
useSessionStore.getState().applyProjectUpdate(serverId, update);
return;
}
useSessionStore.getState().applyProjectUpdate(serverId, {
kind: "upsert",
project: normalizeEmptyProjectDescriptor(update.project),
});
if (!projectHydrationBufferRef.current.buffer(update)) applyProjectUpdate(update);
});
const unsubScriptStatusUpdate = client.on("script_status_update", (message) => {
@@ -1832,6 +1865,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
notifyAgentAttention,
requestCanonicalCatchUp,
applyAgentUpdatePayload,
applyProjectUpdate,
applyWorkspaceSetupProgress,
applyTimelineResponse,
updateSessionServerInfo,

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { createProjectHydrationBuffer } from "./session-workspace-hydration";
type ProjectUpdate =
| { kind: "upsert"; projectId: string; name: string }
| { kind: "remove"; projectId: string };
function applyProjectUpdate(projects: Map<string, string>, update: ProjectUpdate): void {
if (update.kind === "remove") projects.delete(update.projectId);
else projects.set(update.projectId, update.name);
}
describe("workspace hydration project updates", () => {
it("replays project updates after replacing the stale hydration snapshot", () => {
const projects = new Map<string, string>([["project-1", "before hydration"]]);
const hydration = createProjectHydrationBuffer<ProjectUpdate>();
const lease = hydration.begin();
hydration.buffer({ kind: "upsert", projectId: "project-2", name: "arrived live" });
hydration.buffer({ kind: "remove", projectId: "project-1" });
hydration.commit(
lease,
() => {
projects.clear();
projects.set("project-1", "stale snapshot");
},
(update) => applyProjectUpdate(projects, update),
);
expect(projects).toEqual(new Map([["project-2", "arrived live"]]));
});
it("does not let an older hydration overwrite the current one", () => {
const projects = new Map<string, string>();
const hydration = createProjectHydrationBuffer<ProjectUpdate>();
const older = hydration.begin();
const current = hydration.begin();
hydration.buffer({ kind: "upsert", projectId: "project-2", name: "current" });
expect(
hydration.commit(
older,
() => projects.set("project-1", "stale"),
() => {},
),
).toBe(false);
expect(
hydration.commit(
current,
() => projects.set("project-1", "current snapshot"),
(update) => applyProjectUpdate(projects, update),
),
).toBe(true);
expect(projects).toEqual(
new Map([
["project-1", "current snapshot"],
["project-2", "current"],
]),
);
});
});

View File

@@ -0,0 +1,31 @@
/** Orders project updates around an in-flight workspace snapshot. */
export function createProjectHydrationBuffer<Update>() {
let active: { updates: Update[] } | null = null;
return {
begin() {
const hydration = { updates: [] as Update[] };
active = hydration;
return hydration;
},
buffer(update: Update): boolean {
if (!active) return false;
active.updates.push(update);
return true;
},
commit(
hydration: { updates: Update[] },
applySnapshot: () => void,
applyUpdate: (update: Update) => void,
): boolean {
if (active !== hydration) return false;
active = null;
applySnapshot();
for (const update of hydration.updates) applyUpdate(update);
return true;
},
cancel(hydration: { updates: Update[] }): void {
if (active === hydration) active = null;
},
};
}

View File

@@ -22,6 +22,7 @@ import {
AgentSnapshotPayloadSchema,
} from "@getpaseo/protocol/messages";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
@@ -46,6 +47,7 @@ import { WorkspaceAutoName } from "../workspace-auto-name.js";
import { createGitMutationService } from "../session/git-mutation/git-mutation-service.js";
import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.js";
import type { GitHubService } from "../../services/github-service.js";
import { areEquivalentPaths } from "../../utils/path.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
@@ -699,6 +701,24 @@ function createPaseoWorktreeForMcpTest(options: {
: {}),
projectRegistry: {
get: async (projectId) => projects.get(projectId) ?? null,
getOrCreateActiveByRoot: async (allocation) => {
const existing = Array.from(projects.values()).find(
(project) =>
areEquivalentPaths(project.rootPath, allocation.rootPath) &&
!project.archivedAt,
);
if (existing) return existing;
const project = createPersistedProjectRecord({
projectId: `prj_test_${projects.size + 1}`,
rootPath: allocation.rootPath,
kind: allocation.kind,
displayName: allocation.displayName,
createdAt: allocation.timestamp,
updatedAt: allocation.timestamp,
});
projects.set(project.projectId, project);
return project;
},
upsert: async (record) => {
projects.set(record.projectId, record);
},

View File

@@ -1,4 +1,5 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import pino from "pino";
@@ -28,10 +29,21 @@ describe("bootstrap provider availability", () => {
tempRoots.push(root);
const binDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-bin-"));
tempRoots.push(binDir);
const gitPath = execFileSync(process.platform === "win32" ? "where" : "which", ["git"], {
encoding: "utf8",
})
.split(/\r?\n/)[0]
.trim();
if (process.platform === "win32") {
await writeFile(path.join(binDir, "git.cmd"), `@"${gitPath}" %*\r\n`);
} else {
await symlink(gitPath, path.join(binDir, "git"));
}
process.env.PATH = binDir;
if (process.platform === "win32") {
process.env.PATHEXT = ".CMD";
}
expect(execFileSync("git", ["--version"], { encoding: "utf8" })).toMatch(/git version/i);
const paseoHome = path.join(root, ".paseo");
const staticDir = path.join(root, "static");
const agentStoragePath = path.join(paseoHome, "agents");

View File

@@ -797,13 +797,7 @@ export async function createPaseoDaemon(
projectRegistry,
reconciliation: workspaceReconciliation,
logger,
onProjectUpdate: (update) => {
if (update.kind === "upsert") {
wsServer?.publishProjectUpdate(update.project);
} else {
wsServer?.publishProjectRemove(update.projectId);
}
},
onProjectUpdate: (update) => wsServer?.publishProjectUpdate(update),
onWorkspacesChanged: async (workspaceIds) => {
await Promise.all(
(wsServer?.listActiveSessions() ?? []).map((session) =>

View File

@@ -20,6 +20,7 @@ import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
import { createWorktree } from "../utils/worktree.js";
import { isPlatform } from "../test-utils/platform.js";
import { existsSync } from "node:fs";
import { areEquivalentPaths } from "../utils/path.js";
const cleanupPaths: string[] = [];
@@ -97,12 +98,13 @@ test("repairs a legacy source workspace whose project record is missing", async
expect(result.workspace.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
expect(result.workspace.projectId).not.toBe(sourceWorkspace.projectId);
expect(deps.projects.get(result.workspace.projectId)).toMatchObject({
const repairedProject = deps.projects.get(result.workspace.projectId);
expect(repairedProject).toMatchObject({
projectId: result.workspace.projectId,
rootPath: repoDir,
kind: "git",
archivedAt: null,
});
expect(areEquivalentPaths(repairedProject?.rootPath ?? "", repoDir)).toBe(true);
});
test("registers a new worktree in the existing root project after the main checkout workspace is removed", async () => {

View File

@@ -1,10 +1,7 @@
import type pino from "pino";
import { describe, expect, test } from "vitest";
import {
ProjectGitObserverService,
type ProjectGitObserverUpdate,
} from "./project-git-observer-service.js";
import { ProjectGitObserverService, type ProjectUpdate } from "./project-git-observer-service.js";
import {
createPersistedProjectRecord,
type PersistedProjectRecord,
@@ -344,7 +341,7 @@ class ObservedProjects {
private readonly roots = new FakeProjectRoots(this.lifecycleEvents);
private readonly registry: FakeProjectRegistry;
private readonly gitMetadata = new FakeGitMetadata();
private readonly projectEvents: ProjectGitObserverUpdate[] = [];
private readonly projectEvents: ProjectUpdate[] = [];
private readonly workspaceEvents: string[][] = [];
private readonly logRecords: LogRecord[] = [];
private readonly service: ProjectGitObserverService;
@@ -459,7 +456,7 @@ class ObservedProjects {
return [...this.lifecycleEvents];
}
get publishedProjects(): ProjectGitObserverUpdate[] {
get publishedProjects(): ProjectUpdate[] {
return [...this.projectEvents];
}

View File

@@ -9,7 +9,7 @@ import type { WorkspaceReconciliationService } from "./workspace-reconciliation-
const DEFAULT_RESCAN_INTERVAL_MS = 5 * 60_000;
const DEFAULT_DEBOUNCE_MS = 100;
export type ProjectGitObserverUpdate =
export type ProjectUpdate =
| { kind: "upsert"; project: PersistedProjectRecord }
| { kind: "remove"; projectId: string };
@@ -69,7 +69,7 @@ export class ProjectGitObserverService {
projectRegistry: ProjectRegistry;
reconciliation: Pick<WorkspaceReconciliationService, "reconcileGitMetadata">;
logger: pino.Logger;
onProjectUpdate: (update: ProjectGitObserverUpdate) => void;
onProjectUpdate: (update: ProjectUpdate) => void;
onWorkspacesChanged: (workspaceIds: string[]) => Promise<void>;
watch?: ProjectRootWatch;
clock?: ObserverClock;

View File

@@ -18,6 +18,7 @@ import { DownloadTokenStore } from "./file-download/token-store.js";
import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import { createPersistedProjectRecord } from "./workspace-registry.js";
import type { SessionOptions } from "./session.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js";
import {
@@ -352,14 +353,16 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
list: vi.fn().mockResolvedValue([]),
...options.agentStorage,
}),
projectRegistry: options.projectRegistry ?? {
projectRegistry: {
list: vi.fn().mockResolvedValue([]),
get: vi.fn(),
getOrCreateActiveByRoot: vi.fn(),
upsert: vi.fn(),
archive: vi.fn(),
remove: vi.fn(),
initialize: vi.fn(),
existsOnDisk: vi.fn(),
...options.projectRegistry,
},
workspaceRegistry: options.workspaceRegistry ?? {
get: vi.fn(),
@@ -508,12 +511,20 @@ describe("project command-center RPCs", () => {
const parentDirectory = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-session-")));
const directoryPath = join(parentDirectory, "new-project");
const messages: SessionOutboundMessage[] = [];
const projectUpsert = vi.fn().mockResolvedValue(undefined);
const projectAllocation = vi.fn(async (input) =>
createPersistedProjectRecord({
projectId: "prj_created_directory",
rootPath: input.rootPath,
kind: input.kind,
displayName: input.displayName,
createdAt: input.timestamp,
updatedAt: input.timestamp,
}),
);
const session = createSessionForTest({
messages,
projectRegistry: {
list: vi.fn().mockResolvedValue([]),
upsert: projectUpsert,
getOrCreateActiveByRoot: projectAllocation,
},
workspaceGitService: {
getCheckout: vi.fn(async (cwd: string) => ({
@@ -537,7 +548,12 @@ describe("project command-center RPCs", () => {
});
expect(existsSync(directoryPath)).toBe(true);
expect(projectUpsert).toHaveBeenCalledOnce();
expect(projectAllocation).toHaveBeenCalledWith({
rootPath: directoryPath,
kind: "non_git",
displayName: "new-project",
timestamp: expect.any(String),
});
expect(messages).toEqual([
{
type: "project.create_directory.response",
@@ -545,7 +561,7 @@ describe("project command-center RPCs", () => {
requestId: "req-create-directory",
directoryPath,
project: {
projectId: directoryPath,
projectId: "prj_created_directory",
projectDisplayName: "new-project",
projectCustomName: null,
projectRootPath: directoryPath,
@@ -568,8 +584,7 @@ describe("project command-center RPCs", () => {
const session = createSessionForTest({
messages,
projectRegistry: {
list: vi.fn().mockResolvedValue([]),
upsert: vi.fn().mockRejectedValue(new Error("registry unavailable")),
getOrCreateActiveByRoot: vi.fn().mockRejectedValue(new Error("registry unavailable")),
},
workspaceGitService: {
getCheckout: vi.fn(async (cwd: string) => ({

View File

@@ -61,6 +61,7 @@ import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-uti
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
import type { ProjectUpdate } from "./project-git-observer-service.js";
import {
CLIENT_SHUTDOWN_RPC_REASON,
normalizeClientRestartRpcReason,
@@ -970,19 +971,17 @@ export class Session {
return this.clientCapabilities.has(capability);
}
emitProjectUpdate(project: PersistedProjectRecord): void {
emitProjectUpdate(update: ProjectUpdate): void {
if (!this.supports(CLIENT_CAPS.projectUpdates)) return;
this.emit({
type: "project.update",
payload: { kind: "upsert", project: this.buildProjectDescriptor(project) },
payload:
update.kind === "upsert"
? { kind: "upsert", project: this.buildProjectDescriptor(update.project) }
: update,
});
}
emitProjectRemove(projectId: string): void {
if (!this.supports(CLIENT_CAPS.projectUpdates)) return;
this.emit({ type: "project.update", payload: { kind: "remove", projectId } });
}
async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
await this.workspaceGitObserver.syncObserverForWorkspace(workspace);
}

View File

@@ -9,11 +9,8 @@ import type { AgentStorage } from "./agent/agent-storage.js";
import type { DownloadTokenStore } from "./file-download/token-store.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type pino from "pino";
import type {
PersistedProjectRecord,
ProjectRegistry,
WorkspaceRegistry,
} from "./workspace-registry.js";
import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js";
import type { ProjectUpdate } from "./project-git-observer-service.js";
import type { FileBackedChatService } from "./chat/chat-service.js";
import type { LoopService } from "./loop-service.js";
import type { ScheduleService } from "./schedule/service.js";
@@ -769,12 +766,8 @@ export class VoiceAssistantWebSocketServer {
);
}
public publishProjectUpdate(project: PersistedProjectRecord): void {
for (const session of this.listActiveSessions()) session.emitProjectUpdate(project);
}
public publishProjectRemove(projectId: string): void {
for (const session of this.listActiveSessions()) session.emitProjectRemove(projectId);
public publishProjectUpdate(update: ProjectUpdate): void {
for (const session of this.listActiveSessions()) session.emitProjectUpdate(update);
}
public publishSpeechReadiness(readiness: SpeechReadinessSnapshot | null): void {

View File

@@ -348,10 +348,10 @@ describe("wire compatibility", () => {
messages: capableMessages,
});
legacy.emitProjectUpdate(project);
legacy.emitProjectRemove(project.projectId);
capable.emitProjectUpdate(project);
capable.emitProjectRemove(project.projectId);
legacy.emitProjectUpdate({ kind: "upsert", project });
legacy.emitProjectUpdate({ kind: "remove", projectId: project.projectId });
capable.emitProjectUpdate({ kind: "upsert", project });
capable.emitProjectUpdate({ kind: "remove", projectId: project.projectId });
expect(legacyMessages).toEqual([]);
expect(capableMessages.map((message) => SessionOutboundMessageSchema.parse(message))).toEqual([

View File

@@ -1,6 +1,6 @@
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
@@ -11,10 +11,10 @@ import type { WorkspaceGitService } from "./workspace-git-service.js";
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
const NON_GIT_PROJECT = path.resolve("/tmp/non-git-project");
const ARCHIVED_PROJECT = path.resolve("/tmp/archived-project");
const GIT_PROJECT = path.resolve("/tmp/legacy-git-project");
const GIT_WORKTREE = path.resolve("/tmp/legacy-git-project-feature");
let NON_GIT_PROJECT: string;
let ARCHIVED_PROJECT: string;
let GIT_PROJECT: string;
let GIT_WORKTREE: string;
describe("bootstrapWorkspaceRegistries", () => {
let tmpDir: string;
@@ -27,6 +27,10 @@ describe("bootstrapWorkspaceRegistries", () => {
beforeEach(() => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "workspace-bootstrap-"));
NON_GIT_PROJECT = path.join(tmpDir, "non-git-project");
ARCHIVED_PROJECT = path.join(tmpDir, "archived-project");
GIT_PROJECT = path.join(tmpDir, "legacy-git-project");
GIT_WORKTREE = path.join(tmpDir, "legacy-git-project-feature");
paseoHome = path.join(tmpDir, ".paseo");
agentStorage = new AgentStorage(path.join(paseoHome, "agents"), logger);
projectRegistry = new FileBackedProjectRegistry(
@@ -38,12 +42,92 @@ describe("bootstrapWorkspaceRegistries", () => {
logger,
);
workspaceGitService = createNoopWorkspaceGitService();
for (const directory of [NON_GIT_PROJECT, ARCHIVED_PROJECT, GIT_PROJECT, GIT_WORKTREE]) {
mkdirSync(directory, { recursive: true });
}
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
test("skips a legacy agent whose directory no longer exists", async () => {
const missingDirectory = path.join(tmpDir, "missing-project");
const getCheckout = async () => {
throw new Error("Git must not inspect a missing directory");
};
workspaceGitService = { ...createNoopWorkspaceGitService(), getCheckout };
await agentStorage.initialize();
await agentStorage.upsert({
id: "agent-missing-directory",
provider: "codex",
cwd: missingDirectory,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
lastActivityAt: null,
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "idle",
lastModeId: null,
config: null,
runtimeInfo: { provider: "codex", sessionId: null },
persistence: null,
archivedAt: null,
});
await bootstrapWorkspaceRegistries({
paseoHome,
agentStorage,
projectRegistry,
workspaceRegistry,
workspaceGitService,
logger,
});
expect(await projectRegistry.list()).toEqual([]);
expect(await workspaceRegistry.list()).toEqual([]);
});
test("propagates a Git failure for an existing legacy directory", async () => {
const gitFailure = new Error("Git is unavailable");
workspaceGitService = {
...createNoopWorkspaceGitService(),
getCheckout: async () => {
throw gitFailure;
},
};
await agentStorage.initialize();
await agentStorage.upsert({
id: "agent-existing-directory",
provider: "codex",
cwd: NON_GIT_PROJECT,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
lastActivityAt: null,
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "idle",
lastModeId: null,
config: null,
runtimeInfo: { provider: "codex", sessionId: null },
persistence: null,
archivedAt: null,
});
await expect(
bootstrapWorkspaceRegistries({
paseoHome,
agentStorage,
projectRegistry,
workspaceRegistry,
workspaceGitService,
logger,
}),
).rejects.toBe(gitFailure);
});
test("materializes workspace registries from non-archived agent records", async () => {
await agentStorage.initialize();
await agentStorage.upsert({

View File

@@ -1,4 +1,5 @@
import path from "node:path";
import { existsSync } from "node:fs";
import type { Logger } from "pino";
@@ -70,7 +71,10 @@ export async function bootstrapWorkspaceRegistries(options: {
]),
);
const records = await options.agentStorage.list();
const activeRecords = records.filter((record) => !record.archivedAt);
// A legacy agent can outlive its working directory. Reconciliation treats a
// missing directory as absent rather than asking Git about it; bootstrap must
// do the same before materializing its first workspace record.
const activeRecords = records.filter((record) => !record.archivedAt && existsSync(record.cwd));
const recordsByDirectoryKey = new Map<
string,
{

View File

@@ -32,8 +32,13 @@ import {
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
import {
createPersistedProjectRecord,
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
} from "./workspace-registry.js";
import type { GitHubService } from "../services/github-service.js";
import { areEquivalentPaths } from "../utils/path.js";
import {
createPaseoWorktree as createPaseoWorktreeService,
type CreatePaseoWorktreeFn,
@@ -280,6 +285,23 @@ function createPaseoWorktreeForTest(options: {
: {}),
projectRegistry: {
get: async (projectId) => projects.get(projectId) ?? null,
getOrCreateActiveByRoot: async (allocation) => {
const existing = Array.from(projects.values()).find(
(project) =>
areEquivalentPaths(project.rootPath, allocation.rootPath) && !project.archivedAt,
);
if (existing) return existing;
const project = createPersistedProjectRecord({
projectId: `prj_test_${projects.size + 1}`,
rootPath: allocation.rootPath,
kind: allocation.kind,
displayName: allocation.displayName,
createdAt: allocation.timestamp,
updatedAt: allocation.timestamp,
});
projects.set(project.projectId, project);
return project;
},
upsert: async (record) => {
options.events?.push(`project:${record.projectId}`);
projects.set(record.projectId, record);