fix(core): route per-build hook/middleware mutations through local copies - #2542
fix(core): route per-build hook/middleware mutations through local copies#2542xiaowu0203 wants to merge 5 commits into
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
Hi @kratos0718 — the fix is up: #2542 |
kratos0718
left a comment
There was a problem hiding this comment.
Approach looks right to me, and it matches what I'd verified locally — all five mutation points routed through the per-build copies, and passing buildHooks into registerToolsFromHooks is cleaner than leaving it reading the field. Passing the list straight to super(...) is safe too, since AgentBase re-wraps it in a CopyOnWriteArrayList.
Two notes, neither a blocker.
1. configureSkillBox still mutates shared state — same bug family, not covered here.
The hooks/middlewares collections are fixed, but the skillBox field is itself a shared object, and build() still writes to it:
private void configureSkillBox(Toolkit agentToolkit, List<Hook> buildHooks) {
skillBox.bindToolkit(agentToolkit); // SkillBox.java:146 -> this.toolkit = toolkit
skillBox.registerSkillLoadTool();
if (skillBox.isAutoUploadSkill()) {
skillBox.uploadSkillFiles();
}
buildHooks.add(new SkillHook(skillBox));
}SkillHook holds private final SkillBox skillBox (SkillHook.java:46) — the same instance for every agent. So on a shared builder:
- build #1 →
skillBox.toolkit= agent 1's toolkit copy - build #2 →
skillBox.toolkit= agent 2's toolkit copy
and agent 1's SkillHook now reaches agent 2's toolkit. setSkillActive() uses that field at runtime (this.toolkit.updateToolGroups(...), SkillBox.java:343-354), so activating a skill on agent 1 mutates agent 2's tool groups. Concurrently, two build() calls race on the same write.
That's the same failure mode as #2539, just through an object field rather than a collection, and it's the exact configuration the A2A server uses. Might be worth a follow-up issue rather than growing this PR — the fix is a different shape (per-agent SkillBox view, or move the bind to construction time).
registerSkillLoadTool() and uploadSkillFiles() also re-run on every build against the shared box, which is at least wasted work on rebuild.
2. Minor: codecov flags 3 uncovered lines. The skillBox != null and dynamic-skills branches aren't exercised by the new test — the RAG / long-term-memory / task-list paths are. Not important if you'd rather keep the test focused, just noting which of the five are actually covered.
|
Thanks @kratos0718 for the review! |
|
Agreed on both, and no need to file it — I'd already opened #2543 for the SkillBox half just before your reply, so we don't collide. It's scoped to stay independent of this PR: it only changes On coverage: I'd leave your test focused where it is. #2543 carries its own tests for the skill-box path, so between the two PRs the branches are covered without either test having to reach across. |
|
Thanks @kratos0718 |
|
This PR has conflicts with the base branch and cannot be merged. Please rebase or merge the base branch into your branch and resolve the conflicts: git fetch origin
git checkout fix/shared-builder-build-mutation
git rebase origin/main
# resolve conflicts, then:
git push --force-with-leaseThis is a one-time reminder. Feel free to @mention me for a re-review after conflicts are resolved. Automated notification by "github-manager-bot" |
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Reviewed the changes in this PR. The modifications look reasonable and follow the project conventions.
Automated review by "github-manager-bot"
e9f1ab1 to
f5462ef
Compare
@oss-maintainer Conflicts resolved |
28337e5 to
9b55857
Compare
…pies Re-trigger CI: upstream JsonSessionDefaultLocationTest.perUserPartitioning_ viaSharedAgentRoutedByRuntimeContext fails intermittently with @tempdir cleanup DirectoryNotEmptyException (async state write races JUnit cleanup). Content-independent flake, unrelated to this change — same tree was green on run 28337e5. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9b55857 to
e2cbf4b
Compare
AgentScopeJavaBot
left a comment
There was a problem hiding this comment.
🤖 AI Review
Fixes shared builder race condition by snapshotting hooks/middlewares into per-build local copies. Core fix is correct and well-tested. Two pre-existing concurrency concerns (ToolkitAware rebind, skillBox bindToolkit) noted as follow-up items, not blocking. Recommendation: APPROVE.
AgentScopeJavaBot
left a comment
There was a problem hiding this comment.
🤖 AI Review
Fixes shared builder race condition by snapshotting hooks/middlewares into per-build local copies. Core fix is correct and well-tested. Two pre-existing concurrency concerns (ToolkitAware rebind, skillBox bindToolkit) noted as follow-up items, not blocking. Recommendation: APPROVE.
|
On the two follow-ups the AI review flagged — the
So that item is covered whenever a maintainer has a moment for it; it does not need to hold up this PR. The other one, |
|
Thanks @kratos0718 — yes, please go ahead and open that third PR for the ToolkitAware rebind. Much appreciated! |
Closes #2539
Problem
ReActAgent.Builder.build()mutates the (possibly shared) builder'shooks/middlewarescollections in five places —configureLongTermMemory,configureRAG,configureTodoTools,configureSkillBox, and theDynamicSkillMiddlewarebranch. Reusing a shared builder (as the A2A server does with a single builder bean) races on the non-thread-safeLinkedHashSet/ArrayList(write-while-read duringnew ArrayList<>(builder.hooks)/mws.addAll(builder.middlewares)), failing construction with AIOOBE/NPE. Because the installed hooks don't overrideequals/hashCode, each build also leaks a fresh instance into the shared set — rebuilding yields agents carrying 2/3/… copies.Fix
build()now snapshotshooks/middlewaresinto per-build local copies, passes them to theconfigure*methods and theReActAgentconstructor, and never mutates the builder.registerToolsFromHooksruns on the copy taken beforeconfigure*, so configure-installed hooks never contributetools()— matching first-build behaviour and staying consistent across rebuilds.Tests
ReActAgentSharedBuilderRegressionTest: a deterministic rebuild test — rebuilding a shared builder (task-list + long-term-memory + RAG configured) always yields exactly oneTaskReminderMiddleware, oneStaticLongTermMemoryHookand oneGenericRAGHook. All existingReActAgentunit tests pass.