Fix optimistic workspace creation on Android

This commit is contained in:
Mohamed Boudra
2026-04-22 11:03:35 +07:00
parent 7b9cc3d423
commit 42cdabdf57
30 changed files with 1533 additions and 165 deletions
@@ -238,11 +238,8 @@ function createPaseoWorktreeForMcpTest(options: {
},
},
workspaceGitService,
primeWorkspaceGitWatchFingerprints: async () => {},
broadcastWorkspaceUpdate: async (workspaceId) => {
options.broadcasts.push(workspaceId);
},
});
options.broadcasts.push(result.workspace.workspaceId);
options.createdWorkspaceIds?.push(result.workspace.workspaceId);
return result;
};
+8 -18
View File
@@ -505,9 +505,9 @@ export async function createPaseoDaemon(
providerRegistry,
github,
workspaceGitService,
createPaseoWorktree: (input, serviceOptions) => {
createPaseoWorktree: async (input, serviceOptions) => {
const coreDeps = createWorktreeCoreDeps(github);
return createPaseoWorktree(input, {
const result = await createPaseoWorktree(input, {
...coreDeps,
...(serviceOptions?.resolveDefaultBranch
? {
@@ -517,23 +517,13 @@ export async function createPaseoDaemon(
projectRegistry,
workspaceRegistry,
workspaceGitService,
primeWorkspaceGitWatchFingerprints: async (workspace) => {
await Promise.all(
wsServer
?.listActiveSessions()
.map((session) =>
session.primeWorkspaceGitWatchFingerprintForWorkspace(workspace),
) ?? [],
);
},
broadcastWorkspaceUpdate: async (workspaceId) => {
await Promise.all(
wsServer
?.listActiveSessions()
.map((session) => session.emitWorkspaceUpdateForWorkspaceId(workspaceId)) ?? [],
);
},
});
await Promise.all(
wsServer
?.listActiveSessions()
.map((session) => session.warmWorkspaceGitDataForWorkspace(result.workspace)) ?? [],
);
return result;
},
paseoHome: config.paseoHome,
callerAgentId,
@@ -20,11 +20,26 @@ describe("createPaseoWorktree", () => {
}
});
test("creates a worktree, registers the workspace, primes fingerprints, and broadcasts in order", async () => {
test("creates a worktree and registers it in the source workspace project without git snapshot lookup", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const events: string[] = [];
const deps = createDeps({ events });
const sourceProject = createPersistedProjectRecordForTest({
projectId: "remote:github.com/acme/repo",
rootPath: repoDir,
displayName: "acme/repo",
});
const sourceWorkspace = createPersistedWorkspaceRecordForTest({
workspaceId: repoDir,
projectId: sourceProject.projectId,
cwd: repoDir,
kind: "local_checkout",
displayName: "main",
});
deps.projects.set(sourceProject.projectId, sourceProject);
deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace);
deps.workspaceGitService.getSnapshot = vi.fn(deps.workspaceGitService.getSnapshot);
const result = await createPaseoWorktree(
{
@@ -39,15 +54,16 @@ describe("createPaseoWorktree", () => {
expect(result.created).toBe(true);
expect(result.workspace.cwd).toBe(result.worktree.worktreePath);
expect(result.workspace.kind).toBe("worktree");
expect(result.workspace.projectId).toBe("remote:github.com/acme/repo");
expect(result.workspace.displayName).toBe("feature-one");
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(events).toEqual([
`project:${result.workspace.projectId}`,
"project:remote:github.com/acme/repo",
`workspace:${result.workspace.workspaceId}`,
`prime:${result.workspace.workspaceId}`,
`broadcast:${result.workspace.workspaceId}`,
]);
});
test("reuses an existing worktree and still upserts and broadcasts", async () => {
test("reuses an existing worktree and still upserts the workspace", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
@@ -81,7 +97,6 @@ describe("createPaseoWorktree", () => {
expect(second.created).toBe(false);
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath);
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
expect(events).toContain(`broadcast:${second.workspace.workspaceId}`);
});
test("does not mutate registries or broadcast when core worktree creation fails", async () => {
@@ -103,8 +118,6 @@ describe("createPaseoWorktree", () => {
expect(deps.projects.size).toBe(0);
expect(deps.workspaces.size).toBe(0);
expect(deps.broadcastWorkspaceUpdate).not.toHaveBeenCalled();
expect(deps.primeWorkspaceGitWatchFingerprints).not.toHaveBeenCalled();
});
test("keeps direct core worktree creation calls behind the service boundary", () => {
@@ -157,12 +170,41 @@ function createDeps(options?: {
},
},
workspaceGitService: createWorkspaceGitServiceStub(),
primeWorkspaceGitWatchFingerprints: vi.fn(async (workspace: PersistedWorkspaceRecord) => {
events.push(`prime:${workspace.workspaceId}`);
}),
broadcastWorkspaceUpdate: vi.fn(async (workspaceId: string) => {
events.push(`broadcast:${workspaceId}`);
}),
};
}
function createPersistedProjectRecordForTest(input: {
projectId: string;
rootPath: string;
displayName: string;
}): PersistedProjectRecord {
return {
projectId: input.projectId,
rootPath: input.rootPath,
kind: "git",
displayName: input.displayName,
createdAt: "2026-04-22T00:00:00.000Z",
updatedAt: "2026-04-22T00:00:00.000Z",
archivedAt: null,
};
}
function createPersistedWorkspaceRecordForTest(input: {
workspaceId: string;
projectId: string;
cwd: string;
kind: PersistedWorkspaceRecord["kind"];
displayName: string;
}): PersistedWorkspaceRecord {
return {
workspaceId: input.workspaceId,
projectId: input.projectId,
cwd: input.cwd,
kind: input.kind,
displayName: input.displayName,
createdAt: "2026-04-22T00:00:00.000Z",
updatedAt: "2026-04-22T00:00:00.000Z",
archivedAt: null,
};
}
@@ -6,15 +6,7 @@ import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
import {
buildProjectPlacementForCwd,
deriveProjectKind,
deriveProjectRootPath,
deriveWorkspaceDisplayName,
deriveWorkspaceId,
deriveWorkspaceKind,
normalizeWorkspaceId,
} from "./workspace-registry-model.js";
import { deriveProjectGroupingName, normalizeWorkspaceId } from "./workspace-registry-model.js";
import {
createWorktreeCore,
type CreateWorktreeCoreDeps,
@@ -44,8 +36,6 @@ export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps {
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
workspaceGitService: WorkspaceGitService;
broadcastWorkspaceUpdate: (workspaceId: string) => Promise<void> | void;
primeWorkspaceGitWatchFingerprints: (workspace: PersistedWorkspaceRecord) => Promise<void>;
}
export async function createPaseoWorktree(
@@ -54,13 +44,13 @@ export async function createPaseoWorktree(
): Promise<CreatePaseoWorktreeResult> {
const createdWorktree = await createWorktreeCore(input, deps);
const workspace = await upsertWorkspaceForWorktree({
inputCwd: input.cwd,
repoRoot: createdWorktree.repoRoot,
worktree: createdWorktree.worktree,
deps,
});
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
await deps.primeWorkspaceGitWatchFingerprints(workspace);
await deps.broadcastWorkspaceUpdate(workspace.workspaceId);
return {
worktree: createdWorktree.worktree,
@@ -72,35 +62,34 @@ export async function createPaseoWorktree(
}
async function upsertWorkspaceForWorktree(options: {
inputCwd: string;
repoRoot: string;
worktree: WorktreeConfig;
deps: Pick<
CreatePaseoWorktreeDeps,
"projectRegistry" | "workspaceRegistry" | "workspaceGitService"
>;
deps: Pick<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
}): Promise<PersistedWorkspaceRecord> {
const normalizedCwd = normalizeWorkspaceId(options.worktree.worktreePath);
const placement = await buildProjectPlacementForCwd({
cwd: normalizedCwd,
workspaceGitService: options.deps.workspaceGitService,
});
const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
const now = new Date().toISOString();
const existingProject = await options.deps.projectRegistry.get(placement.projectKey);
const normalizedInputCwd = normalizeWorkspaceId(options.inputCwd);
const normalizedRepoRoot = normalizeWorkspaceId(options.repoRoot);
const existingWorkspace = await findWorkspaceByDirectory(
normalizedCwd,
options.deps.workspaceRegistry,
);
const sourceProject = await resolveSourceProjectForWorktree({
inputCwd: normalizedInputCwd,
repoRoot: normalizedRepoRoot,
existingWorkspace,
deps: options.deps,
});
const workspaceId = normalizedCwd;
const now = new Date().toISOString();
await options.deps.projectRegistry.upsert(
createPersistedProjectRecord({
projectId: placement.projectKey,
rootPath: deriveProjectRootPath({
cwd: normalizedCwd,
checkout: placement.checkout,
}),
kind: deriveProjectKind(placement.checkout),
displayName: placement.projectName,
createdAt: existingProject?.createdAt ?? now,
projectId: sourceProject.projectId,
rootPath: sourceProject.rootPath,
kind: sourceProject.kind,
displayName: sourceProject.displayName,
createdAt: sourceProject.createdAt ?? now,
updatedAt: now,
archivedAt: null,
}),
@@ -108,13 +97,10 @@ async function upsertWorkspaceForWorktree(options: {
const workspace = createPersistedWorkspaceRecord({
workspaceId,
projectId: placement.projectKey,
projectId: sourceProject.projectId,
cwd: normalizedCwd,
kind: deriveWorkspaceKind(placement.checkout),
displayName: deriveWorkspaceDisplayName({
cwd: normalizedCwd,
checkout: placement.checkout,
}),
kind: "worktree",
displayName: options.worktree.branchName || normalizedCwd,
createdAt: existingWorkspace?.createdAt ?? now,
updatedAt: now,
archivedAt: null,
@@ -124,6 +110,64 @@ async function upsertWorkspaceForWorktree(options: {
return (await options.deps.workspaceRegistry.get(workspace.workspaceId)) ?? workspace;
}
async function resolveSourceProjectForWorktree(options: {
inputCwd: string;
repoRoot: string;
existingWorkspace: PersistedWorkspaceRecord | null;
deps: Pick<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
}): Promise<{
projectId: string;
rootPath: string;
kind: "git";
displayName: string;
createdAt: string | null;
}> {
const sourceWorkspace =
options.existingWorkspace ??
(await findWorkspaceForSource({
inputCwd: options.inputCwd,
repoRoot: options.repoRoot,
workspaceRegistry: options.deps.workspaceRegistry,
}));
const sourceProject = sourceWorkspace
? await options.deps.projectRegistry.get(sourceWorkspace.projectId)
: null;
if (sourceWorkspace) {
return {
projectId: sourceWorkspace.projectId,
rootPath: sourceProject?.rootPath ?? options.repoRoot,
kind: "git",
displayName:
sourceProject?.displayName ?? deriveProjectGroupingName(sourceWorkspace.projectId),
createdAt: sourceProject?.createdAt ?? null,
};
}
const existingFallbackProject = await options.deps.projectRegistry.get(options.repoRoot);
return {
projectId: options.repoRoot,
rootPath: existingFallbackProject?.rootPath ?? options.repoRoot,
kind: "git",
displayName:
existingFallbackProject?.displayName ?? deriveProjectGroupingName(options.repoRoot),
createdAt: existingFallbackProject?.createdAt ?? null,
};
}
async function findWorkspaceForSource(options: {
inputCwd: string;
repoRoot: string;
workspaceRegistry: Pick<WorkspaceRegistry, "list">;
}): Promise<PersistedWorkspaceRecord | null> {
const workspaces = await options.workspaceRegistry.list();
return (
workspaces.find((workspace) => workspace.cwd === options.inputCwd && !workspace.archivedAt) ??
workspaces.find((workspace) => workspace.cwd === options.repoRoot && !workspace.archivedAt) ??
null
);
}
async function findWorkspaceByDirectory(
cwd: string,
workspaceRegistry: Pick<WorkspaceRegistry, "list">,
+44 -7
View File
@@ -900,6 +900,11 @@ export class Session {
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true });
}
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
await this.primeWorkspaceGitWatchFingerprintForWorkspace(workspace);
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
}
/**
* Get the client's current activity state
*/
@@ -5764,6 +5769,36 @@ export class Session {
};
}
private async describeCreatedWorktreeWorkspace(
result: CreatePaseoWorktreeResult,
): Promise<WorkspaceDescriptorPayload> {
const projectRecord = await this.projectRegistry.get(result.workspace.projectId);
return {
id: result.workspace.workspaceId,
projectId: result.workspace.projectId,
projectDisplayName: projectRecord?.displayName ?? String(result.workspace.projectId),
projectRootPath: projectRecord?.rootPath ?? result.repoRoot,
workspaceDirectory: result.workspace.cwd,
projectKind: "git",
workspaceKind: result.workspace.kind,
name: result.worktree.branchName || result.workspace.displayName,
status: "done",
activityAt: null,
diffStat: { additions: 0, deletions: 0 },
scripts: [],
gitRuntime: {
currentBranch: result.worktree.branchName || null,
remoteUrl: null,
isPaseoOwnedWorktree: true,
isDirty: false,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
},
githubRuntime: null,
};
}
private async buildWorkspaceDescriptor(input: {
workspace: PersistedWorkspaceRecord;
projectRecord?: PersistedProjectRecord | null;
@@ -6252,15 +6287,16 @@ export class Session {
projectRegistry: this.projectRegistry,
workspaceRegistry: this.workspaceRegistry,
workspaceGitService: this.workspaceGitService,
primeWorkspaceGitWatchFingerprints: (workspace) =>
this.primeWorkspaceGitWatchFingerprintForWorkspace(workspace),
broadcastWorkspaceUpdate: (workspaceId) =>
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
});
await Promise.all([
void Promise.all([
this.notifyGitMutation(input.cwd, "create-worktree"),
this.notifyGitMutation(result.worktree.worktreePath, "create-worktree"),
]);
]).catch((error) => {
this.sessionLogger.warn(
{ err: error, cwd: input.cwd, worktreePath: result.worktree.worktreePath },
"Failed to warm git snapshots after creating worktree",
);
});
return result;
}
@@ -6758,9 +6794,10 @@ export class Session {
return handleCreateWorktreeRequest(
{
paseoHome: this.paseoHome,
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecordWithGitData(workspace),
describeWorkspaceRecord: (result) => this.describeCreatedWorktreeWorkspace(result),
emit: (message) => this.emit(message),
createPaseoWorktree: (input) => this.createPaseoWorktree(input),
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
sessionLogger: this.sessionLogger,
runWorktreeSetupInBackground: (options) => this.runWorktreeSetupInBackground(options),
},
@@ -236,12 +236,6 @@ function createPaseoWorktreeForTest(options: {
},
},
workspaceGitService,
primeWorkspaceGitWatchFingerprints: async (workspace) => {
options.events?.push(`prime:${workspace.workspaceId}`);
},
broadcastWorkspaceUpdate: async (workspaceId) => {
options.events?.push(`broadcast:${workspaceId}`);
},
});
};
}
@@ -1041,10 +1035,11 @@ describe("handleCreatePaseoWorktreeRequest", () => {
await handleCreatePaseoWorktreeRequest(
{
paseoHome,
describeWorkspaceRecord: async (workspace) =>
createWorkspaceDescriptor({ workspace, repoDir }),
describeWorkspaceRecord: async (result) =>
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
emit: (message) => emitted.push(message),
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
warmWorkspaceGitData: async () => {},
sessionLogger: logger,
runWorktreeSetupInBackground: async () => {},
},
@@ -1137,7 +1132,6 @@ describe("handleCreatePaseoWorktreeRequest", () => {
expect(result.worktreeBootstrap?.worktree.branchName).toBe("feature/review-pr");
expect(result.worktreeBootstrap?.worktree.worktreePath).toContain("agent-review-pr-123");
expect(events.some((event) => event.startsWith("workspace:"))).toBe(true);
expect(events.some((event) => event.startsWith("broadcast:"))).toBe(true);
const branch = execSync("git branch --show-current", {
cwd: result.sessionConfig.cwd,
@@ -1281,9 +1275,9 @@ describe("handleCreatePaseoWorktreeRequest", () => {
sessionLogger: createLogger(),
emit: (message) => emitted.push(message),
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome, events }),
describeWorkspaceRecord: vi.fn(async (workspace) => ({
id: workspace.workspaceId,
projectId: workspace.projectId,
describeWorkspaceRecord: vi.fn(async (result) => ({
id: result.workspace.workspaceId,
projectId: result.workspace.projectId,
projectDisplayName: path.basename(repoDir),
projectRootPath: repoDir,
projectKind: "git",
@@ -1291,7 +1285,20 @@ describe("handleCreatePaseoWorktreeRequest", () => {
name: "single-call",
status: "done",
activityAt: null,
diffStat: { additions: 0, deletions: 0 },
scripts: [],
gitRuntime: {
currentBranch: "single-call",
remoteUrl: null,
isPaseoOwnedWorktree: true,
isDirty: false,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
},
githubRuntime: null,
})),
warmWorkspaceGitData: async () => {},
runWorktreeSetupInBackground: vi.fn(async () => {}),
},
{
@@ -1303,7 +1310,6 @@ describe("handleCreatePaseoWorktreeRequest", () => {
);
expect(events.some((event) => event.startsWith("workspace:"))).toBe(true);
expect(events.some((event) => event.startsWith("broadcast:"))).toBe(true);
const response = emitted.find(
(
message,
@@ -1321,6 +1327,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
const paseoHome = path.join(tempDir, ".paseo");
const emitted: SessionOutboundMessage[] = [];
const backgroundWork = vi.fn(async () => {});
const warmWorkspaceGitData = vi.fn(async () => {});
let registeredWorktreePath: string | null = null;
try {
@@ -1335,9 +1342,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
registeredWorktreePath = result.worktree.worktreePath;
return result;
},
describeWorkspaceRecord: vi.fn(async (workspace) =>
createWorkspaceDescriptor({ workspace, repoDir }),
describeWorkspaceRecord: vi.fn(async (result) =>
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
),
warmWorkspaceGitData,
runWorktreeSetupInBackground: backgroundWork,
},
{
@@ -1356,8 +1364,26 @@ describe("handleCreatePaseoWorktreeRequest", () => {
);
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBeTruthy();
expect(emitted.map((message) => message.type).slice(0, 2)).toEqual([
"create_paseo_worktree_response",
"workspace_update",
]);
const workspaceUpdate = emitted[1];
expect(workspaceUpdate).toMatchObject({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: response?.payload.workspace,
},
});
expect(registeredWorktreePath).toBeTruthy();
expect(existsSync(registeredWorktreePath!)).toBe(true);
expect(warmWorkspaceGitData).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: response?.payload.workspace?.id,
cwd: registeredWorktreePath,
}),
);
expect(backgroundWork).toHaveBeenCalledWith(
expect.objectContaining({
requestCwd: repoDir,
@@ -1386,9 +1412,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
sessionLogger: createLogger(),
emit: (message) => emitted.push(message),
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
describeWorkspaceRecord: vi.fn(async (workspace) =>
createWorkspaceDescriptor({ workspace, repoDir }),
describeWorkspaceRecord: vi.fn(async (result) =>
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
),
warmWorkspaceGitData: async () => {},
runWorktreeSetupInBackground: vi.fn(async () => {}),
},
{
@@ -1426,9 +1453,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
sessionLogger: createLogger(),
emit: (message) => emitted.push(message),
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
describeWorkspaceRecord: vi.fn(async (workspace) =>
createWorkspaceDescriptor({ workspace, repoDir }),
describeWorkspaceRecord: vi.fn(async (result) =>
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
),
warmWorkspaceGitData: async () => {},
runWorktreeSetupInBackground: vi.fn(async () => {}),
},
{
+16 -2
View File
@@ -119,10 +119,11 @@ type HandleWorkspaceSetupStatusRequestDependencies = {
type HandleCreatePaseoWorktreeRequestDependencies = {
paseoHome?: string;
describeWorkspaceRecord: (
workspace: PersistedWorkspaceRecord,
result: CreatePaseoWorktreeResult,
) => Promise<WorkspaceDescriptorPayload>;
emit: EmitSessionMessage;
createPaseoWorktree: (input: CreatePaseoWorktreeInput) => Promise<CreatePaseoWorktreeResult>;
warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise<void>;
sessionLogger: Logger;
runWorktreeSetupInBackground: (options: {
requestCwd: string;
@@ -604,7 +605,7 @@ export async function handleCreatePaseoWorktreeRequest(
const slug = basename(createdWorktree.worktree.worktreePath);
const workspace = createdWorktree.workspace;
const descriptor = await dependencies.describeWorkspaceRecord(workspace);
const descriptor = await dependencies.describeWorkspaceRecord(createdWorktree);
dependencies.emit({
type: "create_paseo_worktree_response",
payload: {
@@ -614,7 +615,20 @@ export async function handleCreatePaseoWorktreeRequest(
requestId: request.requestId,
},
});
dependencies.emit({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: descriptor,
},
});
void dependencies.warmWorkspaceGitData(workspace).catch((error) => {
dependencies.sessionLogger.warn(
{ err: error, workspaceId: workspace.workspaceId },
"Failed to warm workspace git data after creating worktree",
);
});
void dependencies.runWorktreeSetupInBackground({
requestCwd: request.cwd,
repoRoot: createdWorktree.repoRoot,