From 48ed19c9b6a048487961476859ad052c5b2d2325 Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Sun, 11 Oct 2026 23:35:49 -0300 Subject: [PATCH 1/2] fix(server): thread-bound scheduled tasks run on the thread's current provider A scheduled task bound to a thread passed the model selection it was created with on every run, so a thread the user had switched to another provider was dragged back to the old one each time the task fired. Leave the selection out for thread-bound runs so they use whatever provider and model the thread is on now. Refs #17932 --- .../ThreadLaunchService.test.ts | 68 ++++++++++++++++++- .../scheduledTasks/ScheduledTaskService.ts | 3 +- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 3012e09411ac..f58d00b32961 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -50,6 +50,7 @@ import * as ServerSettings from "../serverSettings.ts"; import * as ScheduledTasks from "../scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import { ClaudeProviderCapabilitiesV2 } from "./Adapters/ClaudeAdapterV2.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; @@ -96,6 +97,18 @@ const adapter = { openSession: () => Effect.die("provider execution is disabled in launch tests"), } as ProviderAdapter.ProviderAdapterV2["Service"]; +const claudeModelSelection = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-5-5", +} as const; +const claudeAdapter = { + instanceId: claudeModelSelection.instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + getCapabilities: () => Effect.succeed(ClaudeProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("provider execution is disabled in launch tests"), +} as ProviderAdapter.ProviderAdapterV2["Service"]; + interface HarnessOptions { readonly managedFolders?: Layer.Layer; readonly createWorktree?: GitWorkflow.GitWorkflowService["Service"]["createWorktree"]; @@ -112,7 +125,7 @@ interface HarnessOptions { function makeHarness(options: HarnessOptions = {}) { const layerDatabase = SqlitePersistence.layerMemory; - const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([adapter]); + const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([adapter, claudeAdapter]); const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( { name: "thread-launch" }, layerRegistry, @@ -365,6 +378,59 @@ it.effect.each( }, ); +it.effect("runs a thread-bound automation on the provider the thread uses now", () => { + const harness = makeHarness(); + const layerScheduledTasks = ScheduledTasks.layer.pipe( + Layer.provide( + Layer.mergeAll( + harness.layer, + NodeCrypto.layer, + Scheduler.layer, + Layer.mock(SecretRequests.SecretRequests)({}), + ), + ), + ); + return Effect.gen(function* () { + const tasks = yield* ScheduledTasks.ScheduledTaskService; + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const launched = yield* launches.launch( + launchInput({ command: "command:switched", thread: "thread:switched" }), + ); + // The task is created while the thread is on Codex... + const { task } = yield* tasks.upsert({ + id: ScheduledTaskId.make("scheduled-task:switched"), + title: "Hourly check", + prompt: "Check on the delegated workers.", + enabled: false, + schedule: { type: "interval", everyMs: 3_600_000 }, + projectId, + threadId: launched.threadId, + workspaceStrategy: { type: "root" }, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + createdBy: "agent", + creationSource: "mcp", + }); + // ...and the user then switches the thread to Claude. + yield* threads.dispatch({ + type: "thread.model-selection.set", + commandId: CommandId.make("command:switch-to-claude"), + threadId: launched.threadId, + modelSelection: claudeModelSelection, + }); + + const result = yield* tasks.runNow({ id: task.id }); + assert.equal(result.task.lastRunStatus, "succeeded"); + const projection = yield* threads.getThreadProjection(launched.threadId); + const message = projection.messages.find((item) => item.scheduledTaskId === task.id); + const run = projection.runs.find((item) => item.id === message?.runId); + assert.deepStrictEqual(run?.modelSelection, claudeModelSelection); + assert.deepStrictEqual(projection.thread.modelSelection, claudeModelSelection); + }).pipe(Effect.provide(Layer.mergeAll(harness.layer, layerScheduledTasks))); +}); + it.effect("retains automation and sender attribution while a message waits in the queue", () => { const harness = makeHarness({ runSetup: () => Effect.never }); return Effect.gen(function* () { diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 51403c124a98..7f614d42ed75 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -814,6 +814,8 @@ export const layer = Layer.effect( }), ) : yield* Effect.exit( + // No modelSelection: the run follows the thread's current + // provider and model, not the one the task was created with. threadManagement.sendToThread({ projectId: active.projectId, commandId, @@ -822,7 +824,6 @@ export const layer = Layer.effect( scheduledTaskId: active.id, text: prompt, attachments: [], - modelSelection: active.modelSelection, // Scheduled prompts must not interrupt tools in the bound thread. mode: "queue", createdBy: active.createdBy, From 0d13876ce796c66350824298f4701450ac3bb431 Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Mon, 12 Oct 2026 01:03:55 -0300 Subject: [PATCH 2/2] test(server): cover a thread-bound task after switching to another account of the same provider --- .../ThreadLaunchService.test.ts | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index f58d00b32961..3c106e28f1af 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -108,6 +108,15 @@ const claudeAdapter = { planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), openSession: () => Effect.die("provider execution is disabled in launch tests"), } as ProviderAdapter.ProviderAdapterV2["Service"]; +// A second account of the same provider, configured as its own instance. +const otherCodexModelSelection = { + instanceId: ProviderInstanceId.make("codex_work"), + model: modelSelection.model, +} as const; +const otherCodexAdapter = { + ...adapter, + instanceId: otherCodexModelSelection.instanceId, +} as ProviderAdapter.ProviderAdapterV2["Service"]; interface HarnessOptions { readonly managedFolders?: Layer.Layer; @@ -125,7 +134,11 @@ interface HarnessOptions { function makeHarness(options: HarnessOptions = {}) { const layerDatabase = SqlitePersistence.layerMemory; - const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([adapter, claudeAdapter]); + const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ + adapter, + claudeAdapter, + otherCodexAdapter, + ]); const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( { name: "thread-launch" }, layerRegistry, @@ -378,8 +391,17 @@ it.effect.each( }, ); -it.effect("runs a thread-bound automation on the provider the thread uses now", () => { - const harness = makeHarness(); +it.effect.each([ + { switchedTo: "another provider", selection: claudeModelSelection }, + { switchedTo: "another account of the same provider", selection: otherCodexModelSelection }, +])("runs a thread-bound automation on $switchedTo the thread uses now", ({ selection }) => { + const harness = makeHarness({ + serverSettings: { + providerInstances: { + [otherCodexModelSelection.instanceId]: { driver: adapter.driver, config: {} }, + }, + }, + }); const layerScheduledTasks = ScheduledTasks.layer.pipe( Layer.provide( Layer.mergeAll( @@ -413,12 +435,12 @@ it.effect("runs a thread-bound automation on the provider the thread uses now", createdBy: "agent", creationSource: "mcp", }); - // ...and the user then switches the thread to Claude. + // ...and the user then switches the thread away from that instance. yield* threads.dispatch({ type: "thread.model-selection.set", - commandId: CommandId.make("command:switch-to-claude"), + commandId: CommandId.make("command:switch-selection"), threadId: launched.threadId, - modelSelection: claudeModelSelection, + modelSelection: selection, }); const result = yield* tasks.runNow({ id: task.id }); @@ -426,8 +448,8 @@ it.effect("runs a thread-bound automation on the provider the thread uses now", const projection = yield* threads.getThreadProjection(launched.threadId); const message = projection.messages.find((item) => item.scheduledTaskId === task.id); const run = projection.runs.find((item) => item.id === message?.runId); - assert.deepStrictEqual(run?.modelSelection, claudeModelSelection); - assert.deepStrictEqual(projection.thread.modelSelection, claudeModelSelection); + assert.deepStrictEqual(run?.modelSelection, selection); + assert.deepStrictEqual(projection.thread.modelSelection, selection); }).pipe(Effect.provide(Layer.mergeAll(harness.layer, layerScheduledTasks))); });