@@ -16,10 +16,17 @@ import {
1616 type OrchestratorMcpDelegateTaskInput ,
1717 type OrchestratorMcpDelegateTaskResult ,
1818 type OrchestratorMcpInteractionMode ,
19+ type OrchestratorMcpDeleteScheduledTaskInput ,
20+ type OrchestratorMcpDeleteScheduledTaskResult ,
21+ type OrchestratorMcpListScheduledTasksResult ,
1922 type OrchestratorMcpRuntimeMode ,
23+ type OrchestratorMcpScheduledTask ,
24+ type OrchestratorMcpScheduleTaskInput ,
25+ type OrchestratorMcpScheduleTaskResult ,
2026 type OrchestratorMcpTarget ,
2127 type OrchestratorMcpTaskCancelInput ,
2228 type OrchestratorMcpTaskCancelResult ,
29+ type OrchestratorMcpUpdateScheduledTaskInput ,
2330 type OrchestratorMcpThreadDetail ,
2431 type OrchestratorMcpThreadInterruptInput ,
2532 type OrchestratorMcpThreadInterruptResult ,
@@ -37,6 +44,8 @@ import {
3744 type ProviderInteractionMode ,
3845 ProviderInstanceId ,
3946 type RuntimeMode ,
47+ type ScheduledTask ,
48+ type ScheduledTaskUpsertInput ,
4049 type ServerProvider ,
4150 ThreadId ,
4251} from "@t3tools/contracts" ;
@@ -59,6 +68,7 @@ import {
5968 ThreadManagementService ,
6069} from "../orchestration-v2/ThreadManagementService.ts" ;
6170import { ProviderRegistry } from "../provider/Services/ProviderRegistry.ts" ;
71+ import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts" ;
6272import type { McpInvocationScope } from "./McpInvocationContext.ts" ;
6373
6474const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000 ;
@@ -98,6 +108,21 @@ export interface OrchestratorMcpServiceShape {
98108 scope : McpInvocationScope ,
99109 input : OrchestratorMcpCreateThreadsInput ,
100110 ) => Effect . Effect < OrchestratorMcpCreateThreadsResult , OrchestratorMcpFailure > ;
111+ readonly scheduleTask : (
112+ scope : McpInvocationScope ,
113+ input : OrchestratorMcpScheduleTaskInput ,
114+ ) => Effect . Effect < OrchestratorMcpScheduleTaskResult , OrchestratorMcpFailure > ;
115+ readonly listScheduledTasks : (
116+ scope : McpInvocationScope ,
117+ ) => Effect . Effect < OrchestratorMcpListScheduledTasksResult , OrchestratorMcpFailure > ;
118+ readonly updateScheduledTask : (
119+ scope : McpInvocationScope ,
120+ input : OrchestratorMcpUpdateScheduledTaskInput ,
121+ ) => Effect . Effect < OrchestratorMcpScheduleTaskResult , OrchestratorMcpFailure > ;
122+ readonly deleteScheduledTask : (
123+ scope : McpInvocationScope ,
124+ input : OrchestratorMcpDeleteScheduledTaskInput ,
125+ ) => Effect . Effect < OrchestratorMcpDeleteScheduledTaskResult , OrchestratorMcpFailure > ;
101126 readonly listThreads : (
102127 scope : McpInvocationScope ,
103128 input : OrchestratorMcpThreadListInput ,
@@ -135,6 +160,33 @@ function errorMessage(error: unknown): string {
135160 return error instanceof Error ? error . message : String ( error ) ;
136161}
137162
163+ /**
164+ * Workspace strategy for a scheduled task created/updated over MCP: bound runs
165+ * post into the existing thread (the strategy is unused, keep root); unbound
166+ * runs launch a fresh worktree per run.
167+ */
168+ function scheduledTaskWorkspaceStrategy (
169+ boundToThread : boolean ,
170+ ) : ScheduledTask [ "workspaceStrategy" ] {
171+ return boundToThread
172+ ? { type : "root" }
173+ : { type : "worktree" , baseRef : "main" , startFromOrigin : true } ;
174+ }
175+
176+ function scheduledTaskSummary ( task : ScheduledTask ) : OrchestratorMcpScheduledTask {
177+ return {
178+ scheduledTaskId : task . id ,
179+ title : task . title ,
180+ prompt : task . prompt ,
181+ enabled : task . enabled ,
182+ projectId : task . projectId ,
183+ boundThreadId : task . threadId ,
184+ schedule : task . schedule ,
185+ nextRunAt : task . nextRunAt ,
186+ lastRunStatus : task . lastRunStatus ,
187+ } ;
188+ }
189+
138190function providerConstraints (
139191 provider : ServerProvider | undefined ,
140192 supportsOrchestrationV2 : boolean ,
@@ -491,6 +543,7 @@ const make = Effect.gen(function* () {
491543 const crypto = yield * Crypto . Crypto ;
492544 const threadManagement = yield * ThreadManagementService ;
493545 const providerRegistry = yield * ProviderRegistry ;
546+ const scheduledTasks = yield * ScheduledTaskService ;
494547
495548 const requireCapability = ( scope : McpInvocationScope ) =>
496549 scope . capabilities . has ( "orchestration" )
@@ -695,7 +748,153 @@ const make = Effect.gen(function* () {
695748 }
696749 } ) . pipe ( Effect . timeoutOption ( Duration . millis ( timeoutMs ) ) ) ;
697750
751+ // Load a single scheduled task and enforce that it belongs to the calling
752+ // thread's project, so agents can only read/mutate tasks in their own scope.
753+ const loadScopedScheduledTask = (
754+ projectId : ScheduledTask [ "projectId" ] ,
755+ scheduledTaskId : ScheduledTask [ "id" ] ,
756+ ) : Effect . Effect < ScheduledTask , OrchestratorMcpFailure > =>
757+ Effect . gen ( function * ( ) {
758+ const { tasks } = yield * scheduledTasks
759+ . list ( )
760+ . pipe (
761+ Effect . mapError ( ( error ) =>
762+ failure ( "orchestration_error" , `Could not load scheduled task: ${ error . message } ` ) ,
763+ ) ,
764+ ) ;
765+ const task = tasks . find ( ( candidate ) => candidate . id === scheduledTaskId ) ;
766+ if ( task === undefined || task . projectId !== projectId ) {
767+ return yield * failure (
768+ "task_not_found" ,
769+ `Scheduled task ${ scheduledTaskId } was not found in the calling project.` ,
770+ ) ;
771+ }
772+ return task ;
773+ } ) ;
774+
698775 return OrchestratorMcpService . of ( {
776+ scheduleTask : ( scope , input ) =>
777+ Effect . gen ( function * ( ) {
778+ yield * requireCapability ( scope ) ;
779+ const parent = yield * loadProjection ( scope . threadId ) ;
780+ const bindToCurrentThread = input . bindToCurrentThread ?? true ;
781+ const derivedTitle = input . prompt . split ( "\n" ) [ 0 ] ?. trim ( ) ?? "" ;
782+ const title =
783+ input . title ?? ( derivedTitle . length > 0 ? derivedTitle . slice ( 0 , 80 ) : "Scheduled task" ) ;
784+ const upsertInput : ScheduledTaskUpsertInput = {
785+ title,
786+ prompt : input . prompt ,
787+ enabled : input . enabled ?? true ,
788+ schedule : input . schedule ,
789+ projectId : parent . thread . projectId ,
790+ threadId : bindToCurrentThread ? scope . threadId : null ,
791+ workspaceStrategy : scheduledTaskWorkspaceStrategy ( bindToCurrentThread ) ,
792+ modelSelection : parent . thread . modelSelection ,
793+ runtimeMode : parent . thread . runtimeMode ,
794+ interactionMode : parent . thread . interactionMode ,
795+ createdBy : "agent" ,
796+ creationSource : "mcp" ,
797+ // Scope the idempotency key by provider session so two callers
798+ // reusing the same clientRequestId cannot collide on one task row.
799+ ...( input . clientRequestId === undefined
800+ ? { }
801+ : {
802+ commandId : stableCommandId ( {
803+ scope,
804+ requestKey : input . clientRequestId ,
805+ operation : "schedule-task" ,
806+ } ) ,
807+ } ) ,
808+ } ;
809+ const { task } = yield * scheduledTasks
810+ . upsert ( upsertInput )
811+ . pipe (
812+ Effect . mapError ( ( error ) =>
813+ failure ( "orchestration_error" , `Could not schedule task: ${ error . message } ` ) ,
814+ ) ,
815+ ) ;
816+ return scheduledTaskSummary ( task ) ;
817+ } ) ,
818+ listScheduledTasks : ( scope ) =>
819+ Effect . gen ( function * ( ) {
820+ yield * requireCapability ( scope ) ;
821+ const parent = yield * loadProjection ( scope . threadId ) ;
822+ const { tasks } = yield * scheduledTasks
823+ . list ( )
824+ . pipe (
825+ Effect . mapError ( ( error ) =>
826+ failure ( "orchestration_error" , `Could not list scheduled tasks: ${ error . message } ` ) ,
827+ ) ,
828+ ) ;
829+ // Only expose tasks belonging to the calling thread's project.
830+ return {
831+ tasks : tasks
832+ . filter ( ( task ) => task . projectId === parent . thread . projectId )
833+ . map ( scheduledTaskSummary ) ,
834+ } ;
835+ } ) ,
836+ updateScheduledTask : ( scope , input ) =>
837+ Effect . gen ( function * ( ) {
838+ yield * requireCapability ( scope ) ;
839+ const parent = yield * loadProjection ( scope . threadId ) ;
840+ const existing = yield * loadScopedScheduledTask (
841+ parent . thread . projectId ,
842+ input . scheduledTaskId ,
843+ ) ;
844+ const threadId =
845+ input . bindToCurrentThread === undefined
846+ ? existing . threadId
847+ : input . bindToCurrentThread
848+ ? scope . threadId
849+ : null ;
850+ // Rebinding changes where runs execute, so the workspace strategy must
851+ // follow: unbinding a root-strategy task would otherwise run loose
852+ // prompts in the shared project checkout.
853+ const workspaceStrategy =
854+ input . bindToCurrentThread === undefined
855+ ? existing . workspaceStrategy
856+ : scheduledTaskWorkspaceStrategy ( input . bindToCurrentThread ) ;
857+ const upsertInput : ScheduledTaskUpsertInput = {
858+ id : existing . id ,
859+ title : input . title ?? existing . title ,
860+ prompt : input . prompt ?? existing . prompt ,
861+ enabled : input . enabled ?? existing . enabled ,
862+ schedule : input . schedule ?? existing . schedule ,
863+ projectId : existing . projectId ,
864+ threadId,
865+ workspaceStrategy,
866+ modelSelection : existing . modelSelection ,
867+ runtimeMode : existing . runtimeMode ,
868+ interactionMode : existing . interactionMode ,
869+ createdBy : existing . createdBy ,
870+ creationSource : existing . creationSource ,
871+ } ;
872+ const { task } = yield * scheduledTasks
873+ . upsert ( upsertInput )
874+ . pipe (
875+ Effect . mapError ( ( error ) =>
876+ failure ( "orchestration_error" , `Could not update scheduled task: ${ error . message } ` ) ,
877+ ) ,
878+ ) ;
879+ return scheduledTaskSummary ( task ) ;
880+ } ) ,
881+ deleteScheduledTask : ( scope , input ) =>
882+ Effect . gen ( function * ( ) {
883+ yield * requireCapability ( scope ) ;
884+ const parent = yield * loadProjection ( scope . threadId ) ;
885+ const existing = yield * loadScopedScheduledTask (
886+ parent . thread . projectId ,
887+ input . scheduledTaskId ,
888+ ) ;
889+ yield * scheduledTasks
890+ . delete ( { id : existing . id } )
891+ . pipe (
892+ Effect . mapError ( ( error ) =>
893+ failure ( "orchestration_error" , `Could not delete scheduled task: ${ error . message } ` ) ,
894+ ) ,
895+ ) ;
896+ return { scheduledTaskId : existing . id , deleted : true } ;
897+ } ) ,
699898 capabilities : ( scope ) =>
700899 Effect . gen ( function * ( ) {
701900 yield * requireCapability ( scope ) ;
@@ -733,6 +932,7 @@ const make = Effect.gen(function* () {
733932 batchThreadCreation : true ,
734933 threadManagement : true ,
735934 incrementalThreadRead : true ,
935+ scheduledTasks : true ,
736936 maxBatchThreads : 20 ,
737937 } ,
738938 } ;
@@ -1190,5 +1390,5 @@ const make = Effect.gen(function* () {
11901390export const layer : Layer . Layer <
11911391 OrchestratorMcpService ,
11921392 never ,
1193- Crypto . Crypto | ThreadManagementService | ProviderRegistry
1393+ Crypto . Crypto | ThreadManagementService | ProviderRegistry | ScheduledTaskService
11941394> = Layer . effect ( OrchestratorMcpService , make ) ;
0 commit comments