feat(opencode): cap direct subagent children per session - #38954
Open
iceteaSA wants to merge 1 commit into
Open
Conversation
1 task
iceteaSA
force-pushed
the
subagent-breadth
branch
2 times, most recently
from
August 2, 2026 11:45
2cf6eb9 to
38853b5
Compare
iceteaSA
force-pushed
the
subagent-breadth
branch
from
August 8, 2026 20:06
38853b5 to
b297c3b
Compare
openchat-ai
pushed a commit
to openchat-ai/opencode
that referenced
this pull request
Aug 10, 2026
iceteaSA
force-pushed
the
subagent-breadth
branch
from
August 19, 2026 18:18
b297c3b to
5cef8f6
Compare
Depth was the only subagent fan-out bound. Once an operator raises subagent_depth, a runaway changes shape from 'a child loops' to 'a child loops spawning' — every spawn billable, none reachable. The per-parent lifetime cap (subagent_max_children, default 32) applies only to subagent spawners (depth > 0). Root sessions are exempt — the orchestrator is operator-supervised and legitimately dispatches hundreds sequentially. A subagent spawning 32 children in its lifetime is almost certainly pathological. Default 32 is generous for legitimate nested use and still stops a runaway promptly. With default subagent_depth: 1 the cap is inert; it exists to make raising subagent_depth safe. The count-and-create critical section is held under a per-parent KeyedMutex — serialization is verified by the primitive tests in packages/core/test/effect/keyed-mutex.test.ts.
iceteaSA
force-pushed
the
subagent-breadth
branch
from
August 22, 2026 19:32
5cef8f6 to
0081aa2
Compare
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.
Issue for this PR
Closes #38960
Type of change
What does this PR do?
subagent_depthbounds how deep a subagent tree can go, but nothing bounds how wide it gets. A subagent in a loop can spawn children indefinitely — each one a real session with real token spend — and the only thing that stops it is the operator noticing.Adds
subagent_max_children(default 32), enforced at spawn time inpackages/opencode/src/tool/task.ts.Two details that are load-bearing:
Root sessions are exempt. The check only applies at
depth > 0. A root orchestrator legitimately dispatches hundreds of subagents over a long session, sequentially — capping that would break normal use. The pathological case is a subagent spawning 32 children, which no reasonable task does.The count is lifetime, not concurrent.
sessions.children()returns every child ever created for that parent, not just live ones. That is deliberate: a live-only count would completely miss a sequential spawn loop, which is the failure mode most likely to run unattended. The tradeoff is that a long-lived subagent doing legitimate repeated dispatch will eventually hit the cap — the root exemption is what keeps that from affecting the common case.Count-and-create runs inside a per-parent
KeyedMutexso two concurrent spawns from the same parent cannot both read a stale count and both create. Session execution is process-local, so a per-parent lock is sufficient.How did you verify your code works?
packages/opencode/test/tool/task.test.ts: under the cap, at the cap, root exemption, and concurrent spawn.packages/core/test/effect/keyed-mutex.test.tsrather than at the tool level —TaskToolcaptures its services at init time insideEffect.fnclosures, so there is no seam to inject a yield point into the lock's critical section from a tool test. NeuteringwithLockto the identity function turns 2 of the 3 primitive tests red; the tool-level test is a smoke check with a comment pointing at the primitive suite.depth > 0breaks root dispatch atlimit=1.bun testinpackages/opencodepasses;bun typecheckclean.Reviewed independently before submission; the first review caught that the original implementation counted children for root sessions too, which would have poisoned any long-lived orchestrator after 32 dispatches.
Screenshots / recordings
Not a UI change.
Checklist