Skip to content

Commit 40359d4

Browse files
Claude Agentclaude
andcommitted
feat(opencode): fail-fast per-session subagent concurrency cap (nested-agents Issue 1)
Phase-2 Issue 1: bound how many subagents a SINGLE session may have running concurrently as its direct children. Implements design-final §10 variant (b) (fail-fast breadth cap per session) — explicitly NOT a tree-wide semaphore over foreground chains, which the design note proves deadlocks (two parallel L2 foreground parents each hold a permit and wait on an L3 child that can never get one). - subagent-limits.ts: SubagentConcurrencyError (TaggedErrorClass, mirrors SubagentTreeLimitError) + concurrencyError() with a model-directed message naming the limit; DEFAULT_SUBAGENT_CONCURRENCY = 8 (conservative; tree limit and depth limit stay the tree-wide ceilings); __testHooks.concurrency seam. - task.ts: per-SPAWNER running counter (keyed by ctx.sessionID, independent of the tree-root tree cap). Synchronous fail-fast gate before sessions.create (coordinated with the existing tree-cap/budget gates, none weakened); slot reserved synchronously, released on create failure via the existing onExit and on child-run end via a one-shot Effect.ensuring — robust against success, error and abort, so the abort cascade never hangs on a permit and the counter never leaks. Resumes/extends reuse a still-running child and never re-reserve. - test/session/nested-concurrency.test.ts: the deadlock counterexample (cap 2, two parallel L2 foreground tasks each with an L3 child → completes, no deadlock), fail-fast over-cap rejection, decrement-after-finish and decrement-on-abort (no leak), and per-spawner budget independence. - subagent-limits.test.ts: pins the constant, the error text, and the seam. Co-Authored-By: Claude Fable 5 <[email protected]>
1 parent 81ea40c commit 40359d4

4 files changed

Lines changed: 584 additions & 9 deletions

File tree

‎packages/opencode/src/session/subagent-limits.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,26 @@ export const HARD_MAX_DEPTH = 10
2828
*/
2929
export const SUBAGENT_TREE_LIMIT = 200
3030

31+
/**
32+
* Fail-fast breadth cap on the number of subagents a SINGLE session may have
33+
* running concurrently as its direct children (design-final §10 variant (b),
34+
* Phase-2 Issue 1). Keyed by the SPAWNING session, NOT the tree root: a
35+
* tree-wide semaphore over foreground chains deadlocks (proven counterexample —
36+
* two parallel L2 foreground parents each hold a permit and wait on an L3 child
37+
* that can never get one). Counting per spawner keeps every level's budget
38+
* independent, so a foreground parent never competes with its own descendants.
39+
*
40+
* The cap is fail-fast: an over-cap spawn raises SubagentConcurrencyError
41+
* immediately instead of queuing/blocking, which is what guarantees the abort
42+
* cascade can never hang on a permit (no waiter exists to interrupt).
43+
*
44+
* Default 8 is deliberately conservative: it is well above the typical handful
45+
* of parallel delegations a single agent issues, while still bounding a runaway
46+
* fan-out at one level. The lifetime SUBAGENT_TREE_LIMIT and the depth limit
47+
* remain the tree-wide ceilings; this only bounds INSTANTANEOUS width per node.
48+
*/
49+
export const DEFAULT_SUBAGENT_CONCURRENCY = 8
50+
3151
/**
3252
* Iteration cap for the `Session.lineage` parent-chain walk. Legitimate chains
3353
* are at most HARD_MAX_DEPTH long; exceeding this means corrupt or cyclic
@@ -96,6 +116,29 @@ export const treeLimitError = (input: { started: number; limit: number }) =>
96116
message: `Subagent limit reached: this session tree has already started ${input.started} of ${input.limit} subagents (the cap guards against runaway delegation). Finish the remaining work directly in this session.`,
97117
})
98118

119+
/**
120+
* Raised when a SINGLE session already has its fail-fast breadth cap of direct
121+
* child subagents running concurrently (DEFAULT_SUBAGENT_CONCURRENCY,
122+
* overridable via `__testHooks.concurrency`). Fail-fast by design (design-final
123+
* §10 variant (b)): the over-cap spawn is refused immediately, never queued, so
124+
* no waiter exists that an abort cascade could hang on. Tests match on the tag;
125+
* the message is model-directed and pinned once in subagent-limits.test.ts.
126+
*/
127+
export class SubagentConcurrencyError extends Schema.TaggedErrorClass<SubagentConcurrencyError>()(
128+
"SubagentConcurrencyError",
129+
{
130+
message: Schema.String,
131+
running: Schema.Finite,
132+
limit: Schema.Finite,
133+
},
134+
) {}
135+
136+
export const concurrencyError = (input: { running: number; limit: number }) =>
137+
new SubagentConcurrencyError({
138+
...input,
139+
message: `Subagent concurrency limit reached: this session already has ${input.running} of ${input.limit} subagents running at once (the cap bounds parallel fan-out). Wait for one to finish, or do the remaining work directly in this session, before delegating again.`,
140+
})
141+
99142
/**
100143
* Raised when `task_id` names an existing session that is NOT a direct child
101144
* of the caller — resuming ancestors or foreign sessions is refused instead of
@@ -150,6 +193,12 @@ export const lineageError = (input: { sessionID: SessionID }) =>
150193
*/
151194
export const __testHooks = {
152195
treeLimit: undefined as number | undefined,
196+
/**
197+
* Test seam for the per-session concurrency cap (Phase-2 Issue 1): when set,
198+
* the task tool gates on this value instead of DEFAULT_SUBAGENT_CONCURRENCY.
199+
* Inert in production.
200+
*/
201+
concurrency: undefined as number | undefined,
153202
}
154203

