Summary
A headless workspace turn started with plain workspace/sendMessage ({model, agentId}, no client attached) is silently cut — finishReason: "tool-calls", no assistant text, nothing resumes — when the turn runs a background bash with a monitor whose process settles before task_await is issued.
Deterministic repro (v0.28.3, Linux server mode)
mux api workspace create --project-path <any repo> --trunk-branch main --title t --branch-name b
mux api workspace send-message --workspace-id <id> --options '{"model":"anthropic:claude-fable-5","agentId":"exec"}' --message '<prompt>' where the prompt instructs: run the bash tool with script true, run_in_background: true, monitor: {"filter":"x","wake_on_exit":true}; then task_await that task; then reply with the single word SURVIVED.
- Inspect
~/.mux/sessions/<id>/chat.jsonl: the last assistant row ends finishReason: "tool-calls" with only dynamic-tool parts and no text. The turn never continues.
Control: same prompt with sleep 5 instead of true → the model replies SURVIVED (finishReason: "stop").
Notes: the same prompt sent as a delegated task(kind="workspace") turn does NOT reproduce (delegated-turn continuation machinery resumes it), and interactive UI sessions are unaffected (wake resolves no send options and stays pending).
Mechanism (from reading dist 0.28.3)
- The monitor settlement wake (
workspaceService.js dispatchBashMonitorWake) resolves send options via getDelegatedTurnContinuationSendOptions() ?? getWorkflowContinuationSendOptions(). For a plain headless turn the delegated lookup is null, but getWorkflowContinuationSendOptions → getGoalContinuationKickoffSendOptions has no goal guard — it returns kickoff options for any registered workspace. The wake is therefore queued with queueDispatchMode: "tool-end".
- The queued tool-end message arms a stream cut (
requestQueuedProviderToolEndDispatch soft-stops the stream at the next tool boundary).
- The in-flight blocking
task_await auto-consumes the settlement signal; bashMonitorWakeReconciler.reconcileOnce collects zero signals and aborts the dispatch, removing the queued wake (removableQueueDedupeKey / cancelBeforeAcceptance).
- The stream-abort handler deliberately skips auto-retry for queued-provider-tool-end aborts (it expects the queued message to continue the turn);
dispatchQueuedProviderToolEndMessageAfterAbort finds an empty queue → phase IDLE. "aborted" is non-retryable, so nothing ever resumes the turn.
Suggested fix directions
Either (a) do not arm the tool-end cut until the wake dispatch is accepted, or (b) when a tool-end-queued message is canceled/removed after the cut was armed and nothing else was dispatched, resume the cut turn (the auto-retry resume machinery already handles this: lastAutoRetryResumeRequest survives the abort). We are running (b) locally as a dist patch: https://github.com/unthought/nixpkgs/pull/704
Also worth considering: getWorkflowContinuationSendOptions returning kickoff options for workspaces with no goal/workflow looks unintended and is what exposes the plain-sendMessage path to this race at all.
Summary
A headless workspace turn started with plain
workspace/sendMessage({model, agentId}, no client attached) is silently cut —finishReason: "tool-calls", no assistant text, nothing resumes — when the turn runs a backgroundbashwith amonitorwhose process settles beforetask_awaitis issued.Deterministic repro (v0.28.3, Linux server mode)
mux api workspace create --project-path <any repo> --trunk-branch main --title t --branch-name bmux api workspace send-message --workspace-id <id> --options '{"model":"anthropic:claude-fable-5","agentId":"exec"}' --message '<prompt>'where the prompt instructs: run the bash tool with scripttrue,run_in_background: true,monitor: {"filter":"x","wake_on_exit":true}; thentask_awaitthat task; then reply with the single word SURVIVED.~/.mux/sessions/<id>/chat.jsonl: the last assistant row endsfinishReason: "tool-calls"with only dynamic-tool parts and no text. The turn never continues.Control: same prompt with
sleep 5instead oftrue→ the model repliesSURVIVED(finishReason: "stop").Notes: the same prompt sent as a delegated
task(kind="workspace")turn does NOT reproduce (delegated-turn continuation machinery resumes it), and interactive UI sessions are unaffected (wake resolves no send options and stays pending).Mechanism (from reading dist 0.28.3)
workspaceService.jsdispatchBashMonitorWake) resolves send options viagetDelegatedTurnContinuationSendOptions() ?? getWorkflowContinuationSendOptions(). For a plain headless turn the delegated lookup is null, butgetWorkflowContinuationSendOptions→getGoalContinuationKickoffSendOptionshas no goal guard — it returns kickoff options for any registered workspace. The wake is therefore queued withqueueDispatchMode: "tool-end".requestQueuedProviderToolEndDispatchsoft-stops the stream at the next tool boundary).task_awaitauto-consumes the settlement signal;bashMonitorWakeReconciler.reconcileOncecollects zero signals and aborts the dispatch, removing the queued wake (removableQueueDedupeKey/cancelBeforeAcceptance).dispatchQueuedProviderToolEndMessageAfterAbortfinds an empty queue → phase IDLE."aborted"is non-retryable, so nothing ever resumes the turn.Suggested fix directions
Either (a) do not arm the tool-end cut until the wake dispatch is accepted, or (b) when a tool-end-queued message is canceled/removed after the cut was armed and nothing else was dispatched, resume the cut turn (the auto-retry resume machinery already handles this:
lastAutoRetryResumeRequestsurvives the abort). We are running (b) locally as a dist patch: https://github.com/unthought/nixpkgs/pull/704Also worth considering:
getWorkflowContinuationSendOptionsreturning kickoff options for workspaces with no goal/workflow looks unintended and is what exposes the plain-sendMessage path to this race at all.