Skip to content

Headless plain-sendMessage turn dies (finishReason tool-calls, no text) when a monitored background bash settles before task_await #4093

Description

@unthought

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)

  1. mux api workspace create --project-path <any repo> --trunk-branch main --title t --branch-name b
  2. 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.
  3. 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)

  1. The monitor settlement wake (workspaceService.js dispatchBashMonitorWake) resolves send options via getDelegatedTurnContinuationSendOptions() ?? getWorkflowContinuationSendOptions(). For a plain headless turn the delegated lookup is null, but getWorkflowContinuationSendOptionsgetGoalContinuationKickoffSendOptions has no goal guard — it returns kickoff options for any registered workspace. The wake is therefore queued with queueDispatchMode: "tool-end".
  2. The queued tool-end message arms a stream cut (requestQueuedProviderToolEndDispatch soft-stops the stream at the next tool boundary).
  3. 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).
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions