Repository navigation
fix: make transfer_task concurrency-safe under parallel tool calls - #4180
bernard-code-lab wants to merge 6 commits into
Conversation
|
👋 Some commits in this PR are not signed and verified by GitHub. Please sign your commits with a GPG or SSH key registered in your GitHub account, then force-push. Commits that are not verified: See GitHub's guide on signing commits for setup instructions. I've added |
7b3dc36 to
b6fdcaf
Compare
|
Friendly follow-up 🙂 The signing issue is sorted out: Since this PR comes from a fork, no workflows have run yet — they need a maintainer to approve the CI run. @aheritier, since you triaged this one, would you mind kicking off CI and/or assigning a reviewer when you get a chance? Happy to rebase, split the change, or adjust the approach if anything looks off. Thanks! |
Review: request changesThe caller-snapshot portion of this fix is sound and the new regression tests are meaningful. However, the runtime-global Blocking
Please scope the claim to the contending sibling set (for example, by parent session), or decide pinning from the batch size at dispatch time. This should also make the intended behavior deterministic. Also needed
Local validation was green: Do not submit a formal GitHub review; post only this PR conversation comment. |
When a model issues several transfer_task calls in one response, the dispatcher runs them in parallel goroutines. Each one re-resolved its caller from the shared current agent that a sibling's swapCurrentAgent had already mutated, so the later calls validated the target against the wrong caller and failed with "target agent not in sub-agents list", and a child that did start could run its turns as another sibling's agent. Resolve the caller from the snapshot Dispatcher.Process already takes before the fan-out, exposed on the per-call tools.Runtime handle. For the shared current agent itself, decide from the shape of the batch rather than by claiming it at runtime. Dispatcher.Process tallies the calls per tool before the fan-out, so a delegation knows whether any sibling delegation runs beside it: a solo one swaps the current agent as before, while every member of a multi-call batch pins its child session instead — the isolation mode background delegations already use. Deciding up front avoids two defects an in-flight claim has. A claim held for the outer delegation's lifetime also pins a sequential nested child, which silently suppresses that child's force_handoff, since the runtime only honours it for unpinned sessions. And letting the first arrival win makes the child that keeps switch events vary between runs. The tally is per tool, not per batch: only delegations contend for the current agent, so a transfer_task sharing its batch with an unrelated tool stays solo. run_skill shares runForwarding and had the same latent defect when it landed in a batch alongside transfer_task; it is fixed by the same change. run_background_agent resolves its caller the same way, but agenttool's HandleRun does not receive a tools.Runtime, so that path is left for a follow-up. Its children are already pinned, which bounds the impact. Fixes docker#4156 Signed-off-by: bernard-code-lab <rafaelc869@gmail.com>
handoff read the shared current agent to decide who was handing off. A transfer_task running beside it in the same parallel batch may already have swapped that field to its own target, so the handoff was validated against the transfer target's handoffs list instead of the caller's and was rejected with "No agents are configured in this list" — the same failure docker#4156 reports for transfer_task, on the sibling tool. Resolve the caller from the dispatcher's pre-fan-out snapshot, the handle transfer_task already uses. Hosts without a batch fall back to session resolution, which is what the old lookup did for them anyway. This covers caller resolution only. handleHandoff still calls setCurrentAgent outside the pin/switch machinery, so a handoff sharing a batch with a delegation can still clobber the current agent; unlike transfer_task's call-scoped swap, handoff rewires the session for every later turn, so giving it the same treatment is a routing change rather than a bug fix. Tracked separately. Signed-off-by: bernard-code-lab <rafaelc869@gmail.com>
b6fdcaf to
6037fe5
Compare
|
Thanks @aheritier — the nested-delegation regression was real, and reproducing it changed the shape of the fix. Force-pushed; the branch is now two commits. The claim is gone, not rescopedYou offered two options: scope the claim to the contending sibling set, or decide pinning from the batch size at dispatch. Working through the second showed it makes the first unnecessary — so
That fixes both of your points at once:
The tally is per tool rather than per batch, so a Verifying it against the merge baseI reproduced your finding before fixing it. The new test is table-driven over three delegation shapes and, on the previous commit, failed exactly where you said:
The nested case passes on the merge base, confirming a regression rather than a gap. A coverage hole in my own fixMutating Second commit:
|
|
Correction to my CI note above: I force-pushed about 30 seconds before posting it, so the green run I cited belonged to the superseded Locally on the current head: @aheritier — could you approve the workflow run when you get a chance? No rush on the review itself. |
| t.Helper() | ||
| finisher, probe := handoffProbe("finisher") | ||
| worker := agent.New("worker", "worker agent", | ||
| agent.WithModel(&mockProvider{id: "test/mock-model", |
There was a problem hiding this comment.
[blocking] lint is red on this line:
pkg/runtime/agent_delegation_test.go:1887:1: File is not properly formatted (gofumpt)
Reproduced locally with golangci-lint 2.13.2. task format fixes it — the same &mockProvider{...} literal needs splitting in all four table cases.
|
|
||
| // handoffProbe returns an agent whose model invocations are counted, standing | ||
| // in for a force_handoff target that must (or must not) be reached. | ||
| func handoffProbe(name string) (*agent.Agent, *handoffRecordingProvider) { |
There was a problem hiding this comment.
[blocking] Second lint failure:
pkg/runtime/agent_delegation_test.go:1844:19: handoffProbe - name always receives "finisher" (unparam)
All four call sites pass "finisher". Simplest fix is to drop the parameter and hardcode the name, since the probe is only ever the force_handoff target.
| // others' turns. Pin every member of the batch rather than letting | ||
| // one win a race — the parallel children are isolated, and which | ||
| // child keeps switch events no longer varies per run (#4156). | ||
| req.PinAgent = true |
There was a problem hiding this comment.
[blocking] Pinning every member of the batch silently disables force_handoff for all of them — loop.go:1035 gates applyForceHandoff on sess.AgentName == "". Your own table encodes this at agent_delegation_test.go:1961 (wantHandoffCalls: 0); on the merge base that case yields 1.
That conflicts with shipped docs that promise the opposite:
docs/configuration/agents/index.md:119— "unconditionally receives the conversation whenever this agent produces a final response … guaranteeing deterministic pipelines"docs/concepts/multi-agent/index.md:147— "force_handoffremoves that uncertainty … the runtime itself routes the conversation"
So a force_handoff pipeline breaks with no error as soon as the model batches its transfers — the same quiet wrong-output class this PR is removing. Please either keep force_handoff reachable for pinned delegation children, or update both doc sites and the SwitchCurrentAgent comment in the same change to state the parallel-batch exception. (The PR body’s "the 2nd..Nth child is pinned" is also stale — all children are.)
| batchByTool := make(map[string]int, len(calls)) | ||
| for _, tc := range calls { | ||
| batchByTool[tc.Function.Name]++ | ||
| } |
There was a problem hiding this comment.
[should-fix] Keying the tally on tc.Function.Name protects transfer_task from its own siblings, but not from the other tool that reaches runForwarding. With a batch of [transfer_task, run_skill] the transfer sees concurrentSiblings == 1, stays solo, and swaps current; runSkillFork leaves its child unpinned for an unpinned parent (skill_runner.go:147), so the fork resolves from the swapped field.
A reproducer on this head expected SKILL-RAN-AS-ROOT but received SKILL-RAN-AS-WORKER; the child had AgentName == "". This is pre-existing rather than a regression, but the PR body’s claim that run_skill is fixed by the same change only holds for caller resolution, not child execution. Count the delegating tools together (or expose batch size so runSkillFork can pin when >1), and add a mixed transfer_task + run_skill batch test beside the think case.
| // same batch may already have swapped it, which would validate the handoff | ||
| // against the transfer target's handoffs list instead of the real | ||
| // caller's (#4156). | ||
| currentAgent := r.callerAgent(rt, sess) |
There was a problem hiding this comment.
[should-fix] This changes behavior outside the parallel-batch scenario. callerAgent falls back to resolveSessionAgent, which prefers sess.AgentName, so a handoff from a pinned background session now validates against the pinned agent’s handoffs instead of the foreground agent’s — then reaches setCurrentAgent(next.Name()) at line 815 with no pinned-session guard, mutating shared current state from a background task. That is what #3886 forbids, and this change appears to make the path newly reachable where it previously failed validation.
Intended? Please add a handoff-from-a-pinned-session test to make the intended behavior explicit. (#4242 covers the batch-clobber case, but not this one.)
|
👋 This PR has merge conflicts with the base branch. Please rebase or merge the latest base branch and resolve them. I've moved it to draft and added |
A pinned session (a background agent's session, or a child pinned by a parallel delegation batch) owns its own agent: the runtime's shared current agent belongs to the concurrent foreground loop and must not be mutated from it (docker#3886). handleHandoff resolved its caller from the batch snapshot, which falls back to session resolution, so a handoff from a pinned session started passing validation and then mutated that shared field. applyForceHandoff had the same shape, which is why loop.go skipped force_handoff for pinned sessions altogether — and why pinning a parallel batch's children silently disabled the "unconditional", "deterministic" routing the docs promise. Both now go through switchSessionAgent, which moves the session's own pin when it has one. That makes the skip unnecessary: the next loop iteration resolves the target instead of the agent that just stopped, and config validation already rejects force_handoff cycles, so the chain terminates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWthkrTCN7ZDCKVYQ9PXgN
The batch tally was keyed by tool name, so a lone transfer_task standing beside a handoff still counted itself solo. It claimed the runtime's shared current agent and restored the caller on the way out, silently undoing the handoff that ran next to it — whether the handoff survived depended on which goroutine finished last, the same quiet wrong-output class this series removes. The dispatcher now hands every call the whole per-tool tally, and ConcurrentCalls sums the tools a handler names: it knows which ones contend for what it is about to claim, the dispatcher does not. transfer_task names handoff alongside itself, so a mixed batch pins the delegation and the handoff's switch stands. handoffProbe now builds a fresh stream per invocation: parallel children all routing to the same force_handoff target reach the probe at once, and mockStream's cursor is single-use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWthkrTCN7ZDCKVYQ9PXgN
A batch of [transfer_task, run_skill] was still unguarded. The delegation saw no sibling of its own name, counted itself solo and swapped the shared current agent to its target; the fork skill's child, left unpinned, then resolved from that same field and ran as the delegation's target instead of as its caller. runSkillFork already resolves the caller before dispatch, so the child is now always pinned to it and never consults the shared field, whatever its siblings do. That closes the shape by construction rather than by naming run_skill in transfer_task's tally — handoff still needs the tally, since it moves the session's own agent and has no child to pin. handoffProbe drops its name parameter: every caller passed "finisher". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWthkrTCN7ZDCKVYQ9PXgN
Resolves status/needs-rebase. One conflict, in handleTaskTransfer's delegationRequest literal: main added directNativeTransfer, this branch added CallerAgent and ConcurrentBatch. Both kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWthkrTCN7ZDCKVYQ9PXgN
|
👋 Some commits in this PR are not signed and verified by GitHub. Please sign your commits with a GPG or SSH key registered in your GitHub account, then force-push. Commits that are not verified: See GitHub's guide on signing commits for setup instructions. I've added |
|
👋 This PR has merge conflicts with the base branch. Please rebase or merge the latest base branch and resolve them. I've moved it to draft and added |
Fixes #4156
Problem
When a model issues several
transfer_taskcalls in one API response,Dispatcher.Processruns them in parallel goroutines viaconcurrent.MapSlice. Each goroutine re-resolved its caller throughresolveSessionAgent, which reads the sharedagentRouter.currentthat a sibling'sswapCurrentAgenthad already mutated:The reported symptom is the loud one:
There is a quieter consequence too.
runForwardingonly pins the child session when the parent is already pinned, so a foreground delegation's child stays unpinned andloop.gore-resolves its agent fromcurrenton every iteration. With the deferred restores interleaving, a child could execute turns with another sibling's identity, toolset and model — no error, wrong output.validateDelegationinherits the same wrong caller, which can also produce a spuriousdelegation cycle detected.atomic.Pointerkeepscurrentfree of data races, but it cannot make the read → validate → mutate → read sequence serializable.Fix
Two orthogonal changes.
1. Resolve the caller from the batch snapshot.
Dispatcher.Processalready resolves the caller exactly once, before the fan-out, and stores it on thecallthat backs every handler'stools.Runtime. It just was not reachable.callRuntimenow exposes it, andLocalRuntime.callerAgentprefers it over session resolution, falling back for hosts that provide no snapshot. No handler signature changes —rt tools.Runtimewas already a parameter ofToolHandlerFunc.2. Let one delegation own the shared current agent.
runForwardingclaims it with a CAS; a sibling that loses the claim pins its child instead of stomping the swap. This is the same isolation mode background delegations already use.run_skillsharesrunForwardingand had the same latent defect when it landed in a batch alongsidetransfer_task; the same change fixes it.Behavior change
With a single
transfer_taskin the batch — the common case — behavior is unchanged: the swap happens, the child stays unpinned, and the existing foreground tests pass untouched.With N parallel transfers, the 2nd..Nth child is pinned. Pinned children do not take the
handoff/force_handoffpath (loop.goguards onsess.AgentName == ""). That is a pre-existing property of pinning, now reachable in a new scenario, and it is documented onSwitchCurrentAgent. If you would rather have no variance between the single and parallel cases, the alternative is to pin every child whenever the batch holds more than one delegation — same trade, but it gives up the untouched single-transfer path.Out of scope
run_background_agentresolves its caller the same way, butagenttool.Handler.HandleRundoes not receive atools.Runtime, so fixing it means changing a signature outsidepkg/runtime. Left for a follow-up; its children are already pinned, which bounds the impact.Testing
Two regression tests, both verified to fail with the source changes reverted:
TestTransferTask_UsesBatchCallerSnapshotNotSharedCurrentAgent— deterministic, reproduces the exact error string from the issue.TestTransferTask_ConcurrentForegroundTransfersStayIsolated— end-to-end throughRunStream→Dispatcher.Process→concurrent.MapSlice, with threetransfer_taskcalls in one assistant response. Without the fix two of the three children return no output and the race detector fires on a shared test stream, which is the cross-wiring made visible: two children had resolved to the same agent. With the fix it is stable over 20 runs under-race.task lint/task testwere run as their underlying commands, sincetaskis not installed locally.