Repository navigation
fix(tool): run background subagents without blocking the parent when the flag is on - #1643
RaghwendraSingh wants to merge 3 commits into
Conversation
…the flag is on `task` threw on `background: true` unconditionally. The `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` flag only toggled the schema field, and the task tool's own JSON schema stripped `background` before the registry saw it, so the field was never offered and the behaviour was never enabled. Every subagent call blocked the parent until the child finished. - With the flag on, `background: true` starts the child prompt without awaiting it and returns at once with `task_id: <child session id>` and a one-line note. A later `task` call with that `task_id` joins: it awaits the child if still running and returns the same output shape as a synchronous task. Joining does not re-prompt the child. - The child keeps the abort listener and `RunBudget.registerQueryDeadline` registration of a synchronous child until it settles. The tool-call signal is the parent loop's generation signal, which the loop aborts on cancel, on a hard run deadline, and in its cleanup when the run ends, so children still running when the parent's run ends are cancelled. - The registry adds `background` to the task schema when the flag is on. With the flag off, the schema and the error text are unchanged. - Replaced the skipped `BackgroundJob`-based tests with tests for the flag-off error, launch then join (with run-budget inheritance), and parent abort; added a registry test for the flag-on schema. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. |
|
👋 This PR was automatically closed by our quality checks. Common reasons:
If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you. |
Gateway run: background subagent with the flag onOne headless
The join is explicit (a second |
sahrizvi
left a comment
There was a problem hiding this comment.
Changes requested. The launch and join path works, and with the flag off nothing changes. But with the flag on, a child's result can be lost or silently replaced, so there are 3 open major findings, each reproduced. All findings are inline.
Major
- If the parent ends its turn without joining, the child is silently killed, while
task.txt:17tells the model it will be notified. - A
task_idcall after the entry is gone re-prompts the child, and a killed child's result is replaced by a fresh answer with no interruption marker. - Cancelling while blocked on a join loses the partial findings the synchronous path keeps.
Minor (none blocks by itself)
- Two flag parsers disagree on
on,yandTRUE. - The signal comment and the soft-interrupt claim are inaccurate.
- There is no cap on background children.
background: truewith a runningtask_idblocks.- A launch on an already-aborted signal reports success.
- The new tests don't exercise the real loop.
Verified
- Flag off: the registry's task schema and description have the same sha256 at base and head, and the error text is unchanged.
- New tests: the two new behaviour tests fail at base and pass at head.
- Suites:
test/session,test/toolandtest/agent, run one after the other on a quiet machine, gave head 2127 pass / 2 fail and base 2124 pass / 2 fail. The same 2 tests fail on both, so there are no head-only failures. Those runs used an install without native builds, so the DuckDB binding was missing; that is the error I saw at those failures. - Guards: the tracker-leak check and the strict marker check both pass.
Everything else is in the PR comment.
| metadata: { sessionId: session.id, model, background: true }, | ||
| output: [ | ||
| `task_id: ${session.id}`, | ||
| "Task is running in the background. Call task again with this task_id to wait for and collect its result.", |
There was a problem hiding this comment.
Major: if the parent ends its turn without joining, the child is silently killed, and the tool description tells the model the opposite.
With the flag on, task.txt:17 still says "For background tasks, you will be notified automatically when the result is ready." No notification exists, and this launch output doesn't say that ending the turn cancels the child. A model that follows task.txt ends its turn. The loop cleanup (session/prompt.ts:724) then aborts the signal and the child is cancelled. Nothing tells the parent or the user.
Reproduced through the real prompt loop with the mock LLM server. The parent replied "Started it. I will be notified when ready." and prompt() returned. The child session ended with MessageAbortedError, and its partial text was left unread in the database.
The same thing happens if compaction drops the id from the parent's context, because nothing lists running children (from reading the code, not run).
Suggested fix: gate the task.txt sentence on the flag and state the join requirement, add "finishing your turn cancels it" to this output, and have the parent loop surface unjoined children before it exits, by joining or reporting them.
There was a problem hiding this comment.
Fixed in 806ff2b: the launch output and the flag-on task description now say the result is collected with task_id and is cancelled if the turn ends first, and an uncollected cancelled child appends its partial findings to the launch tool part (loop test: background-task-loop.test.ts).
| } | ||
| const pending = params.task_id ? backgroundTasks.get(params.task_id) : undefined | ||
| if (params.task_id && pending?.parentSessionID === ctx.sessionID) { | ||
| backgroundTasks.delete(params.task_id) |
There was a problem hiding this comment.
Major: once this entry is gone, a task_id call re-prompts the child instead of collecting its result, and a killed child's outcome is replaced by a fresh answer with no interruption marker.
The entry is deleted here on first join and at line 338 on abort. Later calls take the resume path and send the join's prompt to the child as new work. Reproduced in four cases:
- Child killed at end of turn: the next turn's collect call sent "collect the result" to the child and returned a normal
<task_result>with the new answer. There was nointerruptedflag, and the partial findings never surfaced. - Second join after success: one more model call, returning a new answer.
- Errored child: the first join correctly fails, then the second join re-runs the child and returns a normal result.
- Two parallel joins in one step: the first gets the result, and the second prompts the still-running child (prompts seen:
LAUNCH,JB).
The PR description says "the join then returns the partial findings". That only holds for a join already in flight when the abort fires. The launch output tells the model to collect with this same call, so it can't tell it has asked for new work.
Suggested fix: keep a record of launched children past abort and first join, evicted when the session ends. A task_id naming a cancelled child then returns interruptedTaskOutput(child, messageID) or an explicit "cancelled" result, and a repeat join returns the stored result. Resuming should then be something the model asks for explicitly, not a side effect of a collect call.
There was a problem hiding this comment.
Fixed in 806ff2b: every child keeps a recorded outcome (result, error, or partial findings marked interrupted), so repeated, parallel, post-error and post-cancel collects return that outcome and never re-prompt; loop tests cover all four cases.
| const pending = params.task_id ? backgroundTasks.get(params.task_id) : undefined | ||
| if (params.task_id && pending?.parentSessionID === ctx.sessionID) { | ||
| backgroundTasks.delete(params.task_id) | ||
| return await pending.result |
There was a problem hiding this comment.
Major: cancelling or hitting the hard deadline while blocked on a join loses the child's partial findings. The synchronous path keeps them.
interruptedToolPart (session/processor.ts:64) recovers partial findings only when the running tool part's metadata has sessionId and messageID. The synchronous path publishes them through ctx.metadata, but this join returns before that code.
Same scenario in both modes through the real loop, cancelling the parent while the child is mid-stream:
| path | parent's task part |
|---|---|
| synchronous | completed, "Task interrupted. These are partial findings from before cancellation: PARTIALFINDING" |
| join | error, "Tool execution aborted", no findings |
The test cancelled through SessionPrompt.cancel. A hard deadline aborts the same signal and goes through the same recovery code, but I didn't run that case separately.
Suggested fix: store messageID and model in the map entry and call await ctx.metadata({ title: params.description, metadata: { sessionId, messageID, model } }) before awaiting pending.result.
There was a problem hiding this comment.
Fixed in 806ff2b: a collect publishes sessionId/messageID/model on its running tool part before waiting, so cancelling during a collect now gives completed with "Task interrupted. These are partial findings…" (loop test).
| /** Env fallback for OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS, shared with the registry's schema gate. */ | ||
| export function backgroundSubagentsEnabled() { | ||
| const value = process.env["OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"] | ||
| if (value !== undefined) return ["1", "true", "yes"].includes(value.toLowerCase()) |
There was a problem hiding this comment.
Minor (does not block: edge input): two parsers decide this flag.
The schema gate uses RuntimeFlags (Effect Config.boolean, effect/runtime-flags.ts:12), and production always passes those flags (registry.ts:632). Execution uses this 1/true/yes list. Results from running both:
=onand=y: the runtime flag is true, sobackgroundis offered, and then this check rejects the call, telling the user to set the flag they already set. That is the original bug on these values.=TRUE: the reverse. The field isn't offered, but a hand-sent call is accepted.
The fallback the description calls shared only runs when flags are absent. Suggested fix: read one source in both places.
There was a problem hiding this comment.
Fixed in 806ff2b: the registry passes its RuntimeFlags decision to execution (extra.backgroundSubagents), and the env fallback uses the same Effect Config.boolean parser; a test pins on/y/1/yes/true/TRUE/false/0/off.
|
|
||
| /** | ||
| * Background children keyed by child session id. Each child is bound to the abort signal of the | ||
| * parent tool call that started it. That signal is the parent prompt loop's generation signal, |
There was a problem hiding this comment.
Minor (does not block: documentation): this comment and the PR description misdescribe the signal.
For a model tool call, ctx.abort is options.abortSignal (session/prompt.ts:2837), which is a per-request AbortSignal.any (session/processor.ts:512), not the generation signal. The subtask path does use the generation signal, but it never passes background.
Cancel, hard deadline and end of loop all still reach the child, and the end-of-loop case is confirmed by test. A soft interrupt, however, only aborts the request it targets, so it reaches a child launched in that same request. A child launched in an earlier step should survive it (from reading the code, not run). The description's "a soft run-budget interrupt ... also cancels running background children" is therefore not generally true.
"Run" here also means one loop generation, not a whole CLI run. Please correct both texts and decide which soft-interrupt behaviour you want.
There was a problem hiding this comment.
Fixed in 806ff2b: the comment and PR body now describe the per-request AbortSignal.any and say a soft interrupt reaches only children launched in that request; I kept that behaviour.
| // settles, so a background child remains bound to the parent's abort signal and run budget. | ||
| const result = run() | ||
| result.catch((err) => log.warn("background task failed", { sessionID: session.id, err })) | ||
| backgroundTasks.set(session.id, { parentSessionID: ctx.sessionID, result }) |
There was a problem hiding this comment.
Minor (does not block: flag-gated, safe in a follow-up): no cap on background children.
Launches accumulate across parent steps, each with its own concurrent provider stream, until the loop ends. Parallel calls in one step are already unbounded, but background children add up over the whole run. Suggested fix: a per-parent limit that returns a clear tool error.
There was a problem hiding this comment.
Fixed in 806ff2b: at most 4 running background children per parent; a fifth launch fails with a tool error asking to collect one first.
| throw new Error("Background subagents require OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true") | ||
| } | ||
| const pending = params.task_id ? backgroundTasks.get(params.task_id) : undefined | ||
| if (params.task_id && pending?.parentSessionID === ctx.sessionID) { |
There was a problem hiding this comment.
Minor (does not block: edge input): background: true with the task_id of a running child blocks.
This branch takes the call as a join before it looks at background, so the parent waits for the child. The field's description says "return immediately". Reproduced. Suggested fix: return the running status at once, or reject the combination.
There was a problem hiding this comment.
Fixed in 806ff2b: background: true with a running task_id now returns "still running" at once.
|
|
||
| // The abort listener and query budget registered inside run() stay in place until the child | ||
| // settles, so a background child remains bound to the parent's abort signal and run budget. | ||
| const result = run() |
There was a problem hiding this comment.
Minor (does not block: narrow window, a later join reports AbortError): launching on an already-aborted signal reports success.
run() rejects at throwIfAborted, but this path still returns "Task is running in the background" for a child that was never prompted. Reproduced. An abort listener added to an already-aborted signal doesn't fire (confirmed in Bun), so the entry at line 338 stays until a join. The synchronous path rejects in this case, and task.test.ts:754 covers it. Suggested fix: call ctx.abort.throwIfAborted() before detaching.
There was a problem hiding this comment.
Fixed in 806ff2b: the launch calls ctx.abort.throwIfAborted() before detaching, so an aborted launch fails and never prompts the child.
| const { chat, assistant } = yield* seed() | ||
| const tool = yield* TaskTool | ||
| const def = yield* tool.init() | ||
| background.instance("parent abort cancels a running background child", () => |
There was a problem hiding this comment.
Minor (does not block: test-only, but the fixes above need these tests): the new tests don't reach the paths that break.
Both tests are real and run: they fail at base and pass at head. But they use a stub prompt and a plain AbortController, so these cases aren't covered:
- the real prompt loop and its per-request signal
- a parent that ends its turn without joining
- an errored child
- a second join or parallel joins
- a cancel during a join
- the
task.txtwording
test/session/trace-context-loop.test.ts shows the loop-plus-mock-LLM setup. My probes used it and reproduced each major finding.
There was a problem hiding this comment.
Fixed in 806ff2b: added test/session/background-task-loop.test.ts (real loop + mock LLM: end of turn without collect, parallel/repeated collects, errored child, cancel during collect) and registry tests for the flag-on/off description.
|
Review notes that don't map to a diff line, plus nits. The formal review has the inline findings. Sibling PRs
Proof and description
Scope of the flag
Nits
Checked, no defect
|
…ncellation, never re-prompt on collect - Every launched child keeps a record (result, error, or partial findings when cancelled); a second, parallel or post-error collect returns the same outcome and never sends the child new work - A child cancelled before it was collected appends the cancellation and its partial findings to the launch tool part; a later collect returns them with `metadata.interrupted: true` - A collect publishes the child's `sessionId`/`messageID` on its running tool part before waiting, so cancel or a hard deadline during the wait recovers the partial findings as the synchronous path does - Launch output and, with the flag on, the task description say results are collected with `task_id` and that ending the turn cancels uncollected tasks - The registry passes its `RuntimeFlags` decision to execution; the env fallback uses the same Effect `Config.boolean` parser - `background: true` with a running `task_id` returns its status at once; a launch on an aborted signal fails; at most 4 running children per parent - Real prompt loop tests with a mock LLM for end of turn, parallel/repeated collects, an errored child, and cancel during a collect Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
👋 This PR was automatically closed by our quality checks. Common reasons:
If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you. |
|
Addressed review in 806ff2b (after merging main): all 3 majors (no silent kill at end of turn: launch text and description say collect-or-cancel, plus a cancellation record on the launch part; a recorded outcome per child, so collects never re-prompt; collect publishes child identity so cancel keeps partial findings) and all 6 minors (one flag parser, signal comment, cap of 4, |
Issue for this PR
Closes #1642
Type of change
What does this PR do?
taskthrew onbackground: truewithout checkingOPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS. The flag only decided whether the registry keptbackgroundin the task schema, and the task tool's JSON schema had already removed it, so the field was never offered and the behaviour never ran. The parent always waited for the whole subagent.1. Launch and collect (
tool/task.ts).background: truestarts the child prompt without awaiting it and returns at once. Output:task_id: <child session id>plus "Task is running in the background. Its result is collected by calling task again with task_id=; it is cancelled if your turn ends before you collect it." Metadata:{ sessionId, model, background: true }.taskcall from the same parent session that passes thattask_idcollects it. If the child is still running it waits; the result is the synchronous output (<task_result>…) with the first linetask_id: <id> (background task: calling task with this task_id returns this same result). The collect does not send itspromptto the child.task_id; start a new task to continue the work.background: truewith thetask_idof a running child returns "still running" at once instead of blocking.2. Cancellation is visible and keeps partial findings.
ctx.abort, a per-requestAbortSignal.anyover the loop generation's signal. Cancel, a hard run deadline and the loop's cleanup at the end of the turn abort the generation signal, so they cancel a running child. A soft run-budget interrupt aborts only the request it targets: it cancels a child launched in that request, not one launched in an earlier step (kept as is).<task_result>, withmetadata.interrupted: true. A later collect returns those partial findings withmetadata.interrupted: true.sessionId/messageIDon its own tool part before it waits (after the loop has marked the part running), so cancelling or hitting the deadline while blocked on a collect recovers "Task interrupted. These are partial findings…" as the synchronous path does.3. One flag source (
tool/registry.ts). The registry decides the flag fromRuntimeFlagsand passes that decision to execution (extra.backgroundSubagents), so the schema gate and the execution gate cannot disagree. Without runtime flags (the imperative prompt path) both read the environment through the same EffectConfig.booleanparser (true/yes/on/1/y;TRUEis false). As with the other experiments,OPENCODE_EXPERIMENTAL=truealso enables it.4. Tool contract. With the flag on, the registry publishes
backgroundand replaces thetask.txtsentence "For background tasks, you will be notified automatically…" (no notification exists) with: collect by calling task again with thetask_id; ending the turn cancels uncollected tasks; a backgroundtask_idalways returns the recorded result. With the flag off, the schema, the description and the error text are unchanged.Not in this PR: the
BackgroundJobservice, promotion of running foreground tasks, notifying the parent when a child finishes, and cancelling children on session removal. The "promotes a running foreground task" test stays skipped.Merge note: #1632 edits the block this PR moves into
run(); when it lands, keepemptyTaskStatusinsiderun()so a collected child gets it too.How did you verify your code works?
New tests through the real prompt loop with a mock LLM (
test/session/background-task-loop.test.ts, thetrace-context-loopharness, parent and child on one mock server):Task interrupted…+ the partial text withinterrupted: true, and the child gets no new prompt;CHILDRESULT, one child request;completedwith "Task interrupted. These are partial findings…" and the child's text (waserror, "Tool execution aborted").Unit tests:
test/tool/task.test.ts(already-aborted launch fails and never prompts;background: true+ runningtask_idreturns at once; the cap; the flag parser matchesRuntimeFlagsforon/y/1/yes/true/TRUE/false/0/off),test/tool/registry.test.ts(flag-off description unchanged; flag-on description says collect + cancel and drops "notified automatically"; execution follows the registry's flag with the env unset).All four loop tests, the registry description test and the registry execution-flag test fail on the previous head; the new
task.test.tscases need exports added here.No-regression protocol (
test/tool,test/session,test/agent, one after the other, same machine, on the merged branch before and after this change):bde819ee9d)806ff2b9f3)test/tooltest/sessiontest/agent(fail)list before: none.(fail)list after: none. The touched files (task.test.ts,registry.test.ts,background-task-loop.test.ts) passed 3 runs in a row. Typecheck clean;script/check-tracker-leaks.tsandanalyze.ts --markers --strictpass.Gateway run: one headless run on the real gateway with the flag on, on a local branch that stacks this PR with #1631, #1632, #1639 and #1641 (not this PR alone): #1643 (comment). It shows that the parent keeps working while a child runs. It does not show a cache saving: the worker ran for 0.7 minutes, well inside the cache lifetime.
Cost side effects
Adds no model calls or queries. With the flag on, the parent can keep working while a child runs. That saves cache only when the parent has other work to do and the child outlives the cache lifetime: a parent that collects straight away still blocks, and no run has measured the saving yet. Children cannot outlive the parent's turn, at most 4 run at once per parent, and records are capped at 100 per parent. Flag off: no change.
Screenshots / recordings
N/A
Checklist
🤖 Generated with Claude Code