fix(server): serve workspace list instantly on fetch, reconcile in background (#204)

* fix(server): serve workspace list instantly on fetch, reconcile in background

Previously fetch_workspaces_request awaited reconcileActiveWorkspaceRecords()
before responding — this ran git operations (getCheckoutStatusLite) on every
active workspace, causing projects to appear empty for several seconds after
daemon restart or reconnect.

Now the registry snapshot is returned immediately and reconciliation runs in
the background. Any changed workspaces (e.g. stale worktrees being archived)
are pushed as workspace_update events through the existing subscription.

* fix(server): make workspace snapshot instant by removing per-workspace git calls

describeWorkspaceRecord was running two git operations per workspace:
  - buildProjectPlacement (to refresh display name)
  - getCheckoutShortstat (for diff indicator)

With 13 workspaces this caused fetch_workspaces_request to take 7-15s
(observed as ws_slow_request in logs).

Split into two methods:
  - describeWorkspaceRecord: uses only persisted data, returns instantly
  - describeWorkspaceRecordWithGitData: runs git ops, used for open_project
    and create_worktree responses where fresh data is needed immediately

The snapshot path (initial load + background reconciliation) now uses the
fast variant. diffStat is null on initial load; it will be pushed later via
workspace_update once a git-aware path triggers.
This commit is contained in:
Samuel K
2026-04-07 09:35:07 +07:00
committed by GitHub
parent b2229a28b9
commit cdbaa8d29c
+49 -15
View File
@@ -5307,6 +5307,26 @@ export class Session {
): Promise<WorkspaceDescriptorPayload> {
const resolvedProjectRecord =
projectRecord ?? (await this.projectRegistry.get(workspace.projectId));
return {
id: workspace.workspaceId,
projectId: workspace.projectId,
projectDisplayName: resolvedProjectRecord?.displayName ?? workspace.projectId,
projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd,
projectKind: resolvedProjectRecord?.kind ?? "non_git",
workspaceKind: workspace.kind,
name: workspace.displayName,
status: "done",
activityAt: null,
diffStat: null,
};
}
private async describeWorkspaceRecordWithGitData(
workspace: PersistedWorkspaceRecord,
projectRecord?: PersistedProjectRecord | null,
): Promise<WorkspaceDescriptorPayload> {
const base = await this.describeWorkspaceRecord(workspace, projectRecord);
let displayName = workspace.displayName;
try {
const placement = await this.buildProjectPlacement(workspace.cwd);
@@ -5325,18 +5345,7 @@ export class Session {
// Non-critical — leave null on failure.
}
return {
id: workspace.workspaceId,
projectId: workspace.projectId,
projectDisplayName: resolvedProjectRecord?.displayName ?? workspace.projectId,
projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd,
projectKind: resolvedProjectRecord?.kind ?? "non_git",
workspaceKind: workspace.kind,
name: displayName,
status: "done",
activityAt: null,
diffStat,
};
return { ...base, name: displayName, diffStat };
}
private async listWorkspaceDescriptorsSnapshot(): Promise<WorkspaceDescriptorPayload[]> {
@@ -5412,7 +5421,6 @@ export class Session {
}
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
await this.reconcileActiveWorkspaceRecords();
return this.listWorkspaceDescriptorsSnapshot();
}
@@ -5742,6 +5750,31 @@ export class Session {
}
}
private async reconcileAndEmitWorkspaceUpdates(): Promise<void> {
const subscription = this.workspaceUpdatesSubscription;
if (!subscription) {
return;
}
try {
const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords();
if (changedWorkspaceIds.size === 0) {
return;
}
const all = await this.listWorkspaceDescriptorsSnapshot();
const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const));
for (const workspaceId of changedWorkspaceIds) {
const workspace = descriptorsByWorkspaceId.get(workspaceId) ?? null;
if (workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })) {
this.bufferOrEmitWorkspaceUpdate(subscription, { kind: "upsert", workspace });
} else {
this.bufferOrEmitWorkspaceUpdate(subscription, { kind: "remove", id: workspaceId });
}
}
} catch (error) {
this.sessionLogger.error({ err: error }, "Background workspace reconciliation failed");
}
}
private async emitWorkspaceUpdateForCwd(
cwd: string,
options?: { dedupeGitState?: boolean },
@@ -5942,6 +5975,7 @@ export class Session {
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
this.flushBootstrappedWorkspaceUpdates({ snapshotLatestActivityByWorkspaceId });
void this.reconcileAndEmitWorkspaceUpdates();
}
} catch (error) {
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
@@ -5968,7 +6002,7 @@ export class Session {
try {
const workspace = await this.ensureWorkspaceRegistered(request.cwd);
await this.emitWorkspaceUpdateForCwd(workspace.cwd);
const descriptor = await this.describeWorkspaceRecord(workspace);
const descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
this.emit({
type: "open_project_response",
payload: {
@@ -6068,7 +6102,7 @@ export class Session {
return handleCreateWorktreeRequest(
{
paseoHome: this.paseoHome,
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecord(workspace),
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecordWithGitData(workspace),
emit: (message) => this.emit(message),
registerPendingWorktreeWorkspace: (options) =>
this.registerPendingWorktreeWorkspace(options),