From c376bd873a3122c6fa6f14af99c32e617687388a Mon Sep 17 00:00:00 2001 From: Ali Alwan Date: Fri, 6 Nov 2026 14:51:52 +0000 Subject: [PATCH 01/10] fix(server): queued scheduled deliveries follow the thread's current model A bound scheduled task never picks a model, but its queued run froze the selection at queue time. If the user switched the thread's provider or model while deliveries waited, each one started on the old selection and wrote it back to the thread, undoing the switch. Treat scheduled-task messages like delegated completions: at start time a queued one takes the thread's current selection, so no write-back occurs. Fresh-session deliveries keep their fresh context. User sends queued with an explicit selection keep it. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 4 +- .../src/orchestration-v2/runtimeLayer.test.ts | 104 ++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 3157f55cacfa6..14395a4bf189f 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -832,16 +832,18 @@ function nextQueuedRun( return queuedRunsInDeliveryOrder(projection).find((run) => run.queueHeld !== true); } +// Scheduled deliveries never choose a model, so a queued one runs on the thread's current pick. function followsCurrentConversation( message: | Pick< OrchestrationV2ConversationMessage, - "createdBy" | "creationSource" | "notification" | "delegatedCompletion" + "createdBy" | "creationSource" | "notification" | "delegatedCompletion" | "scheduledTaskId" > | undefined, ): boolean { return ( message?.delegatedCompletion !== undefined || + message?.scheduledTaskId !== undefined || (message?.notification?.source.kind === "monitor" && !isBufferedProviderNotification(message)) ); } diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index a49e323770cc1..22885bb2e1a91 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -6389,6 +6389,110 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each( + [ + { ...modelSelection, model: "gpt-5.5", options: [{ id: "reasoningEffort", value: "high" }] }, + { instanceId: alternateInstanceId, model: "gpt-5.5" }, + ].flatMap((newerSelection) => [ + { newerSelection, freshContext: false }, + { newerSelection, freshContext: true }, + ]), + )( + "starts a queued scheduled delivery on a selection set while it waited: %j", + ({ newerSelection, freshContext }) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make( + `runtime-layer-queued-scheduled-selection-${newerSelection.instanceId}-${newerSelection.model}-${freshContext}`, + ); + const scheduledMessageId = MessageId.make(`${threadId}:scheduled`); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + createdBy: "user", + creationSource: "web", + title: "Scheduled delivery selection", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:active`), + threadId, + messageId: MessageId.make(`${threadId}:active`), + text: "Busy", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const active = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + // Bound scheduled tasks send like ScheduledTaskService: no model of their own. + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:scheduled`), + threadId, + messageId: scheduledMessageId, + scheduledTaskId: ScheduledTaskId.make(`${threadId}:task`), + text: "Scheduled prompt", + attachments: [], + ...(freshContext ? { freshContext: true } : {}), + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queued = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === scheduledMessageId, + )!; + assert.equal(queued.status, "queued"); + assert.deepEqual(queued.modelSelection, modelSelection); + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make(`${threadId}:select-newer`), + threadId, + modelSelection: newerSelection, + }); + const waiting = yield* orchestrator.getThreadProjection(threadId); + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}:completed`), + type: "run.updated", + threadId, + runId: active.id, + occurredAt: now, + payload: { + ...waiting.runs.find((run) => run.id === active.id)!, + status: "completed", + completedAt: now, + }, + }, + ], + }); + yield* orchestrator.resumeQueuedRuns; + const after = yield* orchestrator.getThreadProjection(threadId); + const delivered = after.runs.find((run) => run.userMessageId === scheduledMessageId)!; + assert.equal(delivered.status, "starting"); + assert.deepEqual(delivered.modelSelection, newerSelection); + assert.equal(delivered.freshContext === true, freshContext); + assert.equal( + after.attempts.find((attempt) => attempt.id === delivered.activeAttemptId) + ?.providerInstanceId, + newerSelection.instanceId, + ); + assert.deepEqual(after.thread.modelSelection, newerSelection); + assert.equal(after.thread.providerInstanceId, newerSelection.instanceId); + }), + ); + it.effect("keeps the queue after a user interrupts the active run", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; From 37f50ef937ee8942acce936b6d1286b33cd2951d Mon Sep 17 00:00:00 2001 From: Ali Alwan Date: Fri, 6 Nov 2026 15:06:34 +0000 Subject: [PATCH 02/10] fix(server): promoted scheduled deliveries steer on the thread's current model Promote-to-Steer passed the selection a queued run froze at queue time. For a scheduled delivery queued before the user switched model or provider, steering wrote that old selection back to the thread, undoing the switch. It now resolves the selection like a queued start does, so scheduled deliveries follow the thread and user sends keep their own. Tests cover the promoted scheduled delivery and two scheduled deliveries in a row each starting on the selection current at its start. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 5 +- .../SteeringCompletion.integration.test.ts | 58 +++++++++ .../src/orchestration-v2/runtimeLayer.test.ts | 112 ++++++++++++++++++ 3 files changed, 173 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 14395a4bf189f..1b80af57e61ae 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -832,7 +832,8 @@ function nextQueuedRun( return queuedRunsInDeliveryOrder(projection).find((run) => run.queueHeld !== true); } -// Scheduled deliveries never choose a model, so a queued one runs on the thread's current pick. +// Delegated completions, monitor notifications and scheduled deliveries never choose a model, +// so a queued one runs on the thread's current pick rather than the one frozen at queue time. function followsCurrentConversation( message: | Pick< @@ -10198,7 +10199,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio events, effects, projection, - modelSelection: queuedRun.modelSelection, + modelSelection: queuedRunModelSelection(projection, queuedRun), targetRunId: command.targetRunId, messageId: queuedMessage.id, text: queuedMessage.text, diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index bc8fc9a20e3d3..f8c55bd398da6 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -11,6 +11,7 @@ import { ProviderInstanceId, ProviderThreadId, ProviderTurnId, + ScheduledTaskId, ThreadId, TurnItemId, type ModelSelection, @@ -1671,6 +1672,63 @@ it.effect("steers a promoted queued message with the selection it was queued wit ), ); +it.effect("steers a promoted scheduled delivery with the thread's current selection", () => + Effect.scoped( + Effect.gen(function* () { + const { started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness( + "promote-scheduled-selection", + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + // Bound scheduled tasks send like ScheduledTaskService: no model of their own. + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("scheduled"), + threadId, + messageId: MessageId.make("message:scheduled"), + scheduledTaskId: ScheduledTaskId.make("task:scheduled"), + text: "scheduled", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queuedRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.status === "queued", + )!; + assert.deepEqual(queuedRun.modelSelection, runSelection); + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make("select-composer"), + threadId, + modelSelection: composerSelection, + }); + yield* orchestrator.dispatch({ + type: "queued-message.promote-to-steer", + commandId: CommandId.make("promote"), + threadId, + queuedRunId: queuedRun.id, + targetRunId: started[0]!.runId, + }); + yield* worker.drain(); + + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(steered, ["scheduled"]); + assert.deepEqual(projection.thread.modelSelection, composerSelection); + const promoted = projection.turnItems.find( + (item) => item.type === "user_message" && item.messageId === "message:scheduled", + ); + assert.deepEqual( + promoted?.type === "user_message" ? promoted.scheduledTaskId : undefined, + ScheduledTaskId.make("task:scheduled"), + ); + }).pipe(Effect.provide(layer)); + }), + ), +); + it.effect("marks the model switch on a promoted message that restarts the turn", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 22885bb2e1a91..ee5b4fcd92904 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -6493,6 +6493,118 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("starts each queued scheduled delivery on the selection current at its start", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make("runtime-layer-queued-scheduled-selection-repeated"); + const switchedSelection = { instanceId: alternateInstanceId, model: "gpt-5.5" }; + const laterSelection = { + ...switchedSelection, + options: [{ id: "reasoningEffort", value: "high" }], + }; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + createdBy: "user", + creationSource: "web", + title: "Repeated scheduled deliveries", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:active`), + threadId, + messageId: MessageId.make(`${threadId}:active`), + text: "Busy", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const deliveries = ["first", "second"].map((name) => MessageId.make(`${threadId}:${name}`)); + for (const messageId of deliveries) { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(messageId), + threadId, + messageId, + scheduledTaskId: ScheduledTaskId.make(`${threadId}:task`), + text: "Scheduled prompt", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + } + const completeRunFor = (messageId: MessageId) => + Effect.gen(function* () { + const projection = yield* orchestrator.getThreadProjection(threadId); + const run = projection.runs.find((candidate) => candidate.userMessageId === messageId)!; + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${messageId}:completed`), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "completed", completedAt: now }, + }, + ], + }); + yield* orchestrator.resumeQueuedRuns; + }); + const startedRunFor = (messageId: MessageId) => + orchestrator + .getThreadProjection(threadId) + .pipe( + Effect.map((projection) => + projection.runs.find((candidate) => candidate.userMessageId === messageId), + ), + ); + + // Both deliveries froze the original selection when they queued. + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make(`${threadId}:select-switched`), + threadId, + modelSelection: switchedSelection, + }); + yield* completeRunFor(MessageId.make(`${threadId}:active`)); + const first = yield* startedRunFor(deliveries[0]!); + assert.equal(first?.status, "starting"); + assert.deepEqual(first?.modelSelection, switchedSelection); + assert.equal((yield* startedRunFor(deliveries[1]!))?.status, "queued"); + assert.deepEqual( + (yield* orchestrator.getThreadProjection(threadId)).thread.modelSelection, + switchedSelection, + ); + + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make(`${threadId}:select-later`), + threadId, + modelSelection: laterSelection, + }); + yield* completeRunFor(deliveries[0]!); + const second = yield* startedRunFor(deliveries[1]!); + assert.equal(second?.status, "starting"); + assert.deepEqual(second?.modelSelection, laterSelection); + const after = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(after.thread.modelSelection, laterSelection); + assert.equal(after.thread.providerInstanceId, alternateInstanceId); + }), + ); + it.effect("keeps the queue after a user interrupts the active run", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; From e9e08a1a07aa97eff0d62b90600c003740abddc1 Mon Sep 17 00:00:00 2001 From: Ali Alwan Date: Fri, 6 Nov 2026 20:24:07 +0000 Subject: [PATCH 03/10] fix(server): scheduled deliveries use the current provider for fresh threads and /goal checks A fresh-session scheduled delivery reused its unused reserved provider thread even after the thread switched provider instance, so the run started on instance B against a provider-thread row that still named instance A. Reuse the reserved row only when its instance matches the run's resolved selection. Promoting or editing a queued scheduled /goal also checked goal support against the instance frozen at queue time while the message now runs on the thread's current pick. Resolve the selection once and check against it. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 16 ++- .../SteeringCompletion.integration.test.ts | 122 +++++++++++++++++- .../src/orchestration-v2/runtimeLayer.test.ts | 105 +++++++++++++++ 3 files changed, 236 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 1b80af57e61ae..1632917863f3f 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2060,7 +2060,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? activeProviderThread : rootProviderThreadsForProvider(projection, queuedRun.providerInstanceId)[0] : freshContext && - (storedProviderThread.firstRunOrdinal !== null || + // The reserved row belongs to the instance the run queued on, which may have changed. + (storedProviderThread.providerInstanceId !== queuedRun.providerInstanceId || + storedProviderThread.firstRunOrdinal !== null || storedProviderThread.nativeThreadRef !== null) ? undefined : storedProviderThread; @@ -10073,10 +10075,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const now = yield* DateTime.now; const emitEvent = emit(events, command); + // A scheduled delivery steers on the thread's current pick, so /goal must suit that provider. + const modelSelection = queuedRunModelSelection(projection, queuedRun); const parsedGoalCommand = parseGoalCommand(queuedMessage); const goalCommand = parsedGoalCommand === null || - (yield* goalDriverFor(command, queuedRun.providerInstanceId)) === undefined + (yield* goalDriverFor(command, modelSelection.instanceId)) === undefined ? null : parsedGoalCommand; const target = projection.runs.find((run) => run.id === command.targetRunId); @@ -10090,7 +10094,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio isCompactionCommandMessage(targetMessage) ) { if (goalCommand !== null) { - yield* ensureGoalCommandSupported(command, queuedMessage, queuedRun.providerInstanceId); + yield* ensureGoalCommandSupported(command, queuedMessage, modelSelection.instanceId); } yield* emitEvent({ type: "run.updated", @@ -10123,7 +10127,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } if (goalCommand !== null) { - yield* ensureGoalCommandSupported(command, queuedMessage, queuedRun.providerInstanceId); + yield* ensureGoalCommandSupported(command, queuedMessage, modelSelection.instanceId); const goal = projection.providerThreads.find( (candidate) => candidate.id === target?.providerThreadId, )?.goal; @@ -10199,7 +10203,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio events, effects, projection, - modelSelection: queuedRunModelSelection(projection, queuedRun), + modelSelection, targetRunId: command.targetRunId, messageId: queuedMessage.id, text: queuedMessage.text, @@ -10414,7 +10418,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* ensureGoalCommandSupported( command, { text: command.text, attachments: command.attachments ?? queuedMessage.attachments }, - queuedRun.providerInstanceId, + queuedRunModelSelection(projection, queuedRun).instanceId, ); const queuedTurnItem = projection.turnItems.find( (candidate) => candidate.type === "user_message" && candidate.messageId === queuedMessage.id, diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index f8c55bd398da6..080cafaefb17b 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -490,6 +490,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* interruptTurn: Effect.Effect = Effect.void, steeringAcceptsAttachments = true, nativeGoals?: boolean, + extraAdapters: ReadonlyArray = [], ) { const driver = ProviderDriverKind.make(nativeGoals === undefined ? "codex" : "opencode"); const cwd = yield* checkpointWorkspace(name); @@ -610,7 +611,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* }; const layer = ProviderReplayHarness.layerWithRegistry( { name }, - ProviderAdapterRegistry.layerSingle(adapter), + ProviderAdapterRegistry.layerFromAdapters([adapter, ...extraAdapters]), { runEffectWorker: false }, ); const startFirstTurn = Effect.gen(function* () { @@ -1729,6 +1730,125 @@ it.effect("steers a promoted scheduled delivery with the thread's current select ), ); +const claudeSelection = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-test", +}; +const claudeAdapter: ProviderAdapter.ProviderAdapterV2["Service"] = { + instanceId: claudeSelection.instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), + openSession: () => Effect.die("the Claude instance never starts a turn here"), +}; + +// Queues a scheduled delivery on the Codex thread, then switches the thread to Claude. +const scheduledDeliveryAfterClaudeSwitch = (name: string, text: string) => + Effect.gen(function* () { + const harness = yield* nextTurnSelectionHarness( + name, + false, + true, + Effect.void, + true, + undefined, + [claudeAdapter], + ); + const queueAndSwitch = Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = yield* harness.startFirstTurn; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("scheduled"), + threadId, + messageId: MessageId.make("message:scheduled"), + scheduledTaskId: ScheduledTaskId.make("task:scheduled"), + text, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queuedRun = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.status === "queued", + )!; + assert.equal(queuedRun.providerInstanceId, instanceId); + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make("select-claude"), + threadId, + modelSelection: claudeSelection, + }); + return { threadId, queuedRun }; + }); + return { ...harness, queueAndSwitch }; + }); + +it.effect("checks a promoted scheduled /goal against the thread's current provider", () => + Effect.scoped( + Effect.gen(function* () { + const { started, steered, layer, queueAndSwitch } = yield* scheduledDeliveryAfterClaudeSwitch( + "promote-scheduled-goal", + "/goal pause", + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { threadId, queuedRun } = yield* queueAndSwitch; + const promoteError = yield* orchestrator + .dispatch({ + type: "queued-message.promote-to-steer", + commandId: CommandId.make("promote"), + threadId, + queuedRunId: queuedRun.id, + targetRunId: started[0]!.runId, + }) + .pipe(Effect.flip); + + assert.match(String(promoteError.cause), /Claude goals cannot be paused or resumed/); + assert.deepEqual(steered, []); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.id === queuedRun.id, + )?.status, + "queued", + ); + }).pipe(Effect.provide(layer)); + }), + ), +); + +it.effect("checks an edited scheduled /goal against the thread's current provider", () => + Effect.scoped( + Effect.gen(function* () { + const { layer, queueAndSwitch } = yield* scheduledDeliveryAfterClaudeSwitch( + "edit-scheduled-goal", + "scheduled", + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { threadId, queuedRun } = yield* queueAndSwitch; + const editError = yield* orchestrator + .dispatch({ + type: "queued-run.edit", + commandId: CommandId.make("edit"), + threadId, + runId: queuedRun.id, + text: "/goal pause", + }) + .pipe(Effect.flip); + + assert.match(String(editError.cause), /Claude goals cannot be paused or resumed/); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).messages.find( + (message) => message.id === queuedRun.userMessageId, + )?.text, + "scheduled", + ); + }).pipe(Effect.provide(layer)); + }), + ), +); + it.effect("marks the model switch on a promoted message that restarts the turn", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index ee5b4fcd92904..903ef3ad51dc2 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -6493,6 +6493,111 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect( + "starts a fresh scheduled delivery on a new provider thread after a provider switch", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make("runtime-layer-fresh-scheduled-provider-thread"); + const scheduledMessageId = MessageId.make(`${threadId}:scheduled`); + const switchedSelection = { instanceId: alternateInstanceId, model: "gpt-5.5" }; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + createdBy: "user", + creationSource: "web", + title: "Fresh scheduled provider thread", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:active`), + threadId, + messageId: MessageId.make(`${threadId}:active`), + text: "Busy", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const active = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:scheduled`), + threadId, + messageId: scheduledMessageId, + scheduledTaskId: ScheduledTaskId.make(`${threadId}:task`), + text: "Scheduled prompt", + attachments: [], + freshContext: true, + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queuedProjection = yield* orchestrator.getThreadProjection(threadId); + const queued = queuedProjection.runs.find( + (run) => run.userMessageId === scheduledMessageId, + )!; + // The queue reserved an unused provider thread on the original instance. + const reserved = queuedProjection.providerThreads.find( + (providerThread) => providerThread.id === queued.providerThreadId, + )!; + assert.equal(reserved.providerInstanceId, modelSelection.instanceId); + assert.isNull(reserved.firstRunOrdinal); + assert.isNull(reserved.nativeThreadRef); + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make(`${threadId}:select-switched`), + threadId, + modelSelection: switchedSelection, + }); + const waiting = yield* orchestrator.getThreadProjection(threadId); + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}:completed`), + type: "run.updated", + threadId, + runId: active.id, + occurredAt: now, + payload: { + ...waiting.runs.find((run) => run.id === active.id)!, + status: "completed", + completedAt: now, + }, + }, + ], + }); + yield* orchestrator.resumeQueuedRuns; + + const after = yield* orchestrator.getThreadProjection(threadId); + const delivered = after.runs.find((run) => run.userMessageId === scheduledMessageId)!; + assert.equal(delivered.status, "starting"); + assert.isTrue(delivered.freshContext === true); + assert.notEqual(delivered.providerThreadId, reserved.id); + assert.equal( + after.providerThreads.find( + (providerThread) => providerThread.id === delivered.providerThreadId, + )?.providerInstanceId, + alternateInstanceId, + ); + assert.equal( + after.providerThreads.find((providerThread) => providerThread.id === reserved.id) + ?.providerInstanceId, + modelSelection.instanceId, + ); + }), + ); + it.effect("starts each queued scheduled delivery on the selection current at its start", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; From bdec2c8836a3173e8a34ee23a264093cfd9ea146 Mon Sep 17 00:00:00 2001 From: Ali Alwan Date: Fri, 6 Nov 2026 20:49:17 +0000 Subject: [PATCH 04/10] fix(server): check a queued scheduled /goal against the provider it starts on A scheduled delivery queued as /goal pause or /goal resume on a goal-capable provider started verbatim on the thread's current provider, so after a switch to Claude it became a new goal named "pause". The queued start now checks the command against the resolved selection, as promote and edit already do, and fails that queued run through the existing queued-start failure path. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 12 +++- .../SteeringCompletion.integration.test.ts | 59 +++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 1632917863f3f..362a6dac62a87 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1174,7 +1174,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio /** The driver when this provider runs `/goal` natively, else undefined. */ const goalDriverFor = Effect.fn("Orchestrator.goalDriverFor")(function* ( - command: OrchestrationV2ServerCommand, + command: Pick, providerInstanceId: ProviderInstanceId, ) { const adapter = yield* providerAdapters.get(providerInstanceId).pipe(mapDispatchError(command)); @@ -1189,7 +1189,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio /** Claude Code reads `/goal pause` and `/goal resume` as new goals named "pause" and "resume". */ const ensureGoalCommandSupported = Effect.fn("Orchestrator.ensureGoalCommandSupported")( function* ( - command: OrchestrationV2ServerCommand, + command: Pick, message: { readonly text: string; readonly attachments: ReadonlyArray }, providerInstanceId: ProviderInstanceId, ) { @@ -1205,7 +1205,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); const mapDispatchError = - (command: OrchestrationV2ServerCommand) => + (command: Pick) => (effect: Effect.Effect): Effect.Effect => effect.pipe( Effect.mapError( @@ -2032,6 +2032,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Queued run ${queuedRun.id} is missing projection state.`, }); } + // A scheduled delivery starts on the thread's current pick, so /goal must suit that provider. + yield* ensureGoalCommandSupported( + { commandId, type: "message.dispatch" }, + queuedMessage, + queuedRun.providerInstanceId, + ); const now = yield* DateTime.now; const targetAdapter = yield* providerAdapters.get(queuedRun.providerInstanceId).pipe( diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 080cafaefb17b..e72f604e7eb99 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -1849,6 +1849,65 @@ it.effect("checks an edited scheduled /goal against the thread's current provide ), ); +it.effect("checks a queued scheduled /goal against the thread's current provider at start", () => + Effect.scoped( + Effect.gen(function* () { + const { events, started, layer, queueAndSwitch } = yield* scheduledDeliveryAfterClaudeSwitch( + "start-scheduled-goal", + "/goal pause", + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const { threadId, queuedRun } = yield* queueAndSwitch; + const first = started[0]!; + const settled = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "run.updated" && + event.payload.id === first.runId && + event.payload.status === "waiting", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + const turn = (yield* orchestrator.getThreadProjection(threadId)).providerTurns[0]!; + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { ...turn, status: "completed", completedAt: yield* DateTime.now }, + }); + yield* Queue.offer(events, { + type: "turn.terminal", + driver, + providerThreadId: turn.providerThreadId, + providerTurnId: turn.id, + runOrdinal: first.runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(settled); + yield* worker.drain(); + yield* orchestrator.resumeQueuedRuns; + yield* worker.drain(); + + const projection = yield* orchestrator.getThreadProjection(threadId); + const failure = projection.turnItems.find( + (item) => item.type === "error" && item.runId === queuedRun.id, + ); + assert.equal(started.length, 1); + assert.equal(projection.runs.find((run) => run.id === queuedRun.id)?.status, "failed"); + assert.equal( + failure?.type === "error" ? failure.failure.code : null, + "queued_start_failed", + ); + }).pipe(Effect.provide(layer)); + }), + ), +); + it.effect("marks the model switch on a promoted message that restarts the turn", () => Effect.scoped( Effect.gen(function* () { From fa9e56af2d5283334fc55bb0f86a88a42c38cef1 Mon Sep 17 00:00:00 2001 From: Ali-Al1 Date: Wed, 11 Nov 2026 05:19:19 +0000 Subject: [PATCH 05/10] fix(server): a pinned scheduled delivery starts on its pin CodeRabbit's Linked Issues check on this PR found that followsCurrentConversation returned true for every message with a scheduledTaskId, so a delivery that named its own model resolved the thread's current selection when its queued run started instead of the selection it pinned. Persist the dispatch's explicit modelSelection on the conversation message and keep scheduled deliveries that named a model out of the current-conversation path. Pinned tasks, and fresh-thread launches which seed with the task's model, keep today's behaviour: the queued run starts on its pin. Selectionless bound deliveries still follow the thread's current pick; delegated completions and monitor notifications are untouched. Adds the regression test the check asked for: a pinned scheduled delivery queued behind an active run, the thread moved while it waits, and the run starting on its pinned selection. --- .../src/orchestration-v2/Orchestrator.ts | 11 ++- .../src/orchestration-v2/runtimeLayer.test.ts | 97 +++++++++++++++++++ packages/contracts/src/orchestrationV2.ts | 2 + 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 362a6dac62a87..a08c68ee8c042 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -834,14 +834,21 @@ function nextQueuedRun( // Delegated completions, monitor notifications and scheduled deliveries never choose a model, // so a queued one runs on the thread's current pick rather than the one frozen at queue time. +// A scheduled delivery that named its own model is the exception: its pin wins over the thread. function followsCurrentConversation( message: | Pick< OrchestrationV2ConversationMessage, - "createdBy" | "creationSource" | "notification" | "delegatedCompletion" | "scheduledTaskId" + | "createdBy" + | "creationSource" + | "notification" + | "delegatedCompletion" + | "scheduledTaskId" + | "modelSelection" > | undefined, ): boolean { + if (message?.scheduledTaskId !== undefined && message.modelSelection !== undefined) return false; return ( message?.delegatedCompletion !== undefined || message?.scheduledTaskId !== undefined || @@ -6598,6 +6605,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : { scheduledTaskId: command.scheduledTaskId }), ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), + ...(command.modelSelection === undefined ? {} : { modelSelection: command.modelSelection }), id: command.messageId, threadId: command.threadId, runId, @@ -6985,6 +6993,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : { scheduledTaskId: command.scheduledTaskId }), ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), + ...(command.modelSelection === undefined ? {} : { modelSelection: command.modelSelection }), id: command.messageId, threadId: command.threadId, runId, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 903ef3ad51dc2..e4665bb31cfae 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -6493,6 +6493,103 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect( + "starts a queued scheduled delivery that pinned a model on its pin after the thread moved", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make("runtime-layer-queued-scheduled-pinned-selection"); + const scheduledMessageId = MessageId.make(`${threadId}:scheduled`); + const pinnedSelection = { instanceId: modelSelection.instanceId, model: "gpt-5.5" }; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + createdBy: "user", + creationSource: "web", + title: "Pinned scheduled selection", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:active`), + threadId, + messageId: MessageId.make(`${threadId}:active`), + text: "Busy", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const active = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + // A task that pins a model names it on the delivery, as a fresh-thread launch seeds it. + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:scheduled`), + threadId, + messageId: scheduledMessageId, + scheduledTaskId: ScheduledTaskId.make(`${threadId}:task`), + modelSelection: pinnedSelection, + text: "Pinned scheduled prompt", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queued = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === scheduledMessageId, + )!; + assert.equal(queued.status, "queued"); + assert.deepEqual(queued.modelSelection, pinnedSelection); + // The user moves the thread off the pinned model while the delivery waits. + const movedSelection = { instanceId: alternateInstanceId, model: "gpt-5.5" }; + yield* orchestrator.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make(`${threadId}:select-moved`), + threadId, + modelSelection: movedSelection, + }); + const waiting = yield* orchestrator.getThreadProjection(threadId); + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}:completed`), + type: "run.updated", + threadId, + runId: active.id, + occurredAt: now, + payload: { + ...waiting.runs.find((run) => run.id === active.id)!, + status: "completed", + completedAt: now, + }, + }, + ], + }); + yield* orchestrator.resumeQueuedRuns; + const after = yield* orchestrator.getThreadProjection(threadId); + const delivered = after.runs.find((run) => run.userMessageId === scheduledMessageId)!; + assert.equal(delivered.status, "starting"); + // The pin wins: the run starts on the selection the delivery named, not the thread's later pick. + assert.deepEqual(delivered.modelSelection, pinnedSelection); + assert.equal( + after.attempts.find((attempt) => attempt.id === delivered.activeAttemptId) + ?.providerInstanceId, + pinnedSelection.instanceId, + ); + // Pinned tasks keep today's behaviour: the thread follows the run's selection back. + assert.deepEqual(after.thread.modelSelection, pinnedSelection); + }), + ); + it.effect( "starts a fresh scheduled delivery on a new provider thread after a provider switch", () => diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index b1fd02b3d3a07..4be55a01cff24 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1315,6 +1315,8 @@ export const OrchestrationV2ConversationMessage = Schema.Struct({ notification: Schema.optional(OrchestrationV2Notification), ...OrchestrationV2CreationFields, scheduledTaskId: Schema.optional(ScheduledTaskId), + /** The selection the delivery named when it arrived; a scheduled delivery that named one keeps it. */ + modelSelection: Schema.optional(ModelSelection), senderThreadId: Schema.optional(ThreadId), idleCompaction: Schema.optional(OrchestrationV2IdleCompaction), /** From ec8926b41c395a4a8f88d99e094dff2f0bde0a0a Mon Sep 17 00:00:00 2001 From: Ali-Al1 Date: Wed, 11 Nov 2026 06:56:01 +0000 Subject: [PATCH 06/10] test(server): a fresh scheduled delivery never reuses a reservation across providers The fresh-session case only switched between two instances of the same provider, so the cross-provider reservation guard had no direct coverage: parameterize it over a Codex alternate instance and a Claude instance. --- .../src/orchestration-v2/runtimeLayer.test.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index e4665bb31cfae..6222ba22931c3 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -6590,15 +6590,21 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); - it.effect( - "starts a fresh scheduled delivery on a new provider thread after a provider switch", - () => + it.effect.each([ + // Another instance of the same provider... + { instanceId: alternateInstanceId, model: "gpt-5.5" }, + // ...and a different provider entirely: the reservation is never reused across either. + { instanceId: claudeInstanceId, model: "claude-test" }, + ])( + "starts a fresh scheduled delivery on a new provider thread after a provider switch: %j", + (switchedSelection) => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const eventSink = yield* EventSink.EventSinkV2; - const threadId = ThreadId.make("runtime-layer-fresh-scheduled-provider-thread"); + const threadId = ThreadId.make( + `runtime-layer-fresh-scheduled-provider-thread-${switchedSelection.instanceId}`, + ); const scheduledMessageId = MessageId.make(`${threadId}:scheduled`); - const switchedSelection = { instanceId: alternateInstanceId, model: "gpt-5.5" }; yield* orchestrator.dispatch({ type: "thread.create", commandId: CommandId.make(`${threadId}:create`), @@ -6685,7 +6691,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { after.providerThreads.find( (providerThread) => providerThread.id === delivered.providerThreadId, )?.providerInstanceId, - alternateInstanceId, + switchedSelection.instanceId, ); assert.equal( after.providerThreads.find((providerThread) => providerThread.id === reserved.id) From deb86cd47754935480649ce6be026cc4a8233146 Mon Sep 17 00:00:00 2001 From: Ali-Al1 Date: Thu, 12 Nov 2026 15:15:36 +0000 Subject: [PATCH 07/10] test(scheduled-tasks): switch provider instance through Stop, as upstream now requires Upstream (#22755) refuses a provider-instance change while any run on the thread is active. The scheduled-delivery tests switched instance mid-turn, so they now take the real path: Stop holds the queue, the thread switches, and queue.resume starts the held delivery on the thread's current pick. The promoted scheduled /goal case is dropped: promote needs an active target run, and the thread can no longer change instance during one, so the queued delivery's pick always matches the target's provider there. Co-Authored-By: Claude Opus 5.5 --- .../SteeringCompletion.integration.test.ts | 86 ++++---------- .../src/orchestration-v2/runtimeLayer.test.ts | 110 +++++++++--------- 2 files changed, 75 insertions(+), 121 deletions(-) diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 227777b853a2b..34f77f6eb2a90 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -2236,7 +2236,8 @@ const claudeAdapter: ProviderAdapter.ProviderAdapterV2["Service"] = { openSession: () => Effect.die("the Claude instance never starts a turn here"), }; -// Queues a scheduled delivery on the Codex thread, then switches the thread to Claude. +// Queues a scheduled delivery behind the Codex turn, stops that turn (Stop holds the queue), then +// switches the thread to Claude: a thread changes instance only while no run is active. const scheduledDeliveryAfterClaudeSwitch = (name: string, text: string) => Effect.gen(function* () { const harness = yield* nextTurnSelectionHarness( @@ -2268,6 +2269,21 @@ const scheduledDeliveryAfterClaudeSwitch = (name: string, text: string) => (run) => run.status === "queued", )!; assert.equal(queuedRun.providerInstanceId, instanceId); + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const first = harness.started[0]!; + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("stop"), + threadId, + runId: first.runId, + holdQueue: true, + }); + yield* worker.drain(); + const stopped = (yield* orchestrator.getThreadProjection(threadId)).runs; + assert.equal(stopped.find((run) => run.id === first.runId)?.status, "interrupted"); + const held = stopped.find((run) => run.id === queuedRun.id); + assert.equal(held?.status, "queued"); + assert.isTrue(held?.queueHeld); yield* orchestrator.dispatch({ type: "thread.model-selection.set", commandId: CommandId.make("select-claude"), @@ -2279,39 +2295,6 @@ const scheduledDeliveryAfterClaudeSwitch = (name: string, text: string) => return { ...harness, queueAndSwitch }; }); -it.effect("checks a promoted scheduled /goal against the thread's current provider", () => - Effect.scoped( - Effect.gen(function* () { - const { started, steered, layer, queueAndSwitch } = yield* scheduledDeliveryAfterClaudeSwitch( - "promote-scheduled-goal", - "/goal pause", - ); - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const { threadId, queuedRun } = yield* queueAndSwitch; - const promoteError = yield* orchestrator - .dispatch({ - type: "queued-message.promote-to-steer", - commandId: CommandId.make("promote"), - threadId, - queuedRunId: queuedRun.id, - targetRunId: started[0]!.runId, - }) - .pipe(Effect.flip); - - assert.match(String(promoteError.cause), /Claude goals cannot be paused or resumed/); - assert.deepEqual(steered, []); - assert.equal( - (yield* orchestrator.getThreadProjection(threadId)).runs.find( - (run) => run.id === queuedRun.id, - )?.status, - "queued", - ); - }).pipe(Effect.provide(layer)); - }), - ), -); - it.effect("checks an edited scheduled /goal against the thread's current provider", () => Effect.scoped( Effect.gen(function* () { @@ -2347,7 +2330,7 @@ it.effect("checks an edited scheduled /goal against the thread's current provide it.effect("checks a queued scheduled /goal against the thread's current provider at start", () => Effect.scoped( Effect.gen(function* () { - const { events, started, layer, queueAndSwitch } = yield* scheduledDeliveryAfterClaudeSwitch( + const { started, layer, queueAndSwitch } = yield* scheduledDeliveryAfterClaudeSwitch( "start-scheduled-goal", "/goal pause", ); @@ -2355,36 +2338,11 @@ it.effect("checks a queued scheduled /goal against the thread's current provider const orchestrator = yield* Orchestrator.OrchestratorV2; const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; const { threadId, queuedRun } = yield* queueAndSwitch; - const first = started[0]!; - const settled = yield* orchestrator.streamDomainEvents.pipe( - Stream.filter( - (event) => - event.type === "run.updated" && - event.payload.id === first.runId && - event.payload.status === "waiting", - ), - Stream.take(1), - Stream.runDrain, - Effect.forkScoped, - ); - const turn = (yield* orchestrator.getThreadProjection(threadId)).providerTurns[0]!; - yield* Queue.offer(events, { - type: "provider_turn.updated", - driver, - providerTurn: { ...turn, status: "completed", completedAt: yield* DateTime.now }, - }); - yield* Queue.offer(events, { - type: "turn.terminal", - driver, - providerThreadId: turn.providerThreadId, - providerTurnId: turn.id, - runOrdinal: first.runOrdinal, - status: "completed", - failure: null, - threadDisposition: "reusable", + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make("resume"), + threadId, }); - yield* Fiber.join(settled); - yield* worker.drain(); yield* orchestrator.resumeQueuedRuns; yield* worker.drain(); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 36e103f994d94..5d19abafa6a39 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -8045,7 +8045,6 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { ({ newerSelection, freshContext }) => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; - const eventSink = yield* EventSink.EventSinkV2; const threadId = ThreadId.make( `runtime-layer-queued-scheduled-selection-${newerSelection.instanceId}-${newerSelection.model}-${freshContext}`, ); @@ -8096,29 +8095,24 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { )!; assert.equal(queued.status, "queued"); assert.deepEqual(queued.modelSelection, modelSelection); + // Stop holds the queue; a thread changes instance only while no run is active. + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make(`${threadId}:stop`), + threadId, + runId: active.id, + holdQueue: true, + }); yield* orchestrator.dispatch({ type: "thread.model-selection.set", commandId: CommandId.make(`${threadId}:select-newer`), threadId, modelSelection: newerSelection, }); - const waiting = yield* orchestrator.getThreadProjection(threadId); - const now = yield* DateTime.now; - yield* eventSink.write({ - events: [ - { - id: EventId.make(`${threadId}:completed`), - type: "run.updated", - threadId, - runId: active.id, - occurredAt: now, - payload: { - ...waiting.runs.find((run) => run.id === active.id)!, - status: "completed", - completedAt: now, - }, - }, - ], + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make(`${threadId}:resume`), + threadId, }); yield* orchestrator.resumeQueuedRuns; const after = yield* orchestrator.getThreadProjection(threadId); @@ -8141,7 +8135,6 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; - const eventSink = yield* EventSink.EventSinkV2; const threadId = ThreadId.make("runtime-layer-queued-scheduled-pinned-selection"); const scheduledMessageId = MessageId.make(`${threadId}:scheduled`); const pinnedSelection = { instanceId: modelSelection.instanceId, model: "gpt-5.5" }; @@ -8191,7 +8184,15 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { )!; assert.equal(queued.status, "queued"); assert.deepEqual(queued.modelSelection, pinnedSelection); - // The user moves the thread off the pinned model while the delivery waits. + // Stop holds the queue; a thread changes instance only while no run is active. + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make(`${threadId}:stop`), + threadId, + runId: active.id, + holdQueue: true, + }); + // The user moves the thread off the pinned model while the delivery is held. const movedSelection = { instanceId: alternateInstanceId, model: "gpt-5.5" }; yield* orchestrator.dispatch({ type: "thread.model-selection.set", @@ -8199,23 +8200,10 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { threadId, modelSelection: movedSelection, }); - const waiting = yield* orchestrator.getThreadProjection(threadId); - const now = yield* DateTime.now; - yield* eventSink.write({ - events: [ - { - id: EventId.make(`${threadId}:completed`), - type: "run.updated", - threadId, - runId: active.id, - occurredAt: now, - payload: { - ...waiting.runs.find((run) => run.id === active.id)!, - status: "completed", - completedAt: now, - }, - }, - ], + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make(`${threadId}:resume`), + threadId, }); yield* orchestrator.resumeQueuedRuns; const after = yield* orchestrator.getThreadProjection(threadId); @@ -8243,7 +8231,6 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { (switchedSelection) => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; - const eventSink = yield* EventSink.EventSinkV2; const threadId = ThreadId.make( `runtime-layer-fresh-scheduled-provider-thread-${switchedSelection.instanceId}`, ); @@ -8299,29 +8286,24 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { assert.equal(reserved.providerInstanceId, modelSelection.instanceId); assert.isNull(reserved.firstRunOrdinal); assert.isNull(reserved.nativeThreadRef); + // Stop holds the queue; a thread changes instance only while no run is active. + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make(`${threadId}:stop`), + threadId, + runId: active.id, + holdQueue: true, + }); yield* orchestrator.dispatch({ type: "thread.model-selection.set", commandId: CommandId.make(`${threadId}:select-switched`), threadId, modelSelection: switchedSelection, }); - const waiting = yield* orchestrator.getThreadProjection(threadId); - const now = yield* DateTime.now; - yield* eventSink.write({ - events: [ - { - id: EventId.make(`${threadId}:completed`), - type: "run.updated", - threadId, - runId: active.id, - occurredAt: now, - payload: { - ...waiting.runs.find((run) => run.id === active.id)!, - status: "completed", - completedAt: now, - }, - }, - ], + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make(`${threadId}:resume`), + threadId, }); yield* orchestrator.resumeQueuedRuns; @@ -8423,14 +8405,28 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { ), ); - // Both deliveries froze the original selection when they queued. + // Both deliveries froze the original selection when they queued. Stop holds the queue, since + // a thread changes instance only while no run is active; resuming starts the first delivery. + const active = yield* startedRunFor(MessageId.make(`${threadId}:active`)); + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make(`${threadId}:stop`), + threadId, + runId: active!.id, + holdQueue: true, + }); yield* orchestrator.dispatch({ type: "thread.model-selection.set", commandId: CommandId.make(`${threadId}:select-switched`), threadId, modelSelection: switchedSelection, }); - yield* completeRunFor(MessageId.make(`${threadId}:active`)); + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make(`${threadId}:resume`), + threadId, + }); + yield* orchestrator.resumeQueuedRuns; const first = yield* startedRunFor(deliveries[0]!); assert.equal(first?.status, "starting"); assert.deepEqual(first?.modelSelection, switchedSelection); From 0ddeaf692326fcf81ea78e63fb4fe0744c090c10 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 16:23:50 +0000 Subject: [PATCH 08/10] test(server): trim scheduled-delivery selection tests to distinct regressions Co-Authored-By: Claude Opus 5.5 --- .../SteeringCompletion.integration.test.ts | 7 - .../src/orchestration-v2/runtimeLayer.test.ts | 161 ++---------------- 2 files changed, 11 insertions(+), 157 deletions(-) diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 6b48214e224a2..acfd20af78749 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -2205,13 +2205,6 @@ it.effect("steers a promoted scheduled delivery with the thread's current select const projection = yield* orchestrator.getThreadProjection(threadId); assert.deepEqual(steered, ["scheduled"]); assert.deepEqual(projection.thread.modelSelection, composerSelection); - const promoted = projection.turnItems.find( - (item) => item.type === "user_message" && item.messageId === "message:scheduled", - ); - assert.deepEqual( - promoted?.type === "user_message" ? promoted.scheduledTaskId : undefined, - ScheduledTaskId.make("task:scheduled"), - ); }).pipe(Effect.provide(layer)); }), ), diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 75b15e22df64e..c9a044bc6505d 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -8479,21 +8479,16 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); - it.effect.each( - [ - { ...modelSelection, model: "gpt-5.5", options: [{ id: "reasoningEffort", value: "high" }] }, - { instanceId: alternateInstanceId, model: "gpt-5.5" }, - ].flatMap((newerSelection) => [ - { newerSelection, freshContext: false }, - { newerSelection, freshContext: true }, - ]), - )( + it.effect.each([ + { ...modelSelection, model: "gpt-5.5", options: [{ id: "reasoningEffort", value: "high" }] }, + { instanceId: alternateInstanceId, model: "gpt-5.5" }, + ])( "starts a queued scheduled delivery on a selection set while it waited: %j", - ({ newerSelection, freshContext }) => + (newerSelection) => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const threadId = ThreadId.make( - `runtime-layer-queued-scheduled-selection-${newerSelection.instanceId}-${newerSelection.model}-${freshContext}`, + `runtime-layer-queued-scheduled-selection-${newerSelection.instanceId}-${newerSelection.options === undefined}`, ); const scheduledMessageId = MessageId.make(`${threadId}:scheduled`); yield* orchestrator.dispatch({ @@ -8532,7 +8527,6 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { scheduledTaskId: ScheduledTaskId.make(`${threadId}:task`), text: "Scheduled prompt", attachments: [], - ...(freshContext ? { freshContext: true } : {}), dispatchMode: { type: "queue_after_active" }, createdBy: "user", creationSource: "web", @@ -8566,7 +8560,6 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { const delivered = after.runs.find((run) => run.userMessageId === scheduledMessageId)!; assert.equal(delivered.status, "starting"); assert.deepEqual(delivered.modelSelection, newerSelection); - assert.equal(delivered.freshContext === true, freshContext); assert.equal( after.attempts.find((attempt) => attempt.id === delivered.activeAttemptId) ?.providerInstanceId, @@ -8668,19 +8661,13 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); - it.effect.each([ - // Another instance of the same provider... - { instanceId: alternateInstanceId, model: "gpt-5.5" }, - // ...and a different provider entirely: the reservation is never reused across either. - { instanceId: claudeInstanceId, model: "claude-test" }, - ])( - "starts a fresh scheduled delivery on a new provider thread after a provider switch: %j", - (switchedSelection) => + it.effect( + "starts a fresh scheduled delivery on a new provider thread after a provider switch", + () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; - const threadId = ThreadId.make( - `runtime-layer-fresh-scheduled-provider-thread-${switchedSelection.instanceId}`, - ); + const threadId = ThreadId.make("runtime-layer-fresh-scheduled-provider-thread"); + const switchedSelection = { instanceId: alternateInstanceId, model: "gpt-5.5" }; const scheduledMessageId = MessageId.make(`${threadId}:scheduled`); yield* orchestrator.dispatch({ type: "thread.create", @@ -8773,132 +8760,6 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); - it.effect("starts each queued scheduled delivery on the selection current at its start", () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const eventSink = yield* EventSink.EventSinkV2; - const threadId = ThreadId.make("runtime-layer-queued-scheduled-selection-repeated"); - const switchedSelection = { instanceId: alternateInstanceId, model: "gpt-5.5" }; - const laterSelection = { - ...switchedSelection, - options: [{ id: "reasoningEffort", value: "high" }], - }; - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make(`${threadId}:create`), - threadId, - projectId: ProjectId.make(`${threadId}:project`), - createdBy: "user", - creationSource: "web", - title: "Repeated scheduled deliveries", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: process.cwd(), - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make(`${threadId}:active`), - threadId, - messageId: MessageId.make(`${threadId}:active`), - text: "Busy", - attachments: [], - modelSelection, - dispatchMode: { type: "start_immediately" }, - createdBy: "user", - creationSource: "web", - }); - const deliveries = ["first", "second"].map((name) => MessageId.make(`${threadId}:${name}`)); - for (const messageId of deliveries) { - yield* orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make(messageId), - threadId, - messageId, - scheduledTaskId: ScheduledTaskId.make(`${threadId}:task`), - text: "Scheduled prompt", - attachments: [], - dispatchMode: { type: "queue_after_active" }, - createdBy: "user", - creationSource: "web", - }); - } - const completeRunFor = (messageId: MessageId) => - Effect.gen(function* () { - const projection = yield* orchestrator.getThreadProjection(threadId); - const run = projection.runs.find((candidate) => candidate.userMessageId === messageId)!; - const now = yield* DateTime.now; - yield* eventSink.write({ - events: [ - { - id: EventId.make(`${messageId}:completed`), - type: "run.updated", - threadId, - runId: run.id, - occurredAt: now, - payload: { ...run, status: "completed", completedAt: now }, - }, - ], - }); - yield* orchestrator.resumeQueuedRuns; - }); - const startedRunFor = (messageId: MessageId) => - orchestrator - .getThreadProjection(threadId) - .pipe( - Effect.map((projection) => - projection.runs.find((candidate) => candidate.userMessageId === messageId), - ), - ); - - // Both deliveries froze the original selection when they queued. Stop holds the queue, since - // a thread changes instance only while no run is active; resuming starts the first delivery. - const active = yield* startedRunFor(MessageId.make(`${threadId}:active`)); - yield* orchestrator.dispatch({ - type: "run.interrupt", - commandId: CommandId.make(`${threadId}:stop`), - threadId, - runId: active!.id, - holdQueue: true, - }); - yield* orchestrator.dispatch({ - type: "thread.model-selection.set", - commandId: CommandId.make(`${threadId}:select-switched`), - threadId, - modelSelection: switchedSelection, - }); - yield* orchestrator.dispatch({ - type: "queue.resume", - commandId: CommandId.make(`${threadId}:resume`), - threadId, - }); - yield* orchestrator.resumeQueuedRuns; - const first = yield* startedRunFor(deliveries[0]!); - assert.equal(first?.status, "starting"); - assert.deepEqual(first?.modelSelection, switchedSelection); - assert.equal((yield* startedRunFor(deliveries[1]!))?.status, "queued"); - assert.deepEqual( - (yield* orchestrator.getThreadProjection(threadId)).thread.modelSelection, - switchedSelection, - ); - - yield* orchestrator.dispatch({ - type: "thread.model-selection.set", - commandId: CommandId.make(`${threadId}:select-later`), - threadId, - modelSelection: laterSelection, - }); - yield* completeRunFor(deliveries[0]!); - const second = yield* startedRunFor(deliveries[1]!); - assert.equal(second?.status, "starting"); - assert.deepEqual(second?.modelSelection, laterSelection); - const after = yield* orchestrator.getThreadProjection(threadId); - assert.deepEqual(after.thread.modelSelection, laterSelection); - assert.equal(after.thread.providerInstanceId, alternateInstanceId); - }), - ); - it.effect("keeps the queue after a user interrupts the active run", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; From 936f9243add747bc1a959c7ac448d88cc5f0f99f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 16:27:15 +0000 Subject: [PATCH 09/10] test(server): type-safe thread id in scheduled selection test Co-Authored-By: Claude Opus 5.5 --- apps/server/src/orchestration-v2/runtimeLayer.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index c9a044bc6505d..79bf44d66954f 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -8488,7 +8488,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const threadId = ThreadId.make( - `runtime-layer-queued-scheduled-selection-${newerSelection.instanceId}-${newerSelection.options === undefined}`, + `runtime-layer-queued-scheduled-selection-${newerSelection.instanceId}-${"options" in newerSelection}`, ); const scheduledMessageId = MessageId.make(`${threadId}:scheduled`); yield* orchestrator.dispatch({ From 3692032b06548b32c11954715d227295bb1281c2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 16:39:36 +0000 Subject: [PATCH 10/10] fix(server): scheduled deliveries queued before upgrade keep their selection Mark unpinned scheduled deliveries instead of recording pins, so messages written before this change (no marker) keep the selection they queued with. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 19 ++++++++++--------- packages/contracts/src/orchestrationV2.ts | 4 ++-- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index f9a8f9f27bbad..9c8460b806a88 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -909,9 +909,8 @@ function nextQueuedRun( return queuedRunsInDeliveryOrder(projection).find((run) => run.queueHeld !== true); } -// Delegated completions, monitor notifications and scheduled deliveries never choose a model, -// so a queued one runs on the thread's current pick rather than the one frozen at queue time. -// A scheduled delivery that named its own model is the exception: its pin wins over the thread. +// Delegated completions, monitor notifications and unpinned scheduled deliveries never choose a +// model, so a queued one runs on the thread's current pick rather than the one frozen at queue time. function followsCurrentConversation( message: | Pick< @@ -920,15 +919,13 @@ function followsCurrentConversation( | "creationSource" | "notification" | "delegatedCompletion" - | "scheduledTaskId" - | "modelSelection" + | "followsThreadSelection" > | undefined, ): boolean { - if (message?.scheduledTaskId !== undefined && message.modelSelection !== undefined) return false; return ( message?.delegatedCompletion !== undefined || - message?.scheduledTaskId !== undefined || + message?.followsThreadSelection === true || (message?.notification?.source.kind === "monitor" && !isBufferedProviderNotification(message)) ); } @@ -7457,7 +7454,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), ...(command.linkedSender === undefined ? {} : { linkedSender: command.linkedSender }), ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), - ...(command.modelSelection === undefined ? {} : { modelSelection: command.modelSelection }), + ...(command.scheduledTaskId !== undefined && command.modelSelection === undefined + ? { followsThreadSelection: true as const } + : {}), id: command.messageId, threadId: command.threadId, runId, @@ -7870,7 +7869,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), ...(command.linkedSender === undefined ? {} : { linkedSender: command.linkedSender }), ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), - ...(command.modelSelection === undefined ? {} : { modelSelection: command.modelSelection }), + ...(command.scheduledTaskId !== undefined && command.modelSelection === undefined + ? { followsThreadSelection: true as const } + : {}), id: command.messageId, threadId: command.threadId, runId, diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index a58bba9644d99..4da04a0ebf496 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1352,8 +1352,8 @@ export const OrchestrationV2ConversationMessage = Schema.Struct({ notification: Schema.optional(OrchestrationV2Notification), ...OrchestrationV2CreationFields, scheduledTaskId: Schema.optional(ScheduledTaskId), - /** The selection the delivery named when it arrived; a scheduled delivery that named one keeps it. */ - modelSelection: Schema.optional(ModelSelection), + /** A scheduled delivery that named no model; it runs on the thread's selection at start. */ + followsThreadSelection: Schema.optional(Schema.Literal(true)), senderThreadId: Schema.optional(ThreadId), linkedSender: Schema.optional(OrchestrationV2LinkedSender), idleCompaction: Schema.optional(OrchestrationV2IdleCompaction),