Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion dev/src/utils/agent_loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ export class AgentFile {
*/
export class AgentLoader {
private agentsAlreadyPreloaded = false;
private preloadInFlight?: Promise<void>;
private readonly preloadedAgents: Record<string, AgentFile> = {};
private watcher?: fs.FSWatcher;

Expand Down Expand Up @@ -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<string[]> {
Expand Down Expand Up @@ -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<void> {
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<void> {
const files = (await isFile(this.agentsDirPath))
? [await getFileMetadata(this.agentsDirPath)]
: await getDirFiles(this.agentsDirPath);
Expand Down
67 changes: 67 additions & 0 deletions dev/test/utils/agent_loader_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
Loading