Skip to content
Closed
358 changes: 358 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest";
import {
CommandId,
EnvironmentId,
GitCommandError,
NodeId,
type OrchestrationV2ThreadShell,
type ScheduledTask,
Expand All @@ -13,13 +14,18 @@ import {
RunId,
ThreadId,
type OrchestrationV2ThreadProjection,
type ScheduledTaskUpsertInput,
type ServerProvider,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Ref from "effect/Ref";

import * as ServerConfig from "../config.ts";
import {
DispatchModeLimit,
type DispatchModeRefusal,
Expand All @@ -38,6 +44,7 @@ import * as ProjectService from "../project/ProjectService.ts";
import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as SecretRequests from "../secrets/SecretRequests.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import type { McpInvocationScope } from "./McpInvocationContext.ts";
import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.ts";
import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
Expand Down Expand Up @@ -2201,3 +2208,354 @@ describe("OrchestratorMcpService provider resolution", () => {
);
});
});

describe("OrchestratorMcpService scheduled tasks that launch a fresh worktree per run", () => {
const projectId = ProjectId.make("project:mcp-schedule-base");
const scheduledTaskId = ScheduledTaskId.make("scheduled-task:mcp-schedule-base");
const layerRealGit = GitVcsDriver.layer.pipe(
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-schedule-config-" })),
Layer.provide(VcsProcess.layer),
Layer.provide(NodeServices.layer),
);
// An MCP client outside any T3 thread, so schedules default to unbound.
const clientScope: McpInvocationScope = {
environmentId: EnvironmentId.make("environment:mcp-schedule-base"),
requestNamespace: "client:mcp-schedule-base",
thread: undefined,
client: {
sessionId: "client:mcp-schedule-base",
label: "Test client",
access: "full-access",
},
capabilities: new Set(["orchestration"]),
issuedAt: 1,
};

const git = (cwd: string, args: ReadonlyArray<string>) =>
Effect.gen(function* () {
const driver = yield* GitVcsDriver.GitVcsDriver;
yield* driver.execute({ operation: "test.git", cwd, args, timeoutMs: 10_000 });
}).pipe(Effect.provide(layerRealGit));

/** A repository at `cwd` on `branch` with one commit, independent of the host's git config. */
const initRepository = (cwd: string, branch: string) =>
Effect.gen(function* () {
yield* git(cwd, ["init", "--initial-branch", branch]);
yield* git(cwd, ["config", "user.name", "Test"]);
yield* git(cwd, ["config", "user.email", "test@test.com"]);
yield* git(cwd, ["config", "commit.gpgsign", "false"]);
yield* git(cwd, ["commit", "--allow-empty", "-m", "initial"]);
});

/** A clone of an origin whose default branch is `defaultBranch`, beside `otherBranches`. */
const cloneOf = (defaultBranch: string, otherBranches: ReadonlyArray<string> = []) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-mcp-schedule-" });
const seed = path.join(root, "seed");
yield* fileSystem.makeDirectory(seed);
yield* initRepository(seed, defaultBranch);
for (const branch of otherBranches) yield* git(seed, ["branch", branch]);
yield* git(root, ["clone", "--bare", "seed", "origin.git"]);
yield* git(root, ["clone", "origin.git", "project"]);
return path.join(root, "project");
});

/** A repository with no remote on `branch`, or detached with no branch at all. */
const localRepository = (checkout: string | "detached") =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-mcp-schedule-" });
yield* initRepository(cwd, checkout === "detached" ? "trunk" : checkout);
if (checkout === "detached") {
yield* git(cwd, ["checkout", "--detach"]);
yield* git(cwd, ["branch", "-D", "trunk"]);
}
return cwd;
});

