diff --git a/dev/src/utils/agent_loader.ts b/dev/src/utils/agent_loader.ts index 448a32aeb..05687a199 100644 --- a/dev/src/utils/agent_loader.ts +++ b/dev/src/utils/agent_loader.ts @@ -359,6 +359,7 @@ export class AgentFile { */ export class AgentLoader { private agentsAlreadyPreloaded = false; + private preloadInFlight?: Promise; private readonly preloadedAgents: Record = {}; private watcher?: fs.FSWatcher; @@ -434,6 +435,9 @@ export class AgentLoader { } this.agentsAlreadyPreloaded = false; + // Detach any running scan so the invalidation is not swallowed by a + // caller joining results that were gathered before the change. + this.preloadInFlight = undefined; } async listAgents(): Promise { @@ -478,11 +482,29 @@ export class AgentLoader { ); } - async preloadAgents() { + /** + * Discovers, compiles and imports every agent in the agents directory. + * + * Callers that arrive while a scan is running join it. A rejected scan is + * discarded, so a later call retries from scratch. + */ + async preloadAgents(): Promise { if (this.agentsAlreadyPreloaded) { return; } + // A second concurrent scan re-bundles and re-imports every entrypoint, and + // its AgentFile instances overwrite the first scan's in `preloadedAgents`, + // so the displaced ones are never disposed and their temp directories leak. + this.preloadInFlight ??= this.scanAgents().catch((e: unknown) => { + this.preloadInFlight = undefined; + throw e; + }); + + return this.preloadInFlight; + } + + private async scanAgents(): Promise { const files = (await isFile(this.agentsDirPath)) ? [await getFileMetadata(this.agentsDirPath)] : await getDirFiles(this.agentsDirPath); diff --git a/dev/test/utils/agent_loader_test.ts b/dev/test/utils/agent_loader_test.ts index d53160d9d..f1cbcebd2 100644 --- a/dev/test/utils/agent_loader_test.ts +++ b/dev/test/utils/agent_loader_test.ts @@ -790,5 +790,72 @@ describe('AgentLoader', () => { await loader.disposeAll(); }); + + /** + * The entrypoint each esbuild invocation compiled, in call order. + * Comparing the list against its distinct entries counts duplicated + * discovery work without hardcoding how many entrypoints the fixture has. + */ + function compiledEntryPoints(): string[] { + return (esbuild.build as Mock).mock.calls.map( + (call) => (call[0] as {entryPoints: string[]}).entryPoints[0], + ); + } + + it('runs a single discovery pass for concurrent preloadAgents() calls', async () => { + const loader = new AgentLoader(tempAgentsDir); + await Promise.all([loader.preloadAgents(), loader.preloadAgents()]); + + const compiled = compiledEntryPoints(); + expect(compiled.length).toBe(new Set(compiled).size); + expect(await loader.listAgents()).toEqual(['agent1', 'agent2', 'agent3']); + + await loader.disposeAll(); + }); + + it('re-scans after a failed discovery pass instead of replaying its rejection', async () => { + const compileAgent = (esbuild.build as Mock).getMockImplementation(); + let compilesFail = true; + (esbuild.build as Mock).mockImplementation( + async (options: {entryPoints: string[]; outfile: string}) => { + if (compilesFail) { + throw new Error('compile failed'); + } + + return compileAgent?.(options); + }, + ); + + const loader = new AgentLoader(tempAgentsDir); + await expect(loader.preloadAgents()).rejects.toThrow('compile failed'); + + compilesFail = false; + + await expect(loader.preloadAgents()).resolves.toBeUndefined(); + expect(await loader.listAgents()).toEqual(['agent1', 'agent2', 'agent3']); + + await loader.disposeAll(); + }); + + it('starts a fresh scan when invalidateAll is called during a scan', async () => { + const loader = new AgentLoader(tempAgentsDir); + const invalidatedScan = loader.preloadAgents(); + + (loader as unknown as {invalidateAll: () => void}).invalidateAll(); + + await Promise.all([invalidatedScan, loader.preloadAgents()]); + + // Every entrypoint is compiled once by the discarded scan and once by + // its replacement. + const compiled = compiledEntryPoints(); + expect(compiled.length).toBe(2 * new Set(compiled).size); + + // The replacement scan completed, so listing serves it from the cache + // instead of scanning a third time. + expect(await loader.listAgents()).toEqual(['agent1', 'agent2', 'agent3']); + expect(compiledEntryPoints().length).toBe(compiled.length); + + await loader.disposeAll(); + }); }); });