Skip to content

Commit d8317d8

Browse files
authored
fix(agent-core-v2): cache workspace alias resolution across calls (#3325)
* fix(agent-core-v2): cache workspace alias resolution across calls resolveAliasIds re-read the workspace catalog and the whole session index from disk on every call, so the by_workspace grouping loop and the per-workspace session counts paid repeated full-file reads per workspace per request (~2.4s per 50-group page at 1.1k workspaces, 23 pages serially during a client startup drain). Cache both files as precomputed snapshots (by-id map plus a root-key -> alias ids index) invalidated by the storage watch events, which cover atomic rewrites and cross-process writes; storage backends without watch fall back to reading through. The first resolution primes the workspace merge via IWorkspaceService.list() so the cached catalog matches what WorkspaceService.get() would have returned. * chore: drop the changeset; the user-facing entry ships with the app changelog * fix(agent-core-v2): coalesce cold alias snapshot loads and guard publication Concurrent cold resolveAliasIds callers (the /workspaces route fans out per-workspace counts with Promise.all) all passed the cache check before any caller finished loading, re-running the full catalog and session index reads the cache exists to avoid; memoize the in-flight load promise so a cold batch shares one read. Also capture the invalidation generation before each read and publish the snapshot only when it is unchanged, so a mid-read file replacement cannot leave a stale snapshot installed over the watch invalidation. * fix(agent-core-v2): publish catalog invalidation through the persistence owner A debounced fs watch was the only invalidation channel for the alias catalog snapshot, so an in-process catalog write stayed invisible to resolveAliasIds for up to the watch debounce window while the previous read-through code observed every completed write immediately. IWorkspacePersistence now exposes onDidChange: FileWorkspacePersistence fires it synchronously on save and re-fires the underlying document watch (covering atomic rewrites and cross-process writers), and the aliases service subscribes to it instead of watching raw storage keys. The session index snapshot keeps the filesystem watch, matching the read-side ownership of that file. * fix(agent-core-v2): invalidate session alias snapshots on append-log writes A flushed session_index.jsonl append was invisible to resolveAliasIds for up to the fs-watch debounce window, so a sessions request issued right after a session create could resolve the workspace's aliases from the pre-append snapshot. IAppendLogStore now publishes onDidWrite after each durable flush (append batches and rewrites), and the aliases service drops its session-index snapshot through that event; the raw filesystem watch stays as the channel for cross-process writers. * fix(agent-core-v2): fire append-log write events only after actual writes Once a key has a LogState, every global flush() (WireService flushes after ordinary agent persistence) completed it successfully and fired onDidWrite unconditionally, so idle agent activity kept dropping the alias session-index snapshot and forced full re-reads of an unchanged index. drain() now reports whether it appended anything and the write event fires only when a flush actually persisted a batch or a rewrite. * fix(agent-core-v2): retry shared snapshot loads that span a write Callers joining an in-flight single-flight load after a completed write still received the pre-write snapshot: the generation check only guarded cache publication, not the value returned to awaiters. Each load now carries the generation it started at, and catalog()/sessionIndex() re-read (coalesced through the same single-flight) when the settled load's generation is stale. * fix(agent-core-v2): report partial progress when an append-log drain fails A drain that persisted one batch and then failed the next threw without recording the durable write, so onDidWrite never fired for records that were in fact persisted (the alias session-index snapshot then missed its synchronous invalidation). The write box now threads through the whole owned flush: each successful batch marks it, and the event fires before the failure propagates. * fix(agent-core-v2): retry the whole alias resolution across a mid-write The per-snapshot retry guarded each read on its own, so a write landing between the catalog and session-index reads returned an alias set assembled across two generations. resolveAliasIds now captures the generation once, reads both snapshots together, and retries the whole resolution when either input was invalidated mid-flight. The spanned-write test is reworked to gate after the load (so the snapshot content genuinely predates the write), and a new case covers the cross-generation mix directly. * fix(agent-core-v2): replace the session-index fs watch with a size check A resident chokidar watcher per server on the shared home directory degraded watch delivery for unrelated files under test-suite boot volume (the prompts suite lost the config.toml reload race and the catalog missed a just-written model). In-process appends were already covered synchronously by the append-log write event; cross-process writers now surface through a per-call size comparison on the append-only file, which costs one stat per resolve and needs no resident watcher.
1 parent dc6028d commit d8317d8

11 files changed

Lines changed: 541 additions & 44 deletions

File tree

packages/agent-core-v2/src/app/workspace/fileWorkspacePersistence.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { LifecycleScope } from '#/app/scopes';
22

3+
import { Disposable } from '#/_base/di/lifecycle';
34
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
5+
import { Emitter, type Event } from '#/_base/event';
46
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
57

68
import type { Workspace } from './workspace';
@@ -15,10 +17,20 @@ const WORKSPACE_CATALOG_VERSION = 1;
1517
const WORKSPACE_CATALOG_SCOPE = '';
1618
const WORKSPACE_CATALOG_KEY = 'workspaces.json';
1719

18-
export class FileWorkspacePersistence implements IWorkspacePersistence {
20+
export class FileWorkspacePersistence extends Disposable implements IWorkspacePersistence {
1921
declare readonly _serviceBrand: undefined;
2022

21-
constructor(@IAtomicDocumentStore private readonly docs: IAtomicDocumentStore) {}
23+
private readonly changeEmitter = this._register(new Emitter<void>());
24+
readonly onDidChange: Event<void> = this.changeEmitter.event;
25+
26+
constructor(@IAtomicDocumentStore private readonly docs: IAtomicDocumentStore) {
27+
super();
28+
this._register(
29+
this.docs.watch(WORKSPACE_CATALOG_SCOPE, WORKSPACE_CATALOG_KEY)(() => {
30+
this.changeEmitter.fire();
31+
}),
32+
);
33+
}
2234

2335
async load(): Promise<WorkspaceCatalog | undefined> {
2436
const file = await this.docs.get<PersistedWorkspaceFile>(
@@ -70,6 +82,7 @@ export class FileWorkspacePersistence implements IWorkspacePersistence {
7082
deleted_workspace_ids: [...catalog.deletedIds],
7183
};
7284
await this.docs.set(WORKSPACE_CATALOG_SCOPE, WORKSPACE_CATALOG_KEY, file);
85+
this.changeEmitter.fire();
7386
}
7487
}
7588

packages/agent-core-v2/src/app/workspace/workspacePersistence.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
2+
import type { Event } from '#/_base/event';
23

34
import type { Workspace } from './workspace';
45

@@ -23,6 +24,8 @@ export interface WorkspaceCatalog {
2324
export interface IWorkspacePersistence {
2425
readonly _serviceBrand: undefined;
2526

27+
readonly onDidChange: Event<void>;
28+
2629
load(): Promise<WorkspaceCatalog | undefined>;
2730
save(catalog: WorkspaceCatalog): Promise<void>;
2831
}

packages/agent-core-v2/src/app/workspaceAliases/workspaceAliasesService.ts

Lines changed: 149 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,171 @@
11
import { LifecycleScope } from '#/app/scopes';
22

3+
import { Disposable } from '#/_base/di/lifecycle';
34
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
4-
import { IWorkspaceService } from '#/app/workspace/workspace';
5+
import { encodeWorkDirKey, workspaceRootKey } from '#/_base/utils/workdir-slug';
6+
import { IWorkspaceService, type Workspace } from '#/app/workspace/workspace';
57
import {
6-
collectAliasIds,
78
readSessionIndexEntries,
9+
SESSION_INDEX_KEY,
10+
SESSION_INDEX_SCOPE,
811
} from '#/app/workspace/workspaceAlias';
912
import { IWorkspacePersistence } from '#/app/workspace/workspacePersistence';
13+
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
1014
import { IFileSystemStorageService } from '#/persistence/interface/storage';
1115

1216
import { IWorkspaceAliases } from './workspaceAliases';
1317

14-
export class WorkspaceAliasesService implements IWorkspaceAliases {
18+
interface CatalogSnapshot {
19+
readonly byId: ReadonlyMap<string, Workspace>;
20+
readonly idsByRootKey: ReadonlyMap<string, readonly string[]>;
21+
}
22+
23+
interface SessionIndexSnapshot {
24+
readonly idsByRootKey: ReadonlyMap<string, readonly string[]>;
25+
}
26+
27+
function rootKeyIndex<T>(
28+
items: readonly T[],
29+
rootOf: (item: T) => string,
30+
idOf: (item: T) => string,
31+
): Map<string, readonly string[]> {
32+
const map = new Map<string, string[]>();
33+
for (const item of items) {
34+
const key = workspaceRootKey(rootOf(item));
35+
const id = idOf(item);
36+
const bucket = map.get(key);
37+
if (bucket === undefined) {
38+
map.set(key, [id]);
39+
} else if (!bucket.includes(id)) {
40+
bucket.push(id);
41+
}
42+
}
43+
return map;
44+
}
45+
46+
export class WorkspaceAliasesService extends Disposable implements IWorkspaceAliases {
1547
declare readonly _serviceBrand: undefined;
1648

49+
private catalogCache: CatalogSnapshot | undefined;
50+
private sessionIndexCache: { snapshot: SessionIndexSnapshot; size: number | undefined } | undefined;
51+
private catalogPromise:
52+
| Promise<{ snapshot: CatalogSnapshot; generation: number }>
53+
| undefined;
54+
private sessionIndexPromise:
55+
| Promise<{ snapshot: SessionIndexSnapshot; generation: number }>
56+
| undefined;
57+
private invalidationGeneration = 0;
58+
private catalogMergePrimed = false;
59+
1760
constructor(
1861
@IWorkspaceService private readonly workspaces: IWorkspaceService,
1962
@IWorkspacePersistence private readonly store: IWorkspacePersistence,
2063
@IFileSystemStorageService private readonly storage: IFileSystemStorageService,
21-
) {}
64+
@IAppendLogStore private readonly appendLogs: IAppendLogStore,
65+
) {
66+
super();
67+
this._register(
68+
this.store.onDidChange(() => {
69+
this.invalidationGeneration += 1;
70+
this.catalogCache = undefined;
71+
}),
72+
);
73+
this._register(
74+
this.appendLogs.onDidWrite((write) => {
75+
if (write.scope === SESSION_INDEX_SCOPE && write.key === SESSION_INDEX_KEY) {
76+
this.invalidationGeneration += 1;
77+
this.sessionIndexCache = undefined;
78+
}
79+
}),
80+
);
81+
}
2282

2383
async resolveAliasIds(id: string): Promise<readonly string[]> {
24-
const entry = await this.workspaces.get(id);
25-
if (entry === undefined) return [id];
26-
const catalog = (await this.store.load()) ?? { workspaces: [], deletedIds: [] };
27-
return collectAliasIds(
28-
catalog.workspaces,
29-
await readSessionIndexEntries(this.storage),
30-
entry.root,
31-
);
84+
for (;;) {
85+
const generation = this.invalidationGeneration;
86+
const [catalog, index] = await Promise.all([this.catalog(), this.sessionIndex()]);
87+
if (generation !== this.invalidationGeneration) continue;
88+
const entry = catalog.byId.get(id);
89+
if (entry === undefined) return [id];
90+
const rootKey = workspaceRootKey(entry.root);
91+
const fromCatalog = catalog.idsByRootKey.get(rootKey);
92+
const fromIndex = index.idsByRootKey.get(rootKey);
93+
if (fromCatalog === undefined) return fromIndex ?? [id];
94+
if (fromIndex === undefined) return fromCatalog;
95+
const merged = [...fromCatalog];
96+
for (const alias of fromIndex) {
97+
if (!merged.includes(alias)) merged.push(alias);
98+
}
99+
return merged;
100+
}
101+
}
102+
103+
private async catalog(): Promise<CatalogSnapshot> {
104+
if (this.catalogCache !== undefined) return this.catalogCache;
105+
this.catalogPromise ??= this.loadCatalog();
106+
const { snapshot, generation } = await this.catalogPromise;
107+
if (generation !== this.invalidationGeneration) return this.catalog();
108+
return snapshot;
109+
}
110+
111+
private async loadCatalog(): Promise<{ snapshot: CatalogSnapshot; generation: number }> {
112+
try {
113+
if (!this.catalogMergePrimed) {
114+
await this.workspaces.list();
115+
this.catalogMergePrimed = true;
116+
}
117+
const generation = this.invalidationGeneration;
118+
const workspaces = (await this.store.load())?.workspaces ?? [];
119+
const snapshot: CatalogSnapshot = {
120+
byId: new Map(workspaces.map((ws) => [ws.id, ws] as const)),
121+
idsByRootKey: rootKeyIndex(
122+
workspaces,
123+
(ws) => ws.root,
124+
(ws) => ws.id,
125+
),
126+
};
127+
if (generation === this.invalidationGeneration) {
128+
this.catalogCache = snapshot;
129+
}
130+
return { snapshot, generation };
131+
} finally {
132+
this.catalogPromise = undefined;
133+
}
134+
}
135+
136+
private async sessionIndex(): Promise<SessionIndexSnapshot> {
137+
const cache = this.sessionIndexCache;
138+
if (
139+
cache !== undefined &&
140+
(await this.storage.size(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY)) === cache.size
141+
) {
142+
return cache.snapshot;
143+
}
144+
this.sessionIndexPromise ??= this.loadSessionIndex();
145+
const { snapshot, generation } = await this.sessionIndexPromise;
146+
if (generation !== this.invalidationGeneration) return this.sessionIndex();
147+
return snapshot;
148+
}
149+
150+
private async loadSessionIndex(): Promise<{ snapshot: SessionIndexSnapshot; generation: number }> {
151+
try {
152+
const generation = this.invalidationGeneration;
153+
const entries = await readSessionIndexEntries(this.storage);
154+
const snapshot: SessionIndexSnapshot = {
155+
idsByRootKey: rootKeyIndex(entries, (entry) => entry.workDir, (entry) =>
156+
encodeWorkDirKey(entry.workDir),
157+
),
158+
};
159+
if (generation === this.invalidationGeneration) {
160+
this.sessionIndexCache = {
161+
snapshot,
162+
size: await this.storage.size(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY),
163+
};
164+
}
165+
return { snapshot, generation };
166+
} finally {
167+
this.sessionIndexPromise = undefined;
168+
}
32169
}
33170
}
34171

packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1-
import { toDisposable, type IDisposable } from '#/_base/di/lifecycle';
1+
import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle';
22
import { LifecycleScope } from '#/app/scopes';
33
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
4+
import { Emitter, type Event } from '#/_base/event';
45

56
import { IFileSystemStorageService } from '#/persistence/interface/storage';
67
import {
78
AppendLogCorruptedError,
89
IAppendLogStore,
910
type AppendLogOptions,
1011
type AppendLogReadOptions,
12+
type AppendLogWrite,
1113
} from '#/persistence/interface/appendLogStore';
1214

1315
const textEncoder = new TextEncoder();
@@ -33,12 +35,16 @@ interface LogState {
3335
onError?: (error: unknown) => void;
3436
}
3537

36-
export class AppendLogStore implements IAppendLogStore {
38+
export class AppendLogStore extends Disposable implements IAppendLogStore {
3739
declare readonly _serviceBrand: undefined;
3840

3941
private readonly logs = new Map<string, LogState>();
42+
private readonly writeEmitter = this._register(new Emitter<AppendLogWrite>());
43+
readonly onDidWrite: Event<AppendLogWrite> = this.writeEmitter.event;
4044

41-
constructor(@IFileSystemStorageService private readonly storage: IFileSystemStorageService) {}
45+
constructor(@IFileSystemStorageService private readonly storage: IFileSystemStorageService) {
46+
super();
47+
}
4248

4349
append<R>(scope: string, key: string, record: R, options?: AppendLogOptions): void {
4450
const state = this.state(scope, key);
@@ -117,12 +123,13 @@ export class AppendLogStore implements IAppendLogStore {
117123
try {
118124
await this.storage.write(scope, key, encoded, { atomic: true });
119125
state.storageFailure = undefined;
126+
return true;
120127
} catch (error) {
121128
state.storageFailure = { error };
122129
throw error;
123130
}
124131
});
125-
await this.ownFlush(scope, key, state, rewrite);
132+
await this.ownFlush(scope, key, state, rewrite, { value: false });
126133
}
127134

128135
async flush(): Promise<void> {
@@ -190,7 +197,8 @@ export class AppendLogStore implements IAppendLogStore {
190197
private flushState(scope: string, key: string, state: LogState): Promise<void> {
191198
if (state.flushPromise !== undefined) return state.flushPromise;
192199
if (state.storageFailure !== undefined) return Promise.reject(state.storageFailure.error);
193-
return this.ownFlush(scope, key, state, this.drain(scope, key, state));
200+
const wroteBox = { value: false };
201+
return this.ownFlush(scope, key, state, this.drain(scope, key, state, wroteBox), wroteBox);
194202
}
195203

196204
private release(scope: string, key: string, state: LogState): void {
@@ -216,10 +224,11 @@ export class AppendLogStore implements IAppendLogStore {
216224
scope: string,
217225
key: string,
218226
state: LogState,
219-
operation: Promise<void>,
227+
operation: Promise<boolean>,
228+
wroteBox: { value: boolean },
220229
): Promise<void> {
221230
let owned!: Promise<void>;
222-
owned = this.finishOwnedFlush(scope, key, state, operation, () => owned);
231+
owned = this.finishOwnedFlush(scope, key, state, operation, wroteBox, () => owned);
223232
state.flushPromise = owned;
224233
return owned;
225234
}
@@ -228,47 +237,55 @@ export class AppendLogStore implements IAppendLogStore {
228237
scope: string,
229238
key: string,
230239
state: LogState,
231-
operation: Promise<void>,
240+
operation: Promise<boolean>,
241+
wroteBox: { value: boolean },
232242
owner: () => Promise<void>,
233243
): Promise<void> {
234244
let failure: { readonly error: unknown } | undefined;
235245
try {
236-
await operation;
237-
} catch (error) {
238-
failure = { error };
239-
}
240-
const owned = owner();
241-
if (state.flushPromise === owned) {
242-
try {
243-
if (failure === undefined) {
244-
while (state.flushPromise === owned && state.pending.length > 0) {
245-
await this.drain(scope, key, state);
246-
}
247-
}
248-
} finally {
249-
if (state.flushPromise === owned) {
250-
state.flushPromise = undefined;
246+
if (await operation) wroteBox.value = true;
247+
const owned = owner();
248+
if (state.flushPromise === owned) {
249+
while (state.flushPromise === owned && state.pending.length > 0) {
250+
await this.drain(scope, key, state, wroteBox);
251251
}
252252
}
253+
} catch (error) {
254+
failure ??= { error };
255+
} finally {
256+
const owned = owner();
257+
if (state.flushPromise === owned) {
258+
state.flushPromise = undefined;
259+
}
253260
}
261+
if (wroteBox.value) this.writeEmitter.fire({ scope, key });
254262
if (failure !== undefined) throw failure.error;
255263
}
256264

257-
private async drain(scope: string, key: string, state: LogState): Promise<void> {
265+
private async drain(
266+
scope: string,
267+
key: string,
268+
state: LogState,
269+
wroteBox?: { value: boolean },
270+
): Promise<boolean> {
258271
const cutoverEpoch = state.cutoverEpoch;
259272
await state.ready;
260-
if (state.cutoverEpoch !== cutoverEpoch) return;
273+
if (state.cutoverEpoch !== cutoverEpoch) return false;
274+
let wrote = false;
261275
while (state.pending.length > 0) {
262276
const batch = state.pending.slice();
263277
try {
264278
await this.storage.append(scope, key, encodeBatch(batch), { durable: true });
279+
wrote = true;
280+
if (wroteBox !== undefined) wroteBox.value = true;
265281
} catch (error) {
266282
const failure = (state.storageFailure ??= { error });
267283
throw failure.error;
268284
}
269-
if (state.cutoverEpoch !== cutoverEpoch) return;
285+
if (state.cutoverEpoch !== cutoverEpoch) return wrote;
270286
state.pending.splice(0, batch.length);
271287
}
288+
return wrote;
272289
}
273290
}
274291

0 commit comments

Comments
 (0)