Skip to content

Commit 335708d

Browse files
juliusmarmingeclaude
authored andcommitted
feat: scheduled tasks (automations) (#3638)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7791d6b commit 335708d

24 files changed

Lines changed: 2771 additions & 44 deletions

apps/server/src/mcp/OrchestratorMcpService.ts

Lines changed: 201 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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";
6170
import { ProviderRegistry } from "../provider/Services/ProviderRegistry.ts";
71+
import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts";
6272
import type { McpInvocationScope } from "./McpInvocationContext.ts";
6373

6474
const 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+
138190
function 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* () {
11901390
export 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

Comments
 (0)