155204
export * as SubagentLimits from "./subagent-limits"

‎packages/opencode/src/tool/task.ts‎

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,17 @@ export const TaskTool = Tool.define(
114114
// resumes/extends do not count and workflow dispatches keep their own cap.
115115
const treeSpawnCounts = new Map<SessionID, number>()
116116

117+
// In-memory counter of subagents CURRENTLY RUNNING per SPAWNING session
118+
// (Phase-2 Issue 1, design-final §10 variant (b)). Keyed by the spawner,
119+
// NOT the tree root: a tree-wide semaphore over foreground chains deadlocks
120+
// (two parallel L2 foreground parents each hold a permit and wait on an L3
121+
// child that can never get one). Per-spawner keying gives every level its
122+
// own independent budget, so a foreground parent never competes with its
123+
// own descendants. Incremented synchronously at the spawn gate and
124+
// decremented via Effect.ensuring around the child run (robust against
125+
// success, error and abort) — no leak on any exit path.
126+
const concurrentChildCounts = new Map<SessionID, number>()
127+
117128
const run = Effect.fn("TaskTool.execute")(function* (
118129
params: Schema.Schema.Type<typeof Parameters>,
119130
ctx: Tool.Context,
@@ -183,6 +194,10 @@ export const TaskTool = Tool.define(
183194
pattern: "*" as const,
184195
action: "deny" as const,
185196
})) ?? []
197+
// `session` is set only on the resume/extend path; a fresh spawn (the
198+
// only path that creates a session) is what the breadth cap gates and
199+
// what its concurrency slot is bound to.
200+
const isNewSpawn = session === undefined
186201
const nextSession =
187202
session ??
188203
(yield* Effect.gen(function* () {
@@ -194,6 +209,17 @@ export const TaskTool = Tool.define(
194209
if (started >= treeLimit) {
195210
return yield* Effect.fail(SubagentLimits.treeLimitError({ started, limit: treeLimit }))
196211
}
212+
// Fail-fast breadth cap (design-final §10 variant (b)): how many
213+
// direct children THIS spawner already has running. Keyed by the
214+
// spawner (ctx.sessionID), independent of the tree-wide tree cap and
215+
// the depth limit. Over cap → typed error NOW, no queuing — that is
216+
// exactly what keeps the abort cascade from hanging on a permit.
217+
const concurrencyLimit =
218+
SubagentLimits.__testHooks.concurrency ?? SubagentLimits.DEFAULT_SUBAGENT_CONCURRENCY
219+
const running = concurrentChildCounts.get(ctx.sessionID) ?? 0
220+
if (running >= concurrencyLimit) {
221+
return yield* Effect.fail(SubagentLimits.concurrencyError({ running, limit: concurrencyLimit }))
222+
}
197223
// Budget spawn gate (design-final §4.6, soft cap): an exhausted
198224
// shared turn pool refuses NEW subagents while running ones finish.
199225
// No pool (the interactive default) means no gate. The cast narrows
@@ -206,15 +232,18 @@ export const TaskTool = Tool.define(
206232
return yield* Effect.fail(SubagentLimits.budgetError())
207233
}
208234
}
209-
// Reserve the slot SYNCHRONOUSLY before the async create (the
235+
// Reserve BOTH counters SYNCHRONOUSLY before the async create (the
210236
// TurnBudget.reserve pattern): Bun is single-threaded and there is
211-
// no yield between the `started` read above and this set, so N
212-
// parallel spawns each observe the updated count. Setting AFTER
237+
// no yield between the reads above and these sets, so N parallel
238+
// spawns each observe the updated counts. Setting AFTER
213239
// `sessions.create` (the pre-fix order) was a lost-update race —
214-
// every racer wrote back its stale `started + 1` and the cap was
215-
// systematically undercounted. Released again on create failure or
216-
// interrupt so a session that never existed cannot eat the cap.
240+
// every racer wrote back its stale count and the caps were
241+
// systematically undercounted. Both are released again on create
242+
// failure or interrupt so a session that never existed cannot eat a
243+
// slot; the concurrency slot's success-path release is bound to the
244+
// child run below (Effect.ensuring), not to create.
217245
treeSpawnCounts.set(rootID, started + 1)
246+
concurrentChildCounts.set(ctx.sessionID, running + 1)
218247
const created = yield* sessions
219248
.create({
220249
parentID: ctx.sessionID,
@@ -238,8 +267,10 @@ export const TaskTool = Tool.define(
238267
Exit.isSuccess(exit)
239268
? Effect.void
240269
: Effect.sync(() => {
241-
const current = treeSpawnCounts.get(rootID) ?? 0
242-
treeSpawnCounts.set(rootID, Math.max(0, current - 1))
270+
const currentTree = treeSpawnCounts.get(rootID) ?? 0
271+
treeSpawnCounts.set(rootID, Math.max(0, currentTree - 1))
272+
const currentConcurrent = concurrentChildCounts.get(ctx.sessionID) ?? 0
273+
concurrentChildCounts.set(ctx.sessionID, Math.max(0, currentConcurrent - 1))
243274
}),
244275
),
245276
)
@@ -364,6 +395,23 @@ export const TaskTool = Tool.define(
364395
}
365396
}
366397

398+
// Release the concurrency slot exactly when the child RUN ends (success,
399+
// error or interrupt) — and only for a fresh spawn, the path that
400+
// reserved one above. Bound to the child run via Effect.ensuring so it
401+
// fires for every exit, including a foreground abort and a detached
402+
// background completion: the slot is freed whenever the child stops
403+
// running. The release is ONE-SHOT so it can never double-decrement; a
404+
// resume reused a still-running child whose original spawn still holds
405+
// the slot (slotReleased starts true on that path, making release inert).
406+
let slotReleased = !isNewSpawn
407+
const releaseConcurrencySlot = Effect.sync(() => {
408+
if (slotReleased) return
409+
slotReleased = true
410+
const current = concurrentChildCounts.get(ctx.sessionID) ?? 0
411+
concurrentChildCounts.set(ctx.sessionID, Math.max(0, current - 1))
412+
})
413+
const childRun = isNewSpawn ? runTask().pipe(Effect.ensuring(releaseConcurrencySlot)) : runTask()
414+
367415
const info = yield* background.start({
368416
id: nextSession.id,
369417
type: id,
@@ -376,7 +424,7 @@ export const TaskTool = Tool.define(
376424
}),
377425
notify(nextSession.id),
378426
]),
379-
run: runTask().pipe(Effect.onInterrupt(() => ops.cancel(nextSession.id))),
427+
run: childRun.pipe(Effect.onInterrupt(() => ops.cancel(nextSession.id))),
380428
})
381429

382430
function backgroundResult() {

0 commit comments

Comments
 (0)