/** The service over a real git driver (or `layerGit`), recording every scheduled task upsert. */
const withService = <A, E>(
workspaceRoot: string,
body: (
service: OrchestratorMcpService.OrchestratorMcpService["Service"],
upserts: Ref.Ref<ReadonlyArray<ScheduledTaskUpsertInput>>,
) => Effect.Effect<A, E>,
layerGit: typeof layerRealGit = layerRealGit,
) =>
Effect.gen(function* () {
const upserts = yield* Ref.make<ReadonlyArray<ScheduledTaskUpsertInput>>([]);
const taskFromUpsert = (input: ScheduledTaskUpsertInput) =>
({
id: input.id ?? scheduledTaskId,
title: input.title,
prompt: input.prompt,
enabled: input.enabled,
projectId: input.projectId,
threadId: input.threadId,
schedule: input.schedule,
workspaceStrategy: input.workspaceStrategy,
modelSelection: input.modelSelection,
runtimeMode: input.runtimeMode,
interactionMode: input.interactionMode,
createdBy: "agent",
creationSource: "mcp",
nextRunAt: null,
lastRunStatus: "never",
}) as unknown as ScheduledTask;
const layerDependencies = Layer.mergeAll(
Layer.mock(ThreadManagementService.ThreadManagementService)({}),
Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
list: () => Effect.succeed([]),
}),
Layer.mock(ProjectService.ProjectService)({
getById: (id) =>
Effect.succeed(
id === projectId
? Option.some({
id,
workspaceRoot,
defaultModelSelection: {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5.6-terra",
},
} as never)
: Option.none(),
),
}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(ThreadLaunchService.ThreadLaunchService)({}),
ServerSettings.layerTest(),
layerGit,
Layer.mock(ScheduledTaskService.ScheduledTaskService)({
list: () =>
Ref.get(upserts).pipe(Effect.map((all) => ({ tasks: all.map(taskFromUpsert) }))),
upsert: (input) =>
Ref.update(upserts, (all) => [...all, input]).pipe(
Effect.as({ task: taskFromUpsert(input) } as never),
),
}),
);
return yield* Effect.gen(function* () {
const service = yield* OrchestratorMcpService.OrchestratorMcpService;
return yield* body(service, upserts);
}).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies))));
});

const scheduleUnbound = (service: OrchestratorMcpService.OrchestratorMcpService["Service"]) =>
service.scheduleTask(clientScope, {
projectId,
prompt: "Report repository status.",
schedule: { type: "interval", everyMs: 3_600_000 },
});

const lastWorkspaceStrategy = (upserts: Ref.Ref<ReadonlyArray<ScheduledTaskUpsertInput>>) =>
Ref.get(upserts).pipe(Effect.map((all) => all.at(-1)?.workspaceStrategy));

const expectBase = (
workspaceRoot: string,
baseRef: string,
layerGit: typeof layerRealGit = layerRealGit,
) =>
withService(
workspaceRoot,
(service, upserts) =>
Effect.gen(function* () {
yield* scheduleUnbound(service);
assert.deepStrictEqual(yield* lastWorkspaceStrategy(upserts), {
type: "worktree",
baseRef,
startFromOrigin: true,
});
}),
layerGit,
);

