Repository navigation
Thread model/provider selection is not authoritative: queued runs, cron, delegated completions, and stale clients freeze or durably revert it #19376
Description
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):
- Per-send fallback to the thread's stored selection —
apps/server/src/orchestration-v2/Orchestrator.ts:4747const modelSelection = command.modelSelection ?? projection.thread.modelSelection;
- 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 inapps/web/src/composerDraftStore.ts). So "the thread's stored selection" is only consulted by callers that don't pass one — agentt3_thread_send(no selection,apps/server/src/mcp/OrchestratorMcpService.ts:2592-2608) and continuations. - 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-updatedwith 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. - Queued runs freeze the provider/model at queue time — the queued run record stores
providerInstanceIdandmodelSelection(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. - 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"). - Cron always re-sends the task row's frozen selection, queued —
apps/server/src/scheduledTasks/ScheduledTaskService.ts:802, 825-827passesmodelSelection: active.modelSelection(captured at task creation/thread binding) withmode: "queue". Changing the thread's model afterwards has no effect on cron deliveries.
Reproduction
A — cross-device revert (same as #18762, explained):
- Thread set to provider instance
Claude Bon the phone / device 1 (durable update viathread.model-selection.setworks). - 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). - Any send from device 2 resolves
command.modelSelection = Claude A, runs onClaude A, and writesClaude Aback 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:
- Schedule a task bound to a thread (task row stores the selection at creation time,
scheduled_tasks.model_selection_json). - Change the thread's model/provider.
- 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:
- Send messages that queue behind an active run with model
M1(agents commonly send withqueue; cron always does). - Change the thread's provider/model to
M2. - 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
- 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. - 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. - Cron: only pass the task row's
modelSelectiontosendToThreadwhen the task explicitly pins one; otherwise omit it (chain step 1's fallback then follows the thread's current selection). - Drop the old-run pinning in the steer path (
Orchestrator.ts:4931) and delegated-completion follow-ups (EffectWorker.ts:292-295).
Activity
juliusmarminge commentedon Oct 14, 2026
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:
Orchestrator.ts~5268): [Bug]: Switching provider instance (e.g. another Claude Code account) on one device isn't synced; next send from another device reverts it #18762 / regression: same-instance (opencode) model switch on mobile still leaves desktop composer stale on Orchestrator v2 #15195, fixed by open fix: release sent composer model choices across clients #18612, which releases the stale composer override at its source. The write-back itself is how an explicit per-send pick becomes the thread's selection, and that is intended.EffectWorker.ts~292): only delegated completions are pinned, on purpose ("stays pinned to the run it reports to"). User steers and follow-ups use the current selection.t3_thread_send: it passes no selection, so it already follows the thread.If a concrete vector is still left after #18612 and #19143 land, open a focused issue with repro steps on the latest nightly.