Skip to content

feat(core): bound subagent concurrency - #63227

Open
darkmatter2222 wants to merge 14 commits into
anomalyco:devfrom
darkmatter2222:dev
Open

darkmatter2222 wants to merge 14 commits into
anomalyco:devfrom
darkmatter2222:dev

Conversation

@darkmatter2222

@darkmatter2222 darkmatter2222 commented Nov 11, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #63303
Closes #27110

Related #54935 (same cost story, no cap today).

Type of change

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

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_concurrency takes -1 or no value for unlimited (today's behavior), 0 for no subagents, or any positive count for a real cap.
  • experimental.subagent_over_limit decides what an over-limit call does. "fail" (the default) returns a tool error right away, "wait" queues it until a slot frees.
  • At a cap of 0 the subagent tool leaves the advertised tool list, so the model is never offered a call that can only fail.
  • Children recovered after a restart register against the parent's cap before they resume, so recovered work cannot push a parent over its cap. Registration never blocks or refuses — the work was already admitted before the restart — and the slot frees when the job settles.

Why it works:

  • The cap is per parent session, which is the unit a fan-out is planned in. The slots live in the existing global SubagentJob service as one Map<parentID, {children, waiting}>, the same shape as session/run-coordinator.ts. A limit < 0 short-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-key findLast scan instead of one Config.latest("experimental") block, so a global value survives a project's partial experimental block. subagent_depth keeps its whole-object read. This is the same choice Flag.resolve makes for all_flags.
  • A slot is taken before sessions.create, keyed by a pre-generated SessionSchema.ID.create(), so a rejected call leaves no orphan session. Release is idempotent and owned either by the call fiber (Effect.ensuring with a handoff flag) or, for backgrounded work, by the completion observer.
  • A follow-up on a running child (its sessionID passed 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 the experimental block, and scripted runs can set them through OPENCODE_CONFIG_CONTENT like 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, with CI=true.
    • test/session-subagent-job.test.ts is new: pure limits() 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 queued wait call woken by a release, a cancelled wait, recovered children counted against the cap and freed on release).
    • test/tool-subagent.test.ts adds 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, wait mode runs the queued call, and unlimited fan-out.
    • test/session-execution.test.ts extends 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.ts round-trips both keys and rejects -2, 1.5, "2", "queue"; test/config/normalization.test.ts shows a rejected leaf drops only itself.
  • Latest focused run after the review fixes (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.
  • Full core suite before and after, same machine and flags: 190 failures both ways with an identical failure-name set (the Windows toolchain set: printf/bash oracle specs, rg, symlinks). No new failure appears in the "after" run. test/plugin/optimize.test.ts passes 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/schema 93/0, packages/protocol 14/0, packages/client 255 pass. bun run check:generated exits 0 in packages/protocol and packages/client, and the regenerated openapi.json plus the generated client add only the two keys.
  • bun run check at the root: 35/35 tasks successful. bun typecheck in packages/schema and packages/core is clean (core: 1,961 files across 2 projects).
  • Two gates stop early on this Windows box for reasons unrelated to the change, both recorded here for completeness. @opencode/app#typecheck fails when the checkout has core.symlinks=false, because packages/app/src/custom-elements.d.ts is git mode 120000 and lands as a text file (TS1128); it is 35/35 with the symlink present. The standalone docs build stops at 89 unresolved ../../../../config.mjs imports under src/docs/v1/content/**, because setOf() in src/docs/vite.ts:73 compares a posix Vite id against path.join(...) + path.sep, which never matches on Windows; config.mdx itself compiles with no error before that point.

Visual

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72">
  <rect x="6" y="26" width="56" height="20" rx="6" fill="none" stroke="currentColor" stroke-opacity=".45"/>
  <text x="34" y="40" font-family="ui-monospace,monospace" font-size="9" text-anchor="middle" fill="currentColor" fill-opacity=".7">parent</text>
  <path d="M62 36h14" stroke="currentColor" stroke-opacity=".4"/>
  <g fill="none" stroke="currentColor" stroke-opacity=".45">
    <rect x="80" y="18" width="44" height="36" rx="7"/>
    <rect x="130" y="18" width="44" height="36" rx="7"/>
    <rect x="180" y="18" width="44" height="36" rx="7" stroke-dasharray="3 3" stroke-opacity=".3"/>
  </g>
  <g fill="currentColor">
    <circle cx="102" cy="30" r="5" fill-opacity=".8"/>
    <circle cx="152" cy="30" r="5" fill-opacity=".8">
      <animate attributeName="fill-opacity" values=".8;.8;.2;.2;.8" keyTimes="0;.45;.5;.9;1" dur="2.4s" repeatCount="indefinite"/>
    </circle>
    <circle cx="202" cy="30" r="5" fill-opacity=".18">
      <animate attributeName="fill-opacity" values=".18;.18;.8;.8;.18" keyTimes="0;.5;.55;.95;1" dur="2.4s" repeatCount="indefinite"/>
    </circle>
  </g>
  <text x="102" y="66" font-family="ui-monospace,monospace" font-size="8" text-anchor="middle" fill="currentColor" fill-opacity=".55">1</text>
  <text x="152" y="66" font-family="ui-monospace,monospace" font-size="8" text-anchor="middle" fill="currentColor" fill-opacity=".55">2</text>
  <text x="202" y="66" font-family="ui-monospace,monospace" font-size="8" text-anchor="middle" fill="currentColor" fill-opacity=".55">cap 2</text>
</svg>

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

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

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)

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
@github-actions github-actions Bot added needs:compliance This means the issue will auto-close after 2 hours. needs:title labels Nov 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Hey! Your PR title Subagent Limits doesn't follow conventional commit format.

Please update it to start with one of:

  • feat: or feat(scope): new feature
  • fix: or fix(scope): bug fix
  • docs: or docs(scope): documentation changes
  • chore: or chore(scope): maintenance tasks
  • refactor: or refactor(scope): code refactoring
  • test: or test(scope): adding or updating tests

Where scope is the package name (e.g., app, desktop, opencode).

See CONTRIBUTING.md for details.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) and experimental.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_CONTENT can 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.