it.effect("keeps main when it exists, even if origin's default is another branch", () =>
Effect.gen(function* () {
yield* expectBase(yield* cloneOf("develop", ["main"]), "main");
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("keeps main when only a local main branch exists", () =>
Effect.gen(function* () {
const workspaceRoot = yield* localRepository("trunk");
yield* git(workspaceRoot, ["branch", "main"]);
yield* expectBase(workspaceRoot, "main");
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("without main, branches from origin's default over the checked-out branch", () =>
Effect.gen(function* () {
const workspaceRoot = yield* cloneOf("master");
// Origin's default wins over whatever the project happens to have checked out.
yield* git(workspaceRoot, ["checkout", "-b", "feature"]);
yield* withService(workspaceRoot, (service, upserts) =>
Effect.gen(function* () {
yield* scheduleUnbound(service);
assert.deepStrictEqual(yield* lastWorkspaceStrategy(upserts), {
type: "worktree",
baseRef: "master",
startFromOrigin: true,
});

// Unbinding an existing task again resolves the same base.
yield* service.updateScheduledTask(clientScope, {
scheduledTaskId,
bindToCurrentThread: false,
});
const all = yield* Ref.get(upserts);
assert.equal(all.length, 2);
assert.deepStrictEqual(all[1]?.workspaceStrategy, {
type: "worktree",
baseRef: "master",
startFromOrigin: true,
});
}),
);
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("without main or an origin default, branches from the checked-out branch", () =>
Effect.gen(function* () {
yield* expectBase(yield* localRepository("trunk"), "trunk");
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("keeps main when the checkout is detached and nothing else names a base", () =>
Effect.gen(function* () {
yield* expectBase(yield* localRepository("detached"), "main");
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("keeps main for a project that is not a git repository", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-mcp-schedule-",
});
yield* expectBase(workspaceRoot, "main");
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("keeps main for a project that is not a git repository on a German host", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-mcp-schedule-",
});
const localeKeys = ["LANG", "LC_ALL", "LANGUAGE"] as const;
yield* Effect.acquireRelease(
Effect.sync(() => {
const saved = localeKeys.map((key) => [key, process.env[key]] as const);
process.env.LANG = "de_DE.UTF-8";
process.env.LC_ALL = "de_DE.UTF-8";
process.env.LANGUAGE = "de";
return saved;
}),
(saved) =>
Effect.sync(() => {
for (const [key, value] of saved) {
if (value === undefined) Reflect.deleteProperty(process.env, key);
else process.env[key] = value;
}
}),
);
// Not every git build ships translations, so stand in for a German one:
// its diagnostics are German unless the call itself forces the C locale.
const layerGermanGit = Layer.effect(
GitVcsDriver.GitVcsDriver,
Effect.gen(function* () {
const driver = yield* GitVcsDriver.GitVcsDriver;
return GitVcsDriver.GitVcsDriver.of({
...driver,
execute: (input) =>
driver.execute(input).pipe(
Effect.map((result) =>
(input.env?.LC_ALL ?? process.env.LC_ALL) === "C"
? result
: {
...result,
stderr: result.stderr.replace(
"not a git repository",
"kein Git-Repository",
),
},
),
),
});
}),
).pipe(Layer.provide(layerRealGit));
yield* expectBase(workspaceRoot, "main", layerGermanGit);
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("fails retryably without saving a base when the git status cannot be read", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const workspaceRoot = yield* localRepository("trunk");
// The driver refuses to read status while the index lock exists (raw git status would not).
yield* fileSystem.writeFileString(path.join(workspaceRoot, ".git", "index.lock"), "");
yield* withService(workspaceRoot, (service, upserts) =>
Effect.gen(function* () {
const error = yield* scheduleUnbound(service).pipe(Effect.flip);
assert.equal(error.code, "orchestration_error");
assert.match(error.message, /try again/);
assert.equal((yield* Ref.get(upserts)).length, 0);
}),
);
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("skips an origin default that no longer exists for the checked-out branch", () =>
Effect.gen(function* () {
const workspaceRoot = yield* cloneOf("master");
yield* git(workspaceRoot, ["checkout", "-b", "feature"]);
// origin/HEAD still names a branch deleted since the clone.
yield* git(workspaceRoot, [
"symbolic-ref",
"refs/remotes/origin/HEAD",
"refs/remotes/origin/gone",
]);
yield* expectBase(workspaceRoot, "feature");
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("fails retryably without saving a base when the branches cannot be listed", () =>
Effect.gen(function* () {
const workspaceRoot = yield* localRepository("trunk");
const layerGitFailingBranchList = Layer.effect(
GitVcsDriver.GitVcsDriver,
Effect.gen(function* () {
const driver = yield* GitVcsDriver.GitVcsDriver;
return GitVcsDriver.GitVcsDriver.of({
...driver,
listLocalBranchNames: (cwd) =>
Effect.fail(
new GitCommandError({
operation: "test.listLocalBranchNames",
command: "git",
cwd,
detail: "fatal: unable to read refs",
}),
),
});
}),
).pipe(Layer.provide(layerRealGit));
yield* withService(
workspaceRoot,
(service, upserts) =>
Effect.gen(function* () {
const error = yield* scheduleUnbound(service).pipe(Effect.flip);
assert.equal(error.code, "orchestration_error");
assert.match(error.message, /try again/);
assert.equal((yield* Ref.get(upserts)).length, 0);
}),
layerGitFailingBranchList,
);
}).pipe(Effect.provide(NodeServices.layer)),
);
});
Loading
Loading