fix(orchestrator): Wake settled parents when delegated children finish#4499
fix(orchestrator): Wake settled parents when delegated children finish#4499mwolson wants to merge 1 commit into
Conversation
An app-owned delegated child wrote its result into the parent projection but never started a parent run, so an async delegation's result sat unread until the user's next message. Offer a provider continuation request when a delegated child terminalizes, with a per-task completionWake policy separating async delegations from blocking waits. The wake must also reach the model. ProviderContinuationRequests is a provider-native mechanism whose dispatch only triggers ingestion of output the adapter already buffered, and both ClaudeAdapterV2 and AcpAdapterV2 discard the message text when it is marked creationSource provider. An app-owned child buffers nothing, so a wake marked that way settled instantly having prompted nothing. App-owned wakes now dispatch as message_text via a shared delegatedTaskWakeRequest helper used by both producers.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit cf467be. Configure here.
| * that lock with a full-row payload, and unserialized writers clobber each | ||
| * other (stale policy on the terminal row, or a terminal row regressed to | ||
| * running). | ||
| */ |
There was a problem hiding this comment.
Transfer guard blocks wake retry
Medium Severity
In finalizeAppOwnedSubagent, an existing subagent_result transfer causes an immediate return before the continuation wake is offered. The wake runs only after writeSystemEvents persists that transfer, so any retry or replay after a successful transfer write but before a successful offer permanently skips the parent wake even though the result is already on the parent.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit cf467be. Configure here.
ApprovabilityVerdict: Needs human review Introduces significant new orchestrator behavior (wake policies, new command type, lock ordering changes) rather than a simple fix. An unresolved medium-severity bug comment about wake retry being permanently skipped also warrants human review. You can customize Macroscope's approvability policy. Learn more. |


Summary
delegate_taskchildterminalizes, so an async delegation's result is acted on instead of waiting
for the user's next message.
completionWakepolicy so async delegations andblocking waits do not double-deliver.
if the provider never sees the text.
Problem and Fix
finalizeAppOwnedSubagentwrote the child result transfer but had no wake path, so the parent never ran again after an async delegation. The result card appeared and the thread simply parked.ProviderContinuationRequestfor the parent when a delegated child terminalizes. The existingProviderContinuationServiceworker dispatches it as aqueue_after_activemessage.dispatchthat starts a wake run.completionWakepolicy."always"for async delegations offers on every child terminal and queues behind a live parent run."settled_only"for wait-mode delegations, and for legacy records without the field, offers only when the parent has no run in preparing/starting/running.settled_onlywait child that terminalized under a live parent lost its wake permanently once the blocking wait had timed out.delegated_task.wake-policycommand. On wait timeout the MCP service upgrades the task to"always"best-effort, and for an already-terminal task it offers the wake directly when the parent has a live run, which is exactly the state where finalize's gate skipped it.startNextQueuedRununder the child lock, sequentially and never nested. Gates re-read run state fresh inside the parent lock.ProviderContinuationRequestsis provider-native: its dispatch only triggers ingestion of output an adapter already buffered, and bothClaudeAdapterV2andAcpAdapterV2discard the message text when it is markedcreationSource: "provider". An app-owned child buffers nothing, so the adapter drained an empty buffer and finalized the turn immediately.deliverytoProviderContinuationRequest.adapter_bufferedstays the default and preserves provider-native behavior;message_textdispatchescreationSource: "server", which no adapter mistakes for a buffered wake. Both offer sites build the request through onedelegatedTaskWakeRequesthelper so they cannot diverge.delegate_taskdescription now states that async children wake the thread, so callers should end the turn rather than poll or spawn watchers.Validation
completionWakeand thedelegated_task.wake-policycommand struct;ProviderContinuationServicetests for both delivery mappings; MCP toolkit integration tests covering
wait-completes, async-cancel under a live parent, wait-timeout upgrade, an
already-terminal upgrade in both sub-cases, and a legacy field-omitted child.
The integration probes assert
deliveryat every offer site.apps/serversuite passes on this base: 1674 passed, 10 skipped.vp checkandvp run typecheckclean.transcript rather than the projection, because a projected message does not
prove delivery:
task_status,and replied with the child's result. The wake run took 5.5s, versus an 89ms
empty run before the delivery fix.
alwayspath: the child terminalized 35s before the root turnended, the wake was requested while the parent was still live, queued behind
it, and was delivered exactly once after the root settled.
buffered CLI output and responds normally.
The unit and integration layers prove the offer and the delivery mapping; the
packaged run is what proves the model is actually prompted end to end.
Known limitations
the worker dispatch loses that wake on replay, since the transfer guard blocks
re-offering. A durable outbox keyed by the result transfer is the proper fix.
settled_onlywith a live parent, the parent settles, and a late upgrade thenalso skips because the parent is no longer live. The MCP path awaits the
upgrade inside the parent's turn, so this mostly affects direct command
callers. Tracking wake delivery explicitly, rather than dual-gating on
liveness, would close it.
finalizeAppOwnedSubagententirely, sorecovery-cancelled children produce no transfer and no wake. Pre-existing.
queue_after_activeonly queues behind blocking runs.
Note
Wake settled parent threads when delegated child tasks finish
completionWakepolicy to delegated tasks (alwaysorsettled_only) that controls when a parent thread is woken after a child terminates.alwaysfires unconditionally;settled_onlyfires only when the parent has no live run.delegated_task.wake-policycommand to update a task's wake policy after creation. Upgrading a terminal task toalwayswhile the parent is live immediately enqueues a continuation wake.alwaysbefore returning so the parent is notified when the child eventually finishes.message_textmode inProviderContinuationRequests, which causes a real prompt to be dispatched to the provider (withcreationSource: 'server') rather than relying on adapter buffers.Macroscope summarized cf467be.
Note
High Risk
Changes orchestration command handling, parent/child locking on terminal runs, and provider continuation delivery—areas where missed or duplicate wakes and prompt delivery bugs directly affect agent behavior.
Overview
Settled parent threads now get a continuation when app-owned delegated children finish, so async
delegate_taskresults are acted on instead of sitting in the projection until the user sends another message.Each delegated task carries
completionWake:alwaysfor async MCP delegations (wake on every terminal,queue_after_activebehind a live parent run) andsettled_onlyforwait(no wake while the parent run is preparing/starting/running; the blocking tool already returns the result). Legacy subagents without the field behave assettled_only.When
waittimes out, MCP best-effort upgrades the task viadelegated_task.wake-policytoalwaysso a later terminal can still wake the parent. The same command can retroactively offer a wake for an already-terminal task when finalize skipped undersettled_onlywith a live parent, without double-waking if the parent already settled.Wakes use
ProviderContinuationRequest.delivery: "message_text"andcreationSource: "server"so the prompt is not treated as adapter-buffered output (which adapters would discard).finalizeAppOwnedSubagentnow runs under the parent thread lock (child lock only forstartNextQueuedRun) to avoid races with wake-policy updates.The
delegate_tasktool description tells agents that async completion wakes the thread and they should end the turn instead of polling.Reviewed by Cursor Bugbot for commit cf467be. Bugbot is set up for automated code reviews on this repo. Configure here.