From 9152a0a41e66a76a619c655e3b27a7c030f60d23 Mon Sep 17 00:00:00 2001 From: Sumeet Padavala Date: Tue, 13 Oct 2026 19:45:48 +0000 Subject: [PATCH 1/5] fix(server): agents can choose the model for a scheduled task schedule_task and update_scheduled_task take an optional target, resolved the same way as delegate_task's. Scheduled task summaries now report the provider, model, options, and modes a task runs with. Fixes #16833 Co-Authored-By: Claude Opus 5.5 --- .../src/mcp/OrchestratorMcpService.test.ts | 172 ++++++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 63 ++++++- .../src/mcp/toolkits/orchestrator/tools.ts | 6 +- packages/contracts/src/orchestratorMcp.ts | 17 ++ 4 files changed, 252 insertions(+), 6 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 1b68f710d6df7..8bf6ce6b320b4 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -2169,6 +2169,178 @@ describe("OrchestratorMcpService provider resolution", () => { ); }); + describe("scheduled task targets", () => { + const miniModel = "gpt-5.4-mini"; + const callerSelection = { + instanceId: codexInstanceId, + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "high" }], + }; + const stored: ScheduledTask = { + id: ScheduledTaskId.make("scheduled-task:triage"), + title: "Triage", + prompt: "Triage {{body.issue.title}}", + enabled: true, + schedule: { type: "webhook", signature: null }, + projectId, + threadId: null, + workspaceStrategy: { type: "root" }, + modelSelection: callerSelection, + runtimeMode: "full-access", + interactionMode: "default", + createdBy: "agent", + creationSource: "mcp", + createdAt: "2026-10-05T00:00:00.000Z", + updatedAt: "2026-10-05T00:00:00.000Z", + nextRunAt: null, + lastRunAt: null, + lastRunStatus: "never", + lastRunError: null, + runCount: 0, + }; + const codex = providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }); + + /** The service with one stored task, recording every selection it saves. */ + const service = ( + saved: Ref.Ref>, + enabledForProject = true, + ) => + OrchestratorMcpService.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(parentProjection([], callerSelection)), + isProviderInstanceEnabledForProject: () => Effect.succeed(enabledForProject), + }), + providerRegistryLayer([ + { + ...codex, + models: [ + ...codex.models, + { slug: miniModel, name: miniModel, isCustom: false, capabilities: null }, + ], + }, + ]), + adapterRegistryLayer([codexInstanceId]), + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeedSome({ id: projectId } as never), + }), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({ + list: () => Effect.succeed({ tasks: [stored] }), + upsert: (input) => + Ref.update(saved, (selections) => [...selections, input.modelSelection]).pipe( + Effect.as({ task: { ...stored, modelSelection: input.modelSelection } }), + ), + }), + ServerSettings.layerTest(), + ), + ), + ); + + it.effect("schedules a task on the target model instead of the caller's", () => + Effect.gen(function* () { + const saved = yield* Ref.make>([]); + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.provide(service(saved)), + ); + const schedule = { type: "webhook", signature: null } as const; + const inherited = yield* mcp.scheduleTask(scope, { prompt: "Triage", schedule }); + const targeted = yield* mcp.scheduleTask(scope, { + prompt: "Triage", + schedule, + target: { model: miniModel }, + }); + // The caller's options belong to its own model, so they do not carry over. + assert.deepEqual(yield* Ref.get(saved), [ + callerSelection, + { instanceId: codexInstanceId, model: miniModel }, + ]); + assert.deepEqual( + [inherited, targeted].map(({ providerInstanceId, model, options }) => ({ + providerInstanceId, + model, + options, + })), + [ + { + providerInstanceId: codexInstanceId, + model: "gpt-5.4", + options: callerSelection.options, + }, + { providerInstanceId: codexInstanceId, model: miniModel, options: undefined }, + ], + ); + }), + ); + + it.effect("changes a task's model only when an update names a target", () => + Effect.gen(function* () { + const saved = yield* Ref.make>([]); + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.provide(service(saved)), + ); + yield* mcp.updateScheduledTask(scope, { scheduledTaskId: stored.id, enabled: false }); + const retargeted = yield* mcp.updateScheduledTask(scope, { + scheduledTaskId: stored.id, + target: { model: miniModel }, + }); + // A target that names only options keeps the stored provider and model. + yield* mcp.updateScheduledTask(scope, { + scheduledTaskId: stored.id, + target: { options: [{ id: "reasoningEffort", value: "low" }] }, + }); + assert.deepEqual(yield* Ref.get(saved), [ + callerSelection, + { instanceId: codexInstanceId, model: miniModel }, + { + instanceId: codexInstanceId, + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "low" }], + }, + ]); + assert.equal(retargeted.model, miniModel); + const listed = yield* mcp.listScheduledTasks(scope, {}); + assert.equal(listed.tasks[0]?.model, "gpt-5.4"); + }), + ); + + it.effect("saves nothing when the target cannot run in the project", () => + Effect.gen(function* () { + const saved = yield* Ref.make>([]); + const schedule = { type: "webhook", signature: null } as const; + const unknownModel = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.provide(service(saved)), + Effect.flatMap((mcp) => + mcp + .scheduleTask(scope, { prompt: "Triage", schedule, target: { model: "gpt-unknown" } }) + .pipe(Effect.flip), + ), + ); + assert.equal(unknownModel.code, "model_unavailable"); + // The project turned the provider off. + const disabled = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.provide(service(saved, false)), + Effect.flatMap((mcp) => + mcp + .updateScheduledTask(scope, { + scheduledTaskId: stored.id, + target: { model: miniModel }, + }) + .pipe(Effect.flip), + ), + ); + assert.equal(disabled.code, "invalid_request"); + assert.deepEqual(yield* Ref.get(saved), []); + }), + ); + }); + describe("resolveRuntimeMode", () => { // Muse offers only these two, and runs any other mode as Supervised. const muse = { diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index f9bb61fa810e9..55b5f0ac5de44 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -253,6 +253,11 @@ function scheduledTaskSummary(task: ScheduledTask, mayRun: boolean): Orchestrato schedule: task.schedule, nextRunAt: task.nextRunAt, lastRunStatus: task.lastRunStatus, + providerInstanceId: task.modelSelection.instanceId, + model: task.modelSelection.model, + ...(task.modelSelection.options === undefined ? {} : { options: task.modelSelection.options }), + runtimeMode: task.runtimeMode, + interactionMode: task.interactionMode, // A bare path is not a URL anyone can call, so agents never get one to share. ...(task.webhook?.url == null || !mayRun ? {} : { webhookUrl: task.webhook.url }), ...(task.webhook === undefined @@ -1170,7 +1175,12 @@ const make = Effect.gen(function* () { }; const resolveTarget = (input: { - readonly parent: Pick; + readonly parent: { + readonly thread: Pick< + OrchestrationV2ThreadProjection["thread"], + "projectId" | "modelSelection" + >; + }; readonly target: OrchestratorMcpTarget | undefined; readonly providers: ReadonlyArray; /** @@ -1315,6 +1325,37 @@ const make = Effect.gen(function* () { }; }); + /** + * The selection a scheduled task's runs use when the caller names a target: + * resolved like a work-starting tool's target, with `inherited` standing in + * for the parent thread's selection. + */ + const resolveScheduledTaskModelSelection = (input: { + readonly projectId: ProjectId; + readonly inherited: ModelSelection; + readonly target: OrchestratorMcpTarget; + }) => + Effect.gen(function* () { + const { modelSelection } = yield* resolveTargetRechecking({ + parent: { thread: { projectId: input.projectId, modelSelection: input.inherited } }, + target: input.target, + providers: yield* loadProviders, + }); + const enabled = yield* threadManagement.isProviderInstanceEnabledForProject({ + projectId: input.projectId, + instanceId: modelSelection.instanceId, + }); + if (!enabled) { + return yield* threadManagementFailure( + new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ + projectId: input.projectId, + instanceId: modelSelection.instanceId, + }), + ); + } + return modelSelection; + }); + const requestKey = (clientRequestId: string | undefined): Effect.Effect => clientRequestId === undefined ? crypto.randomUUIDv4.pipe(Effect.orDie) @@ -1632,8 +1673,16 @@ const make = Effect.gen(function* () { : "bindToCurrentThread binds to this thread, which belongs to a different project.", ); } - const modelSelection = + const inherited = parent?.thread.modelSelection ?? (yield* projectDefaultModelSelection(project)); + const modelSelection = + input.target === undefined + ? inherited + : yield* resolveScheduledTaskModelSelection({ + projectId, + inherited, + target: input.target, + }); const derivedTitle = input.prompt.split("\n")[0]?.trim() ?? ""; const title = input.title ?? (derivedTitle.length > 0 ? derivedTitle.slice(0, 80) : "Scheduled task"); @@ -1711,6 +1760,14 @@ const make = Effect.gen(function* () { : input.bindToCurrentThread && parent !== undefined ? parent.thread.id : null; + const modelSelection = + input.target === undefined + ? existing.modelSelection + : yield* resolveScheduledTaskModelSelection({ + projectId: existing.projectId, + inherited: existing.modelSelection, + target: input.target, + }); // Rebinding changes where runs execute, so the workspace strategy must // follow: unbinding a root-strategy task would otherwise run loose // prompts in the shared project checkout. @@ -1727,7 +1784,7 @@ const make = Effect.gen(function* () { projectId: existing.projectId, threadId, workspaceStrategy, - modelSelection: existing.modelSelection, + modelSelection, runtimeMode: existing.runtimeMode, interactionMode: existing.interactionMode, createdBy: existing.createdBy, diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index f2c34bb22cbe1..cfb434c04e1a7 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -116,7 +116,7 @@ const WorkflowStopTool = Tool.make("t3_workflow_stop", { export const ScheduleTaskTool = Tool.make("schedule_task", { description: - "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.", + "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings are copied once from this thread, or from the project default when there is no calling thread. Pass target (provider instance, model, and options from orchestrator_capabilities) to run the task on a different model, e.g. a cheaper one for triage; options carry over only when the provider and model stay the same.", parameters: OrchestratorMcpScheduleTaskInput, success: OrchestratorMcpScheduleTaskResult, failure: OrchestratorMcpFailure, @@ -130,7 +130,7 @@ export const ScheduleTaskTool = Tool.make("schedule_task", { const ListScheduledTasksTool = Tool.make("list_scheduled_tasks", { parameters: OrchestratorMcpListScheduledTasksInput, description: - "List recurring scheduled tasks in a project (omit projectId for the calling thread's project, or every project when there is no calling thread), including their id, schedule, prompt, enabled state, bound thread, next run time, and last run status. Use the returned scheduledTaskId with update_scheduled_task or delete_scheduled_task.", + "List recurring scheduled tasks in a project (omit projectId for the calling thread's project, or every project when there is no calling thread), including their id, schedule, prompt, enabled state, bound thread, provider, model, options, modes, next run time, and last run status. Use the returned scheduledTaskId with update_scheduled_task or delete_scheduled_task.", success: OrchestratorMcpListScheduledTasksResult, failure: OrchestratorMcpFailure, failureMode: "return", @@ -143,7 +143,7 @@ const ListScheduledTasksTool = Tool.make("list_scheduled_tasks", { const UpdateScheduledTaskTool = Tool.make("update_scheduled_task", { description: - "Update an existing scheduled task by scheduledTaskId (from list_scheduled_tasks). Only the provided fields change; omit a field to leave it as-is. Use enabled=false to pause a task without deleting it. Set bindToCurrentThread to move the task between posting into this thread and launching a fresh thread per run.", + "Update an existing scheduled task by scheduledTaskId (from list_scheduled_tasks). Only the provided fields change; omit a field to leave it as-is. Use enabled=false to pause a task without deleting it. Set bindToCurrentThread to move the task between posting into this thread and launching a fresh thread per run. Set target to change the provider instance, model, or options its runs use; the parts of target you leave out keep the task's stored values, except that options reset when the provider or model changes.", parameters: OrchestratorMcpUpdateScheduledTaskInput, success: OrchestratorMcpScheduleTaskResult, failure: OrchestratorMcpFailure, diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 0738426a4449c..6d00857f21101 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -539,6 +539,12 @@ export const OrchestratorMcpScheduleTaskInput = Schema.Struct({ "True (default) posts each run into this thread; false creates a fresh top-level thread per run.", }), ), + /** + * Provider, model, and options each run uses. Resolved like a delegated + * task's target, against the calling thread's selection (or the project + * default when there is no calling thread). Omit to copy that selection. + */ + target: Schema.optional(OrchestratorMcpTarget), clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), }); export type OrchestratorMcpScheduleTaskInput = typeof OrchestratorMcpScheduleTaskInput.Type; @@ -554,6 +560,11 @@ export const OrchestratorMcpScheduledTask = Schema.Struct({ schedule: ScheduledTaskSchedule, nextRunAt: Schema.NullOr(IsoDateTime), lastRunStatus: ScheduledTaskRunStatus, + providerInstanceId: ProviderInstanceId, + model: Schema.String, + options: Schema.optional(Schema.Array(ProviderOptionSelection)), + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, /** For webhook tasks: the public T3 Connect URL. Absent when this environment has no managed tunnel. */ webhookUrl: Schema.optional(Schema.String).annotate({ description: @@ -592,6 +603,12 @@ export const OrchestratorMcpUpdateScheduledTaskInput = Schema.Struct({ schedule: Schema.optional(OrchestratorMcpSchedule), enabled: Schema.optional(Schema.Boolean), bindToCurrentThread: Schema.optional(Schema.Boolean), + /** + * Changes the provider, model, or options the task's runs use. Resolved + * against the task's stored selection, so a field left out keeps its stored + * value. Omit to leave the selection unchanged. + */ + target: Schema.optional(OrchestratorMcpTarget), }); export type OrchestratorMcpUpdateScheduledTaskInput = typeof OrchestratorMcpUpdateScheduledTaskInput.Type; From 0046535653081e20b801f18782be9a7956561e62 Mon Sep 17 00:00:00 2001 From: Sumeet Padavala Date: Tue, 13 Oct 2026 21:40:08 +0000 Subject: [PATCH 2/5] fix(server): scheduled task target docs and test match what the resolver does The update test's scheduler mock now keeps the last saved selection, so an options-only update is checked against the retargeted model. The tool descriptions say where the runtime mode comes from without a calling thread, and that naming a different provider without a model picks that provider's first model. Co-Authored-By: Claude Opus 5.5 --- .../src/mcp/OrchestratorMcpService.test.ts | 18 ++++++++++++++---- .../src/mcp/toolkits/orchestrator/tools.ts | 4 ++-- packages/contracts/src/orchestratorMcp.ts | 5 +++-- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 8bf6ce6b320b4..0c9f9b4638ea0 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -2204,7 +2204,7 @@ describe("OrchestratorMcpService provider resolution", () => { model: "gpt-5.4", }); - /** The service with one stored task, recording every selection it saves. */ + /** The service with one stored task that keeps the last selection saved to it. */ const service = ( saved: Ref.Ref>, enabledForProject = true, @@ -2232,7 +2232,17 @@ describe("OrchestratorMcpService provider resolution", () => { }), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({ - list: () => Effect.succeed({ tasks: [stored] }), + list: () => + Ref.get(saved).pipe( + Effect.map((selections) => ({ + tasks: [ + { + ...stored, + modelSelection: selections[selections.length - 1] ?? stored.modelSelection, + }, + ], + })), + ), upsert: (input) => Ref.update(saved, (selections) => [...selections, input.modelSelection]).pipe( Effect.as({ task: { ...stored, modelSelection: input.modelSelection } }), @@ -2300,13 +2310,13 @@ describe("OrchestratorMcpService provider resolution", () => { { instanceId: codexInstanceId, model: miniModel }, { instanceId: codexInstanceId, - model: "gpt-5.4", + model: miniModel, options: [{ id: "reasoningEffort", value: "low" }], }, ]); assert.equal(retargeted.model, miniModel); const listed = yield* mcp.listScheduledTasks(scope, {}); - assert.equal(listed.tasks[0]?.model, "gpt-5.4"); + assert.equal(listed.tasks[0]?.model, miniModel); }), ); diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index cfb434c04e1a7..de65d75b91e95 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -116,7 +116,7 @@ const WorkflowStopTool = Tool.make("t3_workflow_stop", { export const ScheduleTaskTool = Tool.make("schedule_task", { description: - "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings are copied once from this thread, or from the project default when there is no calling thread. Pass target (provider instance, model, and options from orchestrator_capabilities) to run the task on a different model, e.g. a cheaper one for triage; options carry over only when the provider and model stay the same.", + "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings are copied once from this thread. With no calling thread, provider and model come from the project default and the runtime mode from this client's permission ceiling. Pass target (provider instance, model, and options from orchestrator_capabilities) to run the task on a different model, e.g. a cheaper one for triage; options carry over only when the provider and model stay the same.", parameters: OrchestratorMcpScheduleTaskInput, success: OrchestratorMcpScheduleTaskResult, failure: OrchestratorMcpFailure, @@ -143,7 +143,7 @@ const ListScheduledTasksTool = Tool.make("list_scheduled_tasks", { const UpdateScheduledTaskTool = Tool.make("update_scheduled_task", { description: - "Update an existing scheduled task by scheduledTaskId (from list_scheduled_tasks). Only the provided fields change; omit a field to leave it as-is. Use enabled=false to pause a task without deleting it. Set bindToCurrentThread to move the task between posting into this thread and launching a fresh thread per run. Set target to change the provider instance, model, or options its runs use; the parts of target you leave out keep the task's stored values, except that options reset when the provider or model changes.", + "Update an existing scheduled task by scheduledTaskId (from list_scheduled_tasks). Only the provided fields change; omit a field to leave it as-is. Use enabled=false to pause a task without deleting it. Set bindToCurrentThread to move the task between posting into this thread and launching a fresh thread per run. Set target to change the provider instance, model, or options its runs use; the parts of target you leave out keep the task's stored values, except that naming a different provider without a model picks that provider's first model, and options reset when the provider or model changes.", parameters: OrchestratorMcpUpdateScheduledTaskInput, success: OrchestratorMcpScheduleTaskResult, failure: OrchestratorMcpFailure, diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 6d00857f21101..8388f7bce58eb 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -605,8 +605,9 @@ export const OrchestratorMcpUpdateScheduledTaskInput = Schema.Struct({ bindToCurrentThread: Schema.optional(Schema.Boolean), /** * Changes the provider, model, or options the task's runs use. Resolved - * against the task's stored selection, so a field left out keeps its stored - * value. Omit to leave the selection unchanged. + * like a delegated task's target, against the task's stored selection: a + * field left out keeps its stored value while the provider stays the same. + * Omit to leave the selection unchanged. */ target: Schema.optional(OrchestratorMcpTarget), }); From 734ebbeb24358a74f51dc55c6fc37d4d9a8bd952 Mon Sep 17 00:00:00 2001 From: Sumeet Padavala Date: Wed, 14 Oct 2026 17:57:15 -0400 Subject: [PATCH 3/5] fix(server): keep a scheduled task's model on a provider-only update --- .../src/mcp/OrchestratorMcpService.test.ts | 54 ++++++++++++++++++- apps/server/src/mcp/OrchestratorMcpService.ts | 29 +++++++++- packages/contracts/src/orchestratorMcp.ts | 7 ++- 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 0c9f9b4638ea0..937b9dae4e0a4 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -2208,6 +2208,7 @@ describe("OrchestratorMcpService provider resolution", () => { const service = ( saved: Ref.Ref>, enabledForProject = true, + extraProviders: ReadonlyArray = [], ) => OrchestratorMcpService.layer.pipe( Layer.provide( @@ -2225,8 +2226,12 @@ describe("OrchestratorMcpService provider resolution", () => { { slug: miniModel, name: miniModel, isCustom: false, capabilities: null }, ], }, + ...extraProviders, + ]), + adapterRegistryLayer([ + codexInstanceId, + ...extraProviders.map((provider) => provider.instanceId), ]), - adapterRegistryLayer([codexInstanceId]), Layer.mock(ProjectService.ProjectService)({ getById: () => Effect.succeedSome({ id: projectId } as never), }), @@ -2320,6 +2325,53 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.effect("keeps the stored model on a provider-only update, or refuses it", () => + Effect.gen(function* () { + const sharedInstanceId = ProviderInstanceId.make("codex-shared"); + const otherInstanceId = ProviderInstanceId.make("codex-other"); + const codexDriver = ProviderDriverKind.make("codex"); + // Lists a different default first, so falling back to models[0] would + // silently swap the stored gpt-5.4. + const shared = providerSnapshot({ + instanceId: sharedInstanceId, + driver: codexDriver, + model: "shared-default", + }); + const sharedWithStoredModel = { + ...shared, + models: [ + ...shared.models, + { slug: "gpt-5.4", name: "gpt-5.4", isCustom: false, capabilities: null }, + ], + }; + const other = providerSnapshot({ + instanceId: otherInstanceId, + driver: codexDriver, + model: "other-default", + }); + const saved = yield* Ref.make>([]); + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.provide(service(saved, true, [sharedWithStoredModel, other])), + ); + const moved = yield* mcp.updateScheduledTask(scope, { + scheduledTaskId: stored.id, + target: { providerInstanceId: sharedInstanceId }, + }); + assert.equal(moved.providerInstanceId, sharedInstanceId); + assert.equal(moved.model, "gpt-5.4"); + const refused = yield* mcp + .updateScheduledTask(scope, { + scheduledTaskId: stored.id, + target: { providerInstanceId: otherInstanceId }, + }) + .pipe(Effect.flip); + assert.equal(refused.code, "model_unavailable"); + assert.deepEqual(yield* Ref.get(saved), [ + { instanceId: sharedInstanceId, model: "gpt-5.4" }, + ]); + }), + ); + it.effect("saves nothing when the target cannot run in the project", () => Effect.gen(function* () { const saved = yield* Ref.make>([]); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 55b5f0ac5de44..5ea3b34dc9c44 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1193,6 +1193,14 @@ const make = Effect.gen(function* () { * command id than the one checked for a receipt. */ readonly skipProjectEnablementFilter?: boolean; + /** + * When the target switches provider instance without naming a model, keep + * the inherited model instead of taking the new provider's first model, + * failing with `model_unavailable` when the new provider does not + * advertise it. Scheduled task updates set this so a provider-only change + * never silently swaps the stored model. + */ + readonly keepInheritedModelAcrossProviders?: boolean; }): Effect.Effect => Effect.gen(function* () { const requestedInstanceId = input.target?.providerInstanceId; @@ -1276,9 +1284,23 @@ const make = Effect.gen(function* () { const inheritedSelection = input.parent.thread.modelSelection; const requestedModel = input.target?.model; + const keepInheritedModel = + requestedModel === undefined && + instanceId !== inheritedSelection.instanceId && + input.keepInheritedModelAcrossProviders === true; + if ( + keepInheritedModel && + provider.models.length > 0 && + !provider.models.some((candidate) => candidate.slug === inheritedSelection.model) + ) { + return yield* failure( + "model_unavailable", + `Model ${inheritedSelection.model} is not advertised by provider ${instanceId}; name a model in target to switch providers.`, + ); + } const model = requestedModel ?? - (instanceId === inheritedSelection.instanceId + (instanceId === inheritedSelection.instanceId || keepInheritedModel ? inheritedSelection.model : provider?.models[0]?.slug); if (model === undefined) { @@ -1334,12 +1356,16 @@ const make = Effect.gen(function* () { readonly projectId: ProjectId; readonly inherited: ModelSelection; readonly target: OrchestratorMcpTarget; + readonly keepInheritedModelAcrossProviders?: boolean; }) => Effect.gen(function* () { const { modelSelection } = yield* resolveTargetRechecking({ parent: { thread: { projectId: input.projectId, modelSelection: input.inherited } }, target: input.target, providers: yield* loadProviders, + ...(input.keepInheritedModelAcrossProviders === true + ? { keepInheritedModelAcrossProviders: true } + : {}), }); const enabled = yield* threadManagement.isProviderInstanceEnabledForProject({ projectId: input.projectId, @@ -1767,6 +1793,7 @@ const make = Effect.gen(function* () { projectId: existing.projectId, inherited: existing.modelSelection, target: input.target, + keepInheritedModelAcrossProviders: true, }); // Rebinding changes where runs execute, so the workspace strategy must // follow: unbinding a root-strategy task would otherwise run loose diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 8388f7bce58eb..dad71f3b7b215 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -605,8 +605,11 @@ export const OrchestratorMcpUpdateScheduledTaskInput = Schema.Struct({ bindToCurrentThread: Schema.optional(Schema.Boolean), /** * Changes the provider, model, or options the task's runs use. Resolved - * like a delegated task's target, against the task's stored selection: a - * field left out keeps its stored value while the provider stays the same. + * like a delegated task's target, against the task's stored selection. A + * provider-only change keeps the stored model when the new provider + * advertises it and fails with `model_unavailable` otherwise. Omitted + * options are retained only when both the provider and the model stay the + * same. * Omit to leave the selection unchanged. */ target: Schema.optional(OrchestratorMcpTarget), From 1c0a84e28efc29c61846d800d073596ee69d9f27 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 17:23:40 +0000 Subject: [PATCH 4/5] test(server): cover scheduled task target inheritance and runtime negotiation --- .../src/mcp/OrchestratorMcpService.test.ts | 443 +++++++++++------- 1 file changed, 267 insertions(+), 176 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 7b51017b7946e..46da6b2ef426f 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -2219,6 +2219,7 @@ describe("OrchestratorMcpService provider resolution", () => { .updateScheduledTask(supervisedClient, { scheduledTaskId: bound.id, prompt: "Something else", + target: { model: "gpt-5.4-mini" }, }) .pipe(Effect.flip); assert.equal(error.code, "runtime_mode_escalation_denied"); @@ -2308,207 +2309,297 @@ describe("OrchestratorMcpService provider resolution", () => { lastRunError: null, runCount: 0, }; - const codex = providerSnapshot({ - instanceId: codexInstanceId, - driver: ProviderDriverKind.make("codex"), - model: "gpt-5.4", - }); - /** The service with one stored task that keeps the last selection saved to it. */ - const service = ( - saved: Ref.Ref>, - enabledForProject = true, - extraProviders: ReadonlyArray = [], - ) => - OrchestratorMcpService.layer.pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadRecords: () => Effect.succeed(parentProjection([], callerSelection)), - isProviderInstanceEnabledForProject: () => Effect.succeed(enabledForProject), - }), - providerRegistryLayer([ - { - ...codex, - models: [ - ...codex.models, - { slug: miniModel, name: miniModel, isCustom: false, capabilities: null }, - ], - }, - ...extraProviders, - ]), - adapterRegistryLayer([ - codexInstanceId, - ...extraProviders.map((provider) => provider.instanceId), - ]), - Layer.mock(ProjectService.ProjectService)({ - getById: () => Effect.succeedSome({ id: projectId } as never), - }), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({ - list: () => - Ref.get(saved).pipe( - Effect.map((selections) => ({ - tasks: [ - { - ...stored, - modelSelection: selections[selections.length - 1] ?? stored.modelSelection, - }, - ], - })), - ), - upsert: (input) => - Ref.update(saved, (selections) => [...selections, input.modelSelection]).pipe( - Effect.as({ task: { ...stored, modelSelection: input.modelSelection } }), - ), - }), - ServerSettings.layerTest(), + const codex: ServerProvider = { + ...providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + }), + models: [callerSelection.model, miniModel].map((slug) => ({ + slug, + name: slug, + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning effort", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + }, + ], + }, + })), + }; + const peer: ServerProvider = { + ...providerSnapshot({ + instanceId: antigravityInstanceId, + driver: ProviderDriverKind.make("antigravity"), + }), + models: codex.models.toReversed(), + supportedRuntimeModes: ["approval-required", "full-access"], + }; + const client: McpInvocationScope = { + ...scope, + thread: undefined, + client: { sessionId: "scheduler", label: "Test", access: "full-access" }, + }; + const createInput = { prompt: "Triage", schedule: stored.schedule, bindToCurrentThread: false }; + + const setup = Effect.fnUntraced(function* ( + runtimeMode: ScheduledTask["runtimeMode"] = "full-access", + ) { + const saved = yield* Ref.make>([]); + const providers = yield* Ref.make>([codex, peer]); + const enabled = yield* Ref.make(true); + const reads = yield* Ref.make(0); + const probes = yield* Ref.make>([]); + const initial = { ...stored, runtimeMode }; + const parent = parentProjection([], callerSelection); + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.provide( + layerOrchestratorService.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + ServerSettings.layerTest(), + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => + Effect.succeed({ + ...parent, + thread: { ...parent.thread, runtimeMode }, + }), + isProviderInstanceEnabledForProject: () => Ref.get(enabled), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Ref.update(reads, (count) => count + 1).pipe( + Effect.andThen(Ref.get(providers)), + ), + refreshInstance: (id) => + Ref.update(probes, (ids) => [...ids, id]).pipe(Effect.as([codex, peer])), + }), + adapterRegistryLayer([codexInstanceId, antigravityInstanceId]), + Layer.mock(ProjectService.ProjectService)({ + getById: () => + Effect.succeedSome({ + id: projectId, + defaultModelSelection: { instanceId: codexInstanceId, model: miniModel }, + } as never), + }), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({ + list: () => + Ref.get(saved).pipe( + Effect.map((tasks) => ({ + tasks: [tasks.at(-1) ?? initial], + })), + ), + upsert: (input) => { + const task = { + ...initial, + ...input, + id: input.id ?? initial.id, + threadId: input.threadId ?? null, + createdBy: input.createdBy ?? initial.createdBy, + creationSource: input.creationSource ?? initial.creationSource, + schedule: + input.schedule.type === "webhook" + ? { ...input.schedule, signature: input.schedule.signature ?? null } + : input.schedule, + }; + return Ref.update(saved, (tasks) => [...tasks, task]).pipe(Effect.as({ task })); + }, + }), + ), + ), ), ), ); + return { mcp, saved, providers, enabled, reads, probes }; + }); - it.effect("schedules a task on the target model instead of the caller's", () => + it.effect("preserves legacy selections and modes without consulting providers", () => Effect.gen(function* () { - const saved = yield* Ref.make>([]); - const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( - Effect.provide(service(saved)), - ); - const schedule = { type: "webhook", signature: null } as const; - const inherited = yield* mcp.scheduleTask(scope, { prompt: "Triage", schedule }); - const targeted = yield* mcp.scheduleTask(scope, { - prompt: "Triage", - schedule, - target: { model: miniModel }, - }); - // The caller's options belong to its own model, so they do not carry over. - assert.deepEqual(yield* Ref.get(saved), [ - callerSelection, - { instanceId: codexInstanceId, model: miniModel }, - ]); + const { mcp, saved, providers, reads, probes } = yield* setup("auto"); + yield* Ref.set(providers, []); + yield* mcp.scheduleTask(scope, createInput); + yield* mcp.updateScheduledTask(scope, { scheduledTaskId: stored.id, title: "Renamed" }); + yield* mcp.scheduleTask(client, { ...createInput, projectId }); + yield* mcp.updateScheduledTask(client, { scheduledTaskId: stored.id, enabled: false }); assert.deepEqual( - [inherited, targeted].map(({ providerInstanceId, model, options }) => ({ - providerInstanceId, - model, - options, - })), + (yield* Ref.get(saved)).map((task) => task.modelSelection), [ - { - providerInstanceId: codexInstanceId, - model: "gpt-5.4", - options: callerSelection.options, - }, - { providerInstanceId: codexInstanceId, model: miniModel, options: undefined }, + callerSelection, + callerSelection, + { instanceId: codexInstanceId, model: miniModel }, + { instanceId: codexInstanceId, model: miniModel }, ], ); + assert.deepEqual( + (yield* Ref.get(saved)).map((task) => task.runtimeMode), + ["auto", "auto", "full-access", "full-access"], + ); + assert.equal(yield* Ref.get(reads), 0); + assert.deepEqual(yield* Ref.get(probes), []); }), ); - it.effect("changes a task's model only when an update names a target", () => - Effect.gen(function* () { - const saved = yield* Ref.make>([]); - const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( - Effect.provide(service(saved)), - ); - yield* mcp.updateScheduledTask(scope, { scheduledTaskId: stored.id, enabled: false }); - const retargeted = yield* mcp.updateScheduledTask(scope, { - scheduledTaskId: stored.id, - target: { model: miniModel }, - }); - // A target that names only options keeps the stored provider and model. - yield* mcp.updateScheduledTask(scope, { - scheduledTaskId: stored.id, - target: { options: [{ id: "reasoningEffort", value: "low" }] }, - }); - assert.deepEqual(yield* Ref.get(saved), [ - callerSelection, - { instanceId: codexInstanceId, model: miniModel }, - { - instanceId: codexInstanceId, + it.effect( + "saves targeted selections and reports them in create, update and list summaries", + () => + Effect.gen(function* () { + const { mcp, saved } = yield* setup(); + const created = yield* mcp.scheduleTask(scope, { + ...createInput, + target: { model: miniModel }, + }); + assert.containSubset(created, { + providerInstanceId: codexInstanceId, model: miniModel, - options: [{ id: "reasoningEffort", value: "low" }], - }, - ]); - assert.equal(retargeted.model, miniModel); - const listed = yield* mcp.listScheduledTasks(scope, {}); - assert.equal(listed.tasks[0]?.model, miniModel); - }), + runtimeMode: "full-access", + interactionMode: "default", + }); + assert.isUndefined(created.options); + const options = [{ id: "reasoningEffort", value: "low" }]; + const updated = yield* mcp.updateScheduledTask(scope, { + scheduledTaskId: stored.id, + target: { options }, + }); + const summary = { + providerInstanceId: codexInstanceId, + model: miniModel, + options, + runtimeMode: "full-access", + interactionMode: "default", + }; + assert.containSubset(updated, summary); + assert.containSubset((yield* mcp.listScheduledTasks(scope, {})).tasks, [summary]); + yield* mcp.updateScheduledTask(scope, { + scheduledTaskId: stored.id, + target: { model: callerSelection.model }, + }); + yield* mcp.scheduleTask(client, { ...createInput, projectId, target: { options } }); + assert.deepEqual( + (yield* Ref.get(saved)).map((task) => task.modelSelection), + [ + { instanceId: codexInstanceId, model: miniModel }, + { instanceId: codexInstanceId, model: miniModel, options }, + { instanceId: codexInstanceId, model: callerSelection.model }, + { instanceId: codexInstanceId, model: miniModel, options }, + ], + ); + }), ); - it.effect("keeps the stored model on a provider-only update, or refuses it", () => + it.effect( + "keeps an advertised stored model on provider-only updates and refuses incompatible providers", + () => + Effect.gen(function* () { + const { mcp, saved, providers } = yield* setup(); + const moved = yield* mcp.updateScheduledTask(scope, { + scheduledTaskId: stored.id, + target: { providerInstanceId: peer.instanceId }, + }); + assert.equal(moved.model, callerSelection.model); + assert.isUndefined(moved.options); + yield* Ref.set(providers, [{ ...codex, models: [codex.models[1]!] }, peer]); + const error = yield* mcp + .updateScheduledTask(scope, { + scheduledTaskId: stored.id, + target: { providerInstanceId: codexInstanceId }, + }) + .pipe(Effect.flip); + assert.equal(error.code, "model_unavailable"); + yield* Ref.set(providers, [{ ...codex, models: [] }, peer]); + const unadvertised = yield* mcp + .updateScheduledTask(scope, { + scheduledTaskId: stored.id, + target: { providerInstanceId: codexInstanceId }, + }) + .pipe(Effect.flip); + assert.equal(unadvertised.code, "model_unavailable"); + assert.deepEqual( + (yield* Ref.get(saved)).map((task) => task.modelSelection), + [{ instanceId: peer.instanceId, model: callerSelection.model }], + ); + }), + ); + + it.effect("validates targets before writing and re-probes an unavailable provider once", () => Effect.gen(function* () { - const sharedInstanceId = ProviderInstanceId.make("codex-shared"); - const otherInstanceId = ProviderInstanceId.make("codex-other"); - const codexDriver = ProviderDriverKind.make("codex"); - // Lists a different default first, so falling back to models[0] would - // silently swap the stored gpt-5.4. - const shared = providerSnapshot({ - instanceId: sharedInstanceId, - driver: codexDriver, - model: "shared-default", - }); - const sharedWithStoredModel = { - ...shared, - models: [ - ...shared.models, - { slug: "gpt-5.4", name: "gpt-5.4", isCustom: false, capabilities: null }, - ], - }; - const other = providerSnapshot({ - instanceId: otherInstanceId, - driver: codexDriver, - model: "other-default", - }); - const saved = yield* Ref.make>([]); - const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( - Effect.provide(service(saved, true, [sharedWithStoredModel, other])), - ); - const moved = yield* mcp.updateScheduledTask(scope, { - scheduledTaskId: stored.id, - target: { providerInstanceId: sharedInstanceId }, - }); - assert.equal(moved.providerInstanceId, sharedInstanceId); - assert.equal(moved.model, "gpt-5.4"); - const refused = yield* mcp + const { mcp, saved, providers, enabled, probes } = yield* setup(); + const unknownModel = yield* mcp + .scheduleTask(scope, { + ...createInput, + target: { model: "unknown-model" }, + }) + .pipe(Effect.flip); + assert.equal(unknownModel.code, "model_unavailable"); + const invalidOptions = yield* mcp + .updateScheduledTask(scope, { + scheduledTaskId: stored.id, + target: { options: [{ id: "reasoningEffort", value: "invalid" }] }, + }) + .pipe(Effect.flip); + assert.equal(invalidOptions.code, "invalid_request"); + yield* Ref.set(enabled, false); + const disabled = yield* mcp .updateScheduledTask(scope, { scheduledTaskId: stored.id, - target: { providerInstanceId: otherInstanceId }, + target: { model: miniModel }, + }) + .pipe(Effect.flip); + assert.equal(disabled.code, "invalid_request"); + yield* Ref.set(enabled, true); + const noAdapter = { ...peer, instanceId: ProviderInstanceId.make("no-adapter") }; + yield* Ref.set(providers, [codex, noAdapter]); + const unavailable = yield* mcp + .scheduleTask(scope, { + ...createInput, + target: { providerInstanceId: noAdapter.instanceId }, }) .pipe(Effect.flip); - assert.equal(refused.code, "model_unavailable"); - assert.deepEqual(yield* Ref.get(saved), [ - { instanceId: sharedInstanceId, model: "gpt-5.4" }, - ]); + assert.equal(unavailable.code, "provider_unavailable"); + assert.deepEqual(yield* Ref.get(saved), []); + assert.deepEqual(yield* Ref.get(probes), [noAdapter.instanceId]); + yield* Ref.set(providers, [{ ...peer, installed: false }]); + const recovered = yield* mcp.scheduleTask(scope, { + ...createInput, + target: { providerInstanceId: peer.instanceId }, + }); + assert.equal(recovered.model, miniModel); + assert.equal(recovered.providerInstanceId, peer.instanceId); + assert.deepEqual(yield* Ref.get(probes), [noAdapter.instanceId, peer.instanceId]); + assert.equal((yield* Ref.get(saved)).length, 1); }), ); - it.effect("saves nothing when the target cannot run in the project", () => + it.effect("steps down an inherited runtime when creating a task on another provider", () => Effect.gen(function* () { - const saved = yield* Ref.make>([]); - const schedule = { type: "webhook", signature: null } as const; - const unknownModel = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( - Effect.provide(service(saved)), - Effect.flatMap((mcp) => - mcp - .scheduleTask(scope, { prompt: "Triage", schedule, target: { model: "gpt-unknown" } }) - .pipe(Effect.flip), - ), - ); - assert.equal(unknownModel.code, "model_unavailable"); - // The project turned the provider off. - const disabled = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( - Effect.provide(service(saved, false)), - Effect.flatMap((mcp) => - mcp - .updateScheduledTask(scope, { - scheduledTaskId: stored.id, - target: { model: miniModel }, - }) - .pipe(Effect.flip), - ), - ); - assert.equal(disabled.code, "invalid_request"); - assert.deepEqual(yield* Ref.get(saved), []); + const { mcp, saved } = yield* setup("auto"); + const result = yield* mcp.scheduleTask(scope, { + ...createInput, + target: { providerInstanceId: peer.instanceId }, + }); + assert.equal((yield* Ref.get(saved))[0]?.runtimeMode, "approval-required"); + assert.equal(result.runtimeMode, "approval-required"); + assert.equal(result.interactionMode, "default"); + }), + ); + + it.effect("steps down a stored runtime when updating a task to another provider", () => + Effect.gen(function* () { + const { mcp, saved } = yield* setup("auto"); + const result = yield* mcp.updateScheduledTask(client, { + scheduledTaskId: stored.id, + target: { providerInstanceId: peer.instanceId }, + }); + assert.equal((yield* Ref.get(saved))[0]?.runtimeMode, "approval-required"); + assert.equal(result.runtimeMode, "approval-required"); + assert.equal(result.interactionMode, "default"); }), ); }); From e1ad4391f91f2dd20a6283b8fa132ed7e74633dd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 17:32:09 +0000 Subject: [PATCH 5/5] fix(server): negotiate scheduled task targets and inherited runtime --- apps/server/src/mcp/OrchestratorMcpService.ts | 124 +++++++++--------- .../src/mcp/toolkits/orchestrator/tools.ts | 4 +- packages/contracts/src/orchestratorMcp.ts | 28 ++-- 3 files changed, 73 insertions(+), 83 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 8638086d3a0aa..7141a78db0b1a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -123,6 +123,7 @@ const DEFAULT_THREAD_ITEM_MAX_CHARS = 20_000; interface ResolvedTarget { readonly modelSelection: ModelSelection; + readonly advertisedModels: ServerProvider["models"]; readonly supportedRuntimeModes: ReadonlyArray | undefined; } @@ -1217,14 +1218,6 @@ const make = Effect.gen(function* () { * one checked for a receipt. */ readonly skipProjectEnablementFilter?: boolean; - /** - * When the target switches provider instance without naming a model, keep - * the inherited model instead of taking the new provider's first model, - * failing with `model_unavailable` when the new provider does not - * advertise it. Scheduled task updates set this so a provider-only change - * never silently swaps the stored model. - */ - readonly keepInheritedModelAcrossProviders?: boolean; }): Effect.Effect => Effect.gen(function* () { const requestedInstanceId = input.target?.providerInstanceId; @@ -1303,23 +1296,9 @@ const make = Effect.gen(function* () { const inheritedSelection = input.parent.thread.modelSelection; const requestedModel = input.target?.model; - const keepInheritedModel = - requestedModel === undefined && - instanceId !== inheritedSelection.instanceId && - input.keepInheritedModelAcrossProviders === true; - if ( - keepInheritedModel && - provider.models.length > 0 && - !provider.models.some((candidate) => candidate.slug === inheritedSelection.model) - ) { - return yield* failure( - "model_unavailable", - `Model ${inheritedSelection.model} is not advertised by provider ${instanceId}; name a model in target to switch providers.`, - ); - } const model = requestedModel ?? - (instanceId === inheritedSelection.instanceId || keepInheritedModel + (instanceId === inheritedSelection.instanceId ? inheritedSelection.model : provider?.models[0]?.slug); if (model === undefined) { @@ -1362,44 +1341,38 @@ const make = Effect.gen(function* () { : requestedOptions === undefined ? { instanceId, model } : { instanceId, model, options: requestedOptions }, + advertisedModels: provider.models, supportedRuntimeModes: provider.supportedRuntimeModes, }; }); - /** - * The selection a scheduled task's runs use when the caller names a target: - * resolved like a work-starting tool's target, with `inherited` standing in - * for the parent thread's selection. - */ - const resolveScheduledTaskModelSelection = (input: { + const resolveScheduledTaskTarget = Effect.fnUntraced(function* (input: { readonly projectId: ProjectId; readonly inherited: ModelSelection; readonly target: OrchestratorMcpTarget; - readonly keepInheritedModelAcrossProviders?: boolean; - }) => - Effect.gen(function* () { - const { modelSelection } = yield* resolveTargetRechecking({ - parent: { thread: { projectId: input.projectId, modelSelection: input.inherited } }, - target: input.target, - providers: yield* loadProviders, - ...(input.keepInheritedModelAcrossProviders === true - ? { keepInheritedModelAcrossProviders: true } - : {}), - }); - const enabled = yield* threadManagement.isProviderInstanceEnabledForProject({ - projectId: input.projectId, - instanceId: modelSelection.instanceId, - }); - if (!enabled) { - return yield* threadManagementFailure( - new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ - projectId: input.projectId, - instanceId: modelSelection.instanceId, - }), - ); - } - return modelSelection; + }) { + const target = yield* resolveTargetRechecking({ + parent: { thread: { projectId: input.projectId, modelSelection: input.inherited } }, + target: input.target, + providers: yield* loadProviders, }); + // Driver-only resolution already filters candidates by project enablement. + if ( + (input.target.providerInstanceId !== undefined || input.target.driverKind === undefined) && + !(yield* threadManagement.isProviderInstanceEnabledForProject({ + projectId: input.projectId, + instanceId: target.modelSelection.instanceId, + })) + ) { + return yield* threadManagementFailure( + new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ + projectId: input.projectId, + instanceId: target.modelSelection.instanceId, + }), + ); + } + return target; + }); const requestKey = (clientRequestId: string | undefined): Effect.Effect => clientRequestId === undefined @@ -1724,14 +1697,18 @@ const make = Effect.gen(function* () { } const inherited = parent?.thread.modelSelection ?? (yield* projectDefaultModelSelection(project)); - const modelSelection = + const target = input.target === undefined - ? inherited - : yield* resolveScheduledTaskModelSelection({ + ? undefined + : yield* resolveScheduledTaskTarget({ projectId, inherited, target: input.target, }); + const runtimeMode = + target === undefined + ? limits.runtimeMode + : yield* resolveRuntimeMode(limits.runtimeMode, undefined, target); const derivedTitle = input.prompt.split("\n")[0]?.trim() ?? ""; const title = input.title ?? (derivedTitle.length > 0 ? derivedTitle.slice(0, 80) : "Scheduled task"); @@ -1743,8 +1720,8 @@ const make = Effect.gen(function* () { projectId, threadId: bindToCurrentThread && parent !== undefined ? parent.thread.id : null, workspaceStrategy: scheduledTaskWorkspaceStrategy(bindToCurrentThread), - modelSelection, - runtimeMode: limits.runtimeMode, + modelSelection: target?.modelSelection ?? inherited, + runtimeMode, interactionMode: limits.interactionMode, createdBy: "agent", creationSource: "mcp", @@ -1809,15 +1786,32 @@ const make = Effect.gen(function* () { : input.bindToCurrentThread && parent !== undefined ? parent.thread.id : null; - const modelSelection = + const target = input.target === undefined - ? existing.modelSelection - : yield* resolveScheduledTaskModelSelection({ + ? undefined + : yield* resolveScheduledTaskTarget({ projectId: existing.projectId, inherited: existing.modelSelection, - target: input.target, - keepInheritedModelAcrossProviders: true, + target: { + ...input.target, + model: input.target.model ?? existing.modelSelection.model, + }, }); + if ( + target !== undefined && + input.target?.model === undefined && + target.modelSelection.instanceId !== existing.modelSelection.instanceId && + !target.advertisedModels.some((model) => model.slug === existing.modelSelection.model) + ) { + return yield* failure( + "model_unavailable", + `Model ${existing.modelSelection.model} is not advertised by provider ${target.modelSelection.instanceId}; name a model in target to switch providers.`, + ); + } + const runtimeMode = + target === undefined + ? existing.runtimeMode + : yield* resolveRuntimeMode(existing.runtimeMode, undefined, target); // Rebinding changes where runs execute, so the workspace strategy must // follow: unbinding a root-strategy task would otherwise run loose // prompts in the shared project checkout. @@ -1834,8 +1828,8 @@ const make = Effect.gen(function* () { projectId: existing.projectId, threadId, workspaceStrategy, - modelSelection, - runtimeMode: existing.runtimeMode, + modelSelection: target?.modelSelection ?? existing.modelSelection, + runtimeMode, interactionMode: existing.interactionMode, createdBy: existing.createdBy, creationSource: existing.creationSource, diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 6e3b50b0fa77c..56565d1560a9f 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -116,7 +116,7 @@ const WorkflowStopTool = Tool.make("t3_workflow_stop", { export const ScheduleTaskTool = Tool.make("schedule_task", { description: - "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings are copied once from this thread. With no calling thread, provider and model come from the project default and the runtime mode from this client's permission ceiling. Pass target (provider instance, model, and options from orchestrator_capabilities) to run the task on a different model, e.g. a cheaper one for triage; options carry over only when the provider and model stay the same.", + "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings are copied once from this thread. With no calling thread, provider and model come from the project default and the runtime mode from this client's permission ceiling. Pass target (provider instance, model, and options from orchestrator_capabilities) to run the task on a different model, e.g. a cheaper one for triage; options carry over only when the provider and model stay the same. With target, the inherited runtime steps down when the provider does not support it. Target applies to fresh threads; bound runs follow their thread's current settings.", parameters: OrchestratorMcpScheduleTaskInput, success: OrchestratorMcpScheduleTaskResult, failure: OrchestratorMcpFailure, @@ -143,7 +143,7 @@ const ListScheduledTasksTool = Tool.make("list_scheduled_tasks", { const UpdateScheduledTaskTool = Tool.make("update_scheduled_task", { description: - "Update an existing scheduled task by scheduledTaskId (from list_scheduled_tasks). Only the provided fields change; omit a field to leave it as-is. Use enabled=false to pause a task without deleting it. Set bindToCurrentThread to move the task between posting into this thread and launching a fresh thread per run. Set target to change the provider instance, model, or options its runs use; the parts of target you leave out keep the task's stored values, except that naming a different provider without a model picks that provider's first model, and options reset when the provider or model changes.", + "Update an existing scheduled task by scheduledTaskId (from list_scheduled_tasks). Only the provided fields change; omit a field to leave it as-is. Use enabled=false to pause a task without deleting it. Set bindToCurrentThread to move the task between posting into this thread and launching a fresh thread per run. Set target to change the provider instance, model, or options its runs use; omitted fields inherit the task's stored selection. A provider-only change keeps the stored model if the destination advertises it, otherwise it fails with model_unavailable. Options reset when the provider or model changes. With target, the stored runtime steps down when the provider does not support it. Target applies to fresh threads; bound runs follow their thread's current settings.", parameters: OrchestratorMcpUpdateScheduledTaskInput, success: OrchestratorMcpScheduleTaskResult, failure: OrchestratorMcpFailure, diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 7fb06d1ba2f83..45ec032cada40 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -546,12 +546,12 @@ export const OrchestratorMcpScheduleTaskInput = Schema.Struct({ "True (default) posts each run into this thread; false creates a fresh top-level thread per run.", }), ), - /** - * Provider, model, and options each run uses. Resolved like a delegated - * task's target, against the calling thread's selection (or the project - * default when there is no calling thread). Omit to copy that selection. - */ - target: Schema.optional(OrchestratorMcpTarget), + target: Schema.optional( + OrchestratorMcpTarget.annotate({ + description: + "Provider, model, and options for runs that launch a fresh thread. Omit to inherit the calling thread's selection, or the project default when there is no calling thread. Options carry over only when the provider and model stay the same.", + }), + ), clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), }); export type OrchestratorMcpScheduleTaskInput = typeof OrchestratorMcpScheduleTaskInput.Type; @@ -608,16 +608,12 @@ export const OrchestratorMcpUpdateScheduledTaskInput = Schema.Struct({ schedule: Schema.optional(OrchestratorMcpSchedule), enabled: Schema.optional(Schema.Boolean), bindToCurrentThread: Schema.optional(Schema.Boolean), - /** - * Changes the provider, model, or options the task's runs use. Resolved - * like a delegated task's target, against the task's stored selection. A - * provider-only change keeps the stored model when the new provider - * advertises it and fails with `model_unavailable` otherwise. Omitted - * options are retained only when both the provider and the model stay the - * same. - * Omit to leave the selection unchanged. - */ - target: Schema.optional(OrchestratorMcpTarget), + target: Schema.optional( + OrchestratorMcpTarget.annotate({ + description: + "Change the provider, model, or options for runs that launch a fresh thread. Omitted fields inherit the stored selection. A provider-only change keeps the stored model if the destination advertises it, otherwise it fails with model_unavailable. Options carry over only when the provider and model stay the same.", + }), + ), }); export type OrchestratorMcpUpdateScheduledTaskInput = typeof OrchestratorMcpUpdateScheduledTaskInput.Type;