Skip to content

feat(opencode): cap direct subagent children per session - #38954

Open
iceteaSA wants to merge 1 commit into
anomalyco:devfrom
iceteaSA:subagent-breadth
Open

feat(opencode): cap direct subagent children per session#38954
iceteaSA wants to merge 1 commit into
anomalyco:devfrom
iceteaSA:subagent-breadth

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Jul 26, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #38960

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

subagent_depth bounds 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 in packages/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 KeyedMutex so 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?

  • 4 new tests in packages/opencode/test/tool/task.test.ts: under the cap, at the cap, root exemption, and concurrent spawn.
  • The mutex's serialization is proven in packages/core/test/effect/keyed-mutex.test.ts rather than at the tool level — TaskTool captures its services at init time inside Effect.fn closures, so there is no seam to inject a yield point into the lock's critical section from a tool test. Neutering withLock to 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.
  • Root exemption is load-bearing, not decorative: removing depth > 0 breaks root dispatch at limit=1.
  • bun test in packages/opencode passes; bun typecheck clean.

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

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE]: Subagent fan-out is bounded by depth but not by breadth

1 participant