From 81d3a056e829202ef3f9a34bb4bb3cfe24a62471 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 14:37:34 -0700 Subject: [PATCH 01/15] feat(mcp): add project management tools --- apps/server/src/mcp/McpHttpServer.ts | 11 + apps/server/src/mcp/ProjectMcpService.test.ts | 662 ++++++++++++++++++ apps/server/src/mcp/ProjectMcpService.ts | 464 ++++++++++++ .../src/mcp/toolkits/project/handlers.ts | 40 ++ apps/server/src/mcp/toolkits/project/tools.ts | 100 +++ .../toolkits/worktree/registration.test.ts | 4 + .../Adapters/ClaudeAdapterV2.test.ts | 6 +- .../Adapters/ClaudeAdapterV2.ts | 2 + .../ThreadLaunchService.test.ts | 52 +- .../orchestration-v2/ThreadLaunchService.ts | 129 ++-- .../ThreadManagementService.ts | 7 + .../Layers/ProjectionPipeline.ts | 2 +- apps/server/src/orchestration/decider.ts | 3 +- apps/server/src/orchestration/projector.ts | 2 +- .../server/src/project/ProjectService.test.ts | 13 +- apps/server/src/project/ProjectService.ts | 14 +- .../SourceControlRepositoryService.test.ts | 284 +++++++- .../SourceControlRepositoryService.ts | 198 ++++-- apps/server/src/ws.ts | 8 + .../orchestrator-mcp-server.md | 37 +- docs/user/project-settings.md | 10 + .../src/operations/commands.test.ts | 24 + .../client-runtime/src/operations/commands.ts | 17 + packages/contracts/src/index.ts | 1 + packages/contracts/src/orchestration.ts | 2 + packages/contracts/src/project.ts | 7 +- packages/contracts/src/projectMcp.test.ts | 63 ++ packages/contracts/src/projectMcp.ts | 146 ++++ packages/shared/src/t3McpToolPresentation.ts | 5 + 29 files changed, 2193 insertions(+), 120 deletions(-) create mode 100644 apps/server/src/mcp/ProjectMcpService.test.ts create mode 100644 apps/server/src/mcp/ProjectMcpService.ts create mode 100644 apps/server/src/mcp/toolkits/project/handlers.ts create mode 100644 apps/server/src/mcp/toolkits/project/tools.ts create mode 100644 packages/contracts/src/projectMcp.test.ts create mode 100644 packages/contracts/src/projectMcp.ts diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 03492c3ef366..3d501657243a 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -14,8 +14,12 @@ import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; import * as McpSessionRegistry from "./McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; +import * as ProjectMcpService from "./ProjectMcpService.ts"; +import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; import { OrchestratorToolkitHandlersLive } from "./toolkits/orchestrator/handlers.ts"; import { OrchestratorToolkit } from "./toolkits/orchestrator/tools.ts"; +import { ProjectToolkitHandlersLive } from "./toolkits/project/handlers.ts"; +import { ProjectToolkit } from "./toolkits/project/tools.ts"; import { PreviewSnapshotToolkitHandlersLive, PreviewStandardToolkitHandlersLive, @@ -227,6 +231,12 @@ export const OrchestratorToolkitRegistrationLive = McpServer.toolkit(Orchestrato Layer.provide(OrchestratorMcpService.layer), ); +export const ProjectToolkitRegistrationLive = McpServer.toolkit(ProjectToolkit).pipe( + Layer.provide(ProjectToolkitHandlersLive), + Layer.provide(ProjectMcpService.layer), + Layer.provide(T3ProjectFileLoader.layer), +); + export const WorktreeToolkitRegistrationLive = McpServer.toolkit(WorktreeToolkit).pipe( Layer.provide(WorktreeToolkitHandlersLive), Layer.provide(WorktreeMcpService.layer), @@ -242,5 +252,6 @@ const McpTransportLive = McpServer.layerHttp({ export const layer = Layer.mergeAll( PreviewToolkitRegistrationLive, OrchestratorToolkitRegistrationLive, + ProjectToolkitRegistrationLive, WorktreeToolkitRegistrationLive, ).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/ProjectMcpService.test.ts b/apps/server/src/mcp/ProjectMcpService.test.ts new file mode 100644 index 000000000000..e1d2d3941166 --- /dev/null +++ b/apps/server/src/mcp/ProjectMcpService.test.ts @@ -0,0 +1,662 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + type CommandId, + EnvironmentId, + ProjectMcpFailure, + type Project, + ProjectId, + ProviderInstanceId, + type ServerSettings, + ServerSettingsError, + ThreadId, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import { makeKeyedSerialExecutor } from "../orchestration-v2/KeyedSerialExecutor.ts"; +import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; +import * as ServerSettingsService from "../serverSettings.ts"; +import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; +import type { McpInvocationScope } from "./McpInvocationContext.ts"; +import { ProjectMcpService } from "./ProjectMcpService.ts"; +import * as ProjectMcp from "./ProjectMcpService.ts"; + +const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment-project-mcp"), + threadId: ThreadId.make("thread-project-mcp"), + providerSessionId: "provider-session-project-mcp", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["orchestration"]), + issuedAt: 1, +}; + +const now = "2026-08-29T12:00:00.000Z" as const; + +const makeProject = (input: { + readonly id: ProjectId; + readonly title: string; + readonly workspaceRoot: string; +}): Project => ({ + id: input.id, + title: input.title, + workspaceRoot: input.workspaceRoot, + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, +}); + +it.effect("creates, reads, and updates project defaults through the project MCP service", () => + Effect.gen(function* () { + const state = yield* Ref.make>(new Map()); + const createdInputs = yield* Ref.make>([]); + const updatedInputs = yield* Ref.make>([]); + + const projectService = ProjectService.ProjectService.of({ + create: (input) => + Effect.gen(function* () { + yield* Ref.update(createdInputs, (current) => [...current, input]); + const project = { + ...makeProject({ + id: input.projectId, + title: input.title, + workspaceRoot: input.workspaceRoot, + }), + defaultModelSelection: input.defaultModelSelection ?? null, + defaultThreadEnvMode: input.defaultThreadEnvMode ?? null, + faviconPath: input.faviconPath ?? null, + scripts: [...(input.scripts ?? [])], + } satisfies Project; + yield* Ref.update(state, (current) => new Map(current).set(project.id, project)); + return project; + }), + bootstrap: () => Effect.die("unused"), + update: (input) => + Effect.gen(function* () { + yield* Ref.update(updatedInputs, (current) => [...current, input]); + const current = (yield* Ref.get(state)).get(input.projectId)!; + const project = { + ...current, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.workspaceRoot === undefined ? {} : { workspaceRoot: input.workspaceRoot }), + ...(input.defaultModelSelection === undefined + ? {} + : { defaultModelSelection: input.defaultModelSelection }), + ...(input.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: input.defaultThreadEnvMode }), + ...(input.faviconPath === undefined ? {} : { faviconPath: input.faviconPath }), + ...(input.scripts === undefined ? {} : { scripts: [...input.scripts] }), + } satisfies Project; + yield* Ref.update(state, (projects) => new Map(projects).set(project.id, project)); + return project; + }), + delete: () => Effect.die("unused"), + getById: (projectId) => + Ref.get(state).pipe( + Effect.map((projects) => Option.fromNullishOr(projects.get(projectId))), + ), + getByWorkspaceRoot: () => Effect.succeed(Option.none()), + snapshot: Ref.get(state).pipe( + Effect.map((projects) => ({ projects: [...projects.values()], updatedAt: now })), + ), + }); + const testLayer = ProjectMcp.layer.pipe( + Layer.provide(Layer.succeed(ProjectService.ProjectService, projectService)), + Layer.provide( + Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({ + load: () => Effect.succeed(Option.some({ defaultThreadEnvMode: "worktree" })), + }), + ), + Layer.provide( + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Effect.succeed({ + defaultThreadEnvMode: "local", + } as ServerSettings), + }), + ), + Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), + Layer.provide(Layer.mock(ThreadManagementService)({})), + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const service = yield* ProjectMcpService; + const invalidKey = yield* service + .create(scope, { + title: "Invalid key", + source: { type: "existing_directory", workspaceRoot: "/work/invalid-key" }, + clientRequestId: "create-\ud800-project", + }) + .pipe(Effect.flip); + expect(invalidKey.code).toBe("invalid_request"); + expect(yield* Ref.get(createdInputs)).toEqual([]); + + const created = yield* service.create(scope, { + title: "Created project", + source: { + type: "existing_directory", + workspaceRoot: "/work/created", + createIfMissing: true, + }, + defaultThreadEnvMode: "local", + faviconPath: "/work/created/icon.svg", + clientRequestId: "create-project", + }); + expect(created.projectFileDefaultThreadEnvMode).toBe("worktree"); + expect(created.globalDefaultThreadEnvMode).toBe("local"); + expect(created.effectiveDefaultThreadEnvMode).toBe("local"); + expect((yield* Ref.get(createdInputs))[0]?.createWorkspaceRootIfMissing).toBe(true); + + const updated = yield* service.update(scope, { + projectId: created.id, + defaultThreadEnvMode: null, + faviconPath: null, + clientRequestId: "update-project", + }); + expect(updated.defaultThreadEnvMode).toBeNull(); + expect(updated.faviconPath).toBeNull(); + expect(updated.effectiveDefaultThreadEnvMode).toBe("worktree"); + expect((yield* Ref.get(updatedInputs))[0]).toMatchObject({ + defaultThreadEnvMode: null, + faviconPath: null, + }); + expect(String((yield* Ref.get(updatedInputs))[0]?.commandId)).toContain( + encodeURIComponent(created.id), + ); + }).pipe(Effect.provide(testLayer)); + }), +); + +it.effect("loads response settings before committing project mutations", () => + Effect.gen(function* () { + const createCalls = yield* Ref.make(0); + const testLayer = ProjectMcp.layer.pipe( + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeed(Option.none()), + create: () => Ref.update(createCalls, (count) => count + 1).pipe(Effect.as({} as never)), + }), + ), + Layer.provide(Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({})), + Layer.provide( + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Effect.fail( + new ServerSettingsError({ + settingsPath: "/test/settings.json", + operation: "read-file", + cause: "settings unavailable", + }), + ), + }), + ), + Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), + Layer.provide(Layer.mock(ThreadManagementService)({})), + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const service = yield* ProjectMcpService; + const error = yield* service + .create(scope, { + title: "Uncommitted project", + source: { type: "existing_directory", workspaceRoot: "/work/uncommitted" }, + clientRequestId: "settings-failure", + }) + .pipe(Effect.flip); + expect(error.code).toBe("operation_failed"); + expect(yield* Ref.get(createCalls)).toBe(0); + }).pipe(Effect.provide(testLayer)); + }), +); + +it.effect("clones before registration and requires explicit cascading for nonempty projects", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project:mcp:provider-session-project-mcp:clone-and-delete"); + const project = makeProject({ + id: projectId, + title: "Cloned project", + workspaceRoot: "/work/cloned", + }); + const state = yield* Ref.make(null); + const cloneInputs = yield* Ref.make>([]); + const deletedThreadCommands = yield* Ref.make>([]); + const deletedProjects = yield* Ref.make>([]); + const lockedProjects = yield* Ref.make>([]); + const testLayer = ProjectMcp.layer.pipe( + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + create: (input) => + Effect.gen(function* () { + const created = makeProject({ + id: input.projectId, + title: input.title, + workspaceRoot: input.workspaceRoot, + }); + yield* Ref.set(state, created); + return created; + }), + getById: () => Ref.get(state).pipe(Effect.map(Option.fromNullishOr)), + delete: ({ projectId }) => + Effect.gen(function* () { + yield* Ref.update(deletedProjects, (current) => [...current, projectId]); + const deleted = { ...project, deletedAt: now } satisfies Project; + yield* Ref.set(state, deleted); + return deleted; + }), + }), + ), + Layer.provide( + Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({ + load: () => Effect.succeed(Option.none()), + }), + ), + Layer.provide( + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Effect.succeed({ defaultThreadEnvMode: "local" } as ServerSettings), + }), + ), + Layer.provide( + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ + cloneRepository: (input) => + Ref.update(cloneInputs, (current) => [ + ...current, + { destinationPath: input.destinationPath }, + ]).pipe( + Effect.as({ + cwd: "/work/cloned", + remoteUrl: "https://example.com/acme/repo.git", + repository: null, + }), + ), + }), + ), + Layer.provide( + Layer.mock(ThreadManagementService)({ + withProjectMutationLock: (lockedProjectId, effect) => + Ref.update(lockedProjects, (current) => [...current, lockedProjectId]).pipe( + Effect.andThen(effect), + ), + getShellSnapshot: (options) => + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 1, + threads: + options?.location === "archive" + ? [] + : [ + { + id: ThreadId.make("thread-in-project"), + projectId, + } as never, + ], + archivedThreads: + options?.location === "archive" + ? [ + { + id: ThreadId.make("archived-subagent-in-project"), + projectId, + lineage: { relationshipToParent: "subagent" }, + } as never, + ] + : [], + }), + dispatch: (command) => + command.type === "thread.delete" + ? Ref.update(deletedThreadCommands, (current) => [ + ...current, + command.commandId, + ]).pipe(Effect.as({} as never)) + : Effect.die("unexpected command"), + }), + ), + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const service = yield* ProjectMcpService; + const created = yield* service.create(scope, { + title: project.title, + source: { + type: "clone", + destinationPath: "/work/cloned", + remoteUrl: "https://example.com/acme/repo.git", + }, + clientRequestId: "clone-and-delete", + }); + expect(created.workspaceRoot).toBe("/work/cloned"); + expect(yield* Ref.get(cloneInputs)).toEqual([{ destinationPath: "/work/cloned" }]); + + const refusal = yield* service + .delete(scope, { projectId, clientRequestId: "delete-refused" }) + .pipe(Effect.flip); + expect(refusal.code).toBe("project_not_empty"); + + const deleted = yield* service.delete(scope, { + projectId, + cascadeThreads: true, + clientRequestId: "delete-cascade", + }); + expect(deleted).toMatchObject({ + projectId, + deleted: true, + alreadyDeleted: false, + deletedThreadCount: 2, + workspaceRoot: "/work/cloned", + workspaceFilesDeleted: false, + }); + expect(yield* Ref.get(deletedThreadCommands)).toHaveLength(2); + expect(yield* Ref.get(deletedProjects)).toEqual([projectId]); + + const replayed = yield* service.delete(scope, { + projectId, + cascadeThreads: true, + clientRequestId: "delete-cascade", + }); + expect(replayed).toMatchObject({ + deleted: true, + alreadyDeleted: true, + deletedThreadCount: 0, + workspaceFilesDeleted: false, + }); + expect(yield* Ref.get(deletedProjects)).toEqual([projectId]); + expect(yield* Ref.get(lockedProjects)).toEqual([projectId, projectId, projectId]); + }).pipe(Effect.provide(testLayer)); + }), +); + +it.effect("serializes project deletion against new thread claims", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project:mcp:delete-launch-race"); + const project = makeProject({ + id: projectId, + title: "Delete race", + workspaceRoot: "/work/delete-race", + }); + const state = yield* Ref.make(project); + const snapshotEntered = yield* Deferred.make(); + const allowSnapshot = yield* Deferred.make(); + const competingClaim = yield* Deferred.make(); + const projectMutations = yield* makeKeyedSerialExecutor(); + const threadManagementLayer = Layer.mock(ThreadManagementService)({ + withProjectMutationLock: projectMutations.withLock, + getShellSnapshot: () => + Deferred.succeed(snapshotEntered, undefined).pipe( + Effect.andThen(Deferred.await(allowSnapshot)), + Effect.as({ + schemaVersion: 1, + snapshotSequence: 1, + threads: [], + archivedThreads: [], + }), + ), + dispatch: () => Effect.die("unexpected command"), + }); + const testLayer = ProjectMcp.layer.pipe( + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getById: () => Ref.get(state).pipe(Effect.map(Option.fromNullishOr)), + delete: () => + Ref.updateAndGet(state, (current) => + current === null ? null : { ...current, deletedAt: now }, + ).pipe(Effect.map((deleted) => deleted!)), + }), + ), + Layer.provide(Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({})), + Layer.provide( + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Effect.succeed({ defaultThreadEnvMode: "local" } as ServerSettings), + }), + ), + Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), + Layer.provide(threadManagementLayer), + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const service = yield* ProjectMcpService; + const deletion = yield* Effect.forkChild( + service.delete(scope, { projectId, clientRequestId: "delete-launch-race" }), + { startImmediately: true }, + ); + yield* Deferred.await(snapshotEntered); + const claim = yield* Effect.forkChild( + projectMutations.withLock(projectId, Deferred.succeed(competingClaim, undefined)), + { startImmediately: true }, + ); + expect(yield* Deferred.isDone(competingClaim)).toBe(false); + + yield* Deferred.succeed(allowSnapshot, undefined); + const deleted = yield* Fiber.join(deletion); + yield* Fiber.join(claim); + expect(deleted.deleted).toBe(true); + expect(yield* Deferred.isDone(competingClaim)).toBe(true); + }).pipe(Effect.provide(testLayer)); + }), +); + +it.effect("paginates summaries before loading project files and reads settings once", () => + Effect.gen(function* () { + const projectFileLoads = yield* Ref.make>([]); + const settingsLoads = yield* Ref.make(0); + const projects = Array.from({ length: 40 }, (_, index) => + makeProject({ + id: ProjectId.make(`project:page:${index.toString().padStart(2, "0")}`), + title: `Project ${index}`, + workspaceRoot: `/work/project-${index}`, + }), + ); + const testLayer = ProjectMcp.layer.pipe( + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + snapshot: Effect.succeed({ projects, updatedAt: now }), + }), + ), + Layer.provide( + Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({ + load: (workspaceRoot) => + Ref.update(projectFileLoads, (current) => [...current, workspaceRoot]).pipe( + Effect.as(Option.none()), + ), + }), + ), + Layer.provide( + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Ref.updateAndGet(settingsLoads, (count) => count + 1).pipe( + Effect.as({ defaultThreadEnvMode: "local" } as ServerSettings), + ), + }), + ), + Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), + Layer.provide(Layer.mock(ThreadManagementService)({})), + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const service = yield* ProjectMcpService; + const page = yield* service.list(scope, { cursor: 10, limit: 5 }); + expect(page).toMatchObject({ nextCursor: 15, totalCount: 40 }); + expect(page.projects.map((project) => project.id)).toEqual( + projects.slice(10, 15).map((project) => project.id), + ); + expect(page.projects.every((project) => !("scripts" in project))).toBe(true); + expect(yield* Ref.get(projectFileLoads)).toEqual( + projects.slice(10, 15).map((project) => project.workspaceRoot), + ); + expect(yield* Ref.get(settingsLoads)).toBe(1); + }).pipe(Effect.provide(testLayer)); + }), +); + +it.effect("serializes overlapping create retries with the same idempotency key", () => + Effect.gen(function* () { + const state = yield* Ref.make(null); + const cloneCalls = yield* Ref.make(0); + const createCalls = yield* Ref.make(0); + const createEntered = yield* Deferred.make(); + const allowCreate = yield* Deferred.make(); + const testLayer = ProjectMcp.layer.pipe( + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getById: () => Ref.get(state).pipe(Effect.map(Option.fromNullishOr)), + create: (input) => + Effect.gen(function* () { + yield* Ref.update(createCalls, (count) => count + 1); + yield* Deferred.succeed(createEntered, undefined); + yield* Deferred.await(allowCreate); + const created = makeProject({ + id: input.projectId, + title: input.title, + workspaceRoot: input.workspaceRoot, + }); + yield* Ref.set(state, created); + return created; + }), + }), + ), + Layer.provide( + Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({ + load: () => Effect.succeed(Option.none()), + }), + ), + Layer.provide( + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Effect.succeed({ defaultThreadEnvMode: "local" } as ServerSettings), + }), + ), + Layer.provide( + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ + cloneRepository: () => + Ref.update(cloneCalls, (count) => count + 1).pipe( + Effect.as({ + cwd: "/work/overlap", + remoteUrl: "https://example.com/acme/repo.git", + repository: null, + }), + ), + }), + ), + Layer.provide(Layer.mock(ThreadManagementService)({})), + Layer.provide(NodeServices.layer), + ); + const input = { + title: "Overlapping clone", + source: { + type: "clone" as const, + destinationPath: "/work/overlap", + remoteUrl: "https://example.com/acme/repo.git", + }, + clientRequestId: "overlapping-create", + }; + + yield* Effect.gen(function* () { + const service = yield* ProjectMcpService; + const first = yield* Effect.forkChild(service.create(scope, input), { + startImmediately: true, + }); + yield* Deferred.await(createEntered); + const second = yield* Effect.forkChild(service.create(scope, input), { + startImmediately: true, + }); + yield* Deferred.succeed(allowCreate, undefined); + const [firstResult, secondResult] = yield* Effect.all([ + Fiber.join(first), + Fiber.join(second), + ]); + expect(firstResult.id).toBe(secondResult.id); + expect(yield* Ref.get(cloneCalls)).toBe(1); + expect(yield* Ref.get(createCalls)).toBe(1); + }).pipe(Effect.provide(testLayer)); + }), +); + +it.effect("retries registration after a completed clone without changing the project id", () => + Effect.gen(function* () { + const state = yield* Ref.make(null); + const cloneCalls = yield* Ref.make(0); + const createCalls = yield* Ref.make(0); + const testLayer = ProjectMcp.layer.pipe( + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getById: () => Ref.get(state).pipe(Effect.map(Option.fromNullishOr)), + create: (input) => + Ref.updateAndGet(createCalls, (count) => count + 1).pipe( + Effect.flatMap((attempt) => + attempt === 1 + ? Effect.fail( + new ProjectService.ProjectOperationError({ + operation: "dispatch-project-command", + projectId: input.projectId, + cause: "registration unavailable", + }), + ) + : Effect.sync(() => + makeProject({ + id: input.projectId, + title: input.title, + workspaceRoot: input.workspaceRoot, + }), + ).pipe(Effect.tap((project) => Ref.set(state, project))), + ), + ), + }), + ), + Layer.provide( + Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({ + load: () => Effect.succeed(Option.none()), + }), + ), + Layer.provide( + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Effect.succeed({ defaultThreadEnvMode: "local" } as ServerSettings), + }), + ), + Layer.provide( + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ + cloneRepository: () => + Ref.update(cloneCalls, (count) => count + 1).pipe( + Effect.as({ + cwd: "/work/recovered-clone", + remoteUrl: "https://example.com/acme/repo.git", + repository: null, + }), + ), + }), + ), + Layer.provide(Layer.mock(ThreadManagementService)({})), + Layer.provide(NodeServices.layer), + ); + const input = { + title: "Recovered clone", + source: { + type: "clone" as const, + destinationPath: "/work/recovered-clone", + remoteUrl: "https://example.com/acme/repo.git", + }, + clientRequestId: "retry-registration", + }; + + yield* Effect.gen(function* () { + const service = yield* ProjectMcpService; + const firstFailure = yield* service.create(scope, input).pipe(Effect.flip); + expect(firstFailure).toBeInstanceOf(ProjectMcpFailure); + const retried = yield* service.create(scope, input); + expect(retried.id).toBe( + ProjectId.make("project:mcp:provider-session-project-mcp:retry-registration"), + ); + expect(yield* Ref.get(cloneCalls)).toBe(2); + expect(yield* Ref.get(createCalls)).toBe(2); + }).pipe(Effect.provide(testLayer)); + }), +); diff --git a/apps/server/src/mcp/ProjectMcpService.ts b/apps/server/src/mcp/ProjectMcpService.ts new file mode 100644 index 000000000000..f1a1305696e3 --- /dev/null +++ b/apps/server/src/mcp/ProjectMcpService.ts @@ -0,0 +1,464 @@ +import { + CommandId, + type Project, + ProjectId, + ProjectMcpFailure, + type ProjectMcpCreateInput, + type ProjectMcpDeleteInput, + type ProjectMcpDeleteResult, + type ProjectMcpListInput, + type ProjectMcpListResult, + type ProjectMcpProject, + type ProjectMcpProjectSummary, + type ProjectMcpUpdateInput, + type ServerSettings as ServerSettingsValue, +} from "@t3tools/contracts"; +import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { makeKeyedSerialExecutor } from "../orchestration-v2/KeyedSerialExecutor.ts"; +import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; +import type { McpInvocationScope } from "./McpInvocationContext.ts"; + +export class ProjectMcpService extends Context.Service< + ProjectMcpService, + { + readonly list: ( + scope: McpInvocationScope, + input: ProjectMcpListInput, + ) => Effect.Effect; + readonly read: ( + scope: McpInvocationScope, + projectId: ProjectId, + ) => Effect.Effect; + readonly create: ( + scope: McpInvocationScope, + input: ProjectMcpCreateInput, + ) => Effect.Effect; + readonly update: ( + scope: McpInvocationScope, + input: ProjectMcpUpdateInput, + ) => Effect.Effect; + readonly delete: ( + scope: McpInvocationScope, + input: ProjectMcpDeleteInput, + ) => Effect.Effect; + } +>()("t3/mcp/ProjectMcpService") {} + +function failure(code: ProjectMcpFailure["code"], message: string): ProjectMcpFailure { + return new ProjectMcpFailure({ code, message }); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function stablePart(value: string): string { + return encodeURIComponent(value); +} + +function stableCommandId(input: { + readonly scope: McpInvocationScope; + readonly requestKey: string; + readonly operation: string; + readonly suffix?: string; +}): CommandId { + return CommandId.make( + [ + "command", + "mcp", + stablePart(input.scope.providerSessionId), + stablePart(input.operation), + stablePart(input.requestKey), + ...(input.suffix === undefined ? [] : [stablePart(input.suffix)]), + ].join(":"), + ); +} + +const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const projects = yield* ProjectService.ProjectService; + const projectFiles = yield* T3ProjectFileLoader.T3ProjectFileLoader; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const sourceControl = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const threads = yield* ThreadManagementService; + const projectCreates = yield* makeKeyedSerialExecutor(); + + const requireCapability = (scope: McpInvocationScope) => + scope.capabilities.has("orchestration") + ? Effect.void + : Effect.fail( + failure( + "capability_denied", + "This MCP credential does not grant orchestration capabilities.", + ), + ); + + const requestKey = (clientRequestId: string | undefined) => + clientRequestId === undefined + ? crypto.randomUUIDv4.pipe(Effect.orDie) + : clientRequestId.isWellFormed() + ? Effect.succeed(clientRequestId) + : Effect.fail( + failure("invalid_request", "clientRequestId must contain well-formed Unicode."), + ); + + const loadSettings = serverSettings.getSettings.pipe( + Effect.mapError((error) => + failure("operation_failed", `Unable to read server settings: ${errorMessage(error)}`), + ), + ); + + const projectWorkspaceDefaults = Effect.fn("ProjectMcpService.projectWorkspaceDefaults")( + function* (project: Project, settings: ServerSettingsValue) { + const projectFile = yield* projectFiles.load(project.workspaceRoot); + const projectFileDefaultThreadEnvMode = Option.isSome(projectFile) + ? (projectFile.value.defaultThreadEnvMode ?? null) + : null; + return { + projectFileDefaultThreadEnvMode, + globalDefaultThreadEnvMode: settings.defaultThreadEnvMode, + effectiveDefaultThreadEnvMode: resolveDefaultThreadEnvMode({ + projectSetting: project.defaultThreadEnvMode, + projectFile: projectFileDefaultThreadEnvMode, + globalDefault: settings.defaultThreadEnvMode, + }), + } as const; + }, + ); + + const projectView = Effect.fn("ProjectMcpService.projectView")(function* ( + project: Project, + settings: ServerSettingsValue, + ) { + return { + ...project, + ...(yield* projectWorkspaceDefaults(project, settings)), + } satisfies ProjectMcpProject; + }); + + const projectSummary = Effect.fn("ProjectMcpService.projectSummary")(function* ( + project: Project, + settings: ServerSettingsValue, + ) { + return { + id: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + ...(project.repositoryIdentity === undefined + ? {} + : { repositoryIdentity: project.repositoryIdentity }), + ...(project.faviconPath === undefined ? {} : { faviconPath: project.faviconPath }), + defaultModelSelection: project.defaultModelSelection, + ...(project.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: project.defaultThreadEnvMode }), + createdAt: project.createdAt, + updatedAt: project.updatedAt, + ...(yield* projectWorkspaceDefaults(project, settings)), + } satisfies ProjectMcpProjectSummary; + }); + + const loadProject = Effect.fn("ProjectMcpService.loadProject")(function* (projectId: ProjectId) { + const project = yield* projects + .getById(projectId) + .pipe( + Effect.mapError((error) => + failure( + "operation_failed", + `Unable to read project ${projectId}: ${errorMessage(error)}`, + ), + ), + ); + if (Option.isNone(project)) { + return yield* failure("project_not_found", `Project '${projectId}' was not found.`); + } + return project.value; + }); + + const list: ProjectMcpService["Service"]["list"] = (scope, input) => + Effect.gen(function* () { + yield* requireCapability(scope); + const snapshot = yield* projects.snapshot.pipe( + Effect.mapError((error) => + failure("operation_failed", `Unable to list projects: ${errorMessage(error)}`), + ), + ); + const settings = yield* loadSettings; + const cursor = input.cursor ?? 0; + const limit = input.limit ?? 25; + const sorted = snapshot.projects.toSorted( + (left, right) => + right.updatedAt.localeCompare(left.updatedAt) || left.id.localeCompare(right.id), + ); + const page = sorted.slice(cursor, cursor + limit); + const nextCursor = cursor + page.length < sorted.length ? cursor + page.length : null; + return { + projects: yield* Effect.forEach(page, (project) => projectSummary(project, settings), { + concurrency: 8, + }), + nextCursor, + totalCount: sorted.length, + }; + }); + + const read: ProjectMcpService["Service"]["read"] = (scope, projectId) => + Effect.gen(function* () { + yield* requireCapability(scope); + return yield* projectView(yield* loadProject(projectId), yield* loadSettings); + }); + + const create: ProjectMcpService["Service"]["create"] = (scope, input) => + Effect.gen(function* () { + yield* requireCapability(scope); + const key = yield* requestKey(input.clientRequestId); + const projectId = ProjectId.make( + ["project", "mcp", stablePart(scope.providerSessionId), stablePart(key)].join(":"), + ); + return yield* projectCreates.withLock( + projectId, + Effect.gen(function* () { + const existing = yield* projects + .getById(projectId) + .pipe( + Effect.mapError((error) => + failure( + "operation_failed", + `Unable to retry project creation: ${errorMessage(error)}`, + ), + ), + ); + if (Option.isSome(existing)) { + return yield* projectView(existing.value, yield* loadSettings); + } + const settings = yield* loadSettings; + + let workspaceRoot: string; + let createWorkspaceRootIfMissing = false; + if (input.source.type === "existing_directory") { + workspaceRoot = input.source.workspaceRoot; + createWorkspaceRootIfMissing = input.source.createIfMissing ?? false; + } else { + const hasRemoteUrl = input.source.remoteUrl !== undefined; + const hasRepository = + input.source.provider !== undefined && input.source.repository !== undefined; + if (hasRemoteUrl === hasRepository) { + return yield* failure( + "invalid_request", + "Clone creation requires either remoteUrl or both provider and repository.", + ); + } + const cloned = yield* sourceControl + .cloneRepository({ + destinationPath: input.source.destinationPath, + ...(input.source.remoteUrl === undefined + ? {} + : { remoteUrl: input.source.remoteUrl }), + ...(input.source.provider === undefined ? {} : { provider: input.source.provider }), + ...(input.source.repository === undefined + ? {} + : { repository: input.source.repository }), + ...(input.source.protocol === undefined ? {} : { protocol: input.source.protocol }), + }) + .pipe( + Effect.mapError((error) => + failure("operation_failed", `Unable to clone project: ${errorMessage(error)}`), + ), + ); + workspaceRoot = cloned.cwd; + } + + const project = yield* projects + .create({ + commandId: stableCommandId({ scope, requestKey: key, operation: "project-create" }), + projectId, + title: input.title, + workspaceRoot, + ...(createWorkspaceRootIfMissing ? { createWorkspaceRootIfMissing: true } : {}), + ...(input.defaultModelSelection === undefined + ? {} + : { defaultModelSelection: input.defaultModelSelection }), + ...(input.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: input.defaultThreadEnvMode }), + ...(input.faviconPath === undefined ? {} : { faviconPath: input.faviconPath }), + ...(input.scripts === undefined ? {} : { scripts: input.scripts }), + }) + .pipe( + Effect.mapError((error) => + failure("operation_failed", `Unable to create project: ${errorMessage(error)}`), + ), + ); + return yield* projectView(project, settings); + }), + ); + }); + + const update: ProjectMcpService["Service"]["update"] = (scope, input) => + Effect.gen(function* () { + yield* requireCapability(scope); + if ( + input.title === undefined && + input.workspaceRoot === undefined && + input.defaultModelSelection === undefined && + input.defaultThreadEnvMode === undefined && + input.faviconPath === undefined && + input.scripts === undefined + ) { + return yield* failure("invalid_request", "Provide at least one project field to update."); + } + const key = yield* requestKey(input.clientRequestId); + const settings = yield* loadSettings; + const project = yield* projects + .update({ + commandId: stableCommandId({ + scope, + requestKey: key, + operation: "project-update", + suffix: input.projectId, + }), + projectId: input.projectId, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.workspaceRoot === undefined ? {} : { workspaceRoot: input.workspaceRoot }), + ...(input.defaultModelSelection === undefined + ? {} + : { defaultModelSelection: input.defaultModelSelection }), + ...(input.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: input.defaultThreadEnvMode }), + ...(input.faviconPath === undefined ? {} : { faviconPath: input.faviconPath }), + ...(input.scripts === undefined ? {} : { scripts: input.scripts }), + }) + .pipe( + Effect.mapError((error) => + error._tag === "ProjectNotFoundError" + ? failure("project_not_found", error.message) + : failure("operation_failed", `Unable to update project: ${errorMessage(error)}`), + ), + ); + return yield* projectView(project, settings); + }); + + const deleteProjectUnderLock: ProjectMcpService["Service"]["delete"] = (scope, input) => + Effect.gen(function* () { + yield* requireCapability(scope); + const projectOption = yield* projects + .getById(input.projectId, { includeDeleted: true }) + .pipe( + Effect.mapError((error) => + failure( + "operation_failed", + `Unable to read project ${input.projectId}: ${errorMessage(error)}`, + ), + ), + ); + if (Option.isNone(projectOption)) { + return yield* failure("project_not_found", `Project '${input.projectId}' was not found.`); + } + const project = projectOption.value; + if (project.deletedAt !== null) { + return { + projectId: input.projectId, + deleted: true, + alreadyDeleted: true, + deletedThreadCount: 0, + workspaceRoot: project.workspaceRoot, + workspaceFilesDeleted: false, + } satisfies ProjectMcpDeleteResult; + } + const [active, archived] = yield* Effect.all([ + threads.getShellSnapshot({ location: "active" }), + threads.getShellSnapshot({ location: "archive" }), + ]).pipe( + Effect.mapError((error) => + failure("operation_failed", `Unable to inspect project threads: ${errorMessage(error)}`), + ), + ); + const projectThreads = [ + ...new Map( + [ + ...active.threads, + ...active.archivedThreads, + ...archived.threads, + ...archived.archivedThreads, + ] + .filter((thread) => thread.projectId === input.projectId) + .map((thread) => [thread.id, thread] as const), + ).values(), + ]; + if (projectThreads.length > 0 && input.cascadeThreads !== true) { + return yield* failure( + "project_not_empty", + `Project '${input.projectId}' has ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}. Retry with cascadeThreads=true to delete their records first. Workspace files will remain untouched.`, + ); + } + const key = yield* requestKey(input.clientRequestId); + yield* Effect.forEach( + projectThreads, + (thread) => + threads.dispatch({ + type: "thread.delete", + commandId: stableCommandId({ + scope, + requestKey: key, + operation: "project-delete-thread", + suffix: thread.id, + }), + threadId: thread.id, + }), + { concurrency: 1, discard: true }, + ).pipe( + Effect.mapError((error) => + failure("operation_failed", `Unable to delete project threads: ${errorMessage(error)}`), + ), + ); + yield* projects + .delete({ + commandId: stableCommandId({ + scope, + requestKey: key, + operation: "project-delete", + suffix: input.projectId, + }), + projectId: input.projectId, + }) + .pipe( + Effect.mapError((error) => + failure("operation_failed", `Unable to delete project: ${errorMessage(error)}`), + ), + ); + return { + projectId: input.projectId, + deleted: true, + alreadyDeleted: false, + deletedThreadCount: projectThreads.length, + workspaceRoot: project.workspaceRoot, + workspaceFilesDeleted: false, + } satisfies ProjectMcpDeleteResult; + }); + + const deleteProject: ProjectMcpService["Service"]["delete"] = (scope, input) => + threads.withProjectMutationLock(input.projectId, deleteProjectUnderLock(scope, input)); + + return ProjectMcpService.of({ list, read, create, update, delete: deleteProject }); +}); + +export const layer: Layer.Layer< + ProjectMcpService, + never, + | Crypto.Crypto + | ProjectService.ProjectService + | T3ProjectFileLoader.T3ProjectFileLoader + | ServerSettings.ServerSettingsService + | SourceControlRepositoryService.SourceControlRepositoryService + | ThreadManagementService +> = Layer.effect(ProjectMcpService, make); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts new file mode 100644 index 000000000000..3be9f0fd42fe --- /dev/null +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -0,0 +1,40 @@ +import * as Effect from "effect/Effect"; + +import { McpInvocationContext } from "../../McpInvocationContext.ts"; +import { ProjectMcpService } from "../../ProjectMcpService.ts"; +import { ProjectToolkit } from "./tools.ts"; + +const handlers = { + t3_project_list: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* ProjectMcpService; + return yield* service.list(scope, input); + }), + t3_project_read: ({ projectId }) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* ProjectMcpService; + return yield* service.read(scope, projectId); + }), + t3_project_create: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* ProjectMcpService; + return yield* service.create(scope, input); + }), + t3_project_update: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* ProjectMcpService; + return yield* service.update(scope, input); + }), + t3_project_delete: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* ProjectMcpService; + return yield* service.delete(scope, input); + }), +} satisfies Parameters[0]; + +export const ProjectToolkitHandlersLive = ProjectToolkit.toLayer(handlers); diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts new file mode 100644 index 000000000000..dcb17ad76dfe --- /dev/null +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -0,0 +1,100 @@ +import { + ProjectMcpCreateInput, + ProjectMcpDeleteInput, + ProjectMcpDeleteResult, + ProjectMcpFailure, + ProjectMcpListInput, + ProjectMcpListResult, + ProjectMcpProject, + ProjectMcpReadInput, + ProjectMcpUpdateInput, +} from "@t3tools/contracts"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { ProjectMcpService } from "../../ProjectMcpService.ts"; + +const dependencies = [McpInvocationContext.McpInvocationContext, ProjectMcpService]; + +export const ProjectListTool = Tool.make("t3_project_list", { + description: + "List a bounded page of active project summaries in this T3 environment. Use t3_project_read for configured scripts and full project detail.", + parameters: ProjectMcpListInput, + success: ProjectMcpListResult, + failure: ProjectMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "List T3 projects") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const ProjectReadTool = Tool.make("t3_project_read", { + description: + "Read one active project in this T3 environment by projectId, including its root, defaults, configured scripts, and effective workspace mode.", + parameters: ProjectMcpReadInput, + success: ProjectMcpProject, + failure: ProjectMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Read a T3 project") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const ProjectCreateTool = Tool.make("t3_project_create", { + description: + "Create a T3 project from an existing directory, optionally creating that directory when missing, or clone a repository into a destination and register the clone. Cloning accepts either remoteUrl or provider plus repository. This tool never publishes a local repository to a host.", + parameters: ProjectMcpCreateInput, + success: ProjectMcpProject, + failure: ProjectMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Create a T3 project") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, true); + +export const ProjectUpdateTool = Tool.make("t3_project_update", { + description: + "Update a T3 project. Omitted fields remain unchanged. Explicit null clears the model, workspace-mode, or icon override. Replacing scripts updates configuration only and does not run them.", + parameters: ProjectMcpUpdateInput, + success: ProjectMcpProject, + failure: ProjectMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Update a T3 project") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const ProjectDeleteTool = Tool.make("t3_project_delete", { + description: + "Remove a T3 project record. A project with threads requires cascadeThreads=true, which deletes the thread records first. deletedThreadCount reports this attempt; alreadyDeleted distinguishes an idempotent replay. This tool never deletes the project directory, repository, worktrees, or any workspace files.", + parameters: ProjectMcpDeleteInput, + success: ProjectMcpDeleteResult, + failure: ProjectMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Delete a T3 project") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const ProjectToolkit = Toolkit.make( + ProjectListTool, + ProjectReadTool, + ProjectCreateTool, + ProjectUpdateTool, + ProjectDeleteTool, +); diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 3300a869fe67..f49f6e2def6c 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -15,6 +15,7 @@ import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRu import { ProviderRegistry } from "../../../provider/Services/ProviderRegistry.ts"; import { ScheduledTaskService } from "../../../scheduledTasks/ScheduledTaskService.ts"; import * as ServerSettings from "../../../serverSettings.ts"; +import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts"; import { VcsStatusBroadcaster } from "../../../vcs/VcsStatusBroadcaster.ts"; import * as McpHttpServer from "../../McpHttpServer.ts"; import * as McpSessionRegistry from "../../McpSessionRegistry.ts"; @@ -28,6 +29,7 @@ const StubServicesLive = Layer.mergeAll( ServerSettings.layerTest({}), Layer.mock(GitWorkflowService.GitWorkflowService)({}), Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}), + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({}), Layer.mock(VcsStatusBroadcaster)({}), ); @@ -114,6 +116,8 @@ it.effect("production mcp layer lists worktree tools over http", () => // than replacing them. expect(toolNames).toContain("preview_status"); expect(toolNames).toContain("delegate_task"); + expect(toolNames).toContain("t3_project_list"); + expect(toolNames).toContain("t3_project_delete"); // The handoff tool mutates thread state, reaches the network (origin // fetch), and runs project setup scripts, so its MCP hints must not diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 35a3eb55a7e8..6e70e87229a5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -46,6 +46,7 @@ import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeComp import { attachmentRelativePath } from "../../attachmentStore.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; +import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; import { ProviderAdapterV2RuntimePolicy, @@ -579,8 +580,9 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { }); }); - it("matches the read-only allowlist to the orchestrator toolkit annotations", () => { - const readOnlyToolNames = Object.values(OrchestratorToolkit.tools) + it("matches the read-only allowlist to the permitted toolkit annotations", () => { + const readOnlyToolNames = [OrchestratorToolkit, ProjectToolkit] + .flatMap((toolkit) => Object.values(toolkit.tools)) .filter((tool) => Context.get(tool.annotations, Tool.Readonly)) .map((tool) => `mcp__t3-code__${tool.name}`) .sort(); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index f1ac2fbe2afd..15eccedee8b7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -789,6 +789,8 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__list_scheduled_tasks", "mcp__t3-code__t3_thread_list", "mcp__t3-code__t3_thread_wait", + "mcp__t3-code__t3_project_list", + "mcp__t3-code__t3_project_read", ]; // The SDK's `allowedTools` only pre-approves tool calls; availability is the diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 7f8b86b80c21..de07f06c2e07 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -14,6 +14,7 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -67,6 +68,7 @@ const adapter = { } as ProviderAdapterV2Shape; interface HarnessOptions { + readonly getProjectById?: ProjectService.ProjectService["Service"]["getById"]; readonly createWorktree?: GitWorkflow.GitWorkflowService["Service"]["createWorktree"]; readonly renameBranch?: GitWorkflow.GitWorkflowService["Service"]["renameBranch"]; readonly runSetup?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"]; @@ -112,7 +114,9 @@ function makeHarness(options: HarnessOptions = {}) { bootstrap: () => Effect.die("unused"), update: () => Effect.die("unused"), delete: () => Effect.die("unused"), - getById: (id) => Effect.succeed(id === projectId ? Option.some(project) : Option.none()), + getById: + options.getProjectById ?? + ((id) => Effect.succeed(id === projectId ? Option.some(project) : Option.none())), getByWorkspaceRoot: () => Effect.succeed(Option.some(project)), snapshot: Effect.die("unused"), }), @@ -212,6 +216,52 @@ function waitUntil(predicate: () => Effect.Effect): Effect. }); } +it.effect("claims a thread under the shared project mutation lock", () => + Effect.gen(function* () { + const lockEntered = yield* Deferred.make(); + const releaseLock = yield* Deferred.make(); + const projectRead = yield* Deferred.make(); + const harness = makeHarness({ + getProjectById: (id) => + Deferred.succeed(projectRead, undefined).pipe( + Effect.andThen(Effect.succeed(id === projectId ? Option.some(project) : Option.none())), + ), + }); + + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const blocker = yield* Effect.forkChild( + threads.withProjectMutationLock( + projectId, + Deferred.succeed(lockEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseLock)), + ), + ), + { startImmediately: true }, + ); + yield* Deferred.await(lockEntered); + + const launchFiber = yield* Effect.forkChild( + launches.launch( + launchInput({ + command: "command:launch:project-mutation-lock", + thread: "thread:launch:project-mutation-lock", + }), + ), + { startImmediately: true }, + ); + assert.isFalse(yield* Deferred.isDone(projectRead)); + + yield* Deferred.succeed(releaseLock, undefined); + yield* Fiber.join(blocker); + const launched = yield* Fiber.join(launchFiber); + assert.isTrue(yield* Deferred.isDone(projectRead)); + assert.equal(launched.projection.thread.projectId, projectId); + }).pipe(Effect.provide(harness.layer)); + }), +); + it.effect("returns a visible preparing message while provisioning is still blocked", () => Effect.gen(function* () { const worktreeEntered = yield* Deferred.make(); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 97f9daba388c..7e0bbc0c3230 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -420,69 +420,78 @@ export const make = Effect.gen(function* () { const launch: ThreadLaunchService["Service"]["launch"] = Effect.fn("ThreadLaunchService.launch")( function* (input) { - const project = yield* projects.getById(input.projectId).pipe( - Effect.mapError(mapError(input, "resolve-project")), - Effect.flatMap( - Option.match({ - onNone: () => Effect.fail(mapError(input, "resolve-project")("Project not found.")), - onSome: Effect.succeed, + const { project, launchReceipt, candidateThreadId, claimed } = + yield* threads.withProjectMutationLock( + input.projectId, + Effect.gen(function* () { + const project = yield* projects.getById(input.projectId).pipe( + Effect.mapError(mapError(input, "resolve-project")), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(mapError(input, "resolve-project")("Project not found.")), + onSome: Effect.succeed, + }), + ), + ); + if (input.reuseExistingThread === true && input.threadId === undefined) { + return yield* mapError( + input, + "update-thread", + )("Reusing an existing thread requires a thread id."); + } + + const launchReceipt = yield* readReceipt(input, input.commandId); + const candidateThreadId = + input.threadId ?? + (yield* ids.allocate + .thread({ projectId: input.projectId }) + .pipe(Effect.mapError(mapError(input, "create-thread")))); + + if (input.reuseExistingThread === true && Option.isNone(launchReceipt)) { + yield* validateReusableThread(input, candidateThreadId); + } + + const initialBranch = input.workspaceStrategy.branch ?? null; + const initialWorktreePath = + input.workspaceStrategy.type === "existing_worktree" + ? input.workspaceStrategy.worktreePath + : null; + const claimDispatch = + input.reuseExistingThread === true + ? threads.dispatch({ + type: "thread.metadata.update", + commandId: input.commandId, + threadId: candidateThreadId, + }) + : threads.dispatch({ + type: "thread.create", + commandId: input.commandId, + threadId: candidateThreadId, + projectId: input.projectId, + title: input.title, + modelSelection: input.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + branch: initialBranch, + worktreePath: initialWorktreePath, + createdBy: input.createdBy, + creationSource: input.creationSource, + }); + const claimed = yield* claimDispatch.pipe( + Effect.mapError( + mapError( + input, + input.reuseExistingThread === true ? "update-thread" : "create-thread", + candidateThreadId, + ), + ), + ); + return { project, launchReceipt, candidateThreadId, claimed }; }), - ), - ); - if (input.reuseExistingThread === true && input.threadId === undefined) { - return yield* mapError( - input, - "update-thread", - )("Reusing an existing thread requires a thread id."); - } + ); - const launchReceipt = yield* readReceipt(input, input.commandId); return yield* Effect.gen(function* () { - const candidateThreadId = - input.threadId ?? - (yield* ids.allocate - .thread({ projectId: input.projectId }) - .pipe(Effect.mapError(mapError(input, "create-thread")))); - - if (input.reuseExistingThread === true && Option.isNone(launchReceipt)) { - yield* validateReusableThread(input, candidateThreadId); - } - - const initialBranch = input.workspaceStrategy.branch ?? null; - const initialWorktreePath = - input.workspaceStrategy.type === "existing_worktree" - ? input.workspaceStrategy.worktreePath - : null; - const claimDispatch = - input.reuseExistingThread === true - ? threads.dispatch({ - type: "thread.metadata.update", - commandId: input.commandId, - threadId: candidateThreadId, - }) - : threads.dispatch({ - type: "thread.create", - commandId: input.commandId, - threadId: candidateThreadId, - projectId: input.projectId, - title: input.title, - modelSelection: input.modelSelection, - runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, - branch: initialBranch, - worktreePath: initialWorktreePath, - createdBy: input.createdBy, - creationSource: input.creationSource, - }); - const claimed = yield* claimDispatch.pipe( - Effect.mapError( - mapError( - input, - input.reuseExistingThread === true ? "update-thread" : "create-thread", - candidateThreadId, - ), - ), - ); const threadId = claimed.storedEvents.find((stored) => stored.event.type.startsWith("thread."))?.event .threadId ?? candidateThreadId; diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 22741da769ad..7be3925e185e 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -35,6 +35,7 @@ import { LegacyV1ThreadImporter, type LegacyV1ThreadImportError, } from "./LegacyV1ThreadImporter.ts"; +import { makeKeyedSerialExecutor } from "./KeyedSerialExecutor.ts"; export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart"; @@ -265,6 +266,10 @@ export type ThreadManagementError = typeof ThreadManagementError.Type; type ThreadManagementFailure = ThreadManagementError | OrchestratorV2Error; export interface ThreadManagementServiceShape { + readonly withProjectMutationLock: ( + projectId: ProjectId, + effect: Effect.Effect, + ) => Effect.Effect; readonly ensureLegacyTranscript: ( threadId: ThreadId, ) => Effect.Effect; @@ -361,6 +366,7 @@ export function latestSteerableRun( const make = Effect.gen(function* () { const orchestrator = yield* OrchestratorV2; const legacyImporter = yield* LegacyV1ThreadImporter; + const projectMutations = yield* makeKeyedSerialExecutor(); const ensureLegacyTranscript = Effect.fn( "orchestrationV2.threadManagement.ensureLegacyTranscript", @@ -651,6 +657,7 @@ const make = Effect.gen(function* () { }); return ThreadManagementService.of({ + withProjectMutationLock: projectMutations.withLock, ensureLegacyTranscript, dispatch, getThreadProjection, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 8d42c2dda207..2759632e1575 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -516,7 +516,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti title: event.payload.title, workspaceRoot: event.payload.workspaceRoot, defaultModelSelection: event.payload.defaultModelSelection, - defaultThreadEnvMode: null, + defaultThreadEnvMode: event.payload.defaultThreadEnvMode ?? null, faviconPath: event.payload.faviconPath ?? null, scripts: event.payload.scripts, createdAt: event.payload.createdAt, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 197437054074..e85dd6bd371c 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -250,7 +250,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" title: command.title, workspaceRoot: command.workspaceRoot, defaultModelSelection: command.defaultModelSelection ?? null, - faviconPath: null, + defaultThreadEnvMode: command.defaultThreadEnvMode ?? null, + faviconPath: command.faviconPath ?? null, scripts: command.scripts ?? [], createdAt: command.createdAt, updatedAt: command.createdAt, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 23e425014c59..649453c9c82e 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -218,7 +218,7 @@ export function projectEvent( title: payload.title, workspaceRoot: payload.workspaceRoot, defaultModelSelection: payload.defaultModelSelection, - defaultThreadEnvMode: null, + defaultThreadEnvMode: payload.defaultThreadEnvMode ?? null, faviconPath: payload.faviconPath ?? null, scripts: payload.scripts, createdAt: payload.createdAt, diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index c4a877edc8ae..6e4683005573 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -90,6 +90,8 @@ it.layer(TestLayer)("ProjectService", (it) => { title: "Project", workspaceRoot: "/work/project/", defaultModelSelection: modelSelection, + defaultThreadEnvMode: "worktree", + faviconPath: "/work/project/custom-icon.svg", scripts: [ { id: "setup", @@ -102,22 +104,27 @@ it.layer(TestLayer)("ProjectService", (it) => { }); assert.equal(created.workspaceRoot, "/work/project"); assert.isNull(created.repositoryIdentity); - assert.isNull(created.faviconPath); + assert.equal(created.defaultThreadEnvMode, "worktree"); + assert.equal(created.faviconPath, "/work/project/custom-icon.svg"); const hydratedCreated = yield* waitForProject( service, projectId, - (project) => project.repositoryIdentity !== null && project.faviconPath !== null, + (project) => project.repositoryIdentity !== null, ); assert.equal(hydratedCreated?.repositoryIdentity?.canonicalKey, "github.com/t3tools/project"); - assert.equal(hydratedCreated?.faviconPath, "/work/project/favicon.svg"); + assert.equal(hydratedCreated?.faviconPath, "/work/project/custom-icon.svg"); const updated = yield* service.update({ commandId: CommandId.make("command:project:update"), projectId, title: "Renamed", + defaultThreadEnvMode: null, + faviconPath: null, }); assert.equal(updated.title, "Renamed"); + assert.isNull(updated.defaultThreadEnvMode); + assert.equal(updated.faviconPath, "/work/project/favicon.svg"); assert.equal(updated.createdAt, created.createdAt); const byId = yield* service.getById(projectId); diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 4f11d0111a3b..44a371563877 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -5,6 +5,7 @@ import { type Project, type ProjectScript, type ProjectSnapshot, + type ThreadEnvMode, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -25,6 +26,8 @@ export interface ProjectCreateInput { readonly workspaceRoot: string; readonly createWorkspaceRootIfMissing?: boolean; readonly defaultModelSelection?: ModelSelection | null; + readonly defaultThreadEnvMode?: ThreadEnvMode | null; + readonly faviconPath?: string | null; readonly scripts?: ReadonlyArray; } @@ -34,6 +37,8 @@ export interface ProjectUpdateInput { readonly title?: string; readonly workspaceRoot?: string; readonly defaultModelSelection?: ModelSelection | null; + readonly defaultThreadEnvMode?: ThreadEnvMode | null; + readonly faviconPath?: string | null; readonly scripts?: ReadonlyArray; } @@ -128,8 +133,9 @@ export const make = Effect.gen(function* () { title: row.title, workspaceRoot: row.workspaceRoot, repositoryIdentity: enrichment?.repositoryIdentity ?? null, - faviconPath: enrichment?.faviconPath ?? null, + faviconPath: row.faviconPath ?? enrichment?.faviconPath ?? null, defaultModelSelection: row.defaultModelSelection, + defaultThreadEnvMode: row.defaultThreadEnvMode, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -278,6 +284,8 @@ export const make = Effect.gen(function* () { title: input.title, workspaceRoot, defaultModelSelection: input.defaultModelSelection ?? null, + defaultThreadEnvMode: input.defaultThreadEnvMode ?? null, + faviconPath: input.faviconPath ?? null, scripts: [...(input.scripts ?? [])], createdAt: now, }, @@ -327,6 +335,10 @@ export const make = Effect.gen(function* () { ...(input.defaultModelSelection === undefined ? {} : { defaultModelSelection: input.defaultModelSelection }), + ...(input.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: input.defaultThreadEnvMode }), + ...(input.faviconPath === undefined ? {} : { faviconPath: input.faviconPath }), ...(input.scripts === undefined ? {} : { scripts: [...input.scripts] }), }, (workspaceRoot === existing.value.workspaceRoot diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 861da9a10e05..2761311ea2a9 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -9,8 +9,11 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import type * as SourceControlProvider from "./SourceControlProvider.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts"; @@ -53,6 +56,10 @@ function processOutput(): GitVcsDriver.ExecuteGitResult { }; } +function processOutputWithStdout(stdout: string): GitVcsDriver.ExecuteGitResult { + return { ...processOutput(), stdout }; +} + function makeLayer(input: { readonly provider?: SourceControlProvider.SourceControlProvider["Service"]; readonly git?: Partial; @@ -94,6 +101,23 @@ function makeLayer(input: { : serviceLayer.pipe(Layer.provideMerge(NodeServices.layer)); } +function makeRealGitLayer() { + const gitLayer = GitVcsDriver.layer.pipe( + Layer.provide(VcsProcess.layer), + Layer.provide(NodeServices.layer), + ); + return SourceControlRepositoryService.layer.pipe( + Layer.provide( + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + get: () => Effect.succeed(makeProvider()), + }), + ), + Layer.provideMerge(gitLayer), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-real-git-" })), + Layer.provideMerge(NodeServices.layer), + ); +} + it.effect("looks up repositories through the requested provider without search", () => { const calls: Array<{ cwd: string; repository: string }> = []; const provider = makeProvider({ @@ -156,8 +180,17 @@ it.effect("clones a looked-up repository into the requested destination", () => const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-clone-parent-", }); - const destinationPath = `${parent}/t3code`; + const destinationPath = `${parent}/missing/nested/t3code`; const cloneCalls: Array<{ cwd: string; args: ReadonlyArray }> = []; + const lookupCwds: Array = []; + const provider = makeProvider({ + getRepositoryCloneUrls: (input) => + Effect.gen(function* () { + assert.strictEqual(yield* fs.exists(input.cwd).pipe(Effect.orDie), true); + lookupCwds.push(input.cwd); + return CLONE_URLS; + }), + }); yield* Effect.gen(function* () { const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; @@ -175,7 +208,7 @@ it.effect("clones a looked-up repository into the requested destination", () => }); assert.deepStrictEqual(cloneCalls, [ { - cwd: parent, + cwd: `${parent}/missing/nested`, args: ["clone", CLONE_URLS.url, "t3code"], }, ]); @@ -189,6 +222,253 @@ it.effect("clones a looked-up repository into the requested destination", () => return processOutput(); }), }, + provider, + }), + ), + ); + assert.deepStrictEqual(lookupCwds, [`${parent}/missing/nested`]); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("recovers a matching completed clone and rejects unrelated contents", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const parent = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-source-control-recover-clone-", + }); + const matchingDestination = `${parent}/matching`; + const unrelatedDestination = `${parent}/unrelated`; + const wrongPortDestination = `${parent}/wrong-port`; + const wrongCaseDestination = `${parent}/wrong-case`; + const wrongUserDestination = `${parent}/wrong-user`; + const absoluteSshDestination = `${parent}/absolute-ssh`; + yield* fs.makeDirectory(matchingDestination); + yield* fs.makeDirectory(unrelatedDestination); + yield* fs.makeDirectory(wrongPortDestination); + yield* fs.makeDirectory(wrongCaseDestination); + yield* fs.makeDirectory(wrongUserDestination); + yield* fs.makeDirectory(absoluteSshDestination); + yield* fs.writeFileString(`${matchingDestination}/README.md`, "matching clone"); + yield* fs.writeFileString(`${unrelatedDestination}/README.md`, "unrelated repository"); + yield* fs.writeFileString(`${wrongPortDestination}/README.md`, "different port"); + yield* fs.writeFileString(`${wrongCaseDestination}/README.md`, "different case"); + yield* fs.writeFileString(`${wrongUserDestination}/README.md`, "different user"); + yield* fs.writeFileString(`${absoluteSshDestination}/README.md`, "absolute SSH path"); + const cloneCalls: Array> = []; + + yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const recovered = yield* service.cloneRepository({ + remoteUrl: CLONE_URLS.url, + destinationPath: matchingDestination, + }); + assert.strictEqual(recovered.cwd, matchingDestination); + assert.deepStrictEqual(cloneCalls, []); + + const mismatch = yield* Effect.flip( + service.cloneRepository({ + remoteUrl: CLONE_URLS.url, + destinationPath: unrelatedDestination, + }), + ); + assert.strictEqual( + mismatch.detail, + "Destination path already exists and is not a clone of the requested repository.", + ); + + for (const [destinationPath, remoteUrl] of [ + [wrongPortDestination, "https://git.example:8444/org/repo.git"], + [wrongCaseDestination, "https://git.example/org/repo.git"], + [wrongUserDestination, "bob@git.example:repo.git"], + [absoluteSshDestination, "git@git.example:repo.git"], + ] as const) { + const strictMismatch = yield* Effect.flip( + service.cloneRepository({ remoteUrl, destinationPath }), + ); + assert.strictEqual( + strictMismatch.detail, + "Destination path already exists and is not a clone of the requested repository.", + ); + } + }).pipe( + Effect.provide( + makeLayer({ + git: { + execute: (input) => { + if (input.args[0] === "rev-parse") { + return Effect.succeed( + processOutputWithStdout( + input.cwd === matchingDestination ? matchingDestination : unrelatedDestination, + ), + ); + } + if (input.args[0] === "for-each-ref") { + return Effect.succeed(processOutputWithStdout(input.cwd)); + } + if (input.args[0] === "status") return Effect.succeed(processOutput()); + cloneCalls.push(input.args); + return Effect.succeed(processOutput()); + }, + readConfigValue: (cwd) => + Effect.succeed( + cwd === matchingDestination + ? CLONE_URLS.url + : cwd === wrongPortDestination + ? "https://git.example:8443/org/repo.git" + : cwd === wrongCaseDestination + ? "https://git.example/org/Repo.git" + : cwd === wrongUserDestination + ? "alice@git.example:repo.git" + : cwd === absoluteSshDestination + ? "ssh://git@git.example/repo.git" + : "https://github.com/acme/other.git", + ), + }, + }), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("recovers a real completed clone after a lost result and rejects unsafe adoption", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const git = yield* GitVcsDriver.GitVcsDriver; + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const parent = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-source-control-real-recovery-", + }); + const sourcePath = `${parent}/source`; + const otherSourcePath = `${parent}/other-source`; + const destinationPath = `${parent}/destination`; + const incompletePath = `${parent}/incomplete`; + const unrelatedCleanPath = `${parent}/unrelated-clean`; + + const initializeRepository = (cwd: string, contents: string) => + Effect.gen(function* () { + yield* fs.makeDirectory(cwd); + yield* git.execute({ operation: "test.init", cwd, args: ["init"] }); + yield* git.execute({ + operation: "test.config.email", + cwd, + args: ["config", "user.email", "tests@t3.codes"], + }); + yield* git.execute({ + operation: "test.config.name", + cwd, + args: ["config", "user.name", "T3 Tests"], + }); + yield* fs.writeFileString(`${cwd}/README.md`, contents); + yield* git.execute({ operation: "test.add", cwd, args: ["add", "README.md"] }); + yield* git.execute({ operation: "test.commit", cwd, args: ["commit", "-m", "initial"] }); + }); + + yield* initializeRepository(sourcePath, "source"); + yield* initializeRepository(otherSourcePath, "other source"); + + const cloned = yield* service.cloneRepository({ remoteUrl: sourcePath, destinationPath }); + assert.strictEqual(cloned.cwd, destinationPath); + + // Simulate the caller losing the successful clone result before project registration. + const recovered = yield* service.cloneRepository({ remoteUrl: sourcePath, destinationPath }); + assert.strictEqual(recovered.cwd, destinationPath); + + const unrelated = yield* Effect.flip( + service.cloneRepository({ remoteUrl: otherSourcePath, destinationPath }), + ); + assert.strictEqual( + unrelated.detail, + "Destination path already exists and is not a clone of the requested repository.", + ); + + yield* initializeRepository(unrelatedCleanPath, "unrelated history"); + yield* git.execute({ + operation: "test.unrelated.remote", + cwd: unrelatedCleanPath, + args: ["remote", "add", "origin", sourcePath], + }); + const unrelatedHistory = yield* Effect.flip( + service.cloneRepository({ remoteUrl: sourcePath, destinationPath: unrelatedCleanPath }), + ); + assert.strictEqual( + unrelatedHistory.detail, + "Destination path already exists and is not a clone of the requested repository.", + ); + + yield* git.execute({ + operation: "test.incomplete.clone", + cwd: parent, + args: ["clone", "--no-checkout", sourcePath, "incomplete"], + }); + const incomplete = yield* Effect.flip( + service.cloneRepository({ remoteUrl: sourcePath, destinationPath: incompletePath }), + ); + assert.strictEqual( + incomplete.detail, + "Destination path already exists and is not a clone of the requested repository.", + ); + }).pipe(Effect.provide(makeRealGitLayer())), +); + +it.effect("serializes overlapping clones to one destination and reuses the verified result", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const parent = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-source-control-overlap-clone-", + }); + const realParent = `${parent}/real`; + const aliasParent = `${parent}/alias`; + yield* fs.makeDirectory(realParent); + yield* fs.symlink(realParent, aliasParent); + const destinationPath = `${realParent}/t3code`; + const aliasDestinationPath = `${aliasParent}/t3code`; + const cloneEntered = yield* Deferred.make(); + const allowClone = yield* Deferred.make(); + let cloneCount = 0; + + yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const first = yield* Effect.forkChild( + service.cloneRepository({ remoteUrl: CLONE_URLS.url, destinationPath }), + { startImmediately: true }, + ); + yield* Deferred.await(cloneEntered); + const second = yield* Effect.forkChild( + service.cloneRepository({ + remoteUrl: CLONE_URLS.url, + destinationPath: aliasDestinationPath, + }), + { startImmediately: true }, + ); + yield* Deferred.succeed(allowClone, undefined); + const results = yield* Effect.all([Fiber.join(first), Fiber.join(second)]); + assert.deepStrictEqual( + results.map((result) => result.cwd), + [destinationPath, aliasDestinationPath], + ); + assert.strictEqual(cloneCount, 1); + }).pipe( + Effect.provide( + makeLayer({ + git: { + execute: (input) => { + if (input.args[0] === "clone") { + cloneCount += 1; + return fs + .makeDirectory(destinationPath) + .pipe( + Effect.andThen(fs.writeFileString(`${destinationPath}/README.md`, "cloned")), + Effect.andThen(Deferred.succeed(cloneEntered, undefined)), + Effect.andThen(Deferred.await(allowClone)), + Effect.as(processOutput()), + Effect.orDie, + ); + } + if (input.args[0] === "status") return Effect.succeed(processOutput()); + return Effect.succeed(processOutputWithStdout(destinationPath)); + }, + readConfigValue: () => Effect.succeed(CLONE_URLS.url), + }, }), ), ); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 1b46369e25c4..c115deebeef9 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -18,8 +18,8 @@ import { type SourceControlRepositoryInfo, type SourceControlRepositoryLookupInput, } from "@t3tools/contracts"; - import { ServerConfig } from "../config.ts"; +import { makeKeyedSerialExecutor } from "../orchestration-v2/KeyedSerialExecutor.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); @@ -93,6 +93,7 @@ export const make = Effect.gen(function* () { const git = yield* GitVcsDriver.GitVcsDriver; const path = yield* Path.Path; const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + const cloneOperations = yield* makeKeyedSerialExecutor(); const ensureConcreteProvider = (input: { readonly operation: string; @@ -141,9 +142,26 @@ export const make = Effect.gen(function* () { }, ); + const canonicalDestinationKey = Effect.fn( + "SourceControlRepositoryService.canonicalDestinationKey", + )(function* (destinationPath: string) { + if (yield* fileSystem.exists(destinationPath)) { + return yield* fileSystem.realPath(destinationPath); + } + + const suffix = [path.basename(destinationPath)]; + let ancestor = path.dirname(destinationPath); + while (!(yield* fileSystem.exists(ancestor))) { + const parent = path.dirname(ancestor); + if (parent === ancestor) break; + suffix.unshift(path.basename(ancestor)); + ancestor = parent; + } + return path.join(yield* fileSystem.realPath(ancestor), ...suffix); + }); + const prepareDestination = Effect.fn("SourceControlRepositoryService.prepareDestination")( - function* (destinationPath: string) { - const normalizedDestination = yield* normalizeDestinationPath(destinationPath); + function* (normalizedDestination: string) { if (yield* fileSystem.exists(normalizedDestination)) { const entries = yield* fileSystem .readDirectory(normalizedDestination, { recursive: false }) @@ -158,13 +176,12 @@ export const make = Effect.gen(function* () { }), ), ); - if (entries.length > 0) { - return yield* new SourceControlRepositoryError({ - operation: "cloneRepository", - provider: "unknown", - detail: "Destination path already exists and is not empty.", - }); - } + return { + destinationPath: normalizedDestination, + parentPath: path.dirname(normalizedDestination), + directoryName: path.basename(normalizedDestination), + occupied: entries.length > 0, + } as const; } else { yield* fileSystem.makeDirectory(path.dirname(normalizedDestination), { recursive: true }); } @@ -173,49 +190,144 @@ export const make = Effect.gen(function* () { destinationPath: normalizedDestination, parentPath: path.dirname(normalizedDestination), directoryName: path.basename(normalizedDestination), - }; + occupied: false, + } as const; }, ); + const isMatchingClone = Effect.fn("SourceControlRepositoryService.isMatchingClone")(function* ( + destinationPath: string, + remoteUrl: string, + ) { + const repositoryRoot = yield* git.execute({ + operation: "SourceControlRepositoryService.cloneRepository.verifyRoot", + cwd: destinationPath, + args: ["rev-parse", "--show-toplevel"], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024, + }); + const reportedRoot = repositoryRoot.stdout.trim(); + if (reportedRoot.length === 0) return false; + const [canonicalDestination, canonicalRoot] = yield* Effect.all([ + fileSystem.realPath(destinationPath), + fileSystem.realPath(reportedRoot), + ]); + if (canonicalDestination !== canonicalRoot) return false; + const existingRemote = yield* git.readConfigValue(destinationPath, "remote.origin.url"); + if (existingRemote === null || existingRemote.trim() !== remoteUrl.trim()) { + return false; + } + + // A repository shell with only an origin, or with fetched refs but an + // unfinished checkout, can be left behind by an interrupted clone. + const headCommit = yield* git + .execute({ + operation: "SourceControlRepositoryService.cloneRepository.verifyHead", + cwd: destinationPath, + args: ["rev-parse", "--verify", "HEAD^{commit}"], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024, + }) + .pipe( + Effect.map((result) => result.stdout.trim() || null), + Effect.orElseSucceed(() => null), + ); + if (headCommit === null) return false; + + const remoteCommits = yield* git + .execute({ + operation: "SourceControlRepositoryService.cloneRepository.verifyRemoteRefs", + cwd: destinationPath, + args: ["for-each-ref", "--format=%(objectname)", "refs/remotes/origin"], + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + }) + .pipe( + Effect.map((result) => new Set(result.stdout.split("\n").map((line) => line.trim()))), + Effect.orElseSucceed(() => new Set()), + ); + if (!remoteCommits.has(headCommit)) return false; + + return yield* git + .execute({ + operation: "SourceControlRepositoryService.cloneRepository.verifyCheckout", + cwd: destinationPath, + args: ["status", "--porcelain=v1", "--untracked-files=no"], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024, + }) + .pipe( + Effect.map((result) => result.stdout.trim().length === 0), + Effect.orElseSucceed(() => false), + ); + }); + const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* ( input: SourceControlCloneRepositoryInput, ) { - const preparedDestination = yield* prepareDestination(input.destinationPath); - let repository: SourceControlRepositoryInfo | null = null; - let remoteUrl = input.remoteUrl?.trim() ?? null; - let provider: SourceControlProviderKind = input.provider ?? "unknown"; + const destinationPath = yield* normalizeDestinationPath(input.destinationPath); + const destinationKey = yield* canonicalDestinationKey(destinationPath); + return yield* cloneOperations.withLock( + destinationKey, + Effect.gen(function* () { + let repository: SourceControlRepositoryInfo | null = null; + let remoteUrl = input.remoteUrl?.trim() ?? null; + let provider: SourceControlProviderKind = input.provider ?? "unknown"; - if (input.provider && input.repository) { - repository = yield* lookupRepository({ - provider: input.provider, - repository: input.repository, - cwd: preparedDestination.parentPath, - }); - remoteUrl = selectRemoteUrl(repository, input.protocol); - provider = input.provider; - } + if (!remoteUrl && !(input.provider && input.repository)) { + return yield* new SourceControlRepositoryError({ + operation: "cloneRepository", + provider, + detail: "Enter a repository path or clone URL before cloning.", + }); + } - if (!remoteUrl) { - return yield* new SourceControlRepositoryError({ - operation: "cloneRepository", - provider, - detail: "Enter a repository path or clone URL before cloning.", - }); - } + const preparedDestination = yield* prepareDestination(destinationPath); - yield* git.execute({ - operation: "SourceControlRepositoryService.cloneRepository", - cwd: preparedDestination.parentPath, - args: ["clone", remoteUrl, preparedDestination.directoryName], - timeoutMs: 120_000, - maxOutputBytes: 256 * 1024, - }); + if (input.provider && input.repository) { + repository = yield* lookupRepository({ + provider: input.provider, + repository: input.repository, + cwd: preparedDestination.parentPath, + }); + remoteUrl = selectRemoteUrl(repository, input.protocol); + provider = input.provider; + } + + if (!remoteUrl) { + return yield* new SourceControlRepositoryError({ + operation: "cloneRepository", + provider, + detail: "Enter a repository path or clone URL before cloning.", + }); + } - return { - cwd: preparedDestination.destinationPath, - remoteUrl, - repository, - }; + if (preparedDestination.occupied) { + const matches = yield* isMatchingClone(destinationPath, remoteUrl).pipe( + Effect.orElseSucceed(() => false), + ); + if (!matches) { + return yield* new SourceControlRepositoryError({ + operation: "cloneRepository", + provider, + detail: + "Destination path already exists and is not a clone of the requested repository.", + }); + } + return { cwd: destinationPath, remoteUrl, repository }; + } + + yield* git.execute({ + operation: "SourceControlRepositoryService.cloneRepository", + cwd: preparedDestination.parentPath, + args: ["clone", remoteUrl, preparedDestination.directoryName], + timeoutMs: 120_000, + maxOutputBytes: 256 * 1024, + }); + + return { cwd: destinationPath, remoteUrl, repository }; + }), + ); }); const publishRepository = Effect.fn("SourceControlRepositoryService.publishRepository")( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index cdf098c91f4e..83c5644bfeda 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1186,6 +1186,10 @@ const makeWsRpcLayer = ( ...(mutation.defaultModelSelection === undefined ? {} : { defaultModelSelection: mutation.defaultModelSelection }), + ...(mutation.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: mutation.defaultThreadEnvMode }), + ...(mutation.faviconPath === undefined ? {} : { faviconPath: mutation.faviconPath }), ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), }); case "project.update": @@ -1199,6 +1203,10 @@ const makeWsRpcLayer = ( ...(mutation.defaultModelSelection === undefined ? {} : { defaultModelSelection: mutation.defaultModelSelection }), + ...(mutation.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: mutation.defaultThreadEnvMode }), + ...(mutation.faviconPath === undefined ? {} : { faviconPath: mutation.faviconPath }), ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), }); case "project.delete": { diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 3aa9a6cb401a..7bc0db8b2d65 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -7,11 +7,12 @@ agent can use this endpoint to: - create an app-owned sub-agent on any supported provider instance; - wait for or poll the sub-agent's durable result; -- cancel an active delegated task; and +- cancel an active delegated task; - create one or more ordinary top-level T3 threads; - list and incrementally read project threads; -- send or steer follow-up messages; and -- wait for or interrupt ordinary thread runs. +- send or steer follow-up messages; +- wait for or interrupt ordinary thread runs; and +- list, inspect, create, update, and remove projects in the current environment. These are T3 orchestration operations, not provider-native sub-agent APIs. Delegated tasks always create a T3 child thread and run. The child receives @@ -140,7 +141,33 @@ selection model-visible without allowing a request that cannot run. ## Tool Surface -The server exposes eleven orchestration tools. +The server exposes orchestration, thread, scheduling, and project tools. + +### Project tools + +`t3_project_list` returns a bounded page of active project summaries from the server environment. +Its numeric cursor defaults to the first page and the default page size is 25, with a maximum of 100. Summaries include project identity, root, title, model default, and saved, checked-in, global, +and effective workspace modes. `t3_project_read` returns the full record, including configured +scripts. + +`t3_project_create` accepts one of two typed sources. `existing_directory` registers a directory and +only creates a missing directory when `createIfMissing` is true. `clone` uses the existing repository +clone service with either a URL or a configured source-control provider and repository name, then +registers the cloned directory. It does not publish repositories. If registration fails after a +clone completes, a retry may reuse the destination only when its exact stored origin matches, its +checked-out commit matches a locally recorded `origin` remote-tracking ref, and its tracked checkout +is clean. Recovery does not fetch the remote. Empty, modified, interrupted, and unrelated-history +repositories are not adopted; register an intentional one with `existing_directory` instead. + +`t3_project_update` changes only supplied fields. Explicit `null` clears model, workspace-mode, and +icon overrides, while omitted fields remain unchanged. Script updates change configuration but do +not run scripts. + +`t3_project_delete` rejects a project that still owns thread records unless `cascadeThreads` is true. +The cascade uses normal thread-delete commands before the project-delete command. Project removal +never deletes workspace files, repositories, or worktrees, and the result reports the number of +thread records removed by that attempt. A retry after the project is already deleted returns +`alreadyDeleted: true` with a zero per-attempt count. ### `orchestrator_capabilities` @@ -376,7 +403,9 @@ orchestration_error ## Code Ownership - Shared schemas: `packages/contracts/src/orchestratorMcp.ts` +- Project MCP schemas: `packages/contracts/src/projectMcp.ts` - MCP service: `apps/server/src/mcp/OrchestratorMcpService.ts` +- Project MCP service: `apps/server/src/mcp/ProjectMcpService.ts` - Tool definitions and handlers: `apps/server/src/mcp/toolkits/orchestrator/` - HTTP registration and authentication: diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 56675408fab8..43f4dd6ebf8e 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -14,3 +14,13 @@ T3 Code supports SVG, PNG, ICO, JPEG, GIF, AVIF, and WebP files. The selected pa each checkout in the project group and appears on your connected clients. To use automatic detection again, select **Automatic**. + +# Let an agent manage projects + +Agents running through T3 Code can list and inspect projects in their current environment. They can +also register an existing directory, create a missing directory, clone and register a repository, +or update the same project settings available in the app. + +Removing a project does not remove its directory, repository, worktrees, or other workspace files. +If the project still has threads, the agent must explicitly request that T3 Code delete those thread +records first. This prevents a project removal from silently discarding conversations. diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 90064ccec224..3675ac17dd6d 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -49,6 +49,7 @@ import { settleThread, startThreadTurn, unsettleThread, + updateProject, updateThreadMetadata, } from "./commands.ts"; @@ -158,6 +159,29 @@ describe("V2 environment commands", () => { }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); + it.effect("preserves omitted and explicit-null project settings", () => + Effect.gen(function* () { + const projects: ProjectMutation[] = []; + const supervisor = yield* makeSupervisor({ commands: [], projects }); + + yield* updateProject({ + projectId: ProjectId.make("project-1"), + defaultThreadEnvMode: null, + faviconPath: null, + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + + expect(projects).toEqual([ + { + type: "project.update", + commandId: "00000000-0000-4000-8000-000000000000", + projectId: "project-1", + defaultThreadEnvMode: null, + faviconPath: null, + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + it.effect("preserves caller command ids for idempotent V2 commands", () => Effect.gen(function* () { const commands: OrchestrationV2Command[] = []; diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 3e1262c5dde1..d93d0d074b80 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -18,6 +18,7 @@ import { type RunId, type RuntimeMode, type RuntimeRequestId, + type ThreadEnvMode, type ThreadId, type UploadChatAttachment, } from "@t3tools/contracts"; @@ -38,6 +39,8 @@ export interface CreateProjectInput extends CommandMetadata { readonly workspaceRoot: string; readonly createWorkspaceRootIfMissing?: boolean; readonly defaultModelSelection?: ModelSelection | null; + readonly defaultThreadEnvMode?: ThreadEnvMode | null; + readonly faviconPath?: string | null; readonly scripts?: ReadonlyArray; } @@ -46,6 +49,8 @@ export interface UpdateProjectInput extends CommandMetadata { readonly title?: string; readonly workspaceRoot?: string; readonly defaultModelSelection?: ModelSelection | null; + readonly defaultThreadEnvMode?: ThreadEnvMode | null; + readonly faviconPath?: string | null; readonly scripts?: ReadonlyArray; } @@ -277,6 +282,8 @@ const mutateProject = Effect.fn("EnvironmentCommands.mutateProject")(function* ( readonly workspaceRoot: string; readonly createWorkspaceRootIfMissing?: boolean; readonly defaultModelSelection?: ModelSelection | null; + readonly defaultThreadEnvMode?: ThreadEnvMode | null; + readonly faviconPath?: string | null; readonly scripts?: ReadonlyArray; } | { @@ -286,6 +293,8 @@ const mutateProject = Effect.fn("EnvironmentCommands.mutateProject")(function* ( readonly title?: string; readonly workspaceRoot?: string; readonly defaultModelSelection?: ModelSelection | null; + readonly defaultThreadEnvMode?: ThreadEnvMode | null; + readonly faviconPath?: string | null; readonly scripts?: ReadonlyArray; } | { @@ -313,6 +322,10 @@ export const createProject = Effect.fn("EnvironmentCommands.createProject")(func ...(input.defaultModelSelection === undefined ? {} : { defaultModelSelection: input.defaultModelSelection }), + ...(input.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: input.defaultThreadEnvMode }), + ...(input.faviconPath === undefined ? {} : { faviconPath: input.faviconPath }), ...(input.scripts === undefined ? {} : { scripts: input.scripts }), }); }); @@ -329,6 +342,10 @@ export const updateProject = Effect.fn("EnvironmentCommands.updateProject")(func ...(input.defaultModelSelection === undefined ? {} : { defaultModelSelection: input.defaultModelSelection }), + ...(input.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: input.defaultThreadEnvMode }), + ...(input.faviconPath === undefined ? {} : { faviconPath: input.faviconPath }), ...(input.scripts === undefined ? {} : { scripts: input.scripts }), }); }); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index cfe24f5ffd22..75f65c61cc59 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -32,6 +32,7 @@ export * from "./orchestration.ts"; export * from "./t3ProjectFile.ts"; export * from "./editor.ts"; export * from "./project.ts"; +export * from "./projectMcp.ts"; export * from "./filesystem.ts"; export * from "./assets.ts"; export * from "./review.ts"; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index f51cc77fcf75..f34e7774a0e3 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -441,6 +441,8 @@ export const ProjectCreateCommand = Schema.Struct({ workspaceRoot: TrimmedNonEmptyString, createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), scripts: Schema.optional(Schema.Array(ProjectScript)), createdAt: IsoDateTime, }); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 5f95ecd154a4..d925c59fa196 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { RepositoryIdentity } from "./environment.ts"; +import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; import { ModelSelection } from "./modelSelection.ts"; import { CommandId, @@ -44,6 +44,7 @@ export const Project = Schema.Struct({ repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), defaultModelSelection: Schema.NullOr(ModelSelection), + defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -76,6 +77,8 @@ export const ProjectMutation = Schema.Union([ workspaceRoot: TrimmedNonEmptyString, createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), + faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), scripts: Schema.optional(Schema.Array(ProjectScript)), }), Schema.Struct({ @@ -85,6 +88,8 @@ export const ProjectMutation = Schema.Union([ title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), + faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), scripts: Schema.optional(Schema.Array(ProjectScript)), }), Schema.Struct({ diff --git a/packages/contracts/src/projectMcp.test.ts b/packages/contracts/src/projectMcp.test.ts new file mode 100644 index 000000000000..ea28581a5989 --- /dev/null +++ b/packages/contracts/src/projectMcp.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import { ProjectMcpCreateInput, ProjectMcpListInput } from "./projectMcp.ts"; + +const decodeCreateInput = Schema.decodeUnknownSync(ProjectMcpCreateInput); +const decodeListInput = Schema.decodeUnknownSync(ProjectMcpListInput); + +describe("project MCP contracts", () => { + it("bounds project list pages", () => { + expect(decodeListInput({})).toEqual({}); + expect(decodeListInput({ cursor: 25, limit: 100 })).toEqual({ cursor: 25, limit: 100 }); + expect(() => decodeListInput({ cursor: -1 })).toThrow(); + expect(() => decodeListInput({ limit: 101 })).toThrow(); + }); + + it("accepts exactly one typed clone source", () => { + expect( + decodeCreateInput({ + title: "URL clone", + source: { + type: "clone", + destinationPath: "/work/url-clone", + remoteUrl: "https://example.com/acme/repo.git", + }, + }).source, + ).toMatchObject({ remoteUrl: "https://example.com/acme/repo.git" }); + expect( + decodeCreateInput({ + title: "Provider clone", + source: { + type: "clone", + destinationPath: "/work/provider-clone", + provider: "github", + repository: "acme/repo", + }, + }).source, + ).toMatchObject({ provider: "github", repository: "acme/repo" }); + + expect(() => + decodeCreateInput({ + title: "Ambiguous clone", + source: { + type: "clone", + destinationPath: "/work/ambiguous", + remoteUrl: "https://example.com/acme/repo.git", + provider: "github", + repository: "acme/repo", + }, + }), + ).toThrow(); + expect(() => + decodeCreateInput({ + title: "Incomplete clone", + source: { + type: "clone", + destinationPath: "/work/incomplete", + provider: "github", + }, + }), + ).toThrow(); + }); +}); diff --git a/packages/contracts/src/projectMcp.ts b/packages/contracts/src/projectMcp.ts new file mode 100644 index 000000000000..08a36fa77d51 --- /dev/null +++ b/packages/contracts/src/projectMcp.ts @@ -0,0 +1,146 @@ +import * as Schema from "effect/Schema"; + +import { NonNegativeInt, PositiveInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ThreadEnvMode } from "./environment.ts"; +import { ModelSelection } from "./modelSelection.ts"; +import { Project, ProjectScript } from "./project.ts"; +import { SourceControlCloneProtocol, SourceControlProviderKind } from "./sourceControl.ts"; + +const ProjectMcpClientRequestId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)).annotate({ + description: "Stable idempotency key to reuse when retrying this operation.", +}); + +export const ProjectMcpProject = Schema.Struct({ + ...Project.fields, + projectFileDefaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), + globalDefaultThreadEnvMode: ThreadEnvMode, + effectiveDefaultThreadEnvMode: ThreadEnvMode, +}); +export type ProjectMcpProject = typeof ProjectMcpProject.Type; + +export const ProjectMcpProjectSummary = Schema.Struct({ + id: Project.fields.id, + title: Project.fields.title, + workspaceRoot: Project.fields.workspaceRoot, + repositoryIdentity: Project.fields.repositoryIdentity, + faviconPath: Project.fields.faviconPath, + defaultModelSelection: Project.fields.defaultModelSelection, + defaultThreadEnvMode: Project.fields.defaultThreadEnvMode, + projectFileDefaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), + globalDefaultThreadEnvMode: ThreadEnvMode, + effectiveDefaultThreadEnvMode: ThreadEnvMode, + createdAt: Project.fields.createdAt, + updatedAt: Project.fields.updatedAt, +}); +export type ProjectMcpProjectSummary = typeof ProjectMcpProjectSummary.Type; + +export const ProjectMcpListInput = Schema.Struct({ + cursor: Schema.optional(NonNegativeInt), + limit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(100))), +}); +export type ProjectMcpListInput = typeof ProjectMcpListInput.Type; + +export const ProjectMcpListResult = Schema.Struct({ + projects: Schema.Array(ProjectMcpProjectSummary), + nextCursor: Schema.NullOr(NonNegativeInt), + totalCount: NonNegativeInt, +}); +export type ProjectMcpListResult = typeof ProjectMcpListResult.Type; + +export const ProjectMcpReadInput = Schema.Struct({ projectId: ProjectId }); +export type ProjectMcpReadInput = typeof ProjectMcpReadInput.Type; + +export const ProjectMcpExistingDirectorySource = Schema.Struct({ + type: Schema.Literal("existing_directory"), + workspaceRoot: TrimmedNonEmptyString, + createIfMissing: Schema.optional( + Schema.Boolean.annotate({ + description: "Create workspaceRoot as a directory when it does not exist. Defaults to false.", + }), + ), +}); + +export const ProjectMcpCloneSource = Schema.Struct({ + type: Schema.Literal("clone"), + destinationPath: TrimmedNonEmptyString, + provider: Schema.optional(SourceControlProviderKind), + repository: Schema.optional(TrimmedNonEmptyString), + remoteUrl: Schema.optional(TrimmedNonEmptyString), + protocol: Schema.optional(SourceControlCloneProtocol), +}).check( + Schema.makeFilter((input) => { + const hasRemoteUrl = input.remoteUrl !== undefined; + const hasProviderRepository = input.provider !== undefined && input.repository !== undefined; + const hasPartialProviderRepository = + (input.provider === undefined) !== (input.repository === undefined); + return ( + (!hasPartialProviderRepository && hasRemoteUrl !== hasProviderRepository) || + "Provide exactly one clone source: remoteUrl, or both provider and repository." + ); + }), +); + +export const ProjectMcpCreateSource = Schema.Union([ + ProjectMcpExistingDirectorySource, + ProjectMcpCloneSource, +]); +export type ProjectMcpCreateSource = typeof ProjectMcpCreateSource.Type; + +export const ProjectMcpCreateInput = Schema.Struct({ + title: TrimmedNonEmptyString, + source: ProjectMcpCreateSource, + defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), + faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + scripts: Schema.optional(Schema.Array(ProjectScript)), + clientRequestId: Schema.optional(ProjectMcpClientRequestId), +}); +export type ProjectMcpCreateInput = typeof ProjectMcpCreateInput.Type; + +export const ProjectMcpUpdateInput = Schema.Struct({ + projectId: ProjectId, + title: Schema.optional(TrimmedNonEmptyString), + workspaceRoot: Schema.optional(TrimmedNonEmptyString), + defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), + faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + scripts: Schema.optional(Schema.Array(ProjectScript)), + clientRequestId: Schema.optional(ProjectMcpClientRequestId), +}); +export type ProjectMcpUpdateInput = typeof ProjectMcpUpdateInput.Type; + +export const ProjectMcpDeleteInput = Schema.Struct({ + projectId: ProjectId, + cascadeThreads: Schema.optional( + Schema.Boolean.annotate({ + description: + "Delete the project's thread records before removing the project. Required when the project is not empty.", + }), + ), + clientRequestId: Schema.optional(ProjectMcpClientRequestId), +}); +export type ProjectMcpDeleteInput = typeof ProjectMcpDeleteInput.Type; + +export const ProjectMcpDeleteResult = Schema.Struct({ + projectId: ProjectId, + deleted: Schema.Boolean, + alreadyDeleted: Schema.Boolean, + deletedThreadCount: NonNegativeInt, + workspaceRoot: TrimmedNonEmptyString, + workspaceFilesDeleted: Schema.Literal(false), +}); +export type ProjectMcpDeleteResult = typeof ProjectMcpDeleteResult.Type; + +export class ProjectMcpFailure extends Schema.TaggedErrorClass()( + "ProjectMcpFailure", + { + code: Schema.Literals([ + "capability_denied", + "project_not_found", + "project_not_empty", + "invalid_request", + "operation_failed", + ]), + message: Schema.String, + }, +) {} diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 77498835757c..bea6b19aad1b 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -51,6 +51,11 @@ const T3_MCP_TOOLS: Record< t3_thread_send: { displayName: "Send to a T3 thread", summaryAction: "thread-send" }, t3_thread_wait: { displayName: "Wait for a T3 thread", summaryAction: "thread-wait" }, t3_thread_interrupt: { displayName: "Interrupt a T3 thread", summaryAction: "thread-interrupt" }, + t3_project_list: { displayName: "List T3 projects" }, + t3_project_read: { displayName: "Read a T3 project" }, + t3_project_create: { displayName: "Create a T3 project" }, + t3_project_update: { displayName: "Update a T3 project" }, + t3_project_delete: { displayName: "Delete a T3 project" }, t3_worktree_handoff: { displayName: "Hand off thread to a git worktree" }, t3_worktree_status: { displayName: "Get thread worktree status" }, preview_status: { displayName: "Get preview browser status" }, From 2edcf59ea6a93b5dbc693b231516b757024cca0b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 15:57:36 -0700 Subject: [PATCH 02/15] fix(mcp): serialize project deletion with thread creation --- apps/server/src/mcp/ProjectMcpService.test.ts | 209 +++++++++++++++++- .../src/orchestration-v2/Orchestrator.ts | 14 ++ .../ThreadManagementService.ts | 20 +- apps/server/src/ws.ts | 51 +++-- 4 files changed, 267 insertions(+), 27 deletions(-) diff --git a/apps/server/src/mcp/ProjectMcpService.test.ts b/apps/server/src/mcp/ProjectMcpService.test.ts index e1d2d3941166..3b8a3f67ab53 100644 --- a/apps/server/src/mcp/ProjectMcpService.test.ts +++ b/apps/server/src/mcp/ProjectMcpService.test.ts @@ -1,11 +1,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { - type CommandId, + CommandId, EnvironmentId, + MessageId, ProjectMcpFailure, type Project, ProjectId, + ProviderDriverKind, ProviderInstanceId, type ServerSettings, ServerSettingsError, @@ -18,8 +20,16 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import { makeKeyedSerialExecutor } from "../orchestration-v2/KeyedSerialExecutor.ts"; -import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; +import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; +import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import { + layer as threadManagementLayer, + ThreadManagementService, +} from "../orchestration-v2/ThreadManagementService.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; import * as ServerSettingsService from "../serverSettings.ts"; @@ -446,6 +456,201 @@ it.effect("serializes project deletion against new thread claims", () => }), ); +it.effect("serializes delegated thread admission with cascading project deletion", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project:mcp:delete-delegate-race"); + const parentThreadId = ThreadId.make("thread:mcp:delete-delegate-race:parent"); + const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.1-codex", + } as const; + const project = makeProject({ + id: projectId, + title: "Delete delegation race", + workspaceRoot: "/work/delete-delegate-race", + }); + const projectState = yield* Ref.make(project); + const blockAdapterLookup = yield* Ref.make(false); + const admissionEntered = yield* Deferred.make(); + const allowAdmission = yield* Deferred.make(); + const deleteLockAttempted = yield* Deferred.make(); + const deleteLockAcquired = yield* Deferred.make(); + const snapshotEntered = yield* Deferred.make(); + const allowSnapshot = yield* Deferred.make(); + const adapter = { + instanceId: modelSelection.instanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("provider execution is disabled in project deletion tests"), + } as ProviderAdapterV2Shape; + const registry = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistryV2, + ProviderAdapterRegistry.ProviderAdapterRegistryV2.of({ + get: () => + Ref.get(blockAdapterLookup).pipe( + Effect.flatMap((blocked) => + blocked + ? Deferred.succeed(admissionEntered, undefined).pipe( + Effect.andThen(Deferred.await(allowAdmission)), + Effect.as(adapter), + ) + : Effect.succeed(adapter), + ), + ), + list: () => Effect.succeed([modelSelection.instanceId]), + }), + ); + const orchestrator = makeOrchestratorV2ReplayLayerWithRegistry( + { name: "project-delete-delegate-race" }, + registry, + { databaseLayer: SqlitePersistenceMemory, runEffectWorker: false }, + ); + const actualThreads = threadManagementLayer.pipe(Layer.provide(orchestrator)); + const gatedThreads = Layer.effect( + ThreadManagementService, + Effect.gen(function* () { + const actual = yield* ThreadManagementService; + return ThreadManagementService.of({ + ...actual, + withProjectMutationLock: (lockedProjectId, effect) => + Deferred.succeed(deleteLockAttempted, undefined).pipe( + Effect.andThen( + actual.withProjectMutationLock( + lockedProjectId, + Deferred.succeed(deleteLockAcquired, undefined).pipe(Effect.andThen(effect)), + ), + ), + ), + getShellSnapshot: (options) => + actual.getShellSnapshot(options).pipe( + Effect.tap(() => Deferred.succeed(snapshotEntered, undefined)), + Effect.tap(() => Deferred.await(allowSnapshot)), + ), + }); + }), + ).pipe(Layer.provide(actualThreads)); + const testLayer = ProjectMcp.layer.pipe( + Layer.provideMerge(gatedThreads), + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getById: () => Ref.get(projectState).pipe(Effect.map(Option.some)), + delete: () => + Ref.updateAndGet(projectState, (current) => ({ ...current, deletedAt: now })), + }), + ), + Layer.provide(Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({})), + Layer.provide( + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Effect.succeed({ defaultThreadEnvMode: "local" } as ServerSettings), + }), + ), + Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const projects = yield* ProjectMcpService; + const threads = yield* ThreadManagementService; + yield* threads.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:mcp:delete-delegate-race:create"), + threadId: parentThreadId, + projectId, + title: "Delegation parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* threads.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:mcp:delete-delegate-race:start"), + threadId: parentThreadId, + messageId: MessageId.make("message:mcp:delete-delegate-race:start"), + text: "Keep the parent run active.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + const parent = yield* threads.getThreadProjection(parentThreadId); + const parentRun = parent.runs[0]; + if (parentRun === undefined || parentRun.rootNodeId === null) { + return yield* Effect.die(new Error("Delegation parent run was not created.")); + } + + const delegationCommand = { + type: "delegated_task.request", + createdBy: "agent", + creationSource: "mcp", + commandId: CommandId.make("command:mcp:delete-delegate-race:delegate"), + parentThreadId, + parentRunId: parentRun.id, + parentNodeId: parentRun.rootNodeId, + task: "Race project deletion.", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + } as const; + yield* Ref.set(blockAdapterLookup, true); + const delegation = yield* Effect.forkChild( + threads.dispatch(delegationCommand).pipe(Effect.exit), + { startImmediately: true }, + ); + yield* Deferred.await(admissionEntered); + const deletion = yield* Effect.forkChild( + projects.delete(scope, { + projectId, + cascadeThreads: true, + clientRequestId: "delete-delegate-race", + }), + { startImmediately: true }, + ); + yield* Deferred.await(deleteLockAttempted); + yield* Effect.yieldNow; + expect(yield* Deferred.isDone(deleteLockAcquired)).toBe(false); + + yield* Deferred.succeed(allowAdmission, undefined); + const delegationExit = yield* Fiber.join(delegation); + expect(delegationExit._tag).toBe("Success"); + yield* Deferred.await(deleteLockAcquired); + yield* Deferred.await(snapshotEntered); + yield* Deferred.succeed(allowSnapshot, undefined); + const deleted = yield* Fiber.join(deletion); + expect(deleted).toMatchObject({ deleted: true, deletedThreadCount: 2 }); + const replayedDelegation = yield* threads.dispatch(delegationCommand); + expect( + replayedDelegation.storedEvents.some((event) => event.event.type === "thread.created"), + ).toBe(true); + const rejectedDelegation = yield* threads + .dispatch({ + ...delegationCommand, + commandId: CommandId.make("command:mcp:delete-delegate-race:after-delete"), + }) + .pipe(Effect.exit); + expect(rejectedDelegation._tag).toBe("Failure"); + const [active, archived] = yield* Effect.all([ + threads.getShellSnapshot({ location: "active" }), + threads.getShellSnapshot({ location: "archive" }), + ]); + expect( + [ + ...active.threads, + ...active.archivedThreads, + ...archived.threads, + ...archived.archivedThreads, + ].filter((thread) => thread.projectId === projectId), + ).toEqual([]); + expect((yield* Ref.get(projectState)).deletedAt).toBe(now); + }).pipe(Effect.provide(testLayer)); + }), +); + it.effect("paginates summaries before loading project files and reads settings once", () => Effect.gen(function* () { const projectFileLoads = yield* Ref.make>([]); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 66378584fd21..81308a9be849 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2061,6 +2061,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); + if (sourceProjection.thread.deletedAt !== null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Fork source thread ${command.sourceThreadId} is deleted.`, + }); + } const sourceRun = runForSourcePoint(sourceProjection, command.sourcePoint); @@ -4512,6 +4519,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); + if (parentProjection.thread.deletedAt !== null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Delegated task parent thread ${command.parentThreadId} is deleted.`, + }); + } const parentRun = parentProjection.runs.find( (candidate) => candidate.id === command.parentRunId, ); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 7be3925e185e..63062b08d958 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -428,8 +428,24 @@ const make = Effect.gen(function* () { Effect.andThen(orchestrator.getThreadSnapshotWindow(threadId, options)), ); - const dispatch: ThreadManagementServiceShape["dispatch"] = (command) => - ensureCommandTranscripts(command).pipe(Effect.andThen(orchestrator.dispatch(command))); + const dispatch: ThreadManagementServiceShape["dispatch"] = (command) => { + const admissionThreadId = + command.type === "thread.fork" + ? command.sourceThreadId + : command.type === "delegated_task.request" + ? command.parentThreadId + : undefined; + const dispatchCommand = orchestrator.dispatch(command); + if (admissionThreadId === undefined) { + return ensureCommandTranscripts(command).pipe(Effect.andThen(dispatchCommand)); + } + return ensureCommandTranscripts(command).pipe( + Effect.andThen(orchestrator.getThreadProjection(admissionThreadId)), + Effect.flatMap((projection) => + projectMutations.withLock(projection.thread.projectId, dispatchCommand), + ), + ); + }; const getProjectThread: ThreadManagementServiceShape["getProjectThread"] = (input) => getThreadProjection(input.threadId).pipe( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 83c5644bfeda..c9b0300f7494 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1210,30 +1210,35 @@ const makeWsRpcLayer = ( ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), }); case "project.delete": { - const snapshot = yield* threadManagement.getShellSnapshot(); - const projectThreads = [...snapshot.threads, ...snapshot.archivedThreads].filter( - (thread) => thread.projectId === mutation.projectId, - ); - if (projectThreads.length > 0 && mutation.force !== true) { - return yield* new ProjectMutationError({ - commandId: mutation.commandId, - message: `Project ${mutation.projectId} is not empty.`, - }); - } - yield* Effect.forEach( - projectThreads, - (thread) => - threadManagement.dispatch({ - type: "thread.delete", - commandId: CommandId.make(`${mutation.commandId}:delete-thread:${thread.id}`), - threadId: thread.id, - }), - { concurrency: 1, discard: true }, + return yield* threadManagement.withProjectMutationLock( + mutation.projectId, + Effect.gen(function* () { + const snapshot = yield* threadManagement.getShellSnapshot(); + const projectThreads = [...snapshot.threads, ...snapshot.archivedThreads].filter( + (thread) => thread.projectId === mutation.projectId, + ); + if (projectThreads.length > 0 && mutation.force !== true) { + return yield* new ProjectMutationError({ + commandId: mutation.commandId, + message: `Project ${mutation.projectId} is not empty.`, + }); + } + yield* Effect.forEach( + projectThreads, + (thread) => + threadManagement.dispatch({ + type: "thread.delete", + commandId: CommandId.make(`${mutation.commandId}:delete-thread:${thread.id}`), + threadId: thread.id, + }), + { concurrency: 1, discard: true }, + ); + return yield* projectService.delete({ + commandId: mutation.commandId, + projectId: mutation.projectId, + }); + }), ); - return yield* projectService.delete({ - commandId: mutation.commandId, - projectId: mutation.projectId, - }); } } }); From a68b3b23ca481dd20388a640384f3270a336a751 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:00:15 -0700 Subject: [PATCH 03/15] fix(mcp): reject retries for deleted project ids --- apps/server/src/mcp/ProjectMcpService.test.ts | 25 ++++++++++++++++++- apps/server/src/mcp/ProjectMcpService.ts | 8 +++++- .../server/src/project/ProjectService.test.ts | 12 +++++++++ .../orchestrator-mcp-server.md | 2 ++ packages/contracts/src/projectMcp.test.ts | 13 +++++++++- packages/contracts/src/projectMcp.ts | 1 + 6 files changed, 58 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/ProjectMcpService.test.ts b/apps/server/src/mcp/ProjectMcpService.test.ts index 3b8a3f67ab53..5d4a72eaf09b 100644 --- a/apps/server/src/mcp/ProjectMcpService.test.ts +++ b/apps/server/src/mcp/ProjectMcpService.test.ts @@ -257,7 +257,14 @@ it.effect("clones before registration and requires explicit cascading for nonemp yield* Ref.set(state, created); return created; }), - getById: () => Ref.get(state).pipe(Effect.map(Option.fromNullishOr)), + getById: (_projectId, options) => + Ref.get(state).pipe( + Effect.map((current) => + current === null || (current.deletedAt !== null && options?.includeDeleted !== true) + ? Option.none() + : Option.some(current), + ), + ), delete: ({ projectId }) => Effect.gen(function* () { yield* Ref.update(deletedProjects, (current) => [...current, projectId]); @@ -382,6 +389,22 @@ it.effect("clones before registration and requires explicit cascading for nonemp }); expect(yield* Ref.get(deletedProjects)).toEqual([projectId]); expect(yield* Ref.get(lockedProjects)).toEqual([projectId, projectId, projectId]); + + const createRetry = yield* service + .create(scope, { + title: project.title, + source: { + type: "clone", + destinationPath: "/work/cloned", + remoteUrl: "https://example.com/acme/repo.git", + }, + clientRequestId: "clone-and-delete", + }) + .pipe(Effect.flip); + expect(createRetry).toMatchObject({ code: "project_deleted" }); + expect(createRetry.message).toContain("Use a new clientRequestId"); + expect(yield* Ref.get(cloneInputs)).toEqual([{ destinationPath: "/work/cloned" }]); + expect((yield* Ref.get(state))?.deletedAt).toBe(now); }).pipe(Effect.provide(testLayer)); }), ); diff --git a/apps/server/src/mcp/ProjectMcpService.ts b/apps/server/src/mcp/ProjectMcpService.ts index f1a1305696e3..daa7f06087ea 100644 --- a/apps/server/src/mcp/ProjectMcpService.ts +++ b/apps/server/src/mcp/ProjectMcpService.ts @@ -228,7 +228,7 @@ const make = Effect.gen(function* () { projectId, Effect.gen(function* () { const existing = yield* projects - .getById(projectId) + .getById(projectId, { includeDeleted: true }) .pipe( Effect.mapError((error) => failure( @@ -238,6 +238,12 @@ const make = Effect.gen(function* () { ), ); if (Option.isSome(existing)) { + if (existing.value.deletedAt !== null) { + return yield* failure( + "project_deleted", + `Project '${projectId}' created by this clientRequestId was deleted. Use a new clientRequestId to create a new project; the deleted record will not be resurrected.`, + ); + } return yield* projectView(existing.value, yield* loadSettings); } const settings = yield* loadSettings; diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 6e4683005573..0f687150ade3 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -146,6 +146,18 @@ it.layer(TestLayer)("ProjectService", (it) => { assert.isTrue(Option.isSome(yield* service.getById(projectId, { includeDeleted: true }))); assert.deepEqual((yield* service.snapshot).projects, []); + const replayedCreate = yield* service.create({ + commandId: CommandId.make("command:project:create"), + projectId, + title: "Project", + workspaceRoot: "/work/project/", + defaultModelSelection: modelSelection, + defaultThreadEnvMode: "worktree", + faviconPath: "/work/project/custom-icon.svg", + }); + assert.equal(replayedCreate.deletedAt, deleted.deletedAt); + assert.equal(replayedCreate.title, "Renamed"); + const sql = yield* SqlClient.SqlClient; const changes = yield* sql<{ readonly event_type: string }>` SELECT event_type diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 7bc0db8b2d65..8e452649f17f 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -158,6 +158,8 @@ clone completes, a retry may reuse the destination only when its exact stored or checked-out commit matches a locally recorded `origin` remote-tracking ref, and its tracked checkout is clean. Recovery does not fetch the remote. Empty, modified, interrupted, and unrelated-history repositories are not adopted; register an intentional one with `existing_directory` instead. +If a stable `clientRequestId` already names a deleted project, create fails with `project_deleted` +before touching the filesystem. Use a new request ID; deleted project records are never resurrected. `t3_project_update` changes only supplied fields. Explicit `null` clears model, workspace-mode, and icon overrides, while omitted fields remain unchanged. Script updates change configuration but do diff --git a/packages/contracts/src/projectMcp.test.ts b/packages/contracts/src/projectMcp.test.ts index ea28581a5989..3d26bc62077e 100644 --- a/packages/contracts/src/projectMcp.test.ts +++ b/packages/contracts/src/projectMcp.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from "@effect/vitest"; import * as Schema from "effect/Schema"; -import { ProjectMcpCreateInput, ProjectMcpListInput } from "./projectMcp.ts"; +import { ProjectMcpCreateInput, ProjectMcpFailure, ProjectMcpListInput } from "./projectMcp.ts"; const decodeCreateInput = Schema.decodeUnknownSync(ProjectMcpCreateInput); const decodeListInput = Schema.decodeUnknownSync(ProjectMcpListInput); +const decodeFailure = Schema.decodeUnknownSync(ProjectMcpFailure); describe("project MCP contracts", () => { it("bounds project list pages", () => { @@ -60,4 +61,14 @@ describe("project MCP contracts", () => { }), ).toThrow(); }); + + it("identifies retries bound to deleted project ids", () => { + expect( + decodeFailure({ + _tag: "ProjectMcpFailure", + code: "project_deleted", + message: "Use a new clientRequestId.", + }), + ).toMatchObject({ code: "project_deleted" }); + }); }); diff --git a/packages/contracts/src/projectMcp.ts b/packages/contracts/src/projectMcp.ts index 08a36fa77d51..596a88dd0db7 100644 --- a/packages/contracts/src/projectMcp.ts +++ b/packages/contracts/src/projectMcp.ts @@ -137,6 +137,7 @@ export class ProjectMcpFailure extends Schema.TaggedErrorClass Date: Sat, 29 Aug 2026 16:05:54 -0700 Subject: [PATCH 04/15] style(server): use project service namespace in tests --- apps/server/src/mcp/ProjectMcpService.test.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/server/src/mcp/ProjectMcpService.test.ts b/apps/server/src/mcp/ProjectMcpService.test.ts index 5d4a72eaf09b..d5db4508c527 100644 --- a/apps/server/src/mcp/ProjectMcpService.test.ts +++ b/apps/server/src/mcp/ProjectMcpService.test.ts @@ -35,7 +35,6 @@ import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; import * as ServerSettingsService from "../serverSettings.ts"; import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; -import { ProjectMcpService } from "./ProjectMcpService.ts"; import * as ProjectMcp from "./ProjectMcpService.ts"; const scope: McpInvocationScope = { @@ -142,7 +141,7 @@ it.effect("creates, reads, and updates project defaults through the project MCP ); yield* Effect.gen(function* () { - const service = yield* ProjectMcpService; + const service = yield* ProjectMcp.ProjectMcpService; const invalidKey = yield* service .create(scope, { title: "Invalid key", @@ -217,7 +216,7 @@ it.effect("loads response settings before committing project mutations", () => ); yield* Effect.gen(function* () { - const service = yield* ProjectMcpService; + const service = yield* ProjectMcp.ProjectMcpService; const error = yield* service .create(scope, { title: "Uncommitted project", @@ -342,7 +341,7 @@ it.effect("clones before registration and requires explicit cascading for nonemp ); yield* Effect.gen(function* () { - const service = yield* ProjectMcpService; + const service = yield* ProjectMcp.ProjectMcpService; const created = yield* service.create(scope, { title: project.title, source: { @@ -458,7 +457,7 @@ it.effect("serializes project deletion against new thread claims", () => ); yield* Effect.gen(function* () { - const service = yield* ProjectMcpService; + const service = yield* ProjectMcp.ProjectMcpService; const deletion = yield* Effect.forkChild( service.delete(scope, { projectId, clientRequestId: "delete-launch-race" }), { startImmediately: true }, @@ -573,7 +572,7 @@ it.effect("serializes delegated thread admission with cascading project deletion ); yield* Effect.gen(function* () { - const projects = yield* ProjectMcpService; + const projects = yield* ProjectMcp.ProjectMcpService; const threads = yield* ThreadManagementService; yield* threads.dispatch({ type: "thread.create", @@ -712,7 +711,7 @@ it.effect("paginates summaries before loading project files and reads settings o ); yield* Effect.gen(function* () { - const service = yield* ProjectMcpService; + const service = yield* ProjectMcp.ProjectMcpService; const page = yield* service.list(scope, { cursor: 10, limit: 5 }); expect(page).toMatchObject({ nextCursor: 15, totalCount: 40 }); expect(page.projects.map((project) => project.id)).toEqual( @@ -789,7 +788,7 @@ it.effect("serializes overlapping create retries with the same idempotency key", }; yield* Effect.gen(function* () { - const service = yield* ProjectMcpService; + const service = yield* ProjectMcp.ProjectMcpService; const first = yield* Effect.forkChild(service.create(scope, input), { startImmediately: true, }); @@ -876,7 +875,7 @@ it.effect("retries registration after a completed clone without changing the pro }; yield* Effect.gen(function* () { - const service = yield* ProjectMcpService; + const service = yield* ProjectMcp.ProjectMcpService; const firstFailure = yield* service.create(scope, input).pipe(Effect.flip); expect(firstFailure).toBeInstanceOf(ProjectMcpFailure); const retried = yield* service.create(scope, input); From f0365615d8be1e156bfa38d49a5f9c1883161c26 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:11:22 -0700 Subject: [PATCH 05/15] style(server): namespace thread management test imports --- apps/server/src/mcp/ProjectMcpService.test.ts | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/apps/server/src/mcp/ProjectMcpService.test.ts b/apps/server/src/mcp/ProjectMcpService.test.ts index d5db4508c527..a776c821c787 100644 --- a/apps/server/src/mcp/ProjectMcpService.test.ts +++ b/apps/server/src/mcp/ProjectMcpService.test.ts @@ -25,10 +25,7 @@ import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexA import { makeKeyedSerialExecutor } from "../orchestration-v2/KeyedSerialExecutor.ts"; import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; -import { - layer as threadManagementLayer, - ThreadManagementService, -} from "../orchestration-v2/ThreadManagementService.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; import { makeOrchestratorV2ReplayLayerWithRegistry } from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; @@ -136,7 +133,7 @@ it.effect("creates, reads, and updates project defaults through the project MCP }), ), Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), - Layer.provide(Layer.mock(ThreadManagementService)({})), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), Layer.provide(NodeServices.layer), ); @@ -211,7 +208,7 @@ it.effect("loads response settings before committing project mutations", () => }), ), Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), - Layer.provide(Layer.mock(ThreadManagementService)({})), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), Layer.provide(NodeServices.layer), ); @@ -299,7 +296,7 @@ it.effect("clones before registration and requires explicit cascading for nonemp }), ), Layer.provide( - Layer.mock(ThreadManagementService)({ + Layer.mock(ThreadManagement.ThreadManagementService)({ withProjectMutationLock: (lockedProjectId, effect) => Ref.update(lockedProjects, (current) => [...current, lockedProjectId]).pipe( Effect.andThen(effect), @@ -421,7 +418,7 @@ it.effect("serializes project deletion against new thread claims", () => const allowSnapshot = yield* Deferred.make(); const competingClaim = yield* Deferred.make(); const projectMutations = yield* makeKeyedSerialExecutor(); - const threadManagementLayer = Layer.mock(ThreadManagementService)({ + const blockedThreadManagementLayer = Layer.mock(ThreadManagement.ThreadManagementService)({ withProjectMutationLock: projectMutations.withLock, getShellSnapshot: () => Deferred.succeed(snapshotEntered, undefined).pipe( @@ -452,7 +449,7 @@ it.effect("serializes project deletion against new thread claims", () => }), ), Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), - Layer.provide(threadManagementLayer), + Layer.provide(blockedThreadManagementLayer), Layer.provide(NodeServices.layer), ); @@ -528,12 +525,12 @@ it.effect("serializes delegated thread admission with cascading project deletion registry, { databaseLayer: SqlitePersistenceMemory, runEffectWorker: false }, ); - const actualThreads = threadManagementLayer.pipe(Layer.provide(orchestrator)); + const actualThreads = ThreadManagement.layer.pipe(Layer.provide(orchestrator)); const gatedThreads = Layer.effect( - ThreadManagementService, + ThreadManagement.ThreadManagementService, Effect.gen(function* () { - const actual = yield* ThreadManagementService; - return ThreadManagementService.of({ + const actual = yield* ThreadManagement.ThreadManagementService; + return ThreadManagement.ThreadManagementService.of({ ...actual, withProjectMutationLock: (lockedProjectId, effect) => Deferred.succeed(deleteLockAttempted, undefined).pipe( @@ -573,7 +570,7 @@ it.effect("serializes delegated thread admission with cascading project deletion yield* Effect.gen(function* () { const projects = yield* ProjectMcp.ProjectMcpService; - const threads = yield* ThreadManagementService; + const threads = yield* ThreadManagement.ThreadManagementService; yield* threads.dispatch({ type: "thread.create", createdBy: "user", @@ -706,7 +703,7 @@ it.effect("paginates summaries before loading project files and reads settings o }), ), Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), - Layer.provide(Layer.mock(ThreadManagementService)({})), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), Layer.provide(NodeServices.layer), ); @@ -774,7 +771,7 @@ it.effect("serializes overlapping create retries with the same idempotency key", ), }), ), - Layer.provide(Layer.mock(ThreadManagementService)({})), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), Layer.provide(NodeServices.layer), ); const input = { @@ -861,7 +858,7 @@ it.effect("retries registration after a completed clone without changing the pro ), }), ), - Layer.provide(Layer.mock(ThreadManagementService)({})), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), Layer.provide(NodeServices.layer), ); const input = { From 92e361d9426c327eca158d0d655fc0adf29383df Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:11:25 -0700 Subject: [PATCH 06/15] fix(server): preserve project defaults in HTTP mutations --- apps/server/src/project/http.test.ts | 102 ++++++++++++++++++++++++++- apps/server/src/project/http.ts | 73 +++++++++++-------- 2 files changed, 143 insertions(+), 32 deletions(-) diff --git a/apps/server/src/project/http.test.ts b/apps/server/src/project/http.test.ts index a58e6a299bed..ff89502183f8 100644 --- a/apps/server/src/project/http.test.ts +++ b/apps/server/src/project/http.test.ts @@ -1,17 +1,115 @@ import { assert, it } from "@effect/vitest"; -import { ProjectId } from "@t3tools/contracts"; +import { CommandId, type Project, ProjectId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; import { + type ProjectCreateInput, ProjectConflictError, ProjectNotFoundError, ProjectOperationError, + ProjectService, + type ProjectUpdateInput, } from "./ProjectService.ts"; import { ServerRuntimeStartupError } from "../serverRuntimeStartup.ts"; -import { failProjectMutation } from "./http.ts"; +import { failProjectMutation, projectMutationOperation } from "./http.ts"; const projectId = ProjectId.make("project:http-mutation"); +const project: Project = { + id: projectId, + title: "HTTP project", + workspaceRoot: "/workspace/project", + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + scripts: [], + createdAt: "2026-08-29T12:00:00.000Z", + updatedAt: "2026-08-29T12:00:00.000Z", + deletedAt: null, +}; + +it.effect( + "forwards project defaults through HTTP mutations without collapsing omission and null", + () => + Effect.gen(function* () { + const createInputs = yield* Ref.make>([]); + const updateInputs = yield* Ref.make>([]); + const projectLayer = Layer.mock(ProjectService)({ + create: (input) => + Ref.update(createInputs, (inputs) => [...inputs, input]).pipe(Effect.as(project)), + update: (input) => + Ref.update(updateInputs, (inputs) => [...inputs, input]).pipe(Effect.as(project)), + }); + + yield* Effect.gen(function* () { + const projects = yield* ProjectService; + yield* projectMutationOperation(projects, { + type: "project.create", + commandId: CommandId.make("command:http-project:create-supplied"), + projectId, + title: project.title, + workspaceRoot: project.workspaceRoot, + defaultThreadEnvMode: "worktree", + faviconPath: "/workspace/project/icon.png", + }); + yield* projectMutationOperation(projects, { + type: "project.create", + commandId: CommandId.make("command:http-project:create-omitted"), + projectId, + title: project.title, + workspaceRoot: project.workspaceRoot, + }); + yield* projectMutationOperation(projects, { + type: "project.create", + commandId: CommandId.make("command:http-project:create-null"), + projectId, + title: project.title, + workspaceRoot: project.workspaceRoot, + defaultThreadEnvMode: null, + faviconPath: null, + }); + yield* projectMutationOperation(projects, { + type: "project.update", + commandId: CommandId.make("command:http-project:update-supplied"), + projectId, + defaultThreadEnvMode: "worktree", + faviconPath: "/workspace/project/icon.png", + }); + yield* projectMutationOperation(projects, { + type: "project.update", + commandId: CommandId.make("command:http-project:update-omitted"), + projectId, + }); + yield* projectMutationOperation(projects, { + type: "project.update", + commandId: CommandId.make("command:http-project:update-null"), + projectId, + defaultThreadEnvMode: null, + faviconPath: null, + }); + }).pipe(Effect.provide(projectLayer)); + + const [createSupplied, createOmitted, createNull] = yield* Ref.get(createInputs); + const [updateSupplied, updateOmitted, updateNull] = yield* Ref.get(updateInputs); + + assert.equal(createSupplied?.defaultThreadEnvMode, "worktree"); + assert.equal(createSupplied?.faviconPath, "/workspace/project/icon.png"); + assert.equal(Object.hasOwn(createOmitted!, "defaultThreadEnvMode"), false); + assert.equal(Object.hasOwn(createOmitted!, "faviconPath"), false); + assert.strictEqual(createNull?.defaultThreadEnvMode, null); + assert.strictEqual(createNull?.faviconPath, null); + assert.equal(updateSupplied?.defaultThreadEnvMode, "worktree"); + assert.equal(updateSupplied?.faviconPath, "/workspace/project/icon.png"); + assert.equal(Object.hasOwn(updateOmitted!, "defaultThreadEnvMode"), false); + assert.equal(Object.hasOwn(updateOmitted!, "faviconPath"), false); + assert.strictEqual(updateNull?.defaultThreadEnvMode, null); + assert.strictEqual(updateNull?.faviconPath, null); + }), +); + it.effect.each([ new ProjectNotFoundError({ projectId }), new ProjectConflictError({ diff --git a/apps/server/src/project/http.ts b/apps/server/src/project/http.ts index cb31c38bfcac..951cdf963793 100644 --- a/apps/server/src/project/http.ts +++ b/apps/server/src/project/http.ts @@ -2,6 +2,7 @@ import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, EnvironmentHttpApi, + type ProjectMutation, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; @@ -24,6 +25,47 @@ export const failProjectMutation = Effect.fn("environment.projects.failMutation" return yield* failEnvironmentInternal("project_mutation_failed", cause); }); +export const projectMutationOperation = ( + projects: ProjectService["Service"], + mutation: ProjectMutation, +) => + mutation.type === "project.create" + ? projects.create({ + commandId: mutation.commandId, + projectId: mutation.projectId, + title: mutation.title, + workspaceRoot: mutation.workspaceRoot, + ...(mutation.defaultModelSelection === undefined + ? {} + : { defaultModelSelection: mutation.defaultModelSelection }), + ...(mutation.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: mutation.defaultThreadEnvMode }), + ...(mutation.faviconPath === undefined ? {} : { faviconPath: mutation.faviconPath }), + ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), + }) + : mutation.type === "project.update" + ? projects.update({ + commandId: mutation.commandId, + projectId: mutation.projectId, + ...(mutation.title === undefined ? {} : { title: mutation.title }), + ...(mutation.workspaceRoot === undefined + ? {} + : { workspaceRoot: mutation.workspaceRoot }), + ...(mutation.defaultModelSelection === undefined + ? {} + : { defaultModelSelection: mutation.defaultModelSelection }), + ...(mutation.defaultThreadEnvMode === undefined + ? {} + : { defaultThreadEnvMode: mutation.defaultThreadEnvMode }), + ...(mutation.faviconPath === undefined ? {} : { faviconPath: mutation.faviconPath }), + ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), + }) + : projects.delete({ + commandId: mutation.commandId, + projectId: mutation.projectId, + }); + export const projectHttpApiLayer = HttpApiBuilder.group( EnvironmentHttpApi, "projects", @@ -47,36 +89,7 @@ export const projectHttpApiLayer = HttpApiBuilder.group( Effect.fn("environment.projects.mutate")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationOperateScope); - const mutation = args.payload; - const operation = - mutation.type === "project.create" - ? projects.create({ - commandId: mutation.commandId, - projectId: mutation.projectId, - title: mutation.title, - workspaceRoot: mutation.workspaceRoot, - ...(mutation.defaultModelSelection === undefined - ? {} - : { defaultModelSelection: mutation.defaultModelSelection }), - ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), - }) - : mutation.type === "project.update" - ? projects.update({ - commandId: mutation.commandId, - projectId: mutation.projectId, - ...(mutation.title === undefined ? {} : { title: mutation.title }), - ...(mutation.workspaceRoot === undefined - ? {} - : { workspaceRoot: mutation.workspaceRoot }), - ...(mutation.defaultModelSelection === undefined - ? {} - : { defaultModelSelection: mutation.defaultModelSelection }), - ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), - }) - : projects.delete({ - commandId: mutation.commandId, - projectId: mutation.projectId, - }); + const operation = projectMutationOperation(projects, args.payload); return yield* startup.enqueueCommand(operation).pipe(Effect.catch(failProjectMutation)); }), ); From 5dcced6a86c1610c306a9d2c64e50e9b876e2895 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 10:21:13 -0700 Subject: [PATCH 07/15] fix(mcp): harden project creation boundaries --- apps/server/src/mcp/ProjectMcpService.test.ts | 58 ++++++++++++++ apps/server/src/mcp/ProjectMcpService.ts | 79 ++++++++----------- apps/server/src/mcp/toolkits/project/tools.ts | 2 +- .../toolkits/worktree/registration.test.ts | 6 ++ .../SourceControlRepositoryService.test.ts | 2 +- .../SourceControlRepositoryService.ts | 2 +- packages/contracts/src/orchestration.ts | 8 +- packages/contracts/src/project.test.ts | 27 +++++++ packages/contracts/src/project.ts | 12 ++- packages/contracts/src/projectMcp.test.ts | 29 ++++++- packages/contracts/src/projectMcp.ts | 6 +- 11 files changed, 170 insertions(+), 61 deletions(-) diff --git a/apps/server/src/mcp/ProjectMcpService.test.ts b/apps/server/src/mcp/ProjectMcpService.test.ts index a776c821c787..17bcb8672959 100644 --- a/apps/server/src/mcp/ProjectMcpService.test.ts +++ b/apps/server/src/mcp/ProjectMcpService.test.ts @@ -11,6 +11,7 @@ import { ProviderInstanceId, type ServerSettings, ServerSettingsError, + SourceControlRepositoryError, ThreadId, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; @@ -222,11 +223,68 @@ it.effect("loads response settings before committing project mutations", () => }) .pipe(Effect.flip); expect(error.code).toBe("operation_failed"); + expect(error.message).toBe("Unable to read project defaults."); + expect(error.message).not.toMatch(/settings unavailable|settings\.json/); expect(yield* Ref.get(createCalls)).toBe(0); }).pipe(Effect.provide(testLayer)); }), ); +it.effect("redacts repository failures from public project MCP results", () => + Effect.gen(function* () { + const secretRemote = "https://oauth-token@example.com/private/repo.git"; + const testLayer = ProjectMcp.layer.pipe( + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeed(Option.none()), + }), + ), + Layer.provide(Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({})), + Layer.provide( + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Effect.succeed({ defaultThreadEnvMode: "local" } as ServerSettings), + }), + ), + Layer.provide( + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ + cloneRepository: () => + Effect.fail( + new SourceControlRepositoryError({ + provider: "unknown", + operation: "cloneRepository", + detail: `fatal: authentication failed for ${secretRemote}`, + }), + ), + }), + ), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(NodeServices.layer), + ); + + const error = yield* Effect.gen(function* () { + const service = yield* ProjectMcp.ProjectMcpService; + return yield* service + .create(scope, { + title: "Private clone", + source: { + type: "clone", + destinationPath: "/work/private-clone", + remoteUrl: secretRemote, + }, + clientRequestId: "private-clone", + }) + .pipe(Effect.flip); + }).pipe(Effect.provide(testLayer)); + + expect(error).toMatchObject({ + code: "operation_failed", + message: "Unable to clone the requested repository.", + }); + expect(error.message).not.toMatch(/oauth-token|example\.com|fatal|authentication/i); + expect(error).not.toHaveProperty("cause"); + }), +); + it.effect("clones before registration and requires explicit cascading for nonempty projects", () => Effect.gen(function* () { const projectId = ProjectId.make("project:mcp:provider-session-project-mcp:clone-and-delete"); diff --git a/apps/server/src/mcp/ProjectMcpService.ts b/apps/server/src/mcp/ProjectMcpService.ts index daa7f06087ea..3b48ed6c3eca 100644 --- a/apps/server/src/mcp/ProjectMcpService.ts +++ b/apps/server/src/mcp/ProjectMcpService.ts @@ -58,8 +58,14 @@ function failure(code: ProjectMcpFailure["code"], message: string): ProjectMcpFa return new ProjectMcpFailure({ code, message }); } -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); +function redactOperationFailure(operation: string, publicMessage: string) { + return (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.tapError((error) => + Effect.logWarning("Project MCP operation failed.", { operation, error }), + ), + Effect.mapError(() => failure("operation_failed", publicMessage)), + ); } function stablePart(value: string): string { @@ -113,9 +119,7 @@ const make = Effect.gen(function* () { ); const loadSettings = serverSettings.getSettings.pipe( - Effect.mapError((error) => - failure("operation_failed", `Unable to read server settings: ${errorMessage(error)}`), - ), + redactOperationFailure("read-server-settings", "Unable to read project defaults."), ); const projectWorkspaceDefaults = Effect.fn("ProjectMcpService.projectWorkspaceDefaults")( @@ -171,14 +175,7 @@ const make = Effect.gen(function* () { const loadProject = Effect.fn("ProjectMcpService.loadProject")(function* (projectId: ProjectId) { const project = yield* projects .getById(projectId) - .pipe( - Effect.mapError((error) => - failure( - "operation_failed", - `Unable to read project ${projectId}: ${errorMessage(error)}`, - ), - ), - ); + .pipe(redactOperationFailure("read-project", "Unable to read the requested project.")); if (Option.isNone(project)) { return yield* failure("project_not_found", `Project '${projectId}' was not found.`); } @@ -189,9 +186,7 @@ const make = Effect.gen(function* () { Effect.gen(function* () { yield* requireCapability(scope); const snapshot = yield* projects.snapshot.pipe( - Effect.mapError((error) => - failure("operation_failed", `Unable to list projects: ${errorMessage(error)}`), - ), + redactOperationFailure("list-projects", "Unable to list projects."), ); const settings = yield* loadSettings; const cursor = input.cursor ?? 0; @@ -230,11 +225,9 @@ const make = Effect.gen(function* () { const existing = yield* projects .getById(projectId, { includeDeleted: true }) .pipe( - Effect.mapError((error) => - failure( - "operation_failed", - `Unable to retry project creation: ${errorMessage(error)}`, - ), + redactOperationFailure( + "read-project-create-receipt", + "Unable to check the project creation result.", ), ); if (Option.isSome(existing)) { @@ -276,8 +269,9 @@ const make = Effect.gen(function* () { ...(input.source.protocol === undefined ? {} : { protocol: input.source.protocol }), }) .pipe( - Effect.mapError((error) => - failure("operation_failed", `Unable to clone project: ${errorMessage(error)}`), + redactOperationFailure( + "clone-project-repository", + "Unable to clone the requested repository.", ), ); workspaceRoot = cloned.cwd; @@ -300,9 +294,7 @@ const make = Effect.gen(function* () { ...(input.scripts === undefined ? {} : { scripts: input.scripts }), }) .pipe( - Effect.mapError((error) => - failure("operation_failed", `Unable to create project: ${errorMessage(error)}`), - ), + redactOperationFailure("create-project", "Unable to create the requested project."), ); return yield* projectView(project, settings); }), @@ -345,10 +337,16 @@ const make = Effect.gen(function* () { ...(input.scripts === undefined ? {} : { scripts: input.scripts }), }) .pipe( + Effect.tapError((error) => + Effect.logWarning("Project MCP operation failed.", { + operation: "update-project", + error, + }), + ), Effect.mapError((error) => error._tag === "ProjectNotFoundError" - ? failure("project_not_found", error.message) - : failure("operation_failed", `Unable to update project: ${errorMessage(error)}`), + ? failure("project_not_found", `Project '${input.projectId}' was not found.`) + : failure("operation_failed", "Unable to update the requested project."), ), ); return yield* projectView(project, settings); @@ -359,14 +357,7 @@ const make = Effect.gen(function* () { yield* requireCapability(scope); const projectOption = yield* projects .getById(input.projectId, { includeDeleted: true }) - .pipe( - Effect.mapError((error) => - failure( - "operation_failed", - `Unable to read project ${input.projectId}: ${errorMessage(error)}`, - ), - ), - ); + .pipe(redactOperationFailure("read-project-for-delete", "Unable to read the project.")); if (Option.isNone(projectOption)) { return yield* failure("project_not_found", `Project '${input.projectId}' was not found.`); } @@ -385,8 +376,9 @@ const make = Effect.gen(function* () { threads.getShellSnapshot({ location: "active" }), threads.getShellSnapshot({ location: "archive" }), ]).pipe( - Effect.mapError((error) => - failure("operation_failed", `Unable to inspect project threads: ${errorMessage(error)}`), + redactOperationFailure( + "inspect-project-threads", + "Unable to inspect the project's threads.", ), ); const projectThreads = [ @@ -423,8 +415,9 @@ const make = Effect.gen(function* () { }), { concurrency: 1, discard: true }, ).pipe( - Effect.mapError((error) => - failure("operation_failed", `Unable to delete project threads: ${errorMessage(error)}`), + redactOperationFailure( + "delete-project-threads", + "Unable to delete the project's thread records.", ), ); yield* projects @@ -437,11 +430,7 @@ const make = Effect.gen(function* () { }), projectId: input.projectId, }) - .pipe( - Effect.mapError((error) => - failure("operation_failed", `Unable to delete project: ${errorMessage(error)}`), - ), - ); + .pipe(redactOperationFailure("delete-project", "Unable to delete the requested project.")); return { projectId: input.projectId, deleted: true, diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index dcb17ad76dfe..7189276d5e46 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -58,7 +58,7 @@ export const ProjectCreateTool = Tool.make("t3_project_create", { .annotate(Tool.Title, "Create a T3 project") .annotate(Tool.Readonly, false) .annotate(Tool.Destructive, false) - .annotate(Tool.Idempotent, true) + .annotate(Tool.Idempotent, false) .annotate(Tool.OpenWorld, true); export const ProjectUpdateTool = Tool.make("t3_project_update", { diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index f49f6e2def6c..3e4b5b6ed606 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -118,6 +118,12 @@ it.effect("production mcp layer lists worktree tools over http", () => expect(toolNames).toContain("delegate_task"); expect(toolNames).toContain("t3_project_list"); expect(toolNames).toContain("t3_project_delete"); + const projectCreate = tools.find((tool) => tool.name === "t3_project_create"); + expect( + projectCreate?.annotations && "idempotentHint" in projectCreate.annotations + ? projectCreate.annotations.idempotentHint + : undefined, + ).not.toBe(true); // The handoff tool mutates thread state, reaches the network (origin // fetch), and runs project setup scripts, so its MCP hints must not diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 2761311ea2a9..adf2414292e2 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -209,7 +209,7 @@ it.effect("clones a looked-up repository into the requested destination", () => assert.deepStrictEqual(cloneCalls, [ { cwd: `${parent}/missing/nested`, - args: ["clone", CLONE_URLS.url, "t3code"], + args: ["clone", "--origin", "origin", CLONE_URLS.url, "t3code"], }, ]); }).pipe( diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index c115deebeef9..cc0ecb767df4 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -320,7 +320,7 @@ export const make = Effect.gen(function* () { yield* git.execute({ operation: "SourceControlRepositoryService.cloneRepository", cwd: preparedDestination.parentPath, - args: ["clone", remoteUrl, preparedDestination.directoryName], + args: ["clone", "--origin", "origin", remoteUrl, preparedDestination.directoryName], timeoutMs: 120_000, maxOutputBytes: 256 * 1024, }); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index f34e7774a0e3..ac4685e4ee24 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -28,7 +28,7 @@ import { RuntimeMode, } from "./providerPolicy.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; -import { Project, ProjectScript, ProjectScriptIcon } from "./project.ts"; +import { Project, ProjectFaviconPath, ProjectScript, ProjectScriptIcon } from "./project.ts"; import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; import { OrchestrationProjectShell } from "./orchestrationProject.ts"; import { @@ -62,11 +62,7 @@ export { export const CorrelationId = CommandId; export type CorrelationId = typeof CorrelationId.Type; -export const ProjectFaviconPath = TrimmedNonEmptyString.check( - Schema.isMaxLength(1024), - Schema.isPattern(/\.(?:avif|gif|ico|jpe?g|png|svg|webp)$/i), -); -export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; +export { ProjectFaviconPath } from "./project.ts"; export const OrchestrationProject = Schema.Struct({ id: ProjectId, diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index 8e6771cba88f..3071e2e6299d 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ProjectReadFileError, + ProjectMutation, ProjectSearchContentsError, ProjectSearchContentsInput, ProjectSearchEntriesError, @@ -12,6 +13,7 @@ import { const decodeSearchEntriesInput = Schema.decodeUnknownSync(ProjectSearchEntriesInput); const decodeSearchContentsInput = Schema.decodeUnknownSync(ProjectSearchContentsInput); +const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation); describe("project search inputs", () => { it("allows an empty entries query for bounded frecency browsing", () => { @@ -37,6 +39,31 @@ describe("project search inputs", () => { }); }); +describe("project mutations", () => { + it("rejects favicon paths the asset endpoint cannot serve", () => { + const created = decodeProjectMutation({ + type: "project.create", + commandId: "command-project-create", + projectId: "project-1", + title: "Project", + workspaceRoot: "/work/project", + faviconPath: "/work/project/favicon.png", + }); + expect(created).toMatchObject({ + type: "project.create", + faviconPath: "/work/project/favicon.png", + }); + expect(() => + decodeProjectMutation({ + type: "project.update", + commandId: "command-project-update", + projectId: "project-1", + faviconPath: "/work/project/.env", + }), + ).toThrow(); + }); +}); + describe("project RPC errors", () => { it("derives stable messages from structured request context while retaining causes", () => { const cause = new Error("sensitive platform detail"); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index d925c59fa196..59e9a3b815b1 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -16,6 +16,12 @@ const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500; const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512; +export const ProjectFaviconPath = TrimmedNonEmptyString.check( + Schema.isMaxLength(1024), + Schema.isPattern(/\.(?:avif|gif|ico|jpe?g|png|svg|webp)$/i), +); +export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; + export const ProjectScriptIcon = Schema.Literals([ "play", "test", @@ -42,7 +48,7 @@ export const Project = Schema.Struct({ title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), - faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), defaultModelSelection: Schema.NullOr(ModelSelection), defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), scripts: Schema.Array(ProjectScript), @@ -78,7 +84,7 @@ export const ProjectMutation = Schema.Union([ createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), - faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), scripts: Schema.optional(Schema.Array(ProjectScript)), }), Schema.Struct({ @@ -89,7 +95,7 @@ export const ProjectMutation = Schema.Union([ workspaceRoot: Schema.optional(TrimmedNonEmptyString), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), - faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), scripts: Schema.optional(Schema.Array(ProjectScript)), }), Schema.Struct({ diff --git a/packages/contracts/src/projectMcp.test.ts b/packages/contracts/src/projectMcp.test.ts index 3d26bc62077e..368be27c454c 100644 --- a/packages/contracts/src/projectMcp.test.ts +++ b/packages/contracts/src/projectMcp.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it } from "@effect/vitest"; import * as Schema from "effect/Schema"; -import { ProjectMcpCreateInput, ProjectMcpFailure, ProjectMcpListInput } from "./projectMcp.ts"; +import { + ProjectMcpCreateInput, + ProjectMcpFailure, + ProjectMcpListInput, + ProjectMcpUpdateInput, +} from "./projectMcp.ts"; const decodeCreateInput = Schema.decodeUnknownSync(ProjectMcpCreateInput); const decodeListInput = Schema.decodeUnknownSync(ProjectMcpListInput); +const decodeUpdateInput = Schema.decodeUnknownSync(ProjectMcpUpdateInput); const decodeFailure = Schema.decodeUnknownSync(ProjectMcpFailure); describe("project MCP contracts", () => { @@ -71,4 +77,25 @@ describe("project MCP contracts", () => { }), ).toMatchObject({ code: "project_deleted" }); }); + + it("accepts only favicon paths served by the project asset route", () => { + expect( + decodeCreateInput({ + title: "Icon project", + source: { type: "existing_directory", workspaceRoot: "/work/icon" }, + faviconPath: "/work/icon/favicon.svg", + }).faviconPath, + ).toBe("/work/icon/favicon.svg"); + expect(() => + decodeCreateInput({ + title: "Secret project", + source: { type: "existing_directory", workspaceRoot: "/work/secret" }, + faviconPath: "/work/secret/.env", + }), + ).toThrow(); + expect(decodeUpdateInput({ projectId: "project-1", faviconPath: null }).faviconPath).toBeNull(); + expect(() => + decodeUpdateInput({ projectId: "project-1", faviconPath: "/work/secret/.env" }), + ).toThrow(); + }); }); diff --git a/packages/contracts/src/projectMcp.ts b/packages/contracts/src/projectMcp.ts index 596a88dd0db7..365f1c700380 100644 --- a/packages/contracts/src/projectMcp.ts +++ b/packages/contracts/src/projectMcp.ts @@ -3,7 +3,7 @@ import * as Schema from "effect/Schema"; import { NonNegativeInt, PositiveInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ThreadEnvMode } from "./environment.ts"; import { ModelSelection } from "./modelSelection.ts"; -import { Project, ProjectScript } from "./project.ts"; +import { Project, ProjectFaviconPath, ProjectScript } from "./project.ts"; import { SourceControlCloneProtocol, SourceControlProviderKind } from "./sourceControl.ts"; const ProjectMcpClientRequestId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)).annotate({ @@ -91,7 +91,7 @@ export const ProjectMcpCreateInput = Schema.Struct({ source: ProjectMcpCreateSource, defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), - faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), scripts: Schema.optional(Schema.Array(ProjectScript)), clientRequestId: Schema.optional(ProjectMcpClientRequestId), }); @@ -103,7 +103,7 @@ export const ProjectMcpUpdateInput = Schema.Struct({ workspaceRoot: Schema.optional(TrimmedNonEmptyString), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), - faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), scripts: Schema.optional(Schema.Array(ProjectScript)), clientRequestId: Schema.optional(ProjectMcpClientRequestId), }); From dd458832fae9e23d1a604fa7e44df36bb2f6ec08 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 11:02:16 -0700 Subject: [PATCH 08/15] fix(projects): serialize thread creation with deletion --- apps/server/src/mcp/OrchestratorMcpService.ts | 63 +++++-- apps/server/src/mcp/ProjectMcpService.test.ts | 166 +++++++++++++----- .../ClientCommandDispatch.test.ts | 101 +++++++++++ .../orchestration-v2/ClientCommandDispatch.ts | 35 ++++ .../src/orchestration-v2/Orchestrator.ts | 3 + .../ThreadLaunchService.test.ts | 47 +++++ .../orchestration-v2/ThreadLaunchService.ts | 147 ++++++++-------- .../ThreadManagementService.ts | 13 ++ apps/server/src/ws.ts | 22 ++- 9 files changed, 459 insertions(+), 138 deletions(-) create mode 100644 apps/server/src/orchestration-v2/ClientCommandDispatch.test.ts create mode 100644 apps/server/src/orchestration-v2/ClientCommandDispatch.ts diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 7d7bd24f02de..77b0cc395647 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1370,26 +1370,51 @@ const make = Effect.gen(function* () { title: request.title, index, }); + const createCommandId = stableCommandId({ + scope, + requestKey: key, + operation: "create-thread", + index, + }); + const createCommand = { + type: "thread.create", + createdBy: "agent", + creationSource: "mcp", + commandId: createCommandId, + threadId, + projectId: parent.thread.projectId, + title, + modelSelection: target.modelSelection, + runtimeMode, + interactionMode, + branch: parent.thread.branch, + worktreePath: parent.thread.worktreePath, + } as const; yield* threadManagement - .dispatch({ - type: "thread.create", - createdBy: "agent", - creationSource: "mcp", - commandId: stableCommandId({ - scope, - requestKey: key, - operation: "create-thread", - index, - }), - threadId, - projectId: parent.thread.projectId, - title, - modelSelection: target.modelSelection, - runtimeMode, - interactionMode, - branch: parent.thread.branch, - worktreePath: parent.thread.worktreePath, - }) + .withProjectCreationAdmission( + { projectId: parent.thread.projectId, commandId: createCommandId }, + (receipt) => + Effect.gen(function* () { + if (Option.isNone(receipt)) { + const freshParent = yield* loadProjection(scope.threadId); + const freshParentRun = latestActiveRun(freshParent); + if ( + freshParent.thread.projectId !== parent.thread.projectId || + freshParent.thread.deletedAt !== null || + freshParent.thread.archivedAt !== null || + freshParentRun === undefined || + freshParentRun.rootNodeId === null || + freshParentRun.providerInstanceId !== scope.providerInstanceId + ) { + return yield* failure( + "parent_not_active", + "Thread creation requires an active parent in the target project.", + ); + } + } + return yield* threadManagement.dispatch(createCommand); + }), + ) .pipe( Effect.mapError((error) => failure( diff --git a/apps/server/src/mcp/ProjectMcpService.test.ts b/apps/server/src/mcp/ProjectMcpService.test.ts index 17bcb8672959..d4d4ed76de83 100644 --- a/apps/server/src/mcp/ProjectMcpService.test.ts +++ b/apps/server/src/mcp/ProjectMcpService.test.ts @@ -23,7 +23,6 @@ import * as Ref from "effect/Ref"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; -import { makeKeyedSerialExecutor } from "../orchestration-v2/KeyedSerialExecutor.ts"; import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; @@ -463,43 +462,68 @@ it.effect("clones before registration and requires explicit cascading for nonemp }), ); -it.effect("serializes project deletion against new thread claims", () => +it.effect("serializes receipt-aware thread creation with project deletion", () => Effect.gen(function* () { const projectId = ProjectId.make("project:mcp:delete-launch-race"); + const threadId = ThreadId.make("thread:mcp:delete-launch-race"); + const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.1-codex", + } as const; const project = makeProject({ id: projectId, title: "Delete race", workspaceRoot: "/work/delete-race", }); - const state = yield* Ref.make(project); - const snapshotEntered = yield* Deferred.make(); - const allowSnapshot = yield* Deferred.make(); - const competingClaim = yield* Deferred.make(); - const projectMutations = yield* makeKeyedSerialExecutor(); - const blockedThreadManagementLayer = Layer.mock(ThreadManagement.ThreadManagementService)({ - withProjectMutationLock: projectMutations.withLock, - getShellSnapshot: () => - Deferred.succeed(snapshotEntered, undefined).pipe( - Effect.andThen(Deferred.await(allowSnapshot)), - Effect.as({ - schemaVersion: 1, - snapshotSequence: 1, - threads: [], - archivedThreads: [], - }), + const projectState = yield* Ref.make(project); + const creationEntered = yield* Deferred.make(); + const allowCreation = yield* Deferred.make(); + const deleteLockAttempted = yield* Deferred.make(); + const deleteLockAcquired = yield* Deferred.make(); + const adapter = { + instanceId: modelSelection.instanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("provider execution is disabled in project deletion tests"), + } as ProviderAdapterV2Shape; + const registry = ProviderAdapterRegistry.makeLayer([adapter]); + const orchestrator = makeOrchestratorV2ReplayLayerWithRegistry( + { name: "project-delete-create-race" }, + registry, + { databaseLayer: SqlitePersistenceMemory, runEffectWorker: false }, + ); + const actualThreads = ThreadManagement.layer.pipe(Layer.provide(orchestrator)); + const gatedThreads = Layer.effect( + ThreadManagement.ThreadManagementService, + Effect.gen(function* () { + const actual = yield* ThreadManagement.ThreadManagementService; + return ThreadManagement.ThreadManagementService.of({ + ...actual, + withProjectMutationLock: (lockedProjectId, effect) => + Deferred.succeed(deleteLockAttempted, undefined).pipe( + Effect.andThen( + actual.withProjectMutationLock( + lockedProjectId, + Deferred.succeed(deleteLockAcquired, undefined).pipe(Effect.andThen(effect)), + ), + ), + ), + }); + }), + ).pipe(Layer.provide(actualThreads)); + const projectServiceLayer = Layer.mock(ProjectService.ProjectService)({ + getById: () => + Ref.get(projectState).pipe( + Effect.map((current) => + current.deletedAt === null ? Option.some(current) : Option.none(), + ), ), - dispatch: () => Effect.die("unexpected command"), + delete: () => Ref.updateAndGet(projectState, (current) => ({ ...current, deletedAt: now })), }); const testLayer = ProjectMcp.layer.pipe( - Layer.provide( - Layer.mock(ProjectService.ProjectService)({ - getById: () => Ref.get(state).pipe(Effect.map(Option.fromNullishOr)), - delete: () => - Ref.updateAndGet(state, (current) => - current === null ? null : { ...current, deletedAt: now }, - ).pipe(Effect.map((deleted) => deleted!)), - }), - ), + Layer.provideMerge(gatedThreads), + Layer.provide(projectServiceLayer), Layer.provide(Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({})), Layer.provide( Layer.mock(ServerSettingsService.ServerSettingsService)({ @@ -507,28 +531,88 @@ it.effect("serializes project deletion against new thread claims", () => }), ), Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), - Layer.provide(blockedThreadManagementLayer), Layer.provide(NodeServices.layer), ); yield* Effect.gen(function* () { - const service = yield* ProjectMcp.ProjectMcpService; + const projects = yield* ProjectMcp.ProjectMcpService; + const threads = yield* ThreadManagement.ThreadManagementService; + const createCommand = { + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:mcp:delete-launch-race:create"), + threadId, + projectId, + title: "Racing thread", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + } as const; + const admitCreate = (command: typeof createCommand) => + threads.withProjectCreationAdmission( + { projectId, commandId: command.commandId }, + (receipt) => + Effect.gen(function* () { + if (Option.isNone(receipt)) { + if (command.commandId === createCommand.commandId) { + yield* Deferred.succeed(creationEntered, undefined); + yield* Deferred.await(allowCreation); + } + const current = yield* Ref.get(projectState); + if (current.deletedAt !== null) { + return yield* new ProjectMcpFailure({ + code: "project_deleted", + message: "Project was deleted before thread creation was admitted.", + }); + } + } + return yield* threads.dispatch(command); + }), + ); + + const creation = yield* Effect.forkChild(admitCreate(createCommand), { + startImmediately: true, + }); + yield* Deferred.await(creationEntered); const deletion = yield* Effect.forkChild( - service.delete(scope, { projectId, clientRequestId: "delete-launch-race" }), - { startImmediately: true }, - ); - yield* Deferred.await(snapshotEntered); - const claim = yield* Effect.forkChild( - projectMutations.withLock(projectId, Deferred.succeed(competingClaim, undefined)), + projects.delete(scope, { + projectId, + cascadeThreads: true, + clientRequestId: "delete-launch-race", + }), { startImmediately: true }, ); - expect(yield* Deferred.isDone(competingClaim)).toBe(false); + yield* Deferred.await(deleteLockAttempted); + expect(yield* Deferred.isDone(deleteLockAcquired)).toBe(false); - yield* Deferred.succeed(allowSnapshot, undefined); + yield* Deferred.succeed(allowCreation, undefined); + yield* Fiber.join(creation); + yield* Deferred.await(deleteLockAcquired); const deleted = yield* Fiber.join(deletion); - yield* Fiber.join(claim); - expect(deleted.deleted).toBe(true); - expect(yield* Deferred.isDone(competingClaim)).toBe(true); + expect(deleted).toMatchObject({ deleted: true, deletedThreadCount: 1 }); + + const sequenceBeforeReplay = yield* threads.getThreadEventSequence(threadId); + const replayed = yield* admitCreate(createCommand); + const sequenceAfterReplay = yield* threads.getThreadEventSequence(threadId); + expect(replayed.storedEvents.some((stored) => stored.event.type === "thread.created")).toBe( + true, + ); + expect(sequenceAfterReplay).toBe(sequenceBeforeReplay); + + const freshCommand = { + ...createCommand, + commandId: CommandId.make("command:mcp:delete-launch-race:fresh-after-delete"), + threadId: ThreadId.make("thread:mcp:delete-launch-race:fresh-after-delete"), + }; + const rejected = yield* admitCreate(freshCommand).pipe(Effect.flip); + expect(rejected).toMatchObject({ code: "project_deleted" }); + expect( + Option.isNone(yield* Effect.option(threads.getThreadProjection(freshCommand.threadId))), + ).toBe(true); + expect((yield* Ref.get(projectState)).deletedAt).toBe(now); }).pipe(Effect.provide(testLayer)); }), ); diff --git a/apps/server/src/orchestration-v2/ClientCommandDispatch.test.ts b/apps/server/src/orchestration-v2/ClientCommandDispatch.test.ts new file mode 100644 index 000000000000..6d164eebf857 --- /dev/null +++ b/apps/server/src/orchestration-v2/ClientCommandDispatch.test.ts @@ -0,0 +1,101 @@ +import { expect, it } from "@effect/vitest"; +import { + CommandId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type Project, +} from "@t3tools/contracts"; +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 { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import { dispatchClientCommand } from "./ClientCommandDispatch.ts"; +import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import * as ThreadManagement from "./ThreadManagementService.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; + +it.effect("routes WebSocket thread creation through receipt-aware project admission", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project:client-command-admission"); + const providerInstanceId = ProviderInstanceId.make("codex"); + const project = { + id: projectId, + title: "Client command admission", + workspaceRoot: "/work/client-command-admission", + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + scripts: [], + createdAt: "2026-08-30T00:00:00.000Z", + updatedAt: "2026-08-30T00:00:00.000Z", + deletedAt: null, + } satisfies Project; + const projectState = yield* Ref.make(project); + const adapter = { + instanceId: providerInstanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("provider execution is disabled in client command tests"), + } as ProviderAdapterV2Shape; + const registry = ProviderAdapterRegistry.makeLayer([adapter]); + const orchestrator = makeOrchestratorV2ReplayLayerWithRegistry( + { name: "client-command-admission" }, + registry, + { databaseLayer: SqlitePersistenceMemory, runEffectWorker: false }, + ); + const threadsLayer = ThreadManagement.layer.pipe(Layer.provide(orchestrator)); + const projectsLayer = Layer.mock(ProjectService.ProjectService)({ + getById: () => Ref.get(projectState).pipe(Effect.map(Option.fromNullishOr)), + }); + + yield* Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + const projects = yield* ProjectService.ProjectService; + const command = { + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:client-command-admission:create"), + threadId: ThreadId.make("thread:client-command-admission:create"), + projectId, + title: "Created from WebSocket", + modelSelection: { instanceId: providerInstanceId, model: "gpt-5.1-codex" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + } as const; + + yield* dispatchClientCommand({ command, projects, threads }); + const sequenceBeforeReplay = yield* threads.getThreadEventSequence(command.threadId); + yield* Ref.set(projectState, null); + + yield* dispatchClientCommand({ command, projects, threads }); + expect(yield* threads.getThreadEventSequence(command.threadId)).toBe(sequenceBeforeReplay); + + const freshCommand = { + ...command, + commandId: CommandId.make("command:client-command-admission:fresh"), + threadId: ThreadId.make("thread:client-command-admission:fresh"), + }; + const rejected = yield* dispatchClientCommand({ + command: freshCommand, + projects, + threads, + }).pipe(Effect.flip); + expect(rejected).toMatchObject({ _tag: "ProjectMutationError" }); + expect( + Option.isNone(yield* Effect.option(threads.getThreadProjection(freshCommand.threadId))), + ).toBe(true); + }).pipe(Effect.provide(Layer.mergeAll(threadsLayer, projectsLayer))); + }), +); diff --git a/apps/server/src/orchestration-v2/ClientCommandDispatch.ts b/apps/server/src/orchestration-v2/ClientCommandDispatch.ts new file mode 100644 index 000000000000..b2e483797204 --- /dev/null +++ b/apps/server/src/orchestration-v2/ClientCommandDispatch.ts @@ -0,0 +1,35 @@ +import { ProjectMutationError, type OrchestrationV2Command } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import type * as ProjectService from "../project/ProjectService.ts"; +import type * as ThreadManagement from "./ThreadManagementService.ts"; + +export const dispatchClientCommand = Effect.fn("orchestrationV2.dispatchClientCommand")( + function* (input: { + readonly command: OrchestrationV2Command; + readonly projects: ProjectService.ProjectService["Service"]; + readonly threads: ThreadManagement.ThreadManagementService["Service"]; + }) { + const command = input.command; + if (command.type !== "thread.create") { + return yield* input.threads.dispatch(command); + } + return yield* input.threads.withProjectCreationAdmission( + { projectId: command.projectId, commandId: command.commandId }, + (receipt) => + Effect.gen(function* () { + if (Option.isNone(receipt)) { + const project = yield* input.projects.getById(command.projectId); + if (Option.isNone(project)) { + return yield* new ProjectMutationError({ + commandId: command.commandId, + message: `Project ${command.projectId} does not exist.`, + }); + } + } + return yield* input.threads.dispatch(command); + }), + ); + }, +); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 81308a9be849..24d411d93719 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -176,6 +176,7 @@ export interface OrchestratorV2Shape { readonly dispatch: ( command: OrchestrationV2Command, ) => Effect.Effect; + readonly getCommandReceipt: CommandReceiptStoreV2["Service"]["getByCommandId"]; readonly getThreadProjection: ( threadId: ThreadId, ) => Effect.Effect; @@ -7174,6 +7175,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return OrchestratorV2.of({ resumeQueuedRuns, dispatch: dispatchWithReceipt, + getCommandReceipt: commandReceipts.getByCommandId, getThreadProjection: (threadId) => projectionStore .getThreadProjection(threadId) @@ -7276,6 +7278,7 @@ export const layerUnavailable: Layer.Layer = Layer.succeed( cause: "Orchestration V2 live runtime is not configured.", }), ), + getCommandReceipt: () => Effect.die("Orchestration V2 live runtime is not configured."), getThreadProjection: (threadId) => Effect.fail( new OrchestratorProjectionError({ diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index de07f06c2e07..19213cb2a377 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -262,6 +262,53 @@ it.effect("claims a thread under the shared project mutation lock", () => }), ); +it.effect("replays an accepted launch after project deletion without new preparation", () => + Effect.gen(function* () { + const projectState = yield* Ref.make(project); + const setupEntered = yield* Deferred.make(); + const allowSetup = yield* Deferred.make(); + const setupFinished = yield* Deferred.make(); + const harness = makeHarness({ + getProjectById: (id) => + Ref.get(projectState).pipe( + Effect.map((current) => + id === projectId && current !== null ? Option.some(current) : Option.none(), + ), + ), + runSetup: () => + Deferred.succeed(setupEntered, undefined).pipe( + Effect.andThen(Deferred.await(allowSetup)), + Effect.tap(() => Deferred.succeed(setupFinished, undefined)), + Effect.as({ status: "no-script" as const }), + ), + }); + + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const input = launchInput({ + command: "command:launch:replay-after-project-delete", + thread: "thread:launch:replay-after-project-delete", + }); + + const first = yield* launches.launch(input); + yield* Deferred.await(setupEntered); + yield* Deferred.succeed(allowSetup, undefined); + yield* Deferred.await(setupFinished); + const sequenceBeforeReplay = yield* threads.getThreadEventSequence(first.threadId); + yield* Ref.set(projectState, null); + + const replay = yield* launches.launch(input); + const sequenceAfterReplay = yield* threads.getThreadEventSequence(first.threadId); + assert.isFalse(first.resumed); + assert.isTrue(replay.resumed); + assert.equal(replay.threadId, first.threadId); + assert.equal(sequenceAfterReplay, sequenceBeforeReplay); + assert.equal(harness.runSetup.mock.calls.length, 1); + }).pipe(Effect.provide(harness.layer)); + }), +); + it.effect("returns a visible preparing message while provisioning is still blocked", () => Effect.gen(function* () { const worktreeEntered = yield* Deferred.make(); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 7e0bbc0c3230..50b955a55a80 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -420,84 +420,91 @@ export const make = Effect.gen(function* () { const launch: ThreadLaunchService["Service"]["launch"] = Effect.fn("ThreadLaunchService.launch")( function* (input) { - const { project, launchReceipt, candidateThreadId, claimed } = - yield* threads.withProjectMutationLock( - input.projectId, - Effect.gen(function* () { - const project = yield* projects.getById(input.projectId).pipe( - Effect.mapError(mapError(input, "resolve-project")), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail(mapError(input, "resolve-project")("Project not found.")), - onSome: Effect.succeed, - }), - ), - ); - if (input.reuseExistingThread === true && input.threadId === undefined) { - return yield* mapError( - input, - "update-thread", - )("Reusing an existing thread requires a thread id."); - } - - const launchReceipt = yield* readReceipt(input, input.commandId); - const candidateThreadId = - input.threadId ?? - (yield* ids.allocate - .thread({ projectId: input.projectId }) - .pipe(Effect.mapError(mapError(input, "create-thread")))); - - if (input.reuseExistingThread === true && Option.isNone(launchReceipt)) { - yield* validateReusableThread(input, candidateThreadId); - } - - const initialBranch = input.workspaceStrategy.branch ?? null; - const initialWorktreePath = - input.workspaceStrategy.type === "existing_worktree" - ? input.workspaceStrategy.worktreePath - : null; - const claimDispatch = - input.reuseExistingThread === true - ? threads.dispatch({ - type: "thread.metadata.update", - commandId: input.commandId, - threadId: candidateThreadId, - }) - : threads.dispatch({ - type: "thread.create", - commandId: input.commandId, - threadId: candidateThreadId, - projectId: input.projectId, - title: input.title, - modelSelection: input.modelSelection, - runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, - branch: initialBranch, - worktreePath: initialWorktreePath, - createdBy: input.createdBy, - creationSource: input.creationSource, - }); - const claimed = yield* claimDispatch.pipe( - Effect.mapError( - mapError( + const { launchReceipt, candidateThreadId, claimed } = yield* threads + .withProjectCreationAdmission( + { projectId: input.projectId, commandId: input.commandId }, + (launchReceipt) => + Effect.gen(function* () { + if (input.reuseExistingThread === true && input.threadId === undefined) { + return yield* mapError( input, - input.reuseExistingThread === true ? "update-thread" : "create-thread", - candidateThreadId, + "update-thread", + )("Reusing an existing thread requires a thread id."); + } + + const candidateThreadId = + input.threadId ?? + (Option.isSome(launchReceipt) + ? launchReceipt.value.threadId + : yield* ids.allocate + .thread({ projectId: input.projectId }) + .pipe(Effect.mapError(mapError(input, "create-thread")))); + + if (Option.isNone(launchReceipt)) { + yield* projects.getById(input.projectId).pipe( + Effect.mapError(mapError(input, "resolve-project")), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(mapError(input, "resolve-project")("Project not found.")), + onSome: () => Effect.void, + }), + ), + ); + } + + if (input.reuseExistingThread === true && Option.isNone(launchReceipt)) { + yield* validateReusableThread(input, candidateThreadId); + } + + const initialBranch = input.workspaceStrategy.branch ?? null; + const initialWorktreePath = + input.workspaceStrategy.type === "existing_worktree" + ? input.workspaceStrategy.worktreePath + : null; + const claimDispatch = + input.reuseExistingThread === true + ? threads.dispatch({ + type: "thread.metadata.update", + commandId: input.commandId, + threadId: candidateThreadId, + }) + : threads.dispatch({ + type: "thread.create", + commandId: input.commandId, + threadId: candidateThreadId, + projectId: input.projectId, + title: input.title, + modelSelection: input.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + branch: initialBranch, + worktreePath: initialWorktreePath, + createdBy: input.createdBy, + creationSource: input.creationSource, + }); + const claimed = yield* claimDispatch.pipe( + Effect.mapError( + mapError( + input, + input.reuseExistingThread === true ? "update-thread" : "create-thread", + candidateThreadId, + ), ), - ), - ); - return { project, launchReceipt, candidateThreadId, claimed }; - }), + ); + return { launchReceipt, candidateThreadId, claimed }; + }), + ) + .pipe( + Effect.mapError((cause) => + isThreadLaunchError(cause) ? cause : mapError(input, "read-receipt")(cause), + ), ); return yield* Effect.gen(function* () { const threadId = claimed.storedEvents.find((stored) => stored.event.type.startsWith("thread."))?.event .threadId ?? candidateThreadId; - if (project.id !== input.projectId) { - return yield* mapError(input, "resolve-project", threadId)("Project identity changed."); - } let runId: RunId | null = null; let messageWasAlreadyAccepted = false; diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 63062b08d958..a6e1b0043ad3 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -35,6 +35,7 @@ import { LegacyV1ThreadImporter, type LegacyV1ThreadImportError, } from "./LegacyV1ThreadImporter.ts"; +import type { CommandReceiptStoreV2Error, CommandReceiptV2 } from "./CommandReceiptStore.ts"; import { makeKeyedSerialExecutor } from "./KeyedSerialExecutor.ts"; export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart"; @@ -266,6 +267,10 @@ export type ThreadManagementError = typeof ThreadManagementError.Type; type ThreadManagementFailure = ThreadManagementError | OrchestratorV2Error; export interface ThreadManagementServiceShape { + readonly withProjectCreationAdmission: ( + input: { readonly projectId: ProjectId; readonly commandId: CommandId }, + effect: (receipt: Option.Option) => Effect.Effect, + ) => Effect.Effect; readonly withProjectMutationLock: ( projectId: ProjectId, effect: Effect.Effect, @@ -368,6 +373,13 @@ const make = Effect.gen(function* () { const legacyImporter = yield* LegacyV1ThreadImporter; const projectMutations = yield* makeKeyedSerialExecutor(); + const withProjectCreationAdmission: ThreadManagementServiceShape["withProjectCreationAdmission"] = + (input, effect) => + projectMutations.withLock( + input.projectId, + orchestrator.getCommandReceipt(input.commandId).pipe(Effect.flatMap(effect)), + ); + const ensureLegacyTranscript = Effect.fn( "orchestrationV2.threadManagement.ensureLegacyTranscript", )(function* (threadId: ThreadId) { @@ -673,6 +685,7 @@ const make = Effect.gen(function* () { }); return ThreadManagementService.of({ + withProjectCreationAdmission, withProjectMutationLock: projectMutations.withLock, ensureLegacyTranscript, dispatch, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c9b0300f7494..9c7879509898 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -93,6 +93,7 @@ import * as EnvironmentTheme from "./environmentTheme.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as ThreadManagementService from "./orchestration-v2/ThreadManagementService.ts"; +import { dispatchClientCommand } from "./orchestration-v2/ClientCommandDispatch.ts"; import { ProviderSessionManagerV2 } from "./orchestration-v2/ProviderSessionManager.ts"; import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; @@ -1263,15 +1264,20 @@ const makeWsRpcLayer = ( claimed === null || command.type !== "message.dispatch" ? command : { ...command, attachments: claimed.attachments }; + const provenanceCommand = ThreadManagementService.withCreationProvenance( + effectiveCommand, + { + createdBy: "user", + creationSource: "creationSource" in command ? command.creationSource : "web", + }, + ); + const dispatchCommand = dispatchClientCommand({ + command: provenanceCommand, + projects: projectService, + threads: threadManagement, + }); return yield* startup - .enqueueCommand( - threadManagement.dispatch( - ThreadManagementService.withCreationProvenance(effectiveCommand, { - createdBy: "user", - creationSource: "creationSource" in command ? command.creationSource : "web", - }), - ), - ) + .enqueueCommand(dispatchCommand) .pipe( Effect.tapError(() => claimed === null From 8d74cdba49a0cd6b45bebbebaa7a79a64e9c0ab0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 11:17:01 -0700 Subject: [PATCH 09/15] test(projects): prove shared creation admission --- ...OrchestratorMcpToolkit.integration.test.ts | 244 +++++++++++++++++- apps/server/src/mcp/ProjectMcpService.test.ts | 155 ----------- .../ClientCommandDispatch.test.ts | 14 +- .../orchestration-v2/ClientCommandDispatch.ts | 30 +-- apps/server/src/ws.ts | 9 +- 5 files changed, 261 insertions(+), 191 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index ad6ea73c7688..7c7a8f656b2c 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -19,6 +19,7 @@ import { OrchestratorMcpThreadReadResult, OrchestratorMcpThreadSendResult, OrchestratorMcpThreadWaitResult, + type Project, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -35,7 +36,9 @@ import { import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -44,8 +47,9 @@ import { McpSchema, McpServer } from "effect/unstable/ai"; import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import * as ClientCommandDispatch from "../orchestration-v2/ClientCommandDispatch.ts"; import { OrchestratorV2, type OrchestratorV2Shape } from "../orchestration-v2/Orchestrator.ts"; -import { layer as threadManagementServiceLayer } from "../orchestration-v2/ThreadManagementService.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; import { type ProviderAdapterV2Event, ProviderAdapterProtocolError, @@ -60,12 +64,17 @@ import { import { checkpointWorkspace } from "../orchestration-v2/testkit/ReplayFixtureWorkspace.ts"; import { makeOrchestratorV2ReplayLayerWithRegistry } from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts"; +import * as ProjectService from "../project/ProjectService.ts"; import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; const parentThreadId = ThreadId.make("thread:mcp-orchestrator-parent"); const projectId = ProjectId.make("project:mcp-orchestrator"); +const deletionRaceProjectId = ProjectId.make("project:mcp-orchestrator-delete-race"); +const deletionRaceParentThreadId = ThreadId.make("thread:mcp-orchestrator-delete-race-parent"); const codexInstanceId = ProviderInstanceId.make("codex"); const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); const codexModel = "gpt-5.4"; @@ -429,6 +438,28 @@ describe("orchestrator MCP toolkit", () => { Effect.scoped( Effect.gen(function* () { const cwd = yield* checkpointWorkspace("orchestrator-mcp-toolkit"); + const deletionRaceProject = { + id: deletionRaceProjectId, + title: "MCP deletion race project", + workspaceRoot: cwd, + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: codexSelection, + defaultThreadEnvMode: "local", + scripts: [], + createdAt: "2026-08-30T00:00:00.000Z", + updatedAt: "2026-08-30T00:00:00.000Z", + deletedAt: null, + } satisfies Project; + const deletionRaceProjectState = yield* Ref.make(deletionRaceProject); + const creationAdmissionEntered = yield* Deferred.make(); + const allowCreationAdmission = yield* Deferred.make(); + const deletionLockAttempted = yield* Deferred.make(); + const deletionLockAcquired = yield* Deferred.make(); + const allowDeletion = yield* Deferred.make(); + const deletionRaceCommandId = CommandId.make( + "command:mcp:mcp-provider-session-delete-race:create-thread:delete-create-race:0", + ); const capturedTurns = yield* Ref.make>([]); const parentTerminalGates = new Map>(); const deliveryTerminalGates = new Map>(); @@ -439,7 +470,9 @@ describe("orchestrator MCP toolkit", () => { capabilities: CodexProviderCapabilitiesV2, capturedTurns, shouldComplete: (turn) => - turn.threadId !== parentThreadId && turn.message.text !== cancellationPrompt, + turn.threadId !== parentThreadId && + turn.threadId !== deletionRaceParentThreadId && + turn.message.text !== cancellationPrompt, terminalGate: (turn) => turn.message.text.startsWith("Delegated task") || turn.message.text.startsWith("Delegated tasks") @@ -511,10 +544,42 @@ describe("orchestrator MCP toolkit", () => { }, registryLayer, ).pipe(Layer.provide(continuationProbeLayer)); - const orchestrationLayer = Layer.merge( - orchestratorLayer, - threadManagementServiceLayer.pipe(Layer.provide(orchestratorLayer)), + const threadManagementLayer = ThreadManagement.layer.pipe( + Layer.provide(orchestratorLayer), ); + const gatedThreadManagementLayer = Layer.effect( + ThreadManagement.ThreadManagementService, + Effect.gen(function* () { + const actual = yield* ThreadManagement.ThreadManagementService; + return ThreadManagement.ThreadManagementService.of({ + ...actual, + withProjectCreationAdmission: (input, effect) => + actual.withProjectCreationAdmission(input, (receipt) => + input.commandId === deletionRaceCommandId && Option.isNone(receipt) + ? Deferred.succeed(creationAdmissionEntered, undefined).pipe( + Effect.andThen(Deferred.await(allowCreationAdmission)), + Effect.andThen(effect(receipt)), + ) + : effect(receipt), + ), + withProjectMutationLock: (lockedProjectId, effect) => + lockedProjectId === deletionRaceProjectId + ? Deferred.succeed(deletionLockAttempted, undefined).pipe( + Effect.andThen( + actual.withProjectMutationLock( + lockedProjectId, + Deferred.succeed(deletionLockAcquired, undefined).pipe( + Effect.andThen(Deferred.await(allowDeletion)), + Effect.andThen(effect), + ), + ), + ), + ) + : actual.withProjectMutationLock(lockedProjectId, effect), + }); + }), + ).pipe(Layer.provide(threadManagementLayer)); + const orchestrationLayer = Layer.merge(orchestratorLayer, gatedThreadManagementLayer); const providerRegistryLayer = makeProviderRegistryLayer([ makeProviderSnapshot({ instanceId: codexInstanceId, @@ -570,11 +635,40 @@ describe("orchestrator MCP toolkit", () => { runNow: () => Effect.die("ScheduledTaskService.runNow is unused in this test"), }), ); - const testLayer = McpHttpServer.OrchestratorToolkitRegistrationLive.pipe( + const projectLayer = Layer.mock(ProjectService.ProjectService)({ + getById: (requestedProjectId, options) => + requestedProjectId === deletionRaceProjectId + ? Ref.get(deletionRaceProjectState).pipe( + Effect.map((project) => + project.deletedAt === null || options?.includeDeleted === true + ? Option.some(project) + : Option.none(), + ), + ) + : Effect.succeed(Option.none()), + delete: ({ projectId: deletedProjectId }) => + deletedProjectId === deletionRaceProjectId + ? Ref.updateAndGet(deletionRaceProjectState, (project) => ({ + ...project, + deletedAt: "2026-08-30T00:01:00.000Z", + updatedAt: "2026-08-30T00:01:00.000Z", + })) + : Effect.die(`Unexpected project deletion: ${deletedProjectId}`), + }); + const toolkitRegistrationLayer = Layer.merge( + McpHttpServer.OrchestratorToolkitRegistrationLive, + McpHttpServer.ProjectToolkitRegistrationLive, + ); + const testLayer = toolkitRegistrationLayer.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provideMerge(orchestrationLayer), Layer.provide(providerRegistryLayer), Layer.provide(scheduledTaskStubLayer), + Layer.provideMerge(projectLayer), + Layer.provide( + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({}), + ), + Layer.provide(ServerSettings.layerTest({})), Layer.provide(NodeServices.layer), ); @@ -2613,6 +2707,144 @@ describe("orchestrator MCP toolkit", () => { removedDelivery.subagents.find((task) => task.id === removeTask.id), ).toMatchObject({ result: expect.any(String), status: "completed" }); yield* expectOffersToStay(0); + + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:mcp-delete-race-parent:create"), + threadId: deletionRaceParentThreadId, + projectId: deletionRaceProjectId, + title: "MCP deletion race parent", + modelSelection: codexSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:mcp-delete-race-parent:start"), + threadId: deletionRaceParentThreadId, + messageId: MessageId.make("message:mcp-delete-race-parent:start"), + text: "Stay active while project deletion races thread creation.", + attachments: [], + modelSelection: codexSelection, + dispatchMode: { type: "start_immediately" }, + }); + yield* waitForProjection(orchestrator, deletionRaceParentThreadId, (projection) => + projection.runs.some((run) => run.status === "running"), + ); + + const deletionRaceInvocation: McpInvocationContext.McpInvocationScope = { + ...invocation, + threadId: deletionRaceParentThreadId, + providerSessionId: "mcp-provider-session-delete-race", + }; + const invokeDeletionRace = (name: string, args: Record) => + server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService( + McpInvocationContext.McpInvocationContext, + deletionRaceInvocation, + ), + Effect.provideService(McpSchema.McpServerClient, client), + ); + const creation = yield* Effect.forkChild( + invokeDeletionRace("create_threads", { + clientRequestId: "delete-create-race", + threads: [{ title: "Racing child" }], + }), + { startImmediately: true }, + ); + yield* Deferred.await(creationAdmissionEntered); + const deletion = yield* Effect.forkChild( + invokeDeletionRace("t3_project_delete", { + projectId: deletionRaceProjectId, + cascadeThreads: true, + clientRequestId: "delete-create-race-project", + }), + { startImmediately: true }, + ); + yield* Deferred.await(deletionLockAttempted); + expect(yield* Deferred.isDone(deletionLockAcquired)).toBe(false); + + yield* Deferred.succeed(allowCreationAdmission, undefined); + yield* Deferred.await(deletionLockAcquired); + const creationCall = yield* Fiber.join(creation); + expect(creationCall.isError).toBe(false); + const creationResult = yield* decodeCreateThreadsResult( + creationCall.structuredContent, + ).pipe(Effect.orDie); + const createdThread = creationResult.threads[0]; + if (createdThread === undefined) { + return yield* Effect.die(new Error("Racing MCP thread was not created.")); + } + expect( + (yield* orchestrator.getThreadProjection(createdThread.threadId)).thread.projectId, + ).toBe(deletionRaceProjectId); + + yield* Deferred.succeed(allowDeletion, undefined); + const deletionCall = yield* Fiber.join(deletion); + expect(deletionCall.isError).toBe(false); + expect(deletionCall.structuredContent).toMatchObject({ + projectId: deletionRaceProjectId, + deleted: true, + alreadyDeleted: false, + deletedThreadCount: 2, + workspaceFilesDeleted: false, + }); + expect( + (yield* orchestrator.getThreadProjection(createdThread.threadId)).thread.deletedAt, + ).not.toBeNull(); + expect( + (yield* orchestrator.getThreadProjection(deletionRaceParentThreadId)).thread + .deletedAt, + ).not.toBeNull(); + + const threads = yield* ThreadManagement.ThreadManagementService; + const dispatchClientCommand = yield* ClientCommandDispatch.make; + const acceptedCreateCommand = { + type: "thread.create", + createdBy: "agent", + creationSource: "mcp", + commandId: deletionRaceCommandId, + threadId: createdThread.threadId, + projectId: deletionRaceProjectId, + title: "Racing child", + modelSelection: codexSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + } as const; + const sequenceBeforeReplay = yield* threads.getThreadEventSequence( + createdThread.threadId, + ); + const replayed = yield* dispatchClientCommand(acceptedCreateCommand); + expect( + replayed.storedEvents.some((stored) => stored.event.type === "thread.created"), + ).toBe(true); + expect(yield* threads.getThreadEventSequence(createdThread.threadId)).toBe( + sequenceBeforeReplay, + ); + expect( + (yield* orchestrator.getThreadProjection(createdThread.threadId)).thread.deletedAt, + ).not.toBeNull(); + + const freshThreadId = ThreadId.make("thread:mcp-delete-race:fresh-after-delete"); + const freshFailure = yield* dispatchClientCommand({ + ...acceptedCreateCommand, + commandId: CommandId.make("command:mcp-delete-race:fresh-after-delete"), + threadId: freshThreadId, + }).pipe(Effect.flip); + expect(freshFailure).toMatchObject({ _tag: "ProjectMutationError" }); + expect( + Option.isNone(yield* Effect.option(orchestrator.getThreadProjection(freshThreadId))), + ).toBe(true); }).pipe(Effect.provide(testLayer)); }), ), diff --git a/apps/server/src/mcp/ProjectMcpService.test.ts b/apps/server/src/mcp/ProjectMcpService.test.ts index d4d4ed76de83..2637907adf90 100644 --- a/apps/server/src/mcp/ProjectMcpService.test.ts +++ b/apps/server/src/mcp/ProjectMcpService.test.ts @@ -462,161 +462,6 @@ it.effect("clones before registration and requires explicit cascading for nonemp }), ); -it.effect("serializes receipt-aware thread creation with project deletion", () => - Effect.gen(function* () { - const projectId = ProjectId.make("project:mcp:delete-launch-race"); - const threadId = ThreadId.make("thread:mcp:delete-launch-race"); - const modelSelection = { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.1-codex", - } as const; - const project = makeProject({ - id: projectId, - title: "Delete race", - workspaceRoot: "/work/delete-race", - }); - const projectState = yield* Ref.make(project); - const creationEntered = yield* Deferred.make(); - const allowCreation = yield* Deferred.make(); - const deleteLockAttempted = yield* Deferred.make(); - const deleteLockAcquired = yield* Deferred.make(); - const adapter = { - instanceId: modelSelection.instanceId, - driver: ProviderDriverKind.make("codex"), - getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), - planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), - openSession: () => Effect.die("provider execution is disabled in project deletion tests"), - } as ProviderAdapterV2Shape; - const registry = ProviderAdapterRegistry.makeLayer([adapter]); - const orchestrator = makeOrchestratorV2ReplayLayerWithRegistry( - { name: "project-delete-create-race" }, - registry, - { databaseLayer: SqlitePersistenceMemory, runEffectWorker: false }, - ); - const actualThreads = ThreadManagement.layer.pipe(Layer.provide(orchestrator)); - const gatedThreads = Layer.effect( - ThreadManagement.ThreadManagementService, - Effect.gen(function* () { - const actual = yield* ThreadManagement.ThreadManagementService; - return ThreadManagement.ThreadManagementService.of({ - ...actual, - withProjectMutationLock: (lockedProjectId, effect) => - Deferred.succeed(deleteLockAttempted, undefined).pipe( - Effect.andThen( - actual.withProjectMutationLock( - lockedProjectId, - Deferred.succeed(deleteLockAcquired, undefined).pipe(Effect.andThen(effect)), - ), - ), - ), - }); - }), - ).pipe(Layer.provide(actualThreads)); - const projectServiceLayer = Layer.mock(ProjectService.ProjectService)({ - getById: () => - Ref.get(projectState).pipe( - Effect.map((current) => - current.deletedAt === null ? Option.some(current) : Option.none(), - ), - ), - delete: () => Ref.updateAndGet(projectState, (current) => ({ ...current, deletedAt: now })), - }); - const testLayer = ProjectMcp.layer.pipe( - Layer.provideMerge(gatedThreads), - Layer.provide(projectServiceLayer), - Layer.provide(Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({})), - Layer.provide( - Layer.mock(ServerSettingsService.ServerSettingsService)({ - getSettings: Effect.succeed({ defaultThreadEnvMode: "local" } as ServerSettings), - }), - ), - Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), - Layer.provide(NodeServices.layer), - ); - - yield* Effect.gen(function* () { - const projects = yield* ProjectMcp.ProjectMcpService; - const threads = yield* ThreadManagement.ThreadManagementService; - const createCommand = { - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp:delete-launch-race:create"), - threadId, - projectId, - title: "Racing thread", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - } as const; - const admitCreate = (command: typeof createCommand) => - threads.withProjectCreationAdmission( - { projectId, commandId: command.commandId }, - (receipt) => - Effect.gen(function* () { - if (Option.isNone(receipt)) { - if (command.commandId === createCommand.commandId) { - yield* Deferred.succeed(creationEntered, undefined); - yield* Deferred.await(allowCreation); - } - const current = yield* Ref.get(projectState); - if (current.deletedAt !== null) { - return yield* new ProjectMcpFailure({ - code: "project_deleted", - message: "Project was deleted before thread creation was admitted.", - }); - } - } - return yield* threads.dispatch(command); - }), - ); - - const creation = yield* Effect.forkChild(admitCreate(createCommand), { - startImmediately: true, - }); - yield* Deferred.await(creationEntered); - const deletion = yield* Effect.forkChild( - projects.delete(scope, { - projectId, - cascadeThreads: true, - clientRequestId: "delete-launch-race", - }), - { startImmediately: true }, - ); - yield* Deferred.await(deleteLockAttempted); - expect(yield* Deferred.isDone(deleteLockAcquired)).toBe(false); - - yield* Deferred.succeed(allowCreation, undefined); - yield* Fiber.join(creation); - yield* Deferred.await(deleteLockAcquired); - const deleted = yield* Fiber.join(deletion); - expect(deleted).toMatchObject({ deleted: true, deletedThreadCount: 1 }); - - const sequenceBeforeReplay = yield* threads.getThreadEventSequence(threadId); - const replayed = yield* admitCreate(createCommand); - const sequenceAfterReplay = yield* threads.getThreadEventSequence(threadId); - expect(replayed.storedEvents.some((stored) => stored.event.type === "thread.created")).toBe( - true, - ); - expect(sequenceAfterReplay).toBe(sequenceBeforeReplay); - - const freshCommand = { - ...createCommand, - commandId: CommandId.make("command:mcp:delete-launch-race:fresh-after-delete"), - threadId: ThreadId.make("thread:mcp:delete-launch-race:fresh-after-delete"), - }; - const rejected = yield* admitCreate(freshCommand).pipe(Effect.flip); - expect(rejected).toMatchObject({ code: "project_deleted" }); - expect( - Option.isNone(yield* Effect.option(threads.getThreadProjection(freshCommand.threadId))), - ).toBe(true); - expect((yield* Ref.get(projectState)).deletedAt).toBe(now); - }).pipe(Effect.provide(testLayer)); - }), -); - it.effect("serializes delegated thread admission with cascading project deletion", () => Effect.gen(function* () { const projectId = ProjectId.make("project:mcp:delete-delegate-race"); diff --git a/apps/server/src/orchestration-v2/ClientCommandDispatch.test.ts b/apps/server/src/orchestration-v2/ClientCommandDispatch.test.ts index 6d164eebf857..749f8a1e44be 100644 --- a/apps/server/src/orchestration-v2/ClientCommandDispatch.test.ts +++ b/apps/server/src/orchestration-v2/ClientCommandDispatch.test.ts @@ -15,7 +15,7 @@ import * as Ref from "effect/Ref"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as ProjectService from "../project/ProjectService.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; -import { dispatchClientCommand } from "./ClientCommandDispatch.ts"; +import * as ClientCommandDispatch from "./ClientCommandDispatch.ts"; import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; @@ -59,7 +59,7 @@ it.effect("routes WebSocket thread creation through receipt-aware project admiss yield* Effect.gen(function* () { const threads = yield* ThreadManagement.ThreadManagementService; - const projects = yield* ProjectService.ProjectService; + const dispatchClientCommand = yield* ClientCommandDispatch.make; const command = { type: "thread.create", createdBy: "user", @@ -75,11 +75,11 @@ it.effect("routes WebSocket thread creation through receipt-aware project admiss worktreePath: null, } as const; - yield* dispatchClientCommand({ command, projects, threads }); + yield* dispatchClientCommand(command); const sequenceBeforeReplay = yield* threads.getThreadEventSequence(command.threadId); yield* Ref.set(projectState, null); - yield* dispatchClientCommand({ command, projects, threads }); + yield* dispatchClientCommand(command); expect(yield* threads.getThreadEventSequence(command.threadId)).toBe(sequenceBeforeReplay); const freshCommand = { @@ -87,11 +87,7 @@ it.effect("routes WebSocket thread creation through receipt-aware project admiss commandId: CommandId.make("command:client-command-admission:fresh"), threadId: ThreadId.make("thread:client-command-admission:fresh"), }; - const rejected = yield* dispatchClientCommand({ - command: freshCommand, - projects, - threads, - }).pipe(Effect.flip); + const rejected = yield* dispatchClientCommand(freshCommand).pipe(Effect.flip); expect(rejected).toMatchObject({ _tag: "ProjectMutationError" }); expect( Option.isNone(yield* Effect.option(threads.getThreadProjection(freshCommand.threadId))), diff --git a/apps/server/src/orchestration-v2/ClientCommandDispatch.ts b/apps/server/src/orchestration-v2/ClientCommandDispatch.ts index b2e483797204..7bf457093254 100644 --- a/apps/server/src/orchestration-v2/ClientCommandDispatch.ts +++ b/apps/server/src/orchestration-v2/ClientCommandDispatch.ts @@ -2,25 +2,25 @@ import { ProjectMutationError, type OrchestrationV2Command } from "@t3tools/cont import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import type * as ProjectService from "../project/ProjectService.ts"; -import type * as ThreadManagement from "./ThreadManagementService.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ThreadManagement from "./ThreadManagementService.ts"; -export const dispatchClientCommand = Effect.fn("orchestrationV2.dispatchClientCommand")( - function* (input: { - readonly command: OrchestrationV2Command; - readonly projects: ProjectService.ProjectService["Service"]; - readonly threads: ThreadManagement.ThreadManagementService["Service"]; - }) { - const command = input.command; +export const make = Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const threads = yield* ThreadManagement.ThreadManagementService; + + return Effect.fn("orchestrationV2.dispatchClientCommand")(function* ( + command: OrchestrationV2Command, + ) { if (command.type !== "thread.create") { - return yield* input.threads.dispatch(command); + return yield* threads.dispatch(command); } - return yield* input.threads.withProjectCreationAdmission( + return yield* threads.withProjectCreationAdmission( { projectId: command.projectId, commandId: command.commandId }, (receipt) => Effect.gen(function* () { if (Option.isNone(receipt)) { - const project = yield* input.projects.getById(command.projectId); + const project = yield* projects.getById(command.projectId); if (Option.isNone(project)) { return yield* new ProjectMutationError({ commandId: command.commandId, @@ -28,8 +28,8 @@ export const dispatchClientCommand = Effect.fn("orchestrationV2.dispatchClientCo }); } } - return yield* input.threads.dispatch(command); + return yield* threads.dispatch(command); }), ); - }, -); + }); +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9c7879509898..5c7f44156acc 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -93,7 +93,7 @@ import * as EnvironmentTheme from "./environmentTheme.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as ThreadManagementService from "./orchestration-v2/ThreadManagementService.ts"; -import { dispatchClientCommand } from "./orchestration-v2/ClientCommandDispatch.ts"; +import * as ClientCommandDispatch from "./orchestration-v2/ClientCommandDispatch.ts"; import { ProviderSessionManagerV2 } from "./orchestration-v2/ProviderSessionManager.ts"; import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; @@ -522,6 +522,7 @@ const makeWsRpcLayer = ( const currentSessionId = currentSession.sessionId; const sql = yield* SqlClient.SqlClient; const threadManagement = yield* ThreadManagementService.ThreadManagementService; + const dispatchClientCommand = yield* ClientCommandDispatch.make; const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const providerSessionsV2 = yield* ProviderSessionManagerV2; @@ -1271,11 +1272,7 @@ const makeWsRpcLayer = ( creationSource: "creationSource" in command ? command.creationSource : "web", }, ); - const dispatchCommand = dispatchClientCommand({ - command: provenanceCommand, - projects: projectService, - threads: threadManagement, - }); + const dispatchCommand = dispatchClientCommand(provenanceCommand); return yield* startup .enqueueCommand(dispatchCommand) .pipe( From 2bed9b34032c845d9b1679407c1ff20d37c52622 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 11:41:02 -0700 Subject: [PATCH 10/15] test(projects): harden admission race proof --- ...OrchestratorMcpToolkit.integration.test.ts | 224 ++++++++++-------- 1 file changed, 126 insertions(+), 98 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 7c7a8f656b2c..db9fd6872856 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -154,6 +154,9 @@ function makeDeterministicAdapter(input: { readonly capabilities: OrchestrationV2ProviderCapabilities; readonly capturedTurns: Ref.Ref>; readonly shouldComplete: (turn: ProviderAdapterV2TurnInput) => boolean; + readonly startedSignal?: ( + turn: ProviderAdapterV2TurnInput, + ) => Deferred.Deferred | undefined; readonly terminalGate?: (turn: ProviderAdapterV2TurnInput) => Deferred.Deferred | undefined; readonly response: (turn: ProviderAdapterV2TurnInput) => string; }): ProviderAdapterV2Shape { @@ -256,6 +259,10 @@ function makeDeterministicAdapter(input: { }, }, ]); + const startedSignal = input.startedSignal?.(turnInput); + if (startedSignal !== undefined) { + yield* Deferred.succeed(startedSignal, turnInput); + } const terminalGate = input.terminalGate?.(turnInput); if (terminalGate !== undefined) { yield* Deferred.await(terminalGate); @@ -454,9 +461,10 @@ describe("orchestrator MCP toolkit", () => { const deletionRaceProjectState = yield* Ref.make(deletionRaceProject); const creationAdmissionEntered = yield* Deferred.make(); const allowCreationAdmission = yield* Deferred.make(); - const deletionLockAttempted = yield* Deferred.make(); + const deletionLockAttemptSettled = yield* Deferred.make(); const deletionLockAcquired = yield* Deferred.make(); const allowDeletion = yield* Deferred.make(); + const deletionRaceProviderStarted = yield* Deferred.make(); const deletionRaceCommandId = CommandId.make( "command:mcp:mcp-provider-session-delete-race:create-thread:delete-create-race:0", ); @@ -473,6 +481,10 @@ describe("orchestrator MCP toolkit", () => { turn.threadId !== parentThreadId && turn.threadId !== deletionRaceParentThreadId && turn.message.text !== cancellationPrompt, + startedSignal: (turn) => + turn.threadId === deletionRaceParentThreadId + ? deletionRaceProviderStarted + : undefined, terminalGate: (turn) => turn.message.text.startsWith("Delegated task") || turn.message.text.startsWith("Delegated tasks") @@ -564,8 +576,8 @@ describe("orchestrator MCP toolkit", () => { ), withProjectMutationLock: (lockedProjectId, effect) => lockedProjectId === deletionRaceProjectId - ? Deferred.succeed(deletionLockAttempted, undefined).pipe( - Effect.andThen( + ? Effect.gen(function* () { + const locked = yield* Effect.forkChild( actual.withProjectMutationLock( lockedProjectId, Deferred.succeed(deletionLockAcquired, undefined).pipe( @@ -573,8 +585,11 @@ describe("orchestrator MCP toolkit", () => { Effect.andThen(effect), ), ), - ), - ) + { startImmediately: true }, + ); + yield* Deferred.succeed(deletionLockAttemptSettled, undefined); + return yield* Fiber.join(locked); + }) : actual.withProjectMutationLock(lockedProjectId, effect), }); }), @@ -2734,9 +2749,8 @@ describe("orchestrator MCP toolkit", () => { modelSelection: codexSelection, dispatchMode: { type: "start_immediately" }, }); - yield* waitForProjection(orchestrator, deletionRaceParentThreadId, (projection) => - projection.runs.some((run) => run.status === "running"), - ); + const startedRaceTurn = yield* Deferred.await(deletionRaceProviderStarted); + expect(startedRaceTurn.threadId).toBe(deletionRaceParentThreadId); const deletionRaceInvocation: McpInvocationContext.McpInvocationScope = { ...invocation, @@ -2753,98 +2767,112 @@ describe("orchestrator MCP toolkit", () => { ), Effect.provideService(McpSchema.McpServerClient, client), ); - const creation = yield* Effect.forkChild( - invokeDeletionRace("create_threads", { - clientRequestId: "delete-create-race", - threads: [{ title: "Racing child" }], - }), - { startImmediately: true }, - ); - yield* Deferred.await(creationAdmissionEntered); - const deletion = yield* Effect.forkChild( - invokeDeletionRace("t3_project_delete", { + yield* Effect.gen(function* () { + const creation = yield* Effect.forkChild( + invokeDeletionRace("create_threads", { + clientRequestId: "delete-create-race", + threads: [{ title: "Racing child" }], + }), + { startImmediately: true }, + ); + yield* Deferred.await(creationAdmissionEntered); + const deletion = yield* Effect.forkChild( + invokeDeletionRace("t3_project_delete", { + projectId: deletionRaceProjectId, + cascadeThreads: true, + clientRequestId: "delete-create-race-project", + }), + { startImmediately: true }, + ); + yield* Deferred.await(deletionLockAttemptSettled); + expect(yield* Deferred.isDone(deletionLockAcquired)).toBe(false); + + yield* Deferred.succeed(allowCreationAdmission, undefined); + yield* Deferred.await(deletionLockAcquired); + const creationCall = yield* Fiber.join(creation); + expect(creationCall.isError).toBe(false); + const creationResult = yield* decodeCreateThreadsResult( + creationCall.structuredContent, + ).pipe(Effect.orDie); + const createdThread = creationResult.threads[0]; + if (createdThread === undefined) { + return yield* Effect.die(new Error("Racing MCP thread was not created.")); + } + expect( + (yield* orchestrator.getThreadProjection(createdThread.threadId)).thread.projectId, + ).toBe(deletionRaceProjectId); + + yield* Deferred.succeed(allowDeletion, undefined); + const deletionCall = yield* Fiber.join(deletion); + expect(deletionCall.isError).toBe(false); + expect(deletionCall.structuredContent).toMatchObject({ projectId: deletionRaceProjectId, - cascadeThreads: true, - clientRequestId: "delete-create-race-project", - }), - { startImmediately: true }, - ); - yield* Deferred.await(deletionLockAttempted); - expect(yield* Deferred.isDone(deletionLockAcquired)).toBe(false); - - yield* Deferred.succeed(allowCreationAdmission, undefined); - yield* Deferred.await(deletionLockAcquired); - const creationCall = yield* Fiber.join(creation); - expect(creationCall.isError).toBe(false); - const creationResult = yield* decodeCreateThreadsResult( - creationCall.structuredContent, - ).pipe(Effect.orDie); - const createdThread = creationResult.threads[0]; - if (createdThread === undefined) { - return yield* Effect.die(new Error("Racing MCP thread was not created.")); - } - expect( - (yield* orchestrator.getThreadProjection(createdThread.threadId)).thread.projectId, - ).toBe(deletionRaceProjectId); - - yield* Deferred.succeed(allowDeletion, undefined); - const deletionCall = yield* Fiber.join(deletion); - expect(deletionCall.isError).toBe(false); - expect(deletionCall.structuredContent).toMatchObject({ - projectId: deletionRaceProjectId, - deleted: true, - alreadyDeleted: false, - deletedThreadCount: 2, - workspaceFilesDeleted: false, - }); - expect( - (yield* orchestrator.getThreadProjection(createdThread.threadId)).thread.deletedAt, - ).not.toBeNull(); - expect( - (yield* orchestrator.getThreadProjection(deletionRaceParentThreadId)).thread - .deletedAt, - ).not.toBeNull(); - - const threads = yield* ThreadManagement.ThreadManagementService; - const dispatchClientCommand = yield* ClientCommandDispatch.make; - const acceptedCreateCommand = { - type: "thread.create", - createdBy: "agent", - creationSource: "mcp", - commandId: deletionRaceCommandId, - threadId: createdThread.threadId, - projectId: deletionRaceProjectId, - title: "Racing child", - modelSelection: codexSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - } as const; - const sequenceBeforeReplay = yield* threads.getThreadEventSequence( - createdThread.threadId, - ); - const replayed = yield* dispatchClientCommand(acceptedCreateCommand); - expect( - replayed.storedEvents.some((stored) => stored.event.type === "thread.created"), - ).toBe(true); - expect(yield* threads.getThreadEventSequence(createdThread.threadId)).toBe( - sequenceBeforeReplay, + deleted: true, + alreadyDeleted: false, + deletedThreadCount: 2, + workspaceFilesDeleted: false, + }); + expect( + (yield* orchestrator.getThreadProjection(createdThread.threadId)).thread.deletedAt, + ).not.toBeNull(); + expect( + (yield* orchestrator.getThreadProjection(deletionRaceParentThreadId)).thread + .deletedAt, + ).not.toBeNull(); + + const threads = yield* ThreadManagement.ThreadManagementService; + const dispatchClientCommand = yield* ClientCommandDispatch.make; + const acceptedCreateCommand = { + type: "thread.create", + createdBy: "agent", + creationSource: "mcp", + commandId: deletionRaceCommandId, + threadId: createdThread.threadId, + projectId: deletionRaceProjectId, + title: "Racing child", + modelSelection: codexSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + } as const; + const sequenceBeforeReplay = yield* threads.getThreadEventSequence( + createdThread.threadId, + ); + const replayed = yield* dispatchClientCommand(acceptedCreateCommand); + expect( + replayed.storedEvents.some((stored) => stored.event.type === "thread.created"), + ).toBe(true); + expect(yield* threads.getThreadEventSequence(createdThread.threadId)).toBe( + sequenceBeforeReplay, + ); + expect( + (yield* orchestrator.getThreadProjection(createdThread.threadId)).thread.deletedAt, + ).not.toBeNull(); + + const freshThreadId = ThreadId.make("thread:mcp-delete-race:fresh-after-delete"); + const freshFailure = yield* dispatchClientCommand({ + ...acceptedCreateCommand, + commandId: CommandId.make("command:mcp-delete-race:fresh-after-delete"), + threadId: freshThreadId, + }).pipe(Effect.flip); + expect(freshFailure).toMatchObject({ _tag: "ProjectMutationError" }); + expect( + Option.isNone( + yield* Effect.option(orchestrator.getThreadProjection(freshThreadId)), + ), + ).toBe(true); + }).pipe( + Effect.ensuring( + Effect.all( + [ + Deferred.succeed(allowCreationAdmission, undefined), + Deferred.succeed(allowDeletion, undefined), + ], + { discard: true }, + ), + ), ); - expect( - (yield* orchestrator.getThreadProjection(createdThread.threadId)).thread.deletedAt, - ).not.toBeNull(); - - const freshThreadId = ThreadId.make("thread:mcp-delete-race:fresh-after-delete"); - const freshFailure = yield* dispatchClientCommand({ - ...acceptedCreateCommand, - commandId: CommandId.make("command:mcp-delete-race:fresh-after-delete"), - threadId: freshThreadId, - }).pipe(Effect.flip); - expect(freshFailure).toMatchObject({ _tag: "ProjectMutationError" }); - expect( - Option.isNone(yield* Effect.option(orchestrator.getThreadProjection(freshThreadId))), - ).toBe(true); }).pipe(Effect.provide(testLayer)); }), ), From 5aa964fbb8dcac128ed9a9116f9cc5401da0a4a4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 11:53:56 -0700 Subject: [PATCH 11/15] fix(mcp): preserve project admission failures --- .../src/mcp/OrchestratorMcpService.test.ts | 137 ++++++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 11 +- ...OrchestratorMcpToolkit.integration.test.ts | 2 +- .../ClientCommandDispatch.test.ts | 2 +- .../orchestration-v2/ClientCommandDispatch.ts | 25 +++- .../src/orchestration-v2/runtimeLayer.ts | 5 + apps/server/src/ws.ts | 2 +- 7 files changed, 176 insertions(+), 8 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index d1fd1975003a..5e14bce87ae3 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -2,14 +2,20 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { EnvironmentId, + MessageId, NodeId, + ProjectId, + ProviderDriverKind, ProviderInstanceId, RunId, + type ServerProvider, ThreadId, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; +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 { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; @@ -19,6 +25,137 @@ import type { McpInvocationScope } from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; describe("OrchestratorMcpService", () => { + it.effect("preserves a structured parent admission failure", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-create-admission-parent"); + const parentRunId = RunId.make("run:mcp-create-admission-parent"); + const parentNodeId = NodeId.make("node:mcp-create-admission-parent"); + const projectId = ProjectId.make("project:mcp-create-admission"); + const providerInstanceId = ProviderInstanceId.make("codex"); + const now = DateTime.makeUnsafe("2026-08-30T12:00:00.000Z"); + const activeParent: OrchestrationV2ThreadProjection = { + thread: { + createdBy: "agent", + creationSource: "mcp", + id: parentThreadId, + projectId, + title: "Admission parent", + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: "gpt-test" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + activeProviderThreadId: null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: parentThreadId, + }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + runs: [ + { + id: parentRunId, + threadId: parentThreadId, + ordinal: 1, + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: "gpt-test" }, + providerThreadId: null, + userMessageId: MessageId.make("message:mcp-create-admission-parent"), + rootNodeId: parentNodeId, + activeAttemptId: null, + status: "running", + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + ], + attempts: [], + nodes: [], + subagents: [], + providerSessions: [], + providerThreads: [], + providerTurns: [], + runtimeRequests: [], + messages: [], + plans: [], + turnItems: [], + checkpointScopes: [], + checkpoints: [], + contextHandoffs: [], + contextTransfers: [], + visibleTurnItems: [], + updatedAt: now, + }; + const archivedParent = { + ...activeParent, + thread: { ...activeParent.thread, archivedAt: now }, + } as OrchestrationV2ThreadProjection; + const provider = { + instanceId: providerInstanceId, + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-30T12:00:00.000Z", + models: [{ slug: "gpt-test", name: "GPT Test", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + } satisfies ServerProvider; + const projectionReads = yield* Ref.make(0); + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService)({ + getThreadProjection: () => + Ref.updateAndGet(projectionReads, (count) => count + 1).pipe( + Effect.map((count) => (count === 1 ? activeParent : archivedParent)), + ), + withProjectCreationAdmission: (_input, effect) => effect(Option.none()), + dispatch: () => Effect.die("dispatch must not run after parent admission fails"), + }), + Layer.mock(ProviderRegistry)({ getProviders: Effect.succeed([provider]) }), + Layer.mock(ScheduledTaskService)({}), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-create-admission"), + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-create-admission", + providerInstanceId, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + const error = yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service + .createThreads(scope, { + clientRequestId: "parent-admission-failure", + threads: [{ title: "Must not be created" }], + }) + .pipe(Effect.flip); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + + assert.equal(error.code, "parent_not_active"); + assert.equal( + error.message, + "Thread creation requires an active parent in the target project.", + ); + assert.equal(yield* Ref.get(projectionReads), 2); + }), + ); + it.effect("retries terminal acknowledgement with a fresh command id", () => Effect.gen(function* () { const parentThreadId = ThreadId.make("thread:mcp-ack-parent"); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 77b0cc395647..e000a211e089 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -153,6 +153,7 @@ export class OrchestratorMcpService extends Context.Service< >()("t3/mcp/OrchestratorMcpService") {} const isThreadManagementError = Schema.is(ThreadManagementError); +const isOrchestratorMcpFailure = Schema.is(OrchestratorMcpFailure); function failure(code: OrchestratorMcpFailure["code"], message: string): OrchestratorMcpFailure { return new OrchestratorMcpFailure({ code, message }); @@ -1417,10 +1418,12 @@ const make = Effect.gen(function* () { ) .pipe( Effect.mapError((error) => - failure( - "orchestration_error", - `Unable to create thread ${index + 1}: ${errorMessage(error)}`, - ), + isOrchestratorMcpFailure(error) + ? error + : failure( + "orchestration_error", + `Unable to create thread ${index + 1}: ${errorMessage(error)}`, + ), ), ); if (request.prompt !== undefined) { diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index db9fd6872856..04d29e7281b9 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -2821,7 +2821,7 @@ describe("orchestrator MCP toolkit", () => { ).not.toBeNull(); const threads = yield* ThreadManagement.ThreadManagementService; - const dispatchClientCommand = yield* ClientCommandDispatch.make; + const dispatchClientCommand = (yield* ClientCommandDispatch.make).dispatch; const acceptedCreateCommand = { type: "thread.create", createdBy: "agent", diff --git a/apps/server/src/orchestration-v2/ClientCommandDispatch.test.ts b/apps/server/src/orchestration-v2/ClientCommandDispatch.test.ts index 749f8a1e44be..fdb9cca03a2f 100644 --- a/apps/server/src/orchestration-v2/ClientCommandDispatch.test.ts +++ b/apps/server/src/orchestration-v2/ClientCommandDispatch.test.ts @@ -59,7 +59,7 @@ it.effect("routes WebSocket thread creation through receipt-aware project admiss yield* Effect.gen(function* () { const threads = yield* ThreadManagement.ThreadManagementService; - const dispatchClientCommand = yield* ClientCommandDispatch.make; + const dispatchClientCommand = (yield* ClientCommandDispatch.make).dispatch; const command = { type: "thread.create", createdBy: "user", diff --git a/apps/server/src/orchestration-v2/ClientCommandDispatch.ts b/apps/server/src/orchestration-v2/ClientCommandDispatch.ts index 7bf457093254..72bff396e408 100644 --- a/apps/server/src/orchestration-v2/ClientCommandDispatch.ts +++ b/apps/server/src/orchestration-v2/ClientCommandDispatch.ts @@ -1,15 +1,34 @@ import { ProjectMutationError, type OrchestrationV2Command } from "@t3tools/contracts"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as ProjectService from "../project/ProjectService.ts"; +import type { CommandReceiptStoreV2Error } from "./CommandReceiptStore.ts"; +import type { OrchestratorV2DispatchResult, OrchestratorV2Error } from "./Orchestrator.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; +export class ClientCommandDispatch extends Context.Service< + ClientCommandDispatch, + { + readonly dispatch: ( + command: OrchestrationV2Command, + ) => Effect.Effect< + OrchestratorV2DispatchResult, + | ProjectMutationError + | ProjectService.ProjectOperationError + | CommandReceiptStoreV2Error + | OrchestratorV2Error + >; + } +>()("t3/orchestration-v2/ClientCommandDispatch") {} + export const make = Effect.gen(function* () { const projects = yield* ProjectService.ProjectService; const threads = yield* ThreadManagement.ThreadManagementService; - return Effect.fn("orchestrationV2.dispatchClientCommand")(function* ( + const dispatch = Effect.fn("orchestrationV2.dispatchClientCommand")(function* ( command: OrchestrationV2Command, ) { if (command.type !== "thread.create") { @@ -32,4 +51,8 @@ export const make = Effect.gen(function* () { }), ); }); + + return ClientCommandDispatch.of({ dispatch }); }); + +export const layer = Layer.effect(ClientCommandDispatch, make); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index b6e24a736efe..8c7bbde04c1e 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -10,6 +10,7 @@ import { layer as projectSetupScriptRunnerLayer } from "../project/ProjectSetupS import { layer as checkpointCaptureServiceLayer } from "./CheckpointCaptureService.ts"; import { layer as checkpointServiceLayer } from "./CheckpointService.ts"; import { layer as checkpointRollbackServiceLayer } from "./CheckpointRollbackService.ts"; +import * as ClientCommandDispatch from "./ClientCommandDispatch.ts"; import { layer as commandPolicyLayer } from "./CommandPolicy.ts"; import { layerFromApplicationReceipts as commandReceiptStoreLayer } from "./CommandReceiptStore.ts"; import { layer as contextHandoffServiceLayer } from "./ContextHandoffService.ts"; @@ -185,6 +186,9 @@ const orchestratorProvided = orchestratorLayer.pipe( const threadManagementProvided = threadManagementServiceLayer.pipe( Layer.provide(Layer.merge(orchestratorProvided, legacyV1ThreadImporterProvided)), ); +const clientCommandDispatchProvided = ClientCommandDispatch.layer.pipe( + Layer.provide(Layer.merge(ProjectServiceLayerLive, threadManagementProvided)), +); export const ProjectSetupScriptRunnerLayerLive = projectSetupScriptRunnerLayer.pipe( Layer.provide(ProjectServiceLayerLive), ); @@ -257,6 +261,7 @@ export const OrchestrationV2ProductionLayerLive = Layer.mergeAll( OrchestrationLayerLive, OrchestrationV2LayerLive.pipe(Layer.provide(ProjectServiceLayerLive)), ProjectServiceLayerLive, + clientCommandDispatchProvided, threadLaunchProvided, threadLifecycleProvided, scheduledTaskProvided, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5c7f44156acc..921c8ee0a2a2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -522,7 +522,7 @@ const makeWsRpcLayer = ( const currentSessionId = currentSession.sessionId; const sql = yield* SqlClient.SqlClient; const threadManagement = yield* ThreadManagementService.ThreadManagementService; - const dispatchClientCommand = yield* ClientCommandDispatch.make; + const dispatchClientCommand = (yield* ClientCommandDispatch.ClientCommandDispatch).dispatch; const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const providerSessionsV2 = yield* ProviderSessionManagerV2; From 9755eb81f62b9e8a7cea9a254719364d5ac70027 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 12:41:25 -0700 Subject: [PATCH 12/15] fix(project): forward workspace creation option --- apps/server/src/project/http.test.ts | 11 ++++++++--- apps/server/src/project/http.ts | 3 +++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/server/src/project/http.test.ts b/apps/server/src/project/http.test.ts index ff89502183f8..26185a665e2e 100644 --- a/apps/server/src/project/http.test.ts +++ b/apps/server/src/project/http.test.ts @@ -52,6 +52,7 @@ it.effect( projectId, title: project.title, workspaceRoot: project.workspaceRoot, + createWorkspaceRootIfMissing: true, defaultThreadEnvMode: "worktree", faviconPath: "/workspace/project/icon.png", }); @@ -68,6 +69,7 @@ it.effect( projectId, title: project.title, workspaceRoot: project.workspaceRoot, + createWorkspaceRootIfMissing: false, defaultThreadEnvMode: null, faviconPath: null, }); @@ -92,15 +94,18 @@ it.effect( }); }).pipe(Effect.provide(projectLayer)); - const [createSupplied, createOmitted, createNull] = yield* Ref.get(createInputs); + const [createSupplied, createOmitted, createNullAndFalse] = yield* Ref.get(createInputs); const [updateSupplied, updateOmitted, updateNull] = yield* Ref.get(updateInputs); assert.equal(createSupplied?.defaultThreadEnvMode, "worktree"); assert.equal(createSupplied?.faviconPath, "/workspace/project/icon.png"); + assert.strictEqual(createSupplied?.createWorkspaceRootIfMissing, true); assert.equal(Object.hasOwn(createOmitted!, "defaultThreadEnvMode"), false); assert.equal(Object.hasOwn(createOmitted!, "faviconPath"), false); - assert.strictEqual(createNull?.defaultThreadEnvMode, null); - assert.strictEqual(createNull?.faviconPath, null); + assert.equal(Object.hasOwn(createOmitted!, "createWorkspaceRootIfMissing"), false); + assert.strictEqual(createNullAndFalse?.defaultThreadEnvMode, null); + assert.strictEqual(createNullAndFalse?.faviconPath, null); + assert.strictEqual(createNullAndFalse?.createWorkspaceRootIfMissing, false); assert.equal(updateSupplied?.defaultThreadEnvMode, "worktree"); assert.equal(updateSupplied?.faviconPath, "/workspace/project/icon.png"); assert.equal(Object.hasOwn(updateOmitted!, "defaultThreadEnvMode"), false); diff --git a/apps/server/src/project/http.ts b/apps/server/src/project/http.ts index 951cdf963793..d05d0b9ec9ad 100644 --- a/apps/server/src/project/http.ts +++ b/apps/server/src/project/http.ts @@ -35,6 +35,9 @@ export const projectMutationOperation = ( projectId: mutation.projectId, title: mutation.title, workspaceRoot: mutation.workspaceRoot, + ...(mutation.createWorkspaceRootIfMissing === undefined + ? {} + : { createWorkspaceRootIfMissing: mutation.createWorkspaceRootIfMissing }), ...(mutation.defaultModelSelection === undefined ? {} : { defaultModelSelection: mutation.defaultModelSelection }), From d21e86522d4d5464b1235a315153cbff7fe19ce7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 12:41:30 -0700 Subject: [PATCH 13/15] fix(mcp): order projects by timestamp instant --- apps/server/src/mcp/ProjectMcpService.test.ts | 71 ++++++++++++++++++- apps/server/src/mcp/ProjectMcpService.ts | 5 +- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/ProjectMcpService.test.ts b/apps/server/src/mcp/ProjectMcpService.test.ts index 2637907adf90..1e88d3c26686 100644 --- a/apps/server/src/mcp/ProjectMcpService.test.ts +++ b/apps/server/src/mcp/ProjectMcpService.test.ts @@ -49,6 +49,7 @@ const makeProject = (input: { readonly id: ProjectId; readonly title: string; readonly workspaceRoot: string; + readonly updatedAt?: Project["updatedAt"]; }): Project => ({ id: input.id, title: input.title, @@ -59,7 +60,7 @@ const makeProject = (input: { defaultThreadEnvMode: null, scripts: [], createdAt: now, - updatedAt: now, + updatedAt: input.updatedAt ?? now, deletedAt: null, }); @@ -710,6 +711,74 @@ it.effect("paginates summaries before loading project files and reads settings o }), ); +it.effect("orders mixed-offset project timestamps chronologically across pages", () => + Effect.gen(function* () { + const projects = [ + makeProject({ + id: ProjectId.make("project:mixed:oldest"), + title: "Oldest", + workspaceRoot: "/work/mixed-oldest", + updatedAt: "2026-08-29T09:00:00-04:00", + }), + makeProject({ + id: ProjectId.make("project:mixed:tie-b"), + title: "Tie B", + workspaceRoot: "/work/mixed-tie-b", + updatedAt: "2026-08-29T16:00:00+02:00", + }), + makeProject({ + id: ProjectId.make("project:mixed:newest"), + title: "Newest", + workspaceRoot: "/work/mixed-newest", + updatedAt: "2026-08-29T10:00:00-05:00", + }), + makeProject({ + id: ProjectId.make("project:mixed:tie-a"), + title: "Tie A", + workspaceRoot: "/work/mixed-tie-a", + updatedAt: "2026-08-29T14:00:00Z", + }), + ]; + const testLayer = ProjectMcp.layer.pipe( + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + snapshot: Effect.succeed({ projects, updatedAt: now }), + }), + ), + Layer.provide( + Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({ + load: () => Effect.succeed(Option.none()), + }), + ), + Layer.provide( + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Effect.succeed({ defaultThreadEnvMode: "local" } as ServerSettings), + }), + ), + Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), + Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const service = yield* ProjectMcp.ProjectMcpService; + const firstPage = yield* service.list(scope, { cursor: 0, limit: 2 }); + const secondPage = yield* service.list(scope, { cursor: 2, limit: 2 }); + + expect(firstPage).toMatchObject({ nextCursor: 2, totalCount: 4 }); + expect(firstPage.projects.map((project) => project.id)).toEqual([ + ProjectId.make("project:mixed:newest"), + ProjectId.make("project:mixed:tie-a"), + ]); + expect(secondPage).toMatchObject({ nextCursor: null, totalCount: 4 }); + expect(secondPage.projects.map((project) => project.id)).toEqual([ + ProjectId.make("project:mixed:tie-b"), + ProjectId.make("project:mixed:oldest"), + ]); + }).pipe(Effect.provide(testLayer)); + }), +); + it.effect("serializes overlapping create retries with the same idempotency key", () => Effect.gen(function* () { const state = yield* Ref.make(null); diff --git a/apps/server/src/mcp/ProjectMcpService.ts b/apps/server/src/mcp/ProjectMcpService.ts index 3b48ed6c3eca..7a0a356bd74c 100644 --- a/apps/server/src/mcp/ProjectMcpService.ts +++ b/apps/server/src/mcp/ProjectMcpService.ts @@ -16,6 +16,7 @@ import { import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -193,7 +194,9 @@ const make = Effect.gen(function* () { const limit = input.limit ?? 25; const sorted = snapshot.projects.toSorted( (left, right) => - right.updatedAt.localeCompare(left.updatedAt) || left.id.localeCompare(right.id), + DateTime.toEpochMillis(DateTime.makeUnsafe(right.updatedAt)) - + DateTime.toEpochMillis(DateTime.makeUnsafe(left.updatedAt)) || + left.id.localeCompare(right.id), ); const page = sorted.slice(cursor, cursor + limit); const nextCursor = cursor + page.length < sorted.length ? cursor + page.length : null; From fb49467b5407074bf9f54299bad8c390a072ba36 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 12:45:05 -0700 Subject: [PATCH 14/15] test(mcp): use project settings fixture layer --- apps/server/src/mcp/ProjectMcpService.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/server/src/mcp/ProjectMcpService.test.ts b/apps/server/src/mcp/ProjectMcpService.test.ts index 1e88d3c26686..fe45e7e52475 100644 --- a/apps/server/src/mcp/ProjectMcpService.test.ts +++ b/apps/server/src/mcp/ProjectMcpService.test.ts @@ -750,11 +750,7 @@ it.effect("orders mixed-offset project timestamps chronologically across pages", load: () => Effect.succeed(Option.none()), }), ), - Layer.provide( - Layer.mock(ServerSettingsService.ServerSettingsService)({ - getSettings: Effect.succeed({ defaultThreadEnvMode: "local" } as ServerSettings), - }), - ), + Layer.provide(ServerSettingsService.layerTest({})), Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), Layer.provide(NodeServices.layer), From 4e9c9a50cd3789059e364ff93f948b580525e0ac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 13:31:39 -0700 Subject: [PATCH 15/15] fix(server): keep project admission failures structured --- apps/server/src/mcp/ProjectMcpService.test.ts | 14 ++++++- apps/server/src/mcp/ProjectMcpService.ts | 7 +--- .../ClientCommandDispatch.test.ts | 38 +++++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 10 ++++- .../ThreadManagementService.ts | 16 ++++++-- 5 files changed, 74 insertions(+), 11 deletions(-) diff --git a/apps/server/src/mcp/ProjectMcpService.test.ts b/apps/server/src/mcp/ProjectMcpService.test.ts index fe45e7e52475..1be25861e9f1 100644 --- a/apps/server/src/mcp/ProjectMcpService.test.ts +++ b/apps/server/src/mcp/ProjectMcpService.test.ts @@ -18,6 +18,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -233,6 +234,10 @@ it.effect("loads response settings before committing project mutations", () => it.effect("redacts repository failures from public project MCP results", () => Effect.gen(function* () { const secretRemote = "https://oauth-token@example.com/private/repo.git"; + const logs: Array = []; + const logger = Logger.make(({ message }) => { + logs.push(message); + }); const testLayer = ProjectMcp.layer.pipe( Layer.provide( Layer.mock(ProjectService.ProjectService)({ @@ -274,7 +279,11 @@ it.effect("redacts repository failures from public project MCP results", () => clientRequestId: "private-clone", }) .pipe(Effect.flip); - }).pipe(Effect.provide(testLayer)); + }).pipe( + Effect.provide( + Layer.mergeAll(testLayer, Logger.layer([logger], { mergeWithExisting: false })), + ), + ); expect(error).toMatchObject({ code: "operation_failed", @@ -282,6 +291,9 @@ it.effect("redacts repository failures from public project MCP results", () => }); expect(error.message).not.toMatch(/oauth-token|example\.com|fatal|authentication/i); expect(error).not.toHaveProperty("cause"); + expect(logs).toEqual([ + ["Project MCP operation failed.", { operation: "clone-project-repository" }], + ]); }), ); diff --git a/apps/server/src/mcp/ProjectMcpService.ts b/apps/server/src/mcp/ProjectMcpService.ts index 7a0a356bd74c..65b378c25576 100644 --- a/apps/server/src/mcp/ProjectMcpService.ts +++ b/apps/server/src/mcp/ProjectMcpService.ts @@ -62,9 +62,7 @@ function failure(code: ProjectMcpFailure["code"], message: string): ProjectMcpFa function redactOperationFailure(operation: string, publicMessage: string) { return (effect: Effect.Effect): Effect.Effect => effect.pipe( - Effect.tapError((error) => - Effect.logWarning("Project MCP operation failed.", { operation, error }), - ), + Effect.tapError(() => Effect.logWarning("Project MCP operation failed.", { operation })), Effect.mapError(() => failure("operation_failed", publicMessage)), ); } @@ -340,10 +338,9 @@ const make = Effect.gen(function* () { ...(input.scripts === undefined ? {} : { scripts: input.scripts }), }) .pipe( - Effect.tapError((error) => + Effect.tapError(() => Effect.logWarning("Project MCP operation failed.", { operation: "update-project", - error, }), ), Effect.mapError((error) => diff --git a/apps/server/src/orchestration-v2/ClientCommandDispatch.test.ts b/apps/server/src/orchestration-v2/ClientCommandDispatch.test.ts index fdb9cca03a2f..214fd564d6f7 100644 --- a/apps/server/src/orchestration-v2/ClientCommandDispatch.test.ts +++ b/apps/server/src/orchestration-v2/ClientCommandDispatch.test.ts @@ -16,6 +16,7 @@ import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as ProjectService from "../project/ProjectService.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ClientCommandDispatch from "./ClientCommandDispatch.ts"; +import * as Orchestrator from "./Orchestrator.ts"; import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; @@ -95,3 +96,40 @@ it.effect("routes WebSocket thread creation through receipt-aware project admiss }).pipe(Effect.provide(Layer.mergeAll(threadsLayer, projectsLayer))); }), ); + +it.effect("returns a structured error when creation admission is unavailable", () => + Effect.gen(function* () { + const dispatchClientCommand = (yield* ClientCommandDispatch.make).dispatch; + const command = { + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:client-command-admission:unavailable"), + threadId: ThreadId.make("thread:client-command-admission:unavailable"), + projectId: ProjectId.make("project:client-command-admission:unavailable"), + title: "Unavailable creation", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.1-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + } as const; + + const error = yield* dispatchClientCommand(command).pipe(Effect.flip); + expect(error).toBeInstanceOf(Orchestrator.OrchestratorDispatchError); + expect(error).toMatchObject({ + commandId: command.commandId, + commandType: "thread.create", + }); + }).pipe( + Effect.provide( + Layer.mergeAll( + ThreadManagement.layer.pipe(Layer.provide(Orchestrator.layerUnavailable)), + Layer.mock(ProjectService.ProjectService)({}), + ), + ), + ), +); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 24d411d93719..f122cac0157e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -41,7 +41,7 @@ import * as Stream from "effect/Stream"; import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { CommandPolicyV2 } from "./CommandPolicy.ts"; -import { CommandReceiptStoreV2 } from "./CommandReceiptStore.ts"; +import { CommandReceiptStoreReadError, CommandReceiptStoreV2 } from "./CommandReceiptStore.ts"; import { ContextHandoffServiceV2 } from "./ContextHandoffService.ts"; import { EventSinkV2 } from "./EventSink.ts"; import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; @@ -7278,7 +7278,13 @@ export const layerUnavailable: Layer.Layer = Layer.succeed( cause: "Orchestration V2 live runtime is not configured.", }), ), - getCommandReceipt: () => Effect.die("Orchestration V2 live runtime is not configured."), + getCommandReceipt: (commandId) => + Effect.fail( + new CommandReceiptStoreReadError({ + commandId, + cause: "Orchestration V2 live runtime is not configured.", + }), + ), getThreadProjection: (threadId) => Effect.fail( new OrchestratorProjectionError({ diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index a6e1b0043ad3..66189d0bf0c3 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -35,7 +35,7 @@ import { LegacyV1ThreadImporter, type LegacyV1ThreadImportError, } from "./LegacyV1ThreadImporter.ts"; -import type { CommandReceiptStoreV2Error, CommandReceiptV2 } from "./CommandReceiptStore.ts"; +import type { CommandReceiptV2 } from "./CommandReceiptStore.ts"; import { makeKeyedSerialExecutor } from "./KeyedSerialExecutor.ts"; export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart"; @@ -270,7 +270,7 @@ export interface ThreadManagementServiceShape { readonly withProjectCreationAdmission: ( input: { readonly projectId: ProjectId; readonly commandId: CommandId }, effect: (receipt: Option.Option) => Effect.Effect, - ) => Effect.Effect; + ) => Effect.Effect; readonly withProjectMutationLock: ( projectId: ProjectId, effect: Effect.Effect, @@ -377,7 +377,17 @@ const make = Effect.gen(function* () { (input, effect) => projectMutations.withLock( input.projectId, - orchestrator.getCommandReceipt(input.commandId).pipe(Effect.flatMap(effect)), + orchestrator.getCommandReceipt(input.commandId).pipe( + Effect.mapError( + (cause) => + new OrchestratorDispatchError({ + commandId: input.commandId, + commandType: "thread.create", + cause, + }), + ), + Effect.flatMap(effect), + ), ); const ensureLegacyTranscript = Effect.fn(