Related: #27110, #54935, #50563

* `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", {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@darkmatter2222 darkmatter2222 changed the title Subagent Limits feat(core): bound subagent concurrency Nov 11, 2026
@github-actions github-actions Bot removed needs:compliance This means the issue will auto-close after 2 hours. needs:title labels Nov 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@darkmatter2222

Copy link
Copy Markdown
Author

Ready for a maintainer pass. Two clicks unblock it: Approve and wait on the five pending runs (check, check-test, check-docs, check-nix, check-bot-scope — action_required for a first-time fork contributor) and a review (mergeStateStatus: BLOCKED). check-compliance and check-standards are green and the needs:* labels are cleared.

The change in one breath — 11 files, +864/−57: two optional keys on the experimental block (subagent_concurrency, subagent_over_limit) plus OPENCODE_SUBAGENT_CONCURRENCY, with the slots kept in the existing SubagentJob service. The default is -1, which is today's behavior, so nothing moves until a config sets a key, and the unlimited path takes no bookkeeping at all (limit < 0 short-circuits).

Fastest review path

  • packages/core/src/session/subagent-job.ts — limits() precedence and the per-parent slot registry (acquire/release, the queued wait doorbell).
  • packages/core/src/tool/plugin/subagent.ts — cap enforcement before sessions.create, the handoff ownership split, and the tool being dropped from the advertised list at cap 0.
  • packages/core/test/session-subagent-job.test.ts and packages/core/test/tool-subagent.test.ts — the 82 cases behind the numbers.

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

Closes #63303 and #27110.

# Conflicts:
#	packages/core/src/session/subagent-job.ts
#	packages/core/src/tool/plugin/subagent.ts
#	packages/core/test/tool-subagent.test.ts

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three earlier points are fixed:

  • The environment variable is gone; the cap now lives only in config.
  • release now removes a parent's slot entry once it's empty.
  • Recovered children count against the cap through recover and 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.

Related: #54935, #50563

@darkmatter2222

Copy link
Copy Markdown
Author

The description was updated in the same pass as abf8fef — it no longer mentions OPENCODE_SUBAGENT_CONCURRENCY or a startup switch, and now documents the config-only cap and restart recovery behavior.

@opencodejr

opencodejr Bot commented Nov 12, 2026

Copy link
Copy Markdown
Contributor

Thanks. I checked both fixes in abf8fef in the last review. release now removes the empty slot entry, and recovered children take a slot through recover before they resume and free it when the job settles; the extended restart test covers that. The only thing left from my side is the PR description: it still mentions OPENCODE_SUBAGENT_CONCURRENCY and "a startup switch".

@darkmatter2222

Copy link
Copy Markdown
Author

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 experimental block, and scripted runs can set them through OPENCODE_CONFIG_CONTENT like every other setting." The env-var bullet and docs block were removed in the same pass as abf8fef; your review thread may be holding a pre-edit snapshot.

@opencodejr

opencodejr Bot commented Nov 12, 2026

Copy link
Copy Markdown
Contributor

You're right, thanks. The current description no longer mentions OPENCODE_SUBAGENT_CONCURRENCY or a startup switch; my note was based on an older copy. I have nothing open from my review now.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 recover and 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.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

This branch has not been deployed

No deployments
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]: Setting to limit max number of parallel subagents [FEATURE]: Cap how many subagents one session runs at once

1 participant