Skip to content

Thread model/provider selection is not authoritative: queued runs, cron, delegated completions, and stale clients freeze or durably revert it #19376

Closed as duplicate

Description

@DonutsDelivery

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server (orchestration-v2) — this governs apps/web, apps/mobile, scheduled tasks, and agent-to-agent (t3_thread_send) messages alike.

Summary

The thread's provider/model selection is only authoritative for the client that passes it on each send. Every other writer — queued runs, cron deliveries, delegated completions, steering, and agent-to-agent messages — either runs on a frozen or stale selection, and the server then persists whatever it used back into the thread, so one stale writer silently reverts the user's pick for everyone.

The selection resolution is last-writer-wins with no notion of "the user's explicit pick wins". Verified on main@519d64f6ce.

The dispatch-time resolution chain

Six server behaviors compose into the bug (line numbers on main@519d64f6ce):

  1. Per-send fallback to the thread's stored selection — apps/server/src/orchestration-v2/Orchestrator.ts:4747
    const modelSelection = command.modelSelection ?? projection.thread.modelSelection;
  2. Interactive clients always pass a selection from their local composer draft (packages/client-runtime/src/operations/commands.ts:709, 771; the draft lives client-side in apps/web/src/composerDraftStore.ts). So "the thread's stored selection" is only consulted by callers that don't pass one — agent t3_thread_send (no selection, apps/server/src/mcp/OrchestratorMcpService.ts:2592-2608) and continuations.
  3. The server writes back whatever it used, durably — when the resolved selection differs from the thread record, the dispatch emits thread.provider-switched / thread.model-selection-updated with the used selection as the new durable record (apps/server/src/orchestration-v2/Orchestrator.ts:5268-5290). This makes a stale composer draft, a cron row, or an old run authoritative for everyone afterwards.
  4. Queued runs freeze the provider/model at queue time — the queued run record stores providerInstanceId and modelSelection (Orchestrator.ts:5076-5082) and is started later from that frozen record. A queue that forms before a model change still runs on the old provider/model afterwards.
  5. Steering pins the target run's old model (Orchestrator.ts:4931) and delegated completions pin the completed run's model into their follow-up dispatch (apps/server/src/orchestration-v2/EffectWorker.ts:292-295, "stays pinned to the run it reports to").
  6. Cron always re-sends the task row's frozen selection, queued — apps/server/src/scheduledTasks/ScheduledTaskService.ts:802, 825-827 passes modelSelection: active.modelSelection (captured at task creation/thread binding) with mode: "queue". Changing the thread's model afterwards has no effect on cron deliveries.

Reproduction

A — cross-device revert (same as #18762, explained):

  1. Thread set to provider instance Claude B on the phone / device 1 (durable update via thread.model-selection.set works).
  2. Device 2's composer draft still shows Claude A (the draft is per-device and doesn't hot-sync, cf. regression: same-instance (opencode) model switch on mobile still leaves desktop composer stale on Orchestrator v2 #15195).
  3. Any send from device 2 resolves command.modelSelection = Claude A, runs on Claude A, and writes Claude A back into the thread's durable record (chain step 3). The change made on device 1 is durably reverted.

B — cron ignores the thread's current selection:

  1. Schedule a task bound to a thread (task row stores the selection at creation time, scheduled_tasks.model_selection_json).
  2. Change the thread's model/provider.
  3. Next cron delivery queues and runs on the creation-time selection — silently, and it also reverts the durable selection again via step 3's write-back.

C — queued runs run on a stale model:

  1. Send messages that queue behind an active run with model M1 (agents commonly send with queue; cron always does).
  2. Change the thread's provider/model to M2.
  3. When the queue later starts, each queued run still executes on the queue-time M1.

Actual behavior

Programmatic and cross-device writers can override, freeze, or durably revert the user's provider/model choice. The user only sees "typing works" on the device whose composer they just changed; everything server-initiated (agent messages, delegated completions, cron) and everything queued runs on whatever any of these writers last wrote.

Expected behavior

The thread's durable selection — set by an explicit user action (picker / thread.model-selection.set) or by a send from the user surface — should be authoritative. Programmatic senders should not be able to change it, and queued/cron executions should resolve against the thread's current selection instead of a queue-time or creation-time snapshot.

Impact

Major degradation or frequent failure. Concrete consequences: users switching accounts/models to escape a rate limit get silently reverted from another device (#18762); cron jobs run on a model that may cost more, have a smaller context window, or no longer exist; agent-to-agent traffic runs on whatever the previous writer left behind. There are billing and usage implications since the wrong account/provider can be charged.

Version or commit

Verified against upstream main@519d64f6ce (2026-10-14). Also reproduced behaviorally on a T3 Code Nightly 0.0.46 deployment (hermes/desktop install).

Related symptoms already tracked: #18762 (cross-device revert), #15195 (composer staleness after same-instance switch), #16833 (schedule_task model selection gaps). Those are surface symptoms of this dispatch-time resolution semantics; a client-side picker sync fix would not remove the server-side vectors (4–6).

Suggested fix direction

  1. Make the write-back (Orchestrator.ts:5268) conditional on the sender being the user surface (createdBy: "user" / creationSource: "web" | "mobile" | "desktop", or an explicit selection-update intent on the command). Programmatic senders (mcp, scheduled-task, delegated completions) should not mutate the durable selection.
  2. Resolve provider/model for queued runs at start time against the thread's current selection (re-plan through ProviderSwitchService) instead of the queue-time freeze; if the selection changed while queued, honor the new one or surface it in the queue UI.
  3. Cron: only pass the task row's modelSelection to sendToThread when the task explicitly pins one; otherwise omit it (chain step 1's fallback then follows the thread's current selection).
  4. Drop the old-run pinning in the steer path (Orchestrator.ts:4931) and delegated-completion follow-ups (EffectWorker.ts:292-295).

Activity

  1. juliusmarminge commented on Oct 14, 2026

    @juliusmarminge
    Member

    Note

    Grok responding on behalf of Julius.

    Closing this as an umbrella over issues we already track. Each concrete vector has its own issue and PR:

    If a concrete vector is still left after #18612 and #19143 land, open a focused issue with repro steps on the latest nightly.

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

    duplicateThis issue or pull request already existsvia-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions