Skip to content
Merged
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
22 changes: 22 additions & 0 deletions apps/server/src/mcp/BridgeStatusService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as Bridge from "./BridgeStatusService.ts";
import * as Invocation from "./McpInvocationContext.ts";
import * as Events from "./McpEvents.ts";
import * as Metrics from "./McpBridgeMetrics.ts";
import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";

it.effect(
"reports fresh peer probe results and preserves bridge status when links are unavailable",
Expand Down Expand Up @@ -40,6 +41,20 @@ it.effect(
deliveriesExhaustedRetries: 0,
}),
}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({
reliability: () =>
Effect.succeed([
{ taskId: "scheduled-task:quiet" },
{
taskId: "scheduled-task:late",
missedRun: {
dueAt: "1969-12-31T00:00:00.000Z",
overdueMs: 86_400_000,
reason: "not_dispatched" as const,
},
},
]),
}),
Layer.mock(PeerForwarding.PeerForwarding)({
links: (caller) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -67,6 +82,13 @@ it.effect(
yield* Effect.gen(function* () {
const service = yield* Bridge.BridgeStatusService;
const status = yield* service.status();
expect(status.missedScheduledRuns).toEqual([
{
scheduledTaskId: "scheduled-task:late",
dueAt: "1969-12-31T00:00:00.000Z",
overdueMs: 86_400_000,
},
]);
expect(status.linkedEnvironments).toMatchObject({
checkedAt: "1970-01-01T00:00:00.000Z",
links: [
Expand Down
29 changes: 29 additions & 0 deletions apps/server/src/mcp/BridgeStatusService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import {
EnvironmentId,
EnvironmentConnectReachability,
McpEventSubscriptionsResult,
IsoDateTime,
McpEventWatch,
NonNegativeInt,
OrchestratorMcpFailure,
ScheduledTaskId,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Context from "effect/Context";
Expand All @@ -14,6 +17,7 @@ import * as PeerForwarding from "../peer/PeerForwarding.ts";
import * as Invocation from "./McpInvocationContext.ts";
import * as McpEvents from "./McpEvents.ts";
import * as Metrics from "./McpBridgeMetrics.ts";
import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
export const BridgeStatusResult = Schema.Struct({
serviceStartedAt: Schema.String,
tunnel: Schema.NullOr(EnvironmentConnectReachability),
Expand All @@ -39,6 +43,13 @@ export const BridgeStatusResult = Schema.Struct({
),
}),
),
missedScheduledRuns: Schema.Array(
Schema.Struct({
scheduledTaskId: ScheduledTaskId,
dueAt: IsoDateTime,
overdueMs: NonNegativeInt,
}),
),
toolCalls: Schema.Array(
Schema.Struct({
tool: Schema.String,
Expand All @@ -64,6 +75,7 @@ export const layer = Layer.effect(
const events = yield* McpEvents.McpEvents;
const metrics = yield* Metrics.McpBridgeMetrics;
const forwarding = yield* PeerForwarding.PeerForwarding;
const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService;
return BridgeStatusService.of({
status: Effect.fn("BridgeStatusService.status")(function* () {
const scope = yield* Invocation.McpInvocationContext;
Expand Down Expand Up @@ -93,9 +105,26 @@ export const layer = Layer.effect(
),
Effect.orElseSucceed(() => null),
);
const missedScheduledRuns = yield* scheduledTasks.reliability().pipe(
Effect.map((entries) =>
entries.flatMap(({ taskId, missedRun }) =>
missedRun === undefined
? []
: [
{
scheduledTaskId: ScheduledTaskId.make(taskId),
dueAt: missedRun.dueAt,
overdueMs: missedRun.overdueMs,
},
],
),
),
Effect.orElseSucceed(() => []),
);
return {
...status,
linkedEnvironments,
missedScheduledRuns,
toolCalls: yield* metrics.read(scope.client!.sessionId),
};
}),
Expand Down
39 changes: 39 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
import type { ScheduledTaskReliability } from "../scheduledTasks/ScheduledTaskReliability.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as SecretRequests from "../secrets/SecretRequests.ts";
import * as RemoteDelegation from "../peer/RemoteDelegation.ts";
Expand Down Expand Up @@ -2846,6 +2847,7 @@ describe("OrchestratorMcpService provider resolution", () => {
tasks: ReadonlyArray<ScheduledTask>,
boundThread: OrchestrationV2ThreadShell | null,
upserted: Ref.Ref<number>,
reliability: ReadonlyArray<ScheduledTaskReliability> = [],
) =>
layerOrchestratorService.pipe(
Layer.provide(
Expand All @@ -2866,6 +2868,7 @@ describe("OrchestratorMcpService provider resolution", () => {
Layer.mock(RemoteDelegation.RemoteDelegation)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({
list: () => Effect.succeed({ tasks }),
reliability: () => Effect.succeed(reliability),
upsert: () =>
Ref.update(upserted, (count) => count + 1).pipe(Effect.as({ task: tasks[0]! })),
}),
Expand Down Expand Up @@ -2896,6 +2899,41 @@ describe("OrchestratorMcpService provider resolution", () => {
}),
);

it.effect("adds recent failures and missed runs to the listed task they belong to", () =>
Effect.gen(function* () {
const upserted = yield* Ref.make(0);
const failing = task({ id: ScheduledTaskId.make("scheduled-task:failing") });
const quiet = task({ id: ScheduledTaskId.make("scheduled-task:quiet") });
const recentFailures = {
failed: 3,
cancelled: 6,
lastAt: "2026-11-12T07:11:28.827Z",
lastStatus: "failed" as const,
lastError: "Workspace preparation failed",
};
const missedRun = {
dueAt: "2026-11-12T01:00:00.000Z",
overdueMs: 7_200_000,
reason: "not_dispatched" as const,
};
const listed = yield* OrchestratorMcpService.OrchestratorMcpService.pipe(
Effect.flatMap((mcp) => mcp.listScheduledTasks(supervisedClient, { projectId })),
Effect.provide(
service([failing, quiet], null, upserted, [
{ taskId: failing.id, recentFailures, missedRun },
]),
),
);
assert.deepEqual(
listed.tasks.map((summary) => [summary.recentFailures, summary.missedRun]),
[
[recentFailures, missedRun],
[undefined, undefined],
],
);
}),
);

it.effect("hides a webhook URL from a thread whose turn has ended", () =>
Effect.gen(function* () {
const callerId = ThreadId.make("thread:scheduled-ended");
Expand Down Expand Up @@ -2938,6 +2976,7 @@ describe("OrchestratorMcpService provider resolution", () => {
Layer.mock(RemoteDelegation.RemoteDelegation)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({
list: () => Effect.succeed({ tasks: [task({})] }),
reliability: () => Effect.succeed([]),
}),
),
),
Expand Down
18 changes: 17 additions & 1 deletion apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2132,10 +2132,26 @@ const make = Effect.gen(function* () {
failure("orchestration_error", `Could not list scheduled tasks: ${error.message}`),
),
);
// History is extra: a failed read must not hide the tasks themselves.
const reliability = new Map(
(yield* scheduledTasks.reliability().pipe(Effect.orElseSucceed(() => []))).map(
(entry) => [entry.taskId, entry] as const,
),
);
return {
tasks: yield* Effect.forEach(
tasks.filter((task) => projectId === undefined || task.projectId === projectId),
(task) => summarizeScheduledTask(scope, { parent, limits }, task),
(task) =>
summarizeScheduledTask(scope, { parent, limits }, task).pipe(
Effect.map((summary) => {
const { recentFailures, missedRun } = reliability.get(task.id) ?? {};
return {
...summary,
...(recentFailures === undefined ? {} : { recentFailures }),
...(missedRun === undefined ? {} : { missedRun }),
};
}),
),
),
};
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ const layerUnusedScheduledTaskStub = Layer.succeed(
ScheduledTaskService.ScheduledTaskService.of({
list: () => Effect.succeed({ tasks: [] }),
subscribeList: () => Stream.succeed({ tasks: [] }),
reliability: () => Effect.succeed([]),
upsert: () => Effect.die("ScheduledTaskService.upsert is unused in this test"),
setEnabled: () => Effect.die("ScheduledTaskService.setEnabled is unused in this test"),
delete: () => Effect.die("ScheduledTaskService.delete is unused in this test"),
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/mcp/ThreadLaunchMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { OrchestratorRuntimeModeAboveCeilingError } from "../orchestration-v2/Or
import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts";
import * as ThreadMessageIntake from "../orchestration-v2/ThreadMessageIntake.ts";
import * as Claims from "../orchestration-v2/AttachmentClaims.ts";
import { temporaryFolderWarning } from "../project/temporaryFolder.ts";
import * as Project from "../project/ProjectService.ts";

import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts";
Expand Down Expand Up @@ -90,6 +91,7 @@ export const launchResult = Schema.Struct({
modelSelection: ModelSelection,
runId: Schema.NullOr(RunId),
status: Schema.NullOr(OrchestrationV2RunStatus),
warning: Schema.optional(Schema.String),
});
function worktreeCheckHint(
error:
Expand Down Expand Up @@ -370,6 +372,8 @@ const launch = Effect.fn("ThreadLaunchMcpService.launch")(
.pipe(Effect.ignore({ log: true }));
}
const run = result.projection.runs.find((run) => run.userMessageId === messageId);
const warning =
project === undefined ? undefined : yield* temporaryFolderWarning(project.workspaceRoot);
const notify = yield* ThreadNotifications.finish(
notification,
thread.id,
Expand All @@ -384,6 +388,7 @@ const launch = Effect.fn("ThreadLaunchMcpService.launch")(
modelSelection: thread.modelSelection,
runId: run?.id ?? null,
status: run?.status ?? null,
...(warning === undefined ? {} : { warning }),
};
}),
);
Expand Down
26 changes: 26 additions & 0 deletions apps/server/src/mcp/toolkits/project/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,32 @@ it.effect("a launch with a model its provider does not advertise creates no thre
}),
);

it.effect.each([
["/tmp/review", true],
["/projects/client-target", false],
] as const)("a launch into a project rooted at %s warns: %s", ([workspaceRoot, warns]) =>
Effect.gen(function* () {
const { projectId, dependencies } = clientLaunchHarness({
runtimeModeCeiling: "auto",
launched: [],
workspaceRoot,
});
const toolkit = yield* ProjectToolkit.pipe(
Effect.provide(
McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)),
),
);
const result = yield* toolkit.handle("t3_thread_launch", { title: "Fix", projectId }).pipe(
Stream.unwrap,
Stream.runCollect,
Effect.map((chunk) => chunk.at(-1)?.result),
Effect.provide(dependencies),
);
expect(result).toMatchObject(warns ? { warning: expect.stringContaining(workspaceRoot) } : {});
expect(warns || (result as { warning?: string }).warning === undefined).toBe(true);
}),
);

it.effect("a launch retried with the same clientRequestId replays the first one", () =>
Effect.gen(function* () {
const launched: Array<ThreadLaunch.ThreadLaunchInput> = [];
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/mcp/toolkits/project/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { mutateProjectWithClone } from "../../../project/ProjectMutation.ts";
import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts";

import { temporaryFolderWarning } from "../../../project/temporaryFolder.ts";
import * as McpToolAccess from "../../McpToolAccess.ts";
import { newCommandId, readCaller, unavailable } from "../../threadAccess.ts";
import { ProjectToolkit } from "./tools.ts";
Expand Down Expand Up @@ -104,9 +105,11 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
};
}
const commandId = yield* newCommandId();
return yield* projects
const created = yield* projects
.create({ ...input, workspaceRoot, commandId, projectId: ProjectId.make(commandId) })
.pipe(Effect.mapError(projectFailure));
const warning = yield* temporaryFolderWarning(workspaceRoot);
return warning === undefined ? created : { ...created, warning };
}),
// Linked work may bring the repository it works in; an existing
// registered workspace is still refused.
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/mcp/toolkits/project/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ const ProjectCreateTool = Tool.make("t3_project_create", {
...ProjectCreatePayload.fields,
workspaceRoot: Schema.optional(ProjectCreatePayload.fields.workspaceRoot),
}),
success: Schema.Struct({ ...Project.fields, commitError: Schema.optional(Schema.String) }),
success: Schema.Struct({
...Project.fields,
commitError: Schema.optional(Schema.String),
warning: Schema.optional(Schema.String),
}),
dependencies: [...shared.dependencies, ManagedProjectFolders.ManagedProjectFolders],
}).annotate(Tool.Destructive, true);
const ProjectUpdateTool = Tool.make("t3_project_update", {
Expand Down
Loading
Loading