Fix: de-duplicate concurrent AgentLoader discovery scans - #664
Open
AmaadMartin wants to merge 2 commits into
Open
Fix: de-duplicate concurrent AgentLoader discovery scans#664AmaadMartin wants to merge 2 commits into
AmaadMartin wants to merge 2 commits into
Conversation
added 2 commits
August 4, 2026 17:52
preloadAgents() only set agentsAlreadyPreloaded after Promise.all resolved and had no in-flight guard, so every caller arriving during a scan started a competing one. Each extra 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. Memoize the running scan so concurrent callers join it. A rejected scan is discarded so a later call retries, and invalidateAll() drops it so a file-change reload is not swallowed.
Replaces the private agentsAlreadyPreloaded read with a listAgents() call that must not trigger a third compile pass, so the case is pinned without reaching into the loader's internals.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
N/A — no public issue tracks this flake.
Problem:
AgentLoader.preloadAgents()has no in-flight guard. It setsagentsAlreadyPreloaded = trueonly after itsPromise.allresolves, so everycaller that arrives while a scan is running starts a competing full scan.
A discovery pass is expensive: for each candidate it constructs an
AgentFileand calls
load(), which runs a fullesbuild.build()(bundle: true,minify: true,packages: 'bundle') and then dynamicallyimport()s theresult. A duplicate scan therefore re-bundles and re-imports every entrypoint.
Worse, the second scan's
AgentFileinstances overwrite the first scan's inpreloadedAgents, so the displaced ones are never disposed and their tempdirectories leak —
disposeAll()can only reach the survivors.Two callers hit this in practice:
dev/src/server/adk_api_server.ts—listAgents()/getAgentFile()arereached from request handlers, so two requests arriving before the first scan
finishes each trigger a full rebuild of every agent.
tests/integration/app_loader/app_loader_test.tson themacos-latestCIleg — the first discovery test pays the whole cold-discovery cost; when it is
killed by the per-test timeout the scan it started is still running and the
flag is still
false, so the next test starts a second concurrent scan.That is the mechanism behind "1 test timed out on one run, 2 on the immediate
rerun of the same commit". For public repositories
macos-latestis a 3-vCPU/ 7 GB M1 runner against 4 vCPU / 16 GB for the other two legs
(GitHub-hosted runners reference),
which is why only that leg loses the race.
Solution: memoize the running scan so concurrent callers join it instead of
starting a competing one.
The scan body moved verbatim into a private
scanAgents();preloadAgents()keeps its public
Promise<void>signature andagentsAlreadyPreloadedkeepsits exact meaning (set only on successful completion). Two details matter:
scratch rather than replaying a stale rejection forever. Every joined caller
still receives the rejection.
invalidateAll()drops the in-flight scan, so a file-change reload duringa scan is not silently swallowed by a caller joining pre-change results.
I chose
.catchover.finallydeliberately: on success the slot is notcleared, which means a scan that completes after an
invalidateAll()cannotclobber the replacement scan a later caller already started.
agentsAlreadyPreloadedshort-circuits the field before it can be read again, and
invalidateAll()clears it.
Collision check (562 open PRs on the fork)
The elaborated task had three parts. I checked every open PR on this fork by
touched file (GraphQL
pullRequests(states: OPEN) { files }) before writing anycode, and shipped only the part that is not already open:
dev/src/utils/agent_loader.tsconcurrent-scan de-duplicationpreloadAgentsconcurrency. The nearest is #633, which memoizes discovery as part of a wholesale lazy-discovery restructure (it deletespreloadAgentsand makeslistAgents()lazy) — a different, much larger design.beforeAll+ a justifiedHOOK_TIMEOUTinapp_loader_test.tsawait loader.preloadAgents()warm-up.fail-fast: falsein.github/workflows/validation.yamlFiling a sixth copy of the test/CI changes would have been pure duplicate work,
so this PR is scoped to the production defect. It is complementary to #506/#652
and touches no file they touch, so it can merge in any order relative to them.
Branched from
mainrather than stacked, for that reason.Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.
Three new cases in
dev/test/utils/agent_loader_test.ts. No existing test wasmodified, deleted, skipped, or weakened — the file goes from 30 to 33 tests,
and the two tests that read
agentsAlreadyPreloadeddirectly still passunchanged.
Proof each new test can fail. Every new line of production code was mutated
separately and each mutation killed exactly one test — a clean 1:1 mapping, no
test passing on a bug:
return this.scanAgents();)preloadAgents()callsexpected 6 to be 3(each entrypoint compiled twice).catchclear (??= this.scanAgents();)promise rejected "Error: compile failed" instead of resolvingpreloadInFlight = undefinedfrominvalidateAll()invalidateAllis called during a scanexpected 3 to be 6(the replacement joined the discarded scan)Coverage of the changed lines (v8,
--coverage.include='dev/src/utils/agent_loader.ts'):every new statement and branch is executed — the
??=both-ways, the.catchbody, and the
invalidateAll()reset. No coverage suppression was added and nostructure was changed to chase a number.
The assertions deliberately compare compiled entrypoints against their distinct
set rather than hardcoding "3 builds", so they stay correct if the unit fixture
gains or loses an entrypoint.
Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
1. Real, unmocked proof of the fix (the unit tests mock
esbuild; this doesnot). From a built tree, against the real
tests/integration/app_loader/discoveryfixture — two concurrent
listApps()calls, counting the compiled-outputdirectories the loader actually creates under
os.tmpdir()/adk_agent_loader:main)disposeAll()Both calls return the same correct result in both cases
(
apps = [service_alpha, standalone_app],agents = [service_alpha, service_beta, standalone_agent, standalone_app]) — theold code was correct, just doing the work twice and orphaning half of it.
2. Integration suites (unchanged by this PR, run to prove no collateral
damage):
Note the shape of the first run: the first discovery test still absorbs the
whole cold cost (18.9 s here) because moving that cost into
beforeAllis#506's change, not this one. This PR removes the second scan that the timeout
cascade triggers, which is what turns one slow test into two failing tests.
3. Static checks on the pushed commit:
ts:checkis already red onmain: 281 errors with my change, 281 with thebranch stashed, none in either file I touched. Several open PRs are fixing that
separately; it is not this change.
4. CI on the pushed commit — the
validationworkflow ran the fullrun-testsjob (install, secretlint, build,test:coverage, lint, format,docs) on all three matrix legs and all three passed, including the
macos-latestleg this change targets:Checklist
[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.
Reviewer notes
as unknown asin the new tests, to call the privateinvalidateAll():(loader as unknown as {invalidateAll: () => void}). Thisfollows the in-file precedent (
resets preload cache when invalidateAll is calledalready does exactly this) and there is no public route to the caseunder test —
invalidateAll()is only reachable from the fs watcher, andstartWatching()runs at the end of a scan, so a watcher-driveninvalidation can never land mid-scan. I removed the second private access the
test originally had (
agentsAlreadyPreloaded) and assert the same propertythrough the public API instead: a following
listAgents()must not trigger athird compile pass.
any, no@ts-expect-error, noeslint-disable, no coverage suppressionanywhere in the diff.
AgentLoader-constructing test tips this file past Node'sdefault listener limit, so the run now prints
MaxListenersExceededWarning: … 11 exit listeners added to [process]. That isa pre-existing defect —
AgentLoader's constructor registers fiveprocess.on(...)handlers per instance with no removal path — surfaced, notcaused, by this PR; Fix: make AgentLoader process exit/signal handlers opt-in and removable #511 fixes the root cause. Nothing fails.
listAgents()lazy (it deliberately returnsonly entries that load successfully, which
handles AgentFileLoadingError in directory loadingpins) and switchingpackages: 'bundle'to'external'(changes runtime module resolution for every
adk web/adk runuser).