fix(agent-core-v2): dedupe workspace registry entries by root

Port v1 #1221: collapse registered workspaces that share a root in list(), preferring the entry whose id matches the current canonical encodeWorkDirKey, so a legacy workspaces.json (v1-compatible) does not render the same folder twice through GET /workspaces.
This commit is contained in:
_Kerman 2026-07-09 14:18:11 +08:00
parent d0f969bc30
commit 7c094d8d6f
2 changed files with 49 additions and 1 deletions

View file

@ -49,7 +49,7 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry {
list(): Promise<readonly Workspace[]> {
return this.runExclusive(async () => {
const cache = await this.ensureLoaded();
return [...cache.values()];
return dedupeByRoot(cache);
});
}
@ -174,6 +174,30 @@ function parseSessionIndexLine(line: string): SessionIndexLine | undefined {
}
}
/**
* Collapse registered workspaces that share a `root`. The persisted catalog
* (v1-compatible `workspaces.json`) can hold legacy entries whose id was
* computed by an older `encodeWorkDirKey` (e.g. realpath-based on Windows) for
* the same folder, so one root may map to multiple ids. Prefer the entry whose
* id matches the current canonical key so current sessions' `workspace_id`
* still resolves and the same folder is not listed twice.
*/
function dedupeByRoot(cache: ReadonlyMap<string, Workspace>): Workspace[] {
const byRoot = new Map<string, Workspace>();
for (const ws of cache.values()) {
const existing = byRoot.get(ws.root);
if (existing === undefined) {
byRoot.set(ws.root, ws);
continue;
}
const canonicalId = encodeWorkDirKey(ws.root);
if (existing.id !== canonicalId && ws.id === canonicalId) {
byRoot.set(ws.root, ws);
}
}
return [...byRoot.values()];
}
registerScopedService(
LifecycleScope.App,
IWorkspaceRegistry,

View file

@ -167,4 +167,28 @@ describe('WorkspaceRegistryService (file-backed)', () => {
await build().delete(created.id);
expect(await restart().get(created.id)).toBeUndefined();
});
it('collapses duplicate registered entries for the same root, preferring the canonical id', async () => {
const root = join(homeDir, 'dup');
const canonicalId = encodeWorkDirKey(root);
// Simulate a registry that also holds a legacy id for the same folder (e.g.
// one produced by an older encodeWorkDirKey).
const legacyId = 'wd_duplegacy_deadbeef0000';
const entry: PersistedWorkspaceEntry = {
root,
name: 'dup',
created_at: '2026-01-01T00:00:00.000Z',
last_opened_at: '2026-01-01T00:00:00.000Z',
};
await writeWorkspacesJson({
// Legacy first so the canonical entry must actively replace it.
[legacyId]: entry,
[canonicalId]: entry,
});
const list = await build().list();
const matches = list.filter((w) => w.root === root);
expect(matches).toHaveLength(1);
expect(matches[0]?.id).toBe(canonicalId);
});
});