Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";

import {
Expand Down Expand Up @@ -2175,6 +2176,103 @@ describe("OrchestratorMcpService provider resolution", () => {
assert.equal(updated.webhookUrl, undefined);
}),
);

// Saves echo the task back; the project folder is a Git repository or not.
const workspaceService = (repo: boolean, tasks: ReadonlyArray<ScheduledTask>) =>
OrchestratorMcpService.layer.pipe(
Layer.provide(
Layer.mock(ThreadLaunchService.ThreadLaunchService)({
checkWorktreeBase: () =>
repo
? Effect.void
: Effect.fail(
new ThreadLaunchService.ThreadLaunchNotRepositoryError({
projectId,
projectTitle: "No project",
workspaceRoot: "/scratch",
}),
),
}),
),
Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
Layer.provide(
Layer.mergeAll(
NodeServices.layer,
Layer.mock(ThreadManagementService.ThreadManagementService)({}),
ServerSettings.layerTest(),
Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
list: () => Effect.succeed([]),
}),
Layer.mock(ProjectService.ProjectService)({
getById: (id) =>
Effect.succeed(
Option.some({ id, defaultModelSelection: task({}).modelSelection } as never),
),
}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({
list: () => Effect.succeed({ tasks }),
upsert: (input) =>
Effect.succeed({ task: task({ workspaceStrategy: input.workspaceStrategy }) }),
}),
),
),
);

it.effect.each([
{
name: "a worktree in a Git repository",
repo: true,
workspace: "worktree",
want: "worktree",
},
{ name: "root when asked for", repo: true, workspace: "root", want: "root" },
{
name: "a worktree in a plain folder is refused",
repo: false,
workspace: "worktree",
want: "invalid_request",
},
] as const)("an unbound task over MCP: $name", (testCase) =>
Effect.gen(function* () {
const outcome = yield* OrchestratorMcpService.OrchestratorMcpService.pipe(
Effect.flatMap((mcp) =>
mcp.scheduleTask(supervisedClient, {
projectId,
prompt: "Check the inbox.",
schedule: { type: "interval", everyMs: 3_600_000 },
workspace: testCase.workspace,
}),
),
Effect.map((summary) => summary.workspace),
Effect.catch((error) => Effect.succeed(error.code)),
Effect.provide(workspaceService(testCase.repo, [])),
);
assert.equal(outcome, testCase.want);
}),
);

it.effect("an update keeps the workspace unless the binding or the workspace changes", () =>
Effect.gen(function* () {
// An unbound task that chose root in a Git repository.
const existing = task({ workspaceStrategy: { type: "root" } });
const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe(
Effect.provide(workspaceService(true, [existing])),
);
const repeated = yield* mcp.updateScheduledTask(supervisedClient, {
scheduledTaskId: existing.id,
prompt: "Check the inbox again.",
bindToCurrentThread: false,
});
assert.equal(repeated.workspace, "root");
const switched = yield* mcp.updateScheduledTask(supervisedClient, {
scheduledTaskId: existing.id,
workspace: "worktree",
});
assert.equal(switched.workspace, "worktree");
}),
);
});

