fix(tool): run background subagents without blocking the parent when the flag is on
Issue for this PR
Closes #1642
Type of change
- Bug fix
- New feature
- Refactor / code improvement
- Documentation
What does this PR do?
task threw on background: true without checking OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS. The flag only decided whether the registry kept background in 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).
- With the flag on,
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 }. - A later
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. - Every launched child keeps a record (its result, its error, or its partial findings when cancelled) for the life of the process, bounded to 100 records per parent session (oldest settled first). A second collect, parallel collects in one step, and a collect after an error all return the same recorded outcome; nothing re-prompts the child. A background child is therefore not resumable through its
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.- A parent may have at most 4 running background children; a fifth launch fails with a tool error asking it to collect one first.
- A launch on an already-aborted signal fails, as the synchronous path does, instead of reporting a running task.
2. Cancellation is visible and keeps partial findings.
- The child is bound to the launching call's
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). - A child cancelled before it was collected appends to the launch tool part: "Background task was cancelled at the end of the turn, before its result was collected. Its partial findings:" plus the recovered
<task_result>, withmetadata.interrupted: true. A later collect returns those partial findings withmetadata.interrupted: true. - A collect publishes the child's
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 from RuntimeFlags and 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 Effect Config.boolean parser (true/yes/on/1/y; TRUE is false). As with the other experiments, OPENCODE_EXPERIMENTAL=true also enables it.
4. Tool contract. With the flag on, the registry publishes background and replaces the task.txt sentence "For background tasks, you will be notified automatically…" (no notification exists) with: collect by calling task again with the task_id; ending the turn cancels uncollected tasks; a background task_id always returns the recorded result. With the flag off, the schema, the description and the error text are unchanged.
Not in this PR: the BackgroundJob service, 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, keep emptyTaskStatus inside run() 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, the trace-context-loop harness, parent and child on one mock server):
- the parent ends its turn without collecting: the launch part records the cancellation with the child's partial text; next turn's collect returns
Task interrupted…+ the partial text withinterrupted: true, and the child gets no new prompt; - two parallel collects in one step while the child runs, then a third collect in the next step: all three return the same
CHILDRESULT, one child request; - an errored child: two collects give the same outcome, one child request, no re-prompt;
- cancelling the parent while it is blocked on a collect: the collect part is
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 + running task_id returns at once; the cap; the flag parser matches RuntimeFlags for on/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.ts cases 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):
| suite | before (merged head bde819ee9d) | after (806ff2b9f3) |
|---|---|---|
test/tool | 692 pass, 0 fail | 699 pass, 0 fail |
test/session | 1262 pass, 0 fail | 1266 pass, 0 fail |
test/agent | 176 pass, 0 fail | 176 pass, 0 fail |
(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.ts and analyze.ts --markers --strict pass.
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): https://github.com/AltimateAI/altimate-code/pull/1643#issuecomment-6605547081. 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
- I have tested my changes locally
- I have not included unrelated changes in this PR
- I checked for cost side effects (model calls, queries or cloud resources that grow with user data) and described them, or wrote "none"
🤖 Generated with Claude Code
Summary by CodeRabbit
- New Features
- Added optional background task execution, allowing work to continue while you proceed. You can check whether a task is still running, join it, or collect its result later.
- Background tasks can return partial findings if cancelled. Up to four tasks can run at once, with recent task results retained for later collection.
- Bug Fixes
- Requests to run background tasks when the feature is disabled now return an explicit error.