Repository navigation
feat(core): bound subagent concurrency - #63227
darkmatter2222 wants to merge 14 commits into
Conversation
Caps how many of one session subagents run at once with experimental.subagent_concurrency (-1 unlimited, 0 none, N a limit) and chooses over-limit behavior with experimental.subagent_over_limit (fail or wait). OPENCODE_SUBAGENT_CONCURRENCY outranks every config document, and a cap of 0 hides the subagent tool. Slots live in SubagentJob per parent session, keyed by child session ID, so a rejected call leaves no orphan session.
feat(core): bound subagent concurrency
|
Hey! Your PR title Please update it to start with one of:
Where See CONTRIBUTING.md for details. |
There was a problem hiding this comment.
The slot accounting looks sound, and I couldn't find a way it leaks or deadlocks. Nested calls are counted against their own parent, continuing a running child reuses its slot, a backgrounded child holds its slot until it settles, and both admission failures and cancelled waits give the slot back. I ran the new and nearby tests in packages/core and packages/schema and they pass. I didn't drive it end to end in the CLI, and I couldn't run the typecheck (bun check isn't available in this environment).
Public API impact (all additions, backwards compatible):
- Config:
experimental.subagent_concurrency(integer ≥ -1, default unlimited) andexperimental.subagent_over_limit("fail"|"wait", default"fail"), reflected in the OpenAPI spec and generated client types. - Environment variable:
OPENCODE_SUBAGENT_CONCURRENCY, which outranks every config file. - Behaviour: with a cap of
0, the subagent tool is no longer offered to the model.
Existing configs are unaffected.
Concerns:
- The new environment variable is extra public surface for one setting.
OPENCODE_CONFIG_CONTENTcan already set it for scripted runs (inline note). - The per-parent slot map never drops entries (inline note).
- Subagent jobs recovered on restart (
session/execution/restart.ts) don't take a slot. After a restart, a parent can end up with more running children than the cap until those settle. If that's intended, please say so in the docs.
#55983 adds the same experimental.subagent_concurrency key and is still open, so maintainers will need to pick one. Please also fill in the PR description and say how you tested it.
| * `OPENCODE_SUBAGENT_CONCURRENCY`: a startup override for `experimental.subagent_concurrency`, ahead of | ||
| * every config document, the way `OPENCODE_FLAGS` outranks `experimental.<flag>` in `Flag.resolve`. | ||
| */ | ||
| export const Concurrency = Context.Reference<string | undefined>("@opencode/SubagentJob/Concurrency", { |
There was a problem hiding this comment.
This adds a new public environment variable just for this one setting. Config already has OPENCODE_CONFIG_CONTENT for scripted runs, and OPENCODE_FLAGS is limited to flags. It also brings a second parsing and validation path (parseOverride, the ignored warning) alongside the schema. Could this be dropped so the setting lives only in config? That would also remove the env-var tests and docs.
There was a problem hiding this comment.
Done in abf8fef. OPENCODE_SUBAGENT_CONCURRENCY, parseOverride, and the ignored warning are gone — the cap lives only in config now, read per key by SubagentJob.limits(entries). Scripted runs can use OPENCODE_CONFIG_CONTENT. The env-var tests and the docs block are removed too.
| // One observer per job generation, including continuations of the same child. | ||
| const notifications = new Set<string>() | ||
| // Process-local, like the Job registry this mirrors: a restart re-registers recovered work. | ||
| const slots = new Map<SessionSchema.ID, Slots>() |
There was a problem hiding this comment.
Entries are added to slots for each parent session but never removed. release only empties the set, so a long-running server keeps one entry for every session that ever started a subagent under a positive cap. Deleting the entry in release when children is empty and nobody is waiting would fix it.
There was a problem hiding this comment.
Fixed in abf8fef. release now deletes the parent's entry once children is empty and nobody is waiting. A woken waiter recreates the entry on its retry (slots.get ?? create in acquire), covered by the existing queued-wait test.
The plugin fixture built the real Watcher node, so every it.effect test in that family waited on a native directory subscription whose only bound is an Effect.timeout. TestClock leaves that timeout inert, so a slow initial snapshot on the Windows runner blocks until the test times out at 30s. Watcher.testLayer runs the same layer with a synchronous native, so subscriptions, emit and subscriptions() behave the same without the unbounded wait.
|
Thanks for updating your PR! It now meets our contributing guidelines. 👍 |
There was a problem hiding this comment.
The new push merges dev and switches the plugin test layer to the in-memory watcher. That matches how other core tests set it up, and the subagent, config normalization and plugin optimize tests pass with it. The feature code is unchanged, so both earlier points are still open: the extra OPENCODE_SUBAGENT_CONCURRENCY environment variable, which OPENCODE_CONFIG_CONTENT already covers, and slot entries that are never removed. The earlier note also still applies: subagent jobs recovered after a restart don't take a slot.
The public API impact is as before. It adds experimental.subagent_concurrency, experimental.subagent_over_limit and the OPENCODE_SUBAGENT_CONCURRENCY environment variable, all backwards compatible.
There was a problem hiding this comment.
This push only merges dev. The feature code is unchanged, and its tests still pass on the merged branch. Both earlier points are still open: the extra OPENCODE_SUBAGENT_CONCURRENCY environment variable and slot entries that are never removed. Public API impact is the same as before: it adds experimental.subagent_concurrency, experimental.subagent_over_limit and the OPENCODE_SUBAGENT_CONCURRENCY environment variable, all backwards compatible.
|
Ready for a maintainer pass. Two clicks unblock it: Approve and wait on the five pending runs ( The change in one breath — 11 files, +864/−57: two optional keys on the Fastest review path
Verify locally in two commands cd packages/core && bun run script/test.ts test/session-subagent-job.test.ts test/tool-subagent.test.ts test/config/normalization.test.ts test/flag.test.ts # 82 pass, 0 fail
bun run check && (cd ../protocol && bun run check:generated) && (cd ../client && bun run check:generated) # 34/34, both exit 0 |
# Conflicts: # packages/core/src/session/subagent-job.ts # packages/core/src/tool/plugin/subagent.ts # packages/core/test/tool-subagent.test.ts
There was a problem hiding this comment.
The merges move the feature onto dev's always-background subagents, and the move looks correct. The job's run now releases its slot when it ends, a follow-up to a running child joins that run without taking a second slot, and if the call fails before the job starts, its slot is still freed. The subagent job, subagent tool, config normalization and session execution tests in packages/core pass on the merged branch. Both earlier points are still open: the extra environment variable and slot entries that are never removed. Restart recovery now releases a slot it never took (inline note).
The public API impact is as before. It adds experimental.subagent_concurrency, experimental.subagent_over_limit and the OPENCODE_SUBAGENT_CONCURRENCY environment variable, all backwards compatible.
| execution, | ||
| recovery, | ||
| background.notificationID, | ||
| subagents.release(recovery.parentSessionID, recovery.childSessionID), |
There was a problem hiding this comment.
Recovered children never call acquire, so this release does nothing. After a restart, those children run outside the cap, and the parent can start up to subagent_concurrency more on top of them. Either register recovered children in the parent's slots before resuming them, or drop this argument and document that the cap doesn't cover work recovered after a restart.
There was a problem hiding this comment.
Fixed in abf8fef. Restart now calls SubagentJob.recover(parent, child) before resuming each recovered child, counting it against the parent's cap. recover never blocks or refuses — the work was already admitted before the restart — and the job's existing release frees the slot when it settles. Covered by a new slot unit test and the extended restart recovery test in session-execution.test.ts: the recovered child holds the parent's only slot, a new acquire fails while it runs, and succeeds once the job settles.
Drop the OPENCODE_SUBAGENT_CONCURRENCY startup override so the cap lives only in config, delete a parent's slot entry once nothing holds or waits on it, and register restart-recovered children against the parent's cap before resuming them.
There was a problem hiding this comment.
All three earlier points are fixed:
- The environment variable is gone; the cap now lives only in config.
releasenow removes a parent's slot entry once it's empty.- Recovered children count against the cap through
recoverand give the slot back when they settle.
The subagent job, subagent tool, config normalization and session execution tests in packages/core pass. I didn't run it end to end, and I couldn't typecheck because the installed Bun has no bun check.
Public API impact: it adds the experimental.subagent_concurrency and experimental.subagent_over_limit config keys (also in the OpenAPI spec and generated client types). They are backwards compatible, and existing configs are unaffected.
The PR description still describes OPENCODE_SUBAGENT_CONCURRENCY and "a startup switch". Please update it so it matches the change.
|
The description was updated in the same pass as abf8fef — it no longer mentions |
|
Thanks. I checked both fixes in abf8fef in the last review. |
|
The live description no longer contains either phrase — verified just now against the current body. It opens with: "Adds a hard cap on how many of one session's subagents run at once, configurable from every config layer." and the flag paragraph reads: "Both keys already sit in the |
|
You're right, thanks. The current description no longer mentions |
There was a problem hiding this comment.
This push only merges dev and doesn't touch the pull request's own files. The subagent job, subagent tool and session execution tests in packages/core still pass. All three earlier points were fixed in abf8fef, so I'm resolving those threads:
- The cap lives only in config.
- A parent's slot entry is removed once it's empty.
- Recovered children count against the cap through
recoverand give the slot back when they settle.
Public API impact: it adds the experimental.subagent_concurrency and experimental.subagent_over_limit config keys, which are backwards compatible.
There was a problem hiding this comment.
This push only merges dev and doesn't touch the pull request's own files. The subagent job, subagent tool and session execution tests in packages/core still pass on the merged branch. All three earlier threads were fixed in abf8fef, so I'm resolving them:
- The environment variable is gone.
- Empty slot entries are removed.
- Recovered children count against the cap through
recover.
Public API impact: it adds the experimental.subagent_concurrency and experimental.subagent_over_limit config keys, which are backwards compatible.
There was a problem hiding this comment.
This push only merges dev. The merge brought in upstream changes to restart.ts, but the pull request's change there is unchanged: each recovered child is counted with recover before it resumes and released when it settles. The subagent job, subagent tool and session execution tests in packages/core pass on the merged branch.
All three earlier threads stay fixed, so I'm resolving them: the environment variable is gone, empty slot entries are removed, and recovered children count against the cap.
Public API impact: it adds the experimental.subagent_concurrency and experimental.subagent_over_limit config keys, which are backwards compatible.
There was a problem hiding this comment.
This push only merges dev. It brings in upstream wording changes to the subagent tool and an upstream test expectation update; the concurrency logic is unchanged. The subagent job, subagent tool and session execution tests in packages/core pass on the merged branch.
All three earlier threads stay fixed, so I'm resolving them: the environment variable is gone, empty slot entries are removed, and recovered children count against the cap.
Public API impact: it adds the experimental.subagent_concurrency and experimental.subagent_over_limit config keys, which are backwards compatible.
Issue for this PR
Closes #63303
Closes #27110
Related #54935 (same cost story, no cap today).
Type of change
What does this PR do?
Adds a hard cap on how many of one session's subagents run at once, configurable from every config layer.
experimental.subagent_concurrencytakes-1or no value for unlimited (today's behavior),0for no subagents, or any positive count for a real cap.experimental.subagent_over_limitdecides what an over-limit call does."fail"(the default) returns a tool error right away,"wait"queues it until a slot frees.0thesubagenttool leaves the advertised tool list, so the model is never offered a call that can only fail.Why it works:
SubagentJobservice as oneMap<parentID, {children, waiting}>, the same shape assession/run-coordinator.ts. Alimit < 0short-circuits with no bookkeeping, so the default path stays inert. A parent's entry is deleted once no child holds a slot and nobody waits, so a long-lived server keeps no per-session residue.SubagentJob.limits()reads both keys with a per-keyfindLastscan instead of oneConfig.latest("experimental")block, so a global value survives a project's partialexperimentalblock.subagent_depthkeeps its whole-object read. This is the same choiceFlag.resolvemakes forall_flags.sessions.create, keyed by a pre-generatedSessionSchema.ID.create(), so a rejected call leaves no orphan session. Release is idempotent and owned either by the call fiber (Effect.ensuringwith ahandoffflag) or, for backgrounded work, by the completion observer.sessionIDpassed back in) keeps its existing slot instead of budgeting a second one.No feature flag: the elected default (
-1) reproduces today's behavior exactly, so nothing user-visible changes until a config sets a key. Both keys already sit in theexperimentalblock, and scripted runs can set them throughOPENCODE_CONFIG_CONTENTlike every other setting.How did you verify your code works?
bun run script/test.ts(core's runner, which isolates HOME and sets the 30s timeout) over the touched files, withCI=true.test/session-subagent-job.test.tsis new: purelimits()precedence (no setting, each key from its own document, a later partial block does not shadow an earlier cap) plus slot semantics (unlimited stays unbudgeted, per-parent allowance, idempotent acquire and release, cap of zero in both modes, a queuedwaitcall woken by a release, a cancelled wait, recovered children counted against the cap and freed on release).test/tool-subagent.test.tsadds cap cases: cap of 1 fails a second call and frees the slot when the child settles, a rejected call creates no session, a follow-up needs no second slot, the tool stays listed at cap 2, no tool at cap 0,waitmode runs the queued call, and unlimited fan-out.test/session-execution.test.tsextends the restart recovery test: a recovered child holds its parent's only slot while it runs, a new acquire fails, and the slot frees once the job settles.packages/schema/test/config.test.tsround-trips both keys and rejects-2,1.5,"2","queue";test/config/normalization.test.tsshows a rejected leaf drops only itself.abf8fef60c): 74 pass, 1 fail across the three core files. The one failure (registers globally while resolving agents from the caller location, 30s timeout) reproduces on clean upstream on this Windows box and is unrelated to the cap.printf/bash oracle specs,rg, symlinks). No new failure appears in the "after" run.test/plugin/optimize.test.tspasses 12/12 there and 120/120 under--rerun-each 10, so the 30s Windows timeout from the first CI run is gone with the in-memory watcher in the plugin test layer.packages/schema93/0,packages/protocol14/0,packages/client255 pass.bun run check:generatedexits 0 inpackages/protocolandpackages/client, and the regeneratedopenapi.jsonplus the generated client add only the two keys.bun run checkat the root: 35/35 tasks successful.bun typecheckinpackages/schemaandpackages/coreis clean (core: 1,961 files across 2 projects).@opencode/app#typecheckfails when the checkout hascore.symlinks=false, becausepackages/app/src/custom-elements.d.tsis git mode120000and lands as a text file (TS1128); it is 35/35 with the symlink present. The standalone docs build stops at 89 unresolved../../../../config.mjsimports undersrc/docs/v1/content/**, becausesetOf()insrc/docs/vite.ts:73compares a posix Vite id againstpath.join(...) + path.sep, which never matches on Windows;config.mdxitself compiles with no error before that point.Visual
Two slots run, the third stays dashed until one frees.
Screenshots / recordings
No UI change. The user-facing surface is the two config keys and the new "Subagent concurrency" section in the config docs.
Checklist
Added by the opencode bot (AI-generated).
Related: #54935, #50563
Public API: adds experimental.subagent_concurrency and experimental.subagent_over_limit config keys (backwards compatible)