describe("resolveRuntimeMode", () => {
Expand Down
54 changes: 38 additions & 16 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,19 +229,6 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

/**
* Workspace strategy for a scheduled task created/updated over MCP: bound runs
* post into the existing thread (the strategy is unused, keep root); unbound
* runs launch a fresh worktree per run.
*/
function scheduledTaskWorkspaceStrategy(
boundToThread: boolean,
): ScheduledTask["workspaceStrategy"] {
return boundToThread
? { type: "root" }
: { type: "worktree", baseRef: "main", startFromOrigin: true };
}

/**
* A scheduled task as an agent sees it. `mayRun` says whether the caller may
* run it: a webhook's URL carries the secret that starts the task's runs, so
Expand All @@ -255,6 +242,7 @@ function scheduledTaskSummary(task: ScheduledTask, mayRun: boolean): Orchestrato
enabled: task.enabled,
projectId: task.projectId,
boundThreadId: task.threadId,
workspace: task.workspaceStrategy.type,
schedule: task.schedule,
nextRunAt: task.nextRunAt,
lastRunStatus: task.lastRunStatus,
Expand Down Expand Up @@ -1635,6 +1623,31 @@ const make = Effect.gen(function* () {
return task;
});

/**
* Workspace strategy for a scheduled task created/updated over MCP. Bound
* runs post into the existing thread, so the strategy is unused and stays
* root. Unbound runs get a fresh worktree unless root is asked for; an
* explicit worktree is refused up front when one cannot be made.
*/
const scheduledTaskWorkspaceStrategy = (
projectId: ScheduledTask["projectId"],
boundToThread: boolean,
workspace: "root" | "worktree" | undefined,
): Effect.Effect<ScheduledTask["workspaceStrategy"], OrchestratorMcpFailure> => {
if (boundToThread || workspace === "root") return Effect.succeed({ type: "root" });
const worktree = { type: "worktree", baseRef: "main", startFromOrigin: true } as const;
if (workspace === undefined) return Effect.succeed(worktree);
return threadLaunch.checkWorktreeBase({ projectId, workspaceStrategy: worktree }).pipe(
Effect.as(worktree),
Effect.mapError((error) =>
failure(
"invalid_request",
`${error.message} Pass workspace:"root" to run in the project folder.`,
),
),
);
};

return OrchestratorMcpService.of({
scheduleTask: (scope, input) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -1669,7 +1682,11 @@ const make = Effect.gen(function* () {
schedule: input.schedule,
projectId,
threadId: bindToCurrentThread && parent !== undefined ? parent.thread.id : null,
workspaceStrategy: scheduledTaskWorkspaceStrategy(bindToCurrentThread),
workspaceStrategy: yield* scheduledTaskWorkspaceStrategy(
projectId,
bindToCurrentThread,
input.workspace,
),
modelSelection,
runtimeMode: limits.runtimeMode,
interactionMode: limits.interactionMode,
Expand Down Expand Up @@ -1739,10 +1756,15 @@ const make = Effect.gen(function* () {
// 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.
const rebound = (threadId === null) !== (existing.threadId === null);
const workspaceStrategy =
input.bindToCurrentThread === undefined
!rebound && input.workspace === undefined
? existing.workspaceStrategy
: scheduledTaskWorkspaceStrategy(input.bindToCurrentThread);
: yield* scheduledTaskWorkspaceStrategy(
existing.projectId,
threadId !== null,
input.workspace,
);
const upsertInput: ScheduledTaskUpsertInput = {
id: existing.id,
title: input.title ?? existing.title,
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/mcp/toolkits/orchestrator/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 in a new Git worktree; pass workspace:'root' to run in the project folder instead. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.",
parameters: OrchestratorMcpScheduleTaskInput,
success: OrchestratorMcpScheduleTaskResult,
failure: OrchestratorMcpFailure,
Expand All @@ -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, and workspace to choose where a fresh-thread run works.",
parameters: OrchestratorMcpUpdateScheduledTaskInput,
success: OrchestratorMcpScheduleTaskResult,
failure: OrchestratorMcpFailure,
Expand Down
24 changes: 20 additions & 4 deletions apps/server/src/mcp/toolkits/project/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,16 +475,24 @@ it.effect("a launch into a new worktree creates nothing when its base ref has no
runtimeModeCeiling: "auto",
launched,
checkWorktreeBase: ({ projectId, workspaceStrategy }) =>
workspaceStrategy.type === "worktree" && workspaceStrategy.baseRef === "t3/renamed"
workspaceStrategy.type === "worktree" && workspaceStrategy.baseRef === "plain"
? Effect.fail(
new ThreadLaunch.ThreadLaunchBaseRefError({
new ThreadLaunch.ThreadLaunchNotRepositoryError({
projectId,
projectTitle: "Client target",
workspaceRoot: "/projects/client-target",
baseRef: workspaceStrategy.baseRef,
}),
)
: Effect.void,
: workspaceStrategy.type === "worktree" && workspaceStrategy.baseRef === "t3/renamed"
? Effect.fail(
new ThreadLaunch.ThreadLaunchBaseRefError({
projectId,
projectTitle: "Client target",
workspaceRoot: "/projects/client-target",
baseRef: workspaceStrategy.baseRef,
}),
)
: Effect.void,
});
const toolkit = yield* ProjectToolkit.pipe(
Effect.provide(
Expand All @@ -507,6 +515,14 @@ it.effect("a launch into a new worktree creates nothing when its base ref has no
);
expect(launched).toHaveLength(0);

// A folder that is not a repository points the agent at the root instead.
const plain = (yield* launchFrom("plain")).at(-1)?.result;
expect(plain).toMatchObject({ code: "invalid_request" });
expect((plain as { message: string }).message).toContain(
'No thread was created. Use workspaceStrategy {type:"root"} instead.',
);
expect(launched).toHaveLength(0);

expect((yield* launchFrom("main")).at(-1)?.result).toMatchObject({ projectId });
expect(launched).toHaveLength(1);
}),
Expand Down
13 changes: 8 additions & 5 deletions apps/server/src/mcp/toolkits/project/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,14 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
(error) =>
new OrchestratorMcpFailure({
code: "invalid_request",
message: `${error.message} No thread was created. Pass a branch, tag, or commit that exists there${
strategy.startFromOrigin === true
? "; the project has no origin remote to fetch it from."
: ", or set startFromOrigin:true if it exists only on origin."
}`,
message:
error._tag === "ThreadLaunchNotRepositoryError"
? `${error.message} No thread was created. Use workspaceStrategy {type:"root"} instead.`
: `${error.message} No thread was created. Pass a branch, tag, or commit that exists there${
strategy.startFromOrigin === true
? "; the project has no origin remote to fetch it from."
: ", or set startFromOrigin:true if it exists only on origin."
}`,
}),
),
);
Expand Down
Loading
Loading