From 04bd6a8e4ff894989d0b2d1d18a3a420c6de240f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 14:37:34 -0700 Subject: [PATCH 01/19] 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 | 9 +- apps/server/src/project/ProjectService.ts | 4 + .../SourceControlRepositoryService.test.ts | 286 +++++++- .../SourceControlRepositoryService.ts | 198 ++++-- apps/server/src/ws.ts | 4 + .../orchestrator-mcp-server.md | 37 +- docs/user/project-settings.md | 10 + .../src/operations/commands.test.ts | 1 + .../client-runtime/src/operations/commands.ts | 12 +- packages/contracts/src/index.ts | 1 + packages/contracts/src/orchestration.ts | 2 + packages/contracts/src/project.ts | 2 + packages/contracts/src/projectMcp.test.ts | 63 ++ packages/contracts/src/projectMcp.ts | 146 ++++ packages/shared/src/t3McpToolPresentation.ts | 5 + 29 files changed, 2143 insertions(+), 121 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 aeb47996e690..1a158b6d4180 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -15,8 +15,12 @@ import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; import * as ThreadMetadataMcpService from "./ThreadMetadataMcpService.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, @@ -228,6 +232,12 @@ export const OrchestratorToolkitRegistrationLive = McpServer.toolkit(Orchestrato Layer.provide(ThreadMetadataMcpService.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), @@ -243,5 +253,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: "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/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: "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/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: "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/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: "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/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: "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/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: "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/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: "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/work/cloned", + remoteUrl: "https://example.com/acme/repo.git", + }, + clientRequestId: "clone-and-delete", + }); + expect(created.workspaceRoot).toBe("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/work/cloned"); + expect(yield* Ref.get(cloneInputs)).toEqual([{ destinationPath: "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/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: "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/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: "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/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: "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/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: "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/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: "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/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: "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/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 b422190fc04c..3d4f0f141ee8 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 c0684ca2357c..9250e569dc22 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -808,6 +808,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 924cea782c2e..3e4e4f960d12 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -15,6 +15,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"; @@ -69,6 +70,7 @@ const adapter = { } as ProviderAdapterV2Shape; interface HarnessOptions { + readonly getProjectById?: ProjectService.ProjectService["Service"]["getById"]; readonly createWorktree?: GitWorkflow.GitWorkflowService["Service"]["createWorktree"]; readonly fetchRemote?: GitWorkflow.GitWorkflowService["Service"]["fetchRemote"]; readonly renameBranch?: GitWorkflow.GitWorkflowService["Service"]["renameBranch"]; @@ -115,7 +117,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"), }), @@ -218,6 +222,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 a7695f527450..e362f76bfd2a 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -436,69 +436,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 258c553b705e..435ca9f39eed 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; @@ -362,6 +367,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", @@ -657,6 +663,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 d47006f936e1..cd5847ed66d7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -512,7 +512,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, autoPull: false, faviconPath: event.payload.faviconPath ?? null, projectIcon: event.payload.projectIcon ?? null, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 75d190166403..29038c1d8df7 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -226,7 +226,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // automatic seed here, but only a metadata update records an // explicit project default. defaultModelSelection: null, - faviconPath: null, + defaultThreadEnvMode: command.defaultThreadEnvMode ?? null, + faviconPath: command.faviconPath ?? null, projectIcon: null, scripts: command.scripts ?? [], createdAt: command.createdAt, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 028f0df53586..5341064f9a3b 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -241,7 +241,7 @@ export function projectEvent( title: payload.title, workspaceRoot: payload.workspaceRoot, defaultModelSelection: payload.defaultModelSelection, - defaultThreadEnvMode: null, + defaultThreadEnvMode: payload.defaultThreadEnvMode ?? null, autoPull: false, faviconPath: payload.faviconPath ?? null, projectIcon: payload.projectIcon ?? null, diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index cc3082b5b16e..8e438383f745 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: "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/work/project/", defaultModelSelection: modelSelection, + defaultThreadEnvMode: "worktree", + faviconPath: "/work/project/custom-icon.svg", scripts: [ { id: "setup", @@ -102,15 +104,16 @@ it.layer(TestLayer)("ProjectService", (it) => { }); assert.equal(created.workspaceRoot, "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/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"), diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index f21044361110..0570a7fe0bd7 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -36,6 +36,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; } @@ -314,6 +316,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, }, diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 461bff08668a..2761311ea2a9 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -3,15 +3,17 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; 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"; @@ -54,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; @@ -95,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({ @@ -154,12 +177,20 @@ it.effect("preserves provider failures without deriving the repository message f it.effect("clones a looked-up repository into the requested destination", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-clone-parent-", }); - const destinationPath = path.join(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; @@ -177,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"], }, ]); @@ -191,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" + : "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/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 b38fe3d5c302..f5f1b80630bd 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -17,9 +17,9 @@ import { type SourceControlRepositoryInfo, type SourceControlRepositoryLookupInput, } from "@t3tools/contracts"; - import { ServerConfig } from "../config.ts"; import { expandHomePathWith } from "../pathExpansion.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); @@ -83,6 +83,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; @@ -131,9 +132,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 }) @@ -148,13 +166,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 }); } @@ -163,49 +180,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 10b593a863fc..88d86d96fba3 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1278,6 +1278,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": diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 215a350a2f04..ec010768a03f 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -7,12 +7,13 @@ 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; - rename threads, regenerate titles, and link or unlink pull requests; -- 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 @@ -141,7 +142,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` @@ -404,6 +431,8 @@ orchestration_error `packages/contracts/src/threadMetadataMcp.ts` - MCP services: `apps/server/src/mcp/OrchestratorMcpService.ts` and the focused `apps/server/src/mcp/ThreadMetadataMcpService.ts` +- Project MCP schemas: `packages/contracts/src/projectMcp.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 747bc52c07ec..0616ca978dbf 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -16,3 +16,13 @@ upstream. T3 Code only pulls when it can fast-forward and the checkout has no changed files, untracked files, or local commits. It skips checkouts on another branch or without an upstream. If a checkout has local work, resolve it yourself before automatic pulls can resume. + +## 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 58091de8ff18..0953082ac9e5 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -50,6 +50,7 @@ import { settleThread, startThreadTurn, unsettleThread, + updateProject, updateThreadMetadata, } from "./commands.ts"; diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 4e76017bacb1..f44500100d00 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -19,8 +19,8 @@ import { type RunId, type RuntimeMode, type RuntimeRequestId, - type ThreadId, type ThreadEnvMode, + type ThreadId, type UploadChatAttachment, } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; @@ -40,6 +40,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; } @@ -283,6 +285,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; } | { @@ -292,6 +296,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; } | { @@ -319,6 +325,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 }), }); }); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 092de0f48f26..c1ac45ea6815 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -38,6 +38,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 2c2990a56520..e271a41cb710 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -446,6 +446,8 @@ export const ProjectCreateCommand = Schema.Struct({ // Retained for older clients that sent an automatic create-time seed. The // server ignores it; explicit project defaults use project.meta.update. 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 aa67ed072e13..c148e1a0faf2 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -122,6 +122,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({ 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: "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/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: "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/work/provider-clone", + provider: "github", + repository: "acme/repo", + }, + }).source, + ).toMatchObject({ provider: "github", repository: "acme/repo" }); + + expect(() => + decodeCreateInput({ + title: "Ambiguous clone", + source: { + type: "clone", + destinationPath: "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/work/ambiguous", + remoteUrl: "https://example.com/acme/repo.git", + provider: "github", + repository: "acme/repo", + }, + }), + ).toThrow(); + expect(() => + decodeCreateInput({ + title: "Incomplete clone", + source: { + type: "clone", + destinationPath: "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/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 a9dcd685cc0c..91af118f0005 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -52,6 +52,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 d4eedd0ac850cd633da147cc4c1bc94558b22fc8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 15:57:36 -0700 Subject: [PATCH 02/19] 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 +- 3 files changed, 239 insertions(+), 4 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: "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/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 88fe7598325f..de82ee9ef865 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2034,6 +2034,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); @@ -4549,6 +4556,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 435ca9f39eed..c261d2a61f66 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -434,8 +434,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( From 873f0e8638589fc9dc3000c3114590c27a00b90c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:00:15 -0700 Subject: [PATCH 03/19] 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: "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/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: "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/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 8e438383f745..37738dbaee79 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -166,6 +166,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: "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/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 ec010768a03f..a3080825d828 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -159,6 +159,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/19] 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 b88249113a96febfabc9e882c8813b9339690e57 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:11:22 -0700 Subject: [PATCH 05/19] 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 e395ca9de7cc25314e85616f41b62a52e88d63de Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 16:11:25 -0700 Subject: [PATCH 06/19] fix(server): preserve project defaults in HTTP mutations --- apps/server/src/project/http.test.ts | 102 ++++++++++++++++++++++++++- apps/server/src/project/http.ts | 77 ++++++++++++-------- 2 files changed, 146 insertions(+), 33 deletions(-) diff --git a/apps/server/src/project/http.test.ts b/apps/server/src/project/http.test.ts index ba20c5fbbc9f..5229d381b8f3 100644 --- a/apps/server/src/project/http.test.ts +++ b/apps/server/src/project/http.test.ts @@ -1,18 +1,116 @@ 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, ProjectNotEmptyError, 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: "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/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 ProjectNotEmptyError({ projectId }), diff --git a/apps/server/src/project/http.ts b/apps/server/src/project/http.ts index 0477bcd3b8f0..ed520d783800 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"; @@ -28,6 +29,50 @@ 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.autoPull === undefined ? {} : { autoPull: mutation.autoPull }), + ...(mutation.projectIcon === undefined ? {} : { projectIcon: mutation.projectIcon }), + ...(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, + ...(mutation.force === undefined ? {} : { force: mutation.force }), + }); + export const projectHttpApiLayer = HttpApiBuilder.group( EnvironmentHttpApi, "projects", @@ -51,37 +96,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, - ...(mutation.force === undefined ? {} : { force: mutation.force }), - }); + const operation = projectMutationOperation(projects, args.payload); return yield* startup.enqueueCommand(operation).pipe(Effect.catch(failProjectMutation)); }), ); From 7e7069a26bd80036811d47deee490a5aa00c2c10 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 10:21:13 -0700 Subject: [PATCH 07/19] 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 | 14 ++-- 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, 176 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: "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/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 f5f1b80630bd..fbffe03ee34f 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -310,7 +310,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 e271a41cb710..e964393c9e78 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -26,7 +26,13 @@ import { RuntimeMode, } from "./providerPolicy.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; -import { ProjectScript, ProjectIconOverride } from "./project.ts"; +import { + Project, + ProjectFaviconPath, + ProjectIconOverride, + ProjectScript, + ProjectScriptIcon, +} from "./project.ts"; export { ProjectIconColor, ProjectIconOverride } from "./project.ts"; import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; import { OrchestrationProjectShell } from "./orchestrationProject.ts"; @@ -61,11 +67,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: "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/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 c148e1a0faf2..16814b7006ab 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", @@ -84,7 +90,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)), projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), defaultModelSelection: Schema.NullOr(ModelSelection), defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), @@ -123,7 +129,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({ @@ -135,7 +141,7 @@ export const ProjectMutation = Schema.Union([ defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), autoPull: Schema.optional(Schema.Boolean), projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), - faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), scripts: Schema.optional(Schema.Array(ProjectScript)), }), 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: "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/work/icon" }, + faviconPath: "/work/icon/favicon.svg", + }).faviconPath, + ).toBe("/work/icon/favicon.svg"); + expect(() => + decodeCreateInput({ + title: "Secret project", + source: { type: "existing_directory", workspaceRoot: "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/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 adec222654645ee180e4161611d2b79ded327a32 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 11:02:16 -0700 Subject: [PATCH 08/19] 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 ea0abc1e9785..cf3b9a46389f 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1433,26 +1433,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: "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/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: "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/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 de82ee9ef865..68fe9e6d0175 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -181,6 +181,7 @@ export interface OrchestratorV2Shape { readonly dispatch: ( command: OrchestrationV2Command, ) => Effect.Effect; + readonly getCommandReceipt: CommandReceiptStoreV2["Service"]["getByCommandId"]; readonly getThreadProjection: ( threadId: ThreadId, ) => Effect.Effect; @@ -7349,6 +7350,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return OrchestratorV2.of({ resumeQueuedRuns, dispatch: dispatchWithReceipt, + getCommandReceipt: commandReceipts.getByCommandId, getThreadProjection: (threadId) => projectionStore .getThreadProjection(threadId) @@ -7457,6 +7459,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 3e4e4f960d12..089ac59f0219 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -268,6 +268,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 e362f76bfd2a..db0a27aa4e0a 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -436,84 +436,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 c261d2a61f66..c79b5f26ae7e 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, @@ -369,6 +374,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) { @@ -679,6 +691,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 88d86d96fba3..542423e34fea 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -90,6 +90,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"; @@ -1333,15 +1334,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 9fb259c06461a06aa2ea69fa6b886c993a2ff358 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 11:17:01 -0700 Subject: [PATCH 09/19] test(projects): prove shared creation admission --- ...OrchestratorMcpToolkit.integration.test.ts | 243 +++++++++++++++++- 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, 260 insertions(+), 191 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index d9e06a5bacb6..25006a9d23f4 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, @@ -37,6 +38,7 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; @@ -48,8 +50,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 { CodexOrchestratorReplayHarness } from "../orchestration-v2/Adapters/CodexAdapterV2.testkit.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, @@ -71,13 +74,18 @@ import { materializeReplayTranscriptWorkspace, } from "../orchestration-v2/testkit/ReplayTranscriptNdjson.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"; import { delegatedTaskRun, hasPendingChildRuns } from "./OrchestratorMcpService.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"; @@ -472,6 +480,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>(); @@ -482,7 +512,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") @@ -554,10 +586,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, @@ -613,11 +677,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), ); @@ -2876,6 +2969,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: "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/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 542423e34fea..02f9c5901fac 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -90,7 +90,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"; @@ -528,6 +528,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 canReplayPersistedRange = Effect.fnUntraced(function* ( @@ -1341,11 +1342,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 47d0a44fd0be3c8614283f0c5553a2c3054bddc1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 11:41:02 -0700 Subject: [PATCH 10/19] 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 25006a9d23f4..3a16903d35dc 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -184,6 +184,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 { @@ -286,6 +289,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); @@ -496,9 +503,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", ); @@ -515,6 +523,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") @@ -606,8 +618,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( @@ -615,8 +627,11 @@ describe("orchestrator MCP toolkit", () => { Effect.andThen(effect), ), ), - ), - ) + { startImmediately: true }, + ); + yield* Deferred.succeed(deletionLockAttemptSettled, undefined); + return yield* Fiber.join(locked); + }) : actual.withProjectMutationLock(lockedProjectId, effect), }); }), @@ -2996,9 +3011,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, @@ -3015,98 +3029,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 5ab5e37a9ddd7a950dfc6c21a1c90da8dcf2b27c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 11:53:56 -0700 Subject: [PATCH 11/19] 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 cf3b9a46389f..f6c7b3528b86 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -154,6 +154,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 }); @@ -1480,10 +1481,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 3a16903d35dc..e94b88ba9d0d 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -3083,7 +3083,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 637916e7f9bf..6f257e25eb52 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -11,6 +11,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"; @@ -201,6 +202,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), ); @@ -275,6 +279,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 02f9c5901fac..ab5546a2c087 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -528,7 +528,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 canReplayPersistedRange = Effect.fnUntraced(function* ( From f619cdf30ceb323f6594ed1c7afb9a71ffed180a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 12:41:25 -0700 Subject: [PATCH 12/19] 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 5229d381b8f3..6a11a87fab77 100644 --- a/apps/server/src/project/http.test.ts +++ b/apps/server/src/project/http.test.ts @@ -53,6 +53,7 @@ it.effect( projectId, title: project.title, workspaceRoot: project.workspaceRoot, + createWorkspaceRootIfMissing: true, defaultThreadEnvMode: "worktree", faviconPath: "/workspace/project/icon.png", }); @@ -69,6 +70,7 @@ it.effect( projectId, title: project.title, workspaceRoot: project.workspaceRoot, + createWorkspaceRootIfMissing: false, defaultThreadEnvMode: null, faviconPath: null, }); @@ -93,15 +95,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 ed520d783800..d82137e010d1 100644 --- a/apps/server/src/project/http.ts +++ b/apps/server/src/project/http.ts @@ -39,6 +39,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 2ec2280af10ca3214f009220b739c4c98266e026 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 12:41:30 -0700 Subject: [PATCH 13/19] 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: "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/work/mixed-oldest", + updatedAt: "2026-08-29T09:00:00-04:00", + }), + makeProject({ + id: ProjectId.make("project:mixed:tie-b"), + title: "Tie B", + workspaceRoot: "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/work/mixed-tie-b", + updatedAt: "2026-08-29T16:00:00+02:00", + }), + makeProject({ + id: ProjectId.make("project:mixed:newest"), + title: "Newest", + workspaceRoot: "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/work/mixed-newest", + updatedAt: "2026-08-29T10:00:00-05:00", + }), + makeProject({ + id: ProjectId.make("project:mixed:tie-a"), + title: "Tie A", + workspaceRoot: "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/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 027c99e8c7b9df5ac6be5ef8195aa295c5bbc7e5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 12:45:05 -0700 Subject: [PATCH 14/19] 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 e7b538592f4a9d96c2ccc665dc7a9e5f5c2cc871 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 13:31:39 -0700 Subject: [PATCH 15/19] 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 68fe9e6d0175..9c14f305e5bf 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"; @@ -7459,7 +7459,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 c79b5f26ae7e..ebcbac200b56 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, @@ -378,7 +378,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( From 6c559d7657bb780afbb65a94405d2509a72637f3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:28:20 -0700 Subject: [PATCH 16/19] fix(projects): share creation and deletion admission --- ...OrchestratorMcpToolkit.integration.test.ts | 117 ++++++---- apps/server/src/mcp/ProjectMcpService.test.ts | 94 +++++--- apps/server/src/mcp/ProjectMcpService.ts | 84 ++++---- .../orchestration-v2/ThreadCommandExecutor.ts | 28 ++- .../ThreadLaunchService.test.ts | 1 + .../ThreadManagementService.ts | 17 +- .../project/ProjectService.deletion.test.ts | 78 ++++++- .../server/src/project/ProjectService.test.ts | 2 +- apps/server/src/project/ProjectService.ts | 204 ++++++++++-------- .../src/relay/AgentAwarenessRelay.test.ts | 3 + 10 files changed, 405 insertions(+), 223 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index e94b88ba9d0d..ed7d3252f645 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -51,6 +51,10 @@ import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/Claud import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import { CodexOrchestratorReplayHarness } from "../orchestration-v2/Adapters/CodexAdapterV2.testkit.ts"; import * as ClientCommandDispatch from "../orchestration-v2/ClientCommandDispatch.ts"; +import { + ThreadCommandExecutor, + layer as threadCommandExecutorLayer, +} from "../orchestration-v2/ThreadCommandExecutor.ts"; import { OrchestratorV2, type OrchestratorV2Shape } from "../orchestration-v2/Orchestrator.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; import { @@ -616,23 +620,6 @@ describe("orchestrator MCP toolkit", () => { ) : effect(receipt), ), - withProjectMutationLock: (lockedProjectId, effect) => - lockedProjectId === deletionRaceProjectId - ? Effect.gen(function* () { - const locked = yield* Effect.forkChild( - actual.withProjectMutationLock( - lockedProjectId, - Deferred.succeed(deletionLockAcquired, undefined).pipe( - Effect.andThen(Deferred.await(allowDeletion)), - Effect.andThen(effect), - ), - ), - { startImmediately: true }, - ); - yield* Deferred.succeed(deletionLockAttemptSettled, undefined); - return yield* Fiber.join(locked); - }) - : actual.withProjectMutationLock(lockedProjectId, effect), }); }), ).pipe(Layer.provide(threadManagementLayer)); @@ -692,26 +679,80 @@ describe("orchestrator MCP toolkit", () => { runNow: () => Effect.die("ScheduledTaskService.runNow is unused in this test"), }), ); - 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 projectLayer = Layer.effect( + ProjectService.ProjectService, + Effect.gen(function* () { + const threadCommands = yield* ThreadCommandExecutor; + const threads = yield* ThreadManagement.ThreadManagementService; + return ProjectService.ProjectService.of({ + create: () => Effect.die("unused"), + bootstrap: () => Effect.die("unused"), + update: () => Effect.die("unused"), + delete: () => Effect.die("unused"), + deleteDetailed: ({ commandId, projectId: deletedProjectId }) => + Effect.gen(function* () { + if (deletedProjectId !== deletionRaceProjectId) { + return yield* Effect.die(`Unexpected project deletion: ${deletedProjectId}`); + } + const deletion = yield* Effect.forkChild( + threadCommands.withProjectLock( + deletedProjectId, + Deferred.succeed(deletionLockAcquired, undefined).pipe( + Effect.andThen(Deferred.await(allowDeletion)), + Effect.andThen( + Effect.gen(function* () { + const snapshot = yield* threads.getShellSnapshot(); + const projectThreads = [ + ...snapshot.threads, + ...snapshot.archivedThreads, + ].filter((thread) => thread.projectId === deletedProjectId); + yield* Effect.forEach( + projectThreads, + (thread) => + threads.dispatch({ + type: "thread.delete", + commandId: CommandId.make( + `${commandId}:delete-thread:${thread.id}`, + ), + threadId: thread.id, + }), + { concurrency: 1, discard: true }, + ); + const project = yield* Ref.updateAndGet( + deletionRaceProjectState, + (current) => ({ + ...current, + deletedAt: "2026-08-30T00:01:00.000Z", + updatedAt: "2026-08-30T00:01:00.000Z", + }), + ); + return { project, deletedThreadCount: projectThreads.length }; + }), + ), + ), + ), + { startImmediately: true }, + ); + yield* Deferred.succeed(deletionLockAttemptSettled, undefined); + return yield* Fiber.join(deletion); + }), + 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()), + getByWorkspaceRoot: () => Effect.die("unused"), + snapshot: Effect.die("unused"), + }); + }), + ).pipe( + Layer.provide(Layer.merge(threadCommandExecutorLayer, gatedThreadManagementLayer)), + ); const toolkitRegistrationLayer = Layer.merge( McpHttpServer.OrchestratorToolkitRegistrationLive, McpHttpServer.ProjectToolkitRegistrationLive, @@ -3159,7 +3200,7 @@ describe("orchestrator MCP toolkit", () => { ); const orchestrationLayer = Layer.merge( orchestratorLayer, - threadManagementServiceLayer.pipe(Layer.provide(orchestratorLayer)), + ThreadManagement.layer.pipe(Layer.provide(orchestratorLayer)), ); const providerRegistryLayer = makeProviderRegistryLayer([ makeProviderSnapshot({ diff --git a/apps/server/src/mcp/ProjectMcpService.test.ts b/apps/server/src/mcp/ProjectMcpService.test.ts index 1be25861e9f1..17e69d1527f0 100644 --- a/apps/server/src/mcp/ProjectMcpService.test.ts +++ b/apps/server/src/mcp/ProjectMcpService.test.ts @@ -26,6 +26,10 @@ import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import { + ThreadCommandExecutor, + layer as threadCommandExecutorLayer, +} from "../orchestration-v2/ThreadCommandExecutor.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; import { makeOrchestratorV2ReplayLayerWithRegistry } from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; import * as ProjectService from "../project/ProjectService.ts"; @@ -111,6 +115,7 @@ it.effect("creates, reads, and updates project defaults through the project MCP return project; }), delete: () => Effect.die("unused"), + deleteDetailed: () => Effect.die("unused"), getById: (projectId) => Ref.get(state).pipe( Effect.map((projects) => Option.fromNullishOr(projects.get(projectId))), @@ -307,9 +312,7 @@ it.effect("clones before registration and requires explicit cascading for nonemp }); 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)({ @@ -331,12 +334,12 @@ it.effect("clones before registration and requires explicit cascading for nonemp : Option.some(current), ), ), - delete: ({ projectId }) => + deleteDetailed: ({ 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; + return { project: deleted, deletedThreadCount: 2 }; }), }), ), @@ -367,10 +370,6 @@ it.effect("clones before registration and requires explicit cascading for nonemp ), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ - withProjectMutationLock: (lockedProjectId, effect) => - Ref.update(lockedProjects, (current) => [...current, lockedProjectId]).pipe( - Effect.andThen(effect), - ), getShellSnapshot: (options) => Effect.succeed({ schemaVersion: 1, @@ -395,13 +394,6 @@ it.effect("clones before registration and requires explicit cascading for nonemp ] : [], }), - 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), @@ -439,7 +431,6 @@ it.effect("clones before registration and requires explicit cascading for nonemp workspaceRoot: "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/work/cloned", workspaceFilesDeleted: false, }); - expect(yield* Ref.get(deletedThreadCommands)).toHaveLength(2); expect(yield* Ref.get(deletedProjects)).toEqual([projectId]); const replayed = yield* service.delete(scope, { @@ -454,7 +445,6 @@ it.effect("clones before registration and requires explicit cascading for nonemp workspaceFilesDeleted: false, }); expect(yield* Ref.get(deletedProjects)).toEqual([projectId]); - expect(yield* Ref.get(lockedProjects)).toEqual([projectId, projectId, projectId]); const createRetry = yield* service .create(scope, { @@ -532,15 +522,6 @@ it.effect("serializes delegated thread admission with cascading project deletion 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)), - ), - ), - ), getShellSnapshot: (options) => actual.getShellSnapshot(options).pipe( Effect.tap(() => Deferred.succeed(snapshotEntered, undefined)), @@ -549,15 +530,62 @@ it.effect("serializes delegated thread admission with cascading project deletion }); }), ).pipe(Layer.provide(actualThreads)); + const projectLayer = Layer.effect( + ProjectService.ProjectService, + Effect.gen(function* () { + const threadCommands = yield* ThreadCommandExecutor; + const threadManagement = yield* ThreadManagement.ThreadManagementService; + return ProjectService.ProjectService.of({ + create: () => Effect.die("unused"), + bootstrap: () => Effect.die("unused"), + update: () => Effect.die("unused"), + delete: () => Effect.die("unused"), + deleteDetailed: ({ commandId, projectId: deletedProjectId }) => + Effect.gen(function* () { + const deletion = yield* Effect.forkChild( + threadCommands.withProjectLock( + deletedProjectId, + Deferred.succeed(deleteLockAcquired, undefined).pipe( + Effect.andThen( + Effect.gen(function* () { + const snapshot = yield* threadManagement.getShellSnapshot(); + const projectThreads = [ + ...snapshot.threads, + ...snapshot.archivedThreads, + ].filter((thread) => thread.projectId === deletedProjectId); + yield* Effect.forEach( + projectThreads, + (thread) => + threadManagement.dispatch({ + type: "thread.delete", + commandId: CommandId.make(`${commandId}:delete-thread:${thread.id}`), + threadId: thread.id, + }), + { concurrency: 1, discard: true }, + ); + const project = yield* Ref.updateAndGet(projectState, (current) => ({ + ...current, + deletedAt: now, + })); + return { project, deletedThreadCount: projectThreads.length }; + }), + ), + ), + ), + { startImmediately: true }, + ); + yield* Deferred.succeed(deleteLockAttempted, undefined); + return yield* Fiber.join(deletion); + }), + getById: () => Ref.get(projectState).pipe(Effect.map(Option.some)), + getByWorkspaceRoot: () => Effect.die("unused"), + snapshot: Effect.die("unused"), + }); + }), + ).pipe(Layer.provide(Layer.merge(threadCommandExecutorLayer, gatedThreads))); 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(projectLayer), Layer.provide(Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({})), Layer.provide( Layer.mock(ServerSettingsService.ServerSettingsService)({ diff --git a/apps/server/src/mcp/ProjectMcpService.ts b/apps/server/src/mcp/ProjectMcpService.ts index 65b378c25576..678f38196722 100644 --- a/apps/server/src/mcp/ProjectMcpService.ts +++ b/apps/server/src/mcp/ProjectMcpService.ts @@ -372,17 +372,17 @@ const make = Effect.gen(function* () { workspaceFilesDeleted: false, } satisfies ProjectMcpDeleteResult; } - const [active, archived] = yield* Effect.all([ - threads.getShellSnapshot({ location: "active" }), - threads.getShellSnapshot({ location: "archive" }), - ]).pipe( - redactOperationFailure( - "inspect-project-threads", - "Unable to inspect the project's threads.", - ), - ); - const projectThreads = [ - ...new Map( + if (input.cascadeThreads !== true) { + const [active, archived] = yield* Effect.all([ + threads.getShellSnapshot({ location: "active" }), + threads.getShellSnapshot({ location: "archive" }), + ]).pipe( + redactOperationFailure( + "inspect-project-threads", + "Unable to inspect the project's threads.", + ), + ); + const threadCount = new Set( [ ...active.threads, ...active.archivedThreads, @@ -390,38 +390,18 @@ const make = Effect.gen(function* () { ...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.`, - ); + .map((thread) => thread.id), + ).size; + if (threadCount > 0) { + return yield* failure( + "project_not_empty", + `Project '${input.projectId}' has ${threadCount} thread${threadCount === 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( - redactOperationFailure( - "delete-project-threads", - "Unable to delete the project's thread records.", - ), - ); - yield* projects - .delete({ + const deletion = yield* projects + .deleteDetailed({ commandId: stableCommandId({ scope, requestKey: key, @@ -429,20 +409,34 @@ const make = Effect.gen(function* () { suffix: input.projectId, }), projectId: input.projectId, + ...(input.cascadeThreads === true ? { force: true } : {}), }) - .pipe(redactOperationFailure("delete-project", "Unable to delete the requested project.")); + .pipe( + Effect.tapError(() => + Effect.logWarning("Project MCP operation failed.", { operation: "delete-project" }), + ), + Effect.mapError((error) => + error._tag === "ProjectNotEmptyError" + ? failure( + "project_not_empty", + `Project '${input.projectId}' is not empty. Retry with cascadeThreads=true to delete its thread records first. Workspace files will remain untouched.`, + ) + : error._tag === "ProjectNotFoundError" + ? failure("project_not_found", `Project '${input.projectId}' was not found.`) + : failure("operation_failed", "Unable to delete the requested project."), + ), + ); return { projectId: input.projectId, deleted: true, alreadyDeleted: false, - deletedThreadCount: projectThreads.length, + deletedThreadCount: deletion.deletedThreadCount, workspaceRoot: project.workspaceRoot, workspaceFilesDeleted: false, } satisfies ProjectMcpDeleteResult; }); - const deleteProject: ProjectMcpService["Service"]["delete"] = (scope, input) => - threads.withProjectMutationLock(input.projectId, deleteProjectUnderLock(scope, input)); + const deleteProject: ProjectMcpService["Service"]["delete"] = deleteProjectUnderLock; return ProjectMcpService.of({ list, read, create, update, delete: deleteProject }); }); diff --git a/apps/server/src/orchestration-v2/ThreadCommandExecutor.ts b/apps/server/src/orchestration-v2/ThreadCommandExecutor.ts index c9863776d44a..eaa2bd254b3a 100644 --- a/apps/server/src/orchestration-v2/ThreadCommandExecutor.ts +++ b/apps/server/src/orchestration-v2/ThreadCommandExecutor.ts @@ -1,13 +1,33 @@ -import type { ThreadId } from "@t3tools/contracts"; +import type { ProjectId, ThreadId } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { makeKeyedSerialExecutor, type KeyedSerialExecutor } from "./KeyedSerialExecutor.ts"; +import { makeKeyedSerialExecutor } from "./KeyedSerialExecutor.ts"; + +interface ThreadCommandExecutorShape { + readonly withLock: ( + threadId: ThreadId, + effect: Effect.Effect, + ) => Effect.Effect; + readonly withProjectLock: ( + projectId: ProjectId, + effect: Effect.Effect, + ) => Effect.Effect; +} /** Shared by thread commands and project deletion so both plan against current thread state. */ export class ThreadCommandExecutor extends Context.Service< ThreadCommandExecutor, - KeyedSerialExecutor + ThreadCommandExecutorShape >()("t3/orchestration-v2/ThreadCommandExecutor") {} -export const layer = Layer.effect(ThreadCommandExecutor, makeKeyedSerialExecutor()); +const make = Effect.gen(function* () { + const executor = yield* makeKeyedSerialExecutor(); + return { + withLock: (threadId, effect) => executor.withLock(`thread:${threadId}`, effect), + withProjectLock: (projectId, effect) => executor.withLock(`project:${projectId}`, effect), + } satisfies ThreadCommandExecutorShape; +}); + +export const layer = Layer.effect(ThreadCommandExecutor, make); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 089ac59f0219..893c85007473 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -117,6 +117,7 @@ function makeHarness(options: HarnessOptions = {}) { bootstrap: () => Effect.die("unused"), update: () => Effect.die("unused"), delete: () => Effect.die("unused"), + deleteDetailed: () => Effect.die("unused"), getById: options.getProjectById ?? ((id) => Effect.succeed(id === projectId ? Option.some(project) : Option.none())), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index ebcbac200b56..cebedb291cbd 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -36,7 +36,10 @@ import { type LegacyV1ThreadImportError, } from "./LegacyV1ThreadImporter.ts"; import type { CommandReceiptV2 } from "./CommandReceiptStore.ts"; -import { makeKeyedSerialExecutor } from "./KeyedSerialExecutor.ts"; +import { + ThreadCommandExecutor, + layer as threadCommandExecutorLayer, +} from "./ThreadCommandExecutor.ts"; export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart"; @@ -372,11 +375,11 @@ export function latestSteerableRun( const make = Effect.gen(function* () { const orchestrator = yield* OrchestratorV2; const legacyImporter = yield* LegacyV1ThreadImporter; - const projectMutations = yield* makeKeyedSerialExecutor(); + const threadCommands = yield* ThreadCommandExecutor; const withProjectCreationAdmission: ThreadManagementServiceShape["withProjectCreationAdmission"] = (input, effect) => - projectMutations.withLock( + threadCommands.withProjectLock( input.projectId, orchestrator.getCommandReceipt(input.commandId).pipe( Effect.mapError( @@ -470,7 +473,7 @@ const make = Effect.gen(function* () { return ensureCommandTranscripts(command).pipe( Effect.andThen(orchestrator.getThreadProjection(admissionThreadId)), Effect.flatMap((projection) => - projectMutations.withLock(projection.thread.projectId, dispatchCommand), + threadCommands.withProjectLock(projection.thread.projectId, dispatchCommand), ), ); }; @@ -702,7 +705,7 @@ const make = Effect.gen(function* () { return ThreadManagementService.of({ withProjectCreationAdmission, - withProjectMutationLock: projectMutations.withLock, + withProjectMutationLock: threadCommands.withProjectLock, ensureLegacyTranscript, dispatch, getThreadProjection, @@ -736,10 +739,10 @@ const legacyV1ThreadImporterNoopLayer = Layer.succeed( export const layer: Layer.Layer = Layer.effect( ThreadManagementService, make, -).pipe(Layer.provide(legacyV1ThreadImporterNoopLayer)); +).pipe(Layer.provide(legacyV1ThreadImporterNoopLayer), Layer.provide(threadCommandExecutorLayer)); export const layerWithLegacyImporter: Layer.Layer< ThreadManagementService, never, LegacyV1ThreadImporter | OrchestratorV2 -> = Layer.effect(ThreadManagementService, make); +> = Layer.effect(ThreadManagementService, make).pipe(Layer.provide(threadCommandExecutorLayer)); diff --git a/apps/server/src/project/ProjectService.deletion.test.ts b/apps/server/src/project/ProjectService.deletion.test.ts index 01825da3348c..ffd29dd27e95 100644 --- a/apps/server/src/project/ProjectService.deletion.test.ts +++ b/apps/server/src/project/ProjectService.deletion.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { assert, it, vi } from "@effect/vitest"; import { CommandId, EventId, @@ -9,7 +9,9 @@ import { ThreadId, } from "@t3tools/contracts"; 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 Schema from "effect/Schema"; @@ -39,7 +41,10 @@ import { ProjectionStoreV2, layer as projectionStoreLayer, } from "../orchestration-v2/ProjectionStore.ts"; -import { layer as threadCommandExecutorLayer } from "../orchestration-v2/ThreadCommandExecutor.ts"; +import { + ThreadCommandExecutor, + layer as threadCommandExecutorLayer, +} from "../orchestration-v2/ThreadCommandExecutor.ts"; import { ProjectionProjectRepositoryLive } from "../persistence/Layers/ProjectionProjects.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -136,6 +141,75 @@ function nativeThreadCreated(projectId: ProjectId, threadId: ThreadId) { }; } +it.effect("serializes admitted thread creation with project deletion", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project:create-delete-race"); + const threadId = ThreadId.make("thread:create-delete-race"); + const createCommandId = CommandId.make("command:create-delete-race:create"); + const deleteCommandId = CommandId.make("command:create-delete-race:delete"); + yield* seedProject(projectId); + + yield* Effect.gen(function* () { + const executor = yield* ThreadCommandExecutor; + const eventSink = yield* EventSinkV2; + const projections = yield* ProjectionStoreV2; + const service = yield* ProjectService.make; + const creationEntered = yield* Deferred.make(); + const allowCreation = yield* Deferred.make(); + const deletionAttempted = yield* Deferred.make(); + const withProjectLock = executor.withProjectLock; + let projectLockCalls = 0; + const lockSpy = vi.spyOn(executor, "withProjectLock").mockImplementation((key, effect) => { + if (key !== projectId || ++projectLockCalls !== 2) { + return withProjectLock(key, effect); + } + return Deferred.succeed(deletionAttempted, undefined).pipe( + Effect.andThen(withProjectLock(key, effect)), + ); + }); + + yield* Effect.gen(function* () { + const creation = yield* executor + .withProjectLock( + projectId, + Deferred.succeed(creationEntered, undefined).pipe( + Effect.andThen(Deferred.await(allowCreation)), + Effect.andThen( + eventSink.commitCommand({ + commandId: createCommandId, + commandType: "thread.create", + threadId, + acceptedAt: DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"), + events: [nativeThreadCreated(projectId, threadId)], + effects: [], + }), + ), + ), + ) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(creationEntered); + + const deletion = yield* service + .deleteDetailed({ commandId: deleteCommandId, projectId, force: true }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(deletionAttempted); + assert.isUndefined(deletion.pollUnsafe()); + + yield* Deferred.succeed(allowCreation, undefined); + const accepted = yield* Fiber.join(creation); + assert.equal(accepted.receipt.status, "accepted"); + const result = yield* Fiber.join(deletion); + assert.equal(result.deletedThreadCount, 1); + assert.isNotNull(result.project.deletedAt); + assert.isNotNull((yield* projections.getThreadProjection(threadId)).thread.deletedAt); + }).pipe( + Effect.ensuring(Deferred.succeed(allowCreation, undefined)), + Effect.ensuring(Effect.sync(() => lockSpy.mockRestore())), + ); + }).pipe(Effect.provide(servicesLayer)); + }).pipe(Effect.provide(databaseLayer)), +); + it.effect("retries a partial project deletion without repeating child events or cleanup", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 37738dbaee79..79845d7bf72c 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -150,7 +150,7 @@ it.layer(TestLayer)("ProjectService", (it) => { }); assert.isFalse(reset.autoPull); assert.isNull(reset.projectIcon); - assert.equal(reset.faviconPath, hydratedCreated.faviconPath); + assert.equal(reset.faviconPath, "/work/project/favicon.svg"); assert.isNull(reset.defaultThreadEnvMode); assert.deepEqual( (yield* service.snapshot).projects.map((project) => project.id), diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 0570a7fe0bd7..64d8b1a15500 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -62,6 +62,11 @@ export interface ProjectDeleteInput { readonly force?: boolean; } +export interface ProjectDeleteResult { + readonly project: Project; + readonly deletedThreadCount: number; +} + export class ProjectNotFoundError extends Schema.TaggedErrorClass()( "ProjectNotFoundError", { projectId: ProjectId }, @@ -132,6 +137,9 @@ export class ProjectService extends Context.Service< >; readonly update: (input: ProjectUpdateInput) => Effect.Effect; readonly delete: (input: ProjectDeleteInput) => Effect.Effect; + readonly deleteDetailed: ( + input: ProjectDeleteInput, + ) => Effect.Effect; readonly getById: ( projectId: ProjectId, options?: { readonly includeDeleted?: boolean }, @@ -391,103 +399,112 @@ export const make = Effect.gen(function* () { }, ); - const deleteProject: ProjectService["Service"]["delete"] = Effect.fn("ProjectService.delete")( - function* (input) { - const { projectId } = input; - const existing = yield* projects - .getById({ projectId }) - .pipe( - Effect.mapError( - (cause) => new ProjectOperationError({ operation: "read-project", projectId, cause }), - ), - ); - if (Option.isNone(existing) || existing.value.deletedAt !== null) { - return yield* new ProjectNotFoundError({ projectId }); - } + const deleteProjectDetailed: ProjectService["Service"]["deleteDetailed"] = Effect.fn( + "ProjectService.deleteDetailed", + )(function* (input) { + return yield* threadCommands.withProjectLock( + input.projectId, + Effect.gen(function* () { + const { projectId } = input; + const existing = yield* projects + .getById({ projectId }) + .pipe( + Effect.mapError( + (cause) => new ProjectOperationError({ operation: "read-project", projectId, cause }), + ), + ); + if (Option.isNone(existing) || existing.value.deletedAt !== null) { + return yield* new ProjectNotFoundError({ projectId }); + } - const snapshot = yield* threadProjections - .getShellSnapshot() - .pipe( - Effect.mapError( - (cause) => new ProjectOperationError({ operation: "list-threads", projectId, cause }), - ), - ); - const projectThreads = [...snapshot.threads, ...snapshot.archivedThreads].filter( - (thread) => thread.projectId === projectId, - ); - if (projectThreads.length > 0 && input.force !== true) { - return yield* new ProjectNotEmptyError({ projectId }); - } + const snapshot = yield* threadProjections + .getShellSnapshot() + .pipe( + Effect.mapError( + (cause) => new ProjectOperationError({ operation: "list-threads", projectId, cause }), + ), + ); + const projectThreads = [...snapshot.threads, ...snapshot.archivedThreads] + .filter((thread) => thread.projectId === projectId) + .toSorted((left, right) => left.id.localeCompare(right.id)); + if (projectThreads.length > 0 && input.force !== true) { + return yield* new ProjectNotEmptyError({ projectId }); + } - // Delete children durably before the project. Stable command IDs let a retry - // finish a partially completed cascade without repeating cleanup effects. - yield* Effect.forEach( - projectThreads, - (thread) => - threadCommands - .withLock( - thread.id, - Effect.gen(function* () { - yield* legacyImporter.ensureTranscript(thread.id); - const projection = yield* threadProjections.getThreadProjection(thread.id); - if ( - projection.thread.deletedAt !== null || - projection.thread.projectId !== projectId - ) { - return; - } - const command = { - type: "thread.delete" as const, - commandId: CommandId.make(`${input.commandId}:delete-thread:${thread.id}`), - threadId: thread.id, - }; - const now = yield* DateTime.now; - const plan = yield* planThreadDeletion({ command, projection, now, idAllocator }); - const committed = yield* threadEvents.commitCommand({ - commandId: command.commandId, - commandType: command.type, - threadId: command.threadId, - acceptedAt: now, - events: plan.events, - effects: plan.effects, - }); - if ( - committed.receipt.threadId !== command.threadId || - committed.receipt.commandType !== command.type - ) { - return yield* Effect.fail( - "The thread deletion command ID belongs to a different command.", - ); - } - if (committed.receipt.status === "rejected") { - return yield* Effect.fail( - committed.receipt.error ?? "Thread deletion was previously rejected.", - ); - } - }), - ) - .pipe( - Effect.mapError( - (cause) => - new ProjectOperationError({ operation: "delete-thread", projectId, cause }), + // Delete children durably before the project. Stable command IDs let a retry + // finish a partially completed cascade without repeating cleanup effects. + yield* Effect.forEach( + projectThreads, + (thread) => + threadCommands + .withLock( + thread.id, + Effect.gen(function* () { + yield* legacyImporter.ensureTranscript(thread.id); + const projection = yield* threadProjections.getThreadProjection(thread.id); + if ( + projection.thread.deletedAt !== null || + projection.thread.projectId !== projectId + ) { + return; + } + const command = { + type: "thread.delete" as const, + commandId: CommandId.make(`${input.commandId}:delete-thread:${thread.id}`), + threadId: thread.id, + }; + const now = yield* DateTime.now; + const plan = yield* planThreadDeletion({ command, projection, now, idAllocator }); + const committed = yield* threadEvents.commitCommand({ + commandId: command.commandId, + commandType: command.type, + threadId: command.threadId, + acceptedAt: now, + events: plan.events, + effects: plan.effects, + }); + if ( + committed.receipt.threadId !== command.threadId || + committed.receipt.commandType !== command.type + ) { + return yield* Effect.fail( + "The thread deletion command ID belongs to a different command.", + ); + } + if (committed.receipt.status === "rejected") { + return yield* Effect.fail( + committed.receipt.error ?? "Thread deletion was previously rejected.", + ); + } + }), + ) + .pipe( + Effect.mapError( + (cause) => + new ProjectOperationError({ operation: "delete-thread", projectId, cause }), + ), ), - ), - { concurrency: 1, discard: true }, - ); - return yield* dispatch( - projectId, - { - type: "project.delete", - commandId: input.commandId, + { concurrency: 1, discard: true }, + ); + const project = yield* dispatch( projectId, - ...(input.force === undefined ? {} : { force: input.force }), - }, - invalidateEnrichment(existing.value.workspaceRoot).pipe( - Effect.andThen(readCommitted(projectId)), - ), - ); - }, - ); + { + type: "project.delete", + commandId: input.commandId, + projectId, + ...(input.force === undefined ? {} : { force: input.force }), + }, + invalidateEnrichment(existing.value.workspaceRoot).pipe( + Effect.andThen(readCommitted(projectId)), + ), + ); + return { project, deletedThreadCount: projectThreads.length }; + }), + ); + }); + + const deleteProject: ProjectService["Service"]["delete"] = (input) => + deleteProjectDetailed(input).pipe(Effect.map((result) => result.project)); const snapshot = Effect.gen(function* () { const rows = (yield* readRows()).filter((row) => row.deletedAt === null); @@ -503,6 +520,7 @@ export const make = Effect.gen(function* () { bootstrap, update, delete: deleteProject, + deleteDetailed: deleteProjectDetailed, getById, getByWorkspaceRoot, snapshot, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index d19d819e595c..f6475483e8b4 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -121,6 +121,8 @@ const makeTestRelay = Effect.fnUntraced(function* ( const currentShell = yield* Ref.make(shell()); const shellReads: ThreadId[] = []; const threads = ThreadManagementService.of({ + withProjectCreationAdmission: (_input, effect) => effect(Option.none()), + withProjectMutationLock: (_projectId, effect) => effect, getThreadShell: (threadId) => Effect.sync(() => shellReads.push(threadId)).pipe(Effect.andThen(Ref.get(currentShell))), getShellSnapshot: unused, @@ -180,6 +182,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( bootstrap: unused, update: unused, delete: unused, + deleteDetailed: unused, getByWorkspaceRoot: unused, snapshot: unused(), getById: () => From 2a04a7b4d6bea1bfa6f5820de5f75da6bb874a73 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:34:25 -0700 Subject: [PATCH 17/19] fix(projects): report committed cascade deletions --- .../orchestration-v2/ThreadCommandExecutor.ts | 36 ++++---- .../project/ProjectService.deletion.test.ts | 82 ++++++++++++++++++- apps/server/src/project/ProjectService.ts | 16 +++- 3 files changed, 113 insertions(+), 21 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadCommandExecutor.ts b/apps/server/src/orchestration-v2/ThreadCommandExecutor.ts index eaa2bd254b3a..390d63cbb150 100644 --- a/apps/server/src/orchestration-v2/ThreadCommandExecutor.ts +++ b/apps/server/src/orchestration-v2/ThreadCommandExecutor.ts @@ -5,29 +5,33 @@ import * as Layer from "effect/Layer"; import { makeKeyedSerialExecutor } from "./KeyedSerialExecutor.ts"; -interface ThreadCommandExecutorShape { - readonly withLock: ( - threadId: ThreadId, - effect: Effect.Effect, - ) => Effect.Effect; - readonly withProjectLock: ( - projectId: ProjectId, - effect: Effect.Effect, - ) => Effect.Effect; -} - /** Shared by thread commands and project deletion so both plan against current thread state. */ export class ThreadCommandExecutor extends Context.Service< ThreadCommandExecutor, - ThreadCommandExecutorShape + { + readonly withLock: ( + threadId: ThreadId, + effect: Effect.Effect, + ) => Effect.Effect; + readonly withProjectLock: ( + projectId: ProjectId, + effect: Effect.Effect, + ) => Effect.Effect; + } >()("t3/orchestration-v2/ThreadCommandExecutor") {} const make = Effect.gen(function* () { const executor = yield* makeKeyedSerialExecutor(); - return { - withLock: (threadId, effect) => executor.withLock(`thread:${threadId}`, effect), - withProjectLock: (projectId, effect) => executor.withLock(`project:${projectId}`, effect), - } satisfies ThreadCommandExecutorShape; + const withLock: ThreadCommandExecutor["Service"]["withLock"] = (threadId, effect) => + executor.withLock(`thread:${threadId}`, effect); + const withProjectLock: ThreadCommandExecutor["Service"]["withProjectLock"] = ( + projectId, + effect, + ) => executor.withLock(`project:${projectId}`, effect); + return ThreadCommandExecutor.of({ + withLock, + withProjectLock, + }); }); export const layer = Layer.effect(ThreadCommandExecutor, make); diff --git a/apps/server/src/project/ProjectService.deletion.test.ts b/apps/server/src/project/ProjectService.deletion.test.ts index ffd29dd27e95..bb84e91cadc6 100644 --- a/apps/server/src/project/ProjectService.deletion.test.ts +++ b/apps/server/src/project/ProjectService.deletion.test.ts @@ -28,7 +28,7 @@ import { layer as eventSinkLayer, } from "../orchestration-v2/EventSink.ts"; import { layer as eventStoreLayer } from "../orchestration-v2/EventStore.ts"; -import { layer as idAllocatorLayer } from "../orchestration-v2/IdAllocator.ts"; +import { IdAllocatorV2, layer as idAllocatorLayer } from "../orchestration-v2/IdAllocator.ts"; import { LegacyV1ThreadImporter, layer as legacyImporterLayer, @@ -45,6 +45,7 @@ import { ThreadCommandExecutor, layer as threadCommandExecutorLayer, } from "../orchestration-v2/ThreadCommandExecutor.ts"; +import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; import { ProjectionProjectRepositoryLive } from "../persistence/Layers/ProjectionProjects.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -210,6 +211,85 @@ it.effect("serializes admitted thread creation with project deletion", () => }).pipe(Effect.provide(databaseLayer)), ); +it.effect("counts only children deleted by the project cascade", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project:concurrent-child-delete"); + const threadId = ThreadId.make("thread:concurrent-child-delete"); + const projectDeleteCommandId = CommandId.make("command:concurrent-child-delete:project"); + yield* seedProject(projectId); + + yield* Effect.gen(function* () { + const eventSink = yield* EventSinkV2; + const executor = yield* ThreadCommandExecutor; + const idAllocator = yield* IdAllocatorV2; + const projections = yield* ProjectionStoreV2; + const service = yield* ProjectService.make; + const cascadeLockAttempted = yield* Deferred.make(); + const allowCascadeLock = yield* Deferred.make(); + const withLock = executor.withLock; + let threadLockCalls = 0; + const lockSpy = vi.spyOn(executor, "withLock").mockImplementation((key, effect) => { + if (key !== threadId || ++threadLockCalls !== 1) { + return withLock(key, effect); + } + return Deferred.succeed(cascadeLockAttempted, undefined).pipe( + Effect.andThen(Deferred.await(allowCascadeLock)), + Effect.andThen(withLock(key, effect)), + ); + }); + + yield* Effect.gen(function* () { + yield* eventSink.write({ events: [nativeThreadCreated(projectId, threadId)] }); + const projectDeletion = yield* service + .deleteDetailed({ + commandId: projectDeleteCommandId, + projectId, + force: true, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(cascadeLockAttempted); + + const directDeletion = yield* executor.withLock( + threadId, + Effect.gen(function* () { + const command = { + type: "thread.delete" as const, + commandId: CommandId.make("command:concurrent-child-delete:thread"), + threadId, + }; + const projection = yield* projections.getThreadProjection(threadId); + const acceptedAt = yield* DateTime.now; + const plan = yield* planThreadDeletion({ + command, + projection, + now: acceptedAt, + idAllocator, + }); + return yield* eventSink.commitCommand({ + commandId: command.commandId, + commandType: command.type, + threadId, + acceptedAt, + events: plan.events, + effects: plan.effects, + }); + }), + ); + assert.isTrue(directDeletion.committed); + yield* Deferred.succeed(allowCascadeLock, undefined); + + const result = yield* Fiber.join(projectDeletion); + assert.equal(result.deletedThreadCount, 0); + assert.isNotNull(result.project.deletedAt); + assert.isNotNull((yield* projections.getThreadProjection(threadId)).thread.deletedAt); + }).pipe( + Effect.ensuring(Deferred.succeed(allowCascadeLock, undefined)), + Effect.ensuring(Effect.sync(() => lockSpy.mockRestore())), + ); + }).pipe(Effect.provide(servicesLayer)); + }).pipe(Effect.provide(databaseLayer)), +); + it.effect("retries a partial project deletion without repeating child events or cleanup", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 64d8b1a15500..32a694190d73 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -433,7 +433,7 @@ export const make = Effect.gen(function* () { // Delete children durably before the project. Stable command IDs let a retry // finish a partially completed cascade without repeating cleanup effects. - yield* Effect.forEach( + const deletedThreadCounts = yield* Effect.forEach( projectThreads, (thread) => threadCommands @@ -446,7 +446,7 @@ export const make = Effect.gen(function* () { projection.thread.deletedAt !== null || projection.thread.projectId !== projectId ) { - return; + return 0; } const command = { type: "thread.delete" as const, @@ -476,6 +476,11 @@ export const make = Effect.gen(function* () { committed.receipt.error ?? "Thread deletion was previously rejected.", ); } + return committed.committed + ? committed.storedEvents.filter( + (stored) => stored.event.type === "thread.deleted", + ).length + : 0; }), ) .pipe( @@ -484,7 +489,7 @@ export const make = Effect.gen(function* () { new ProjectOperationError({ operation: "delete-thread", projectId, cause }), ), ), - { concurrency: 1, discard: true }, + { concurrency: 1 }, ); const project = yield* dispatch( projectId, @@ -498,7 +503,10 @@ export const make = Effect.gen(function* () { Effect.andThen(readCommitted(projectId)), ), ); - return { project, deletedThreadCount: projectThreads.length }; + return { + project, + deletedThreadCount: deletedThreadCounts.reduce((total, count) => total + count, 0), + }; }), ); }); From 93b2157b129b8d5179c9bab56b57f8fdcaf63f28 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:36:57 -0700 Subject: [PATCH 18/19] test(projects): keep lower fixtures type-safe --- .../OrchestratorMcpToolkit.integration.test.ts | 18 ++++++++++-------- apps/server/src/mcp/ProjectMcpService.test.ts | 16 ++++++++++------ .../project/ProjectService.deletion.test.ts | 15 +++++++++++---- .../src/operations/commands.test.ts | 1 - 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index ed7d3252f645..e8930dc44fcc 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -709,13 +709,15 @@ describe("orchestrator MCP toolkit", () => { yield* Effect.forEach( projectThreads, (thread) => - threads.dispatch({ - type: "thread.delete", - commandId: CommandId.make( - `${commandId}:delete-thread:${thread.id}`, - ), - threadId: thread.id, - }), + threads + .dispatch({ + type: "thread.delete", + commandId: CommandId.make( + `${commandId}:delete-thread:${thread.id}`, + ), + threadId: thread.id, + }) + .pipe(Effect.orDie), { concurrency: 1, discard: true }, ); const project = yield* Ref.updateAndGet( @@ -735,7 +737,7 @@ describe("orchestrator MCP toolkit", () => { ); yield* Deferred.succeed(deletionLockAttemptSettled, undefined); return yield* Fiber.join(deletion); - }), + }).pipe(Effect.orDie), getById: (requestedProjectId, options) => requestedProjectId === deletionRaceProjectId ? Ref.get(deletionRaceProjectState).pipe( diff --git a/apps/server/src/mcp/ProjectMcpService.test.ts b/apps/server/src/mcp/ProjectMcpService.test.ts index 17e69d1527f0..c23a51ac843e 100644 --- a/apps/server/src/mcp/ProjectMcpService.test.ts +++ b/apps/server/src/mcp/ProjectMcpService.test.ts @@ -556,11 +556,15 @@ it.effect("serializes delegated thread admission with cascading project deletion yield* Effect.forEach( projectThreads, (thread) => - threadManagement.dispatch({ - type: "thread.delete", - commandId: CommandId.make(`${commandId}:delete-thread:${thread.id}`), - threadId: thread.id, - }), + threadManagement + .dispatch({ + type: "thread.delete", + commandId: CommandId.make( + `${commandId}:delete-thread:${thread.id}`, + ), + threadId: thread.id, + }) + .pipe(Effect.orDie), { concurrency: 1, discard: true }, ); const project = yield* Ref.updateAndGet(projectState, (current) => ({ @@ -576,7 +580,7 @@ it.effect("serializes delegated thread admission with cascading project deletion ); yield* Deferred.succeed(deleteLockAttempted, undefined); return yield* Fiber.join(deletion); - }), + }).pipe(Effect.orDie), getById: () => Ref.get(projectState).pipe(Effect.map(Option.some)), getByWorkspaceRoot: () => Effect.die("unused"), snapshot: Effect.die("unused"), diff --git a/apps/server/src/project/ProjectService.deletion.test.ts b/apps/server/src/project/ProjectService.deletion.test.ts index bb84e91cadc6..e3ffb762c4fa 100644 --- a/apps/server/src/project/ProjectService.deletion.test.ts +++ b/apps/server/src/project/ProjectService.deletion.test.ts @@ -160,14 +160,20 @@ it.effect("serializes admitted thread creation with project deletion", () => const deletionAttempted = yield* Deferred.make(); const withProjectLock = executor.withProjectLock; let projectLockCalls = 0; - const lockSpy = vi.spyOn(executor, "withProjectLock").mockImplementation((key, effect) => { + const interceptProjectLock: ThreadCommandExecutor["Service"]["withProjectLock"] = ( + key, + effect, + ) => { if (key !== projectId || ++projectLockCalls !== 2) { return withProjectLock(key, effect); } return Deferred.succeed(deletionAttempted, undefined).pipe( Effect.andThen(withProjectLock(key, effect)), ); - }); + }; + const lockSpy = vi + .spyOn(executor, "withProjectLock") + .mockImplementation(interceptProjectLock); yield* Effect.gen(function* () { const creation = yield* executor @@ -228,7 +234,7 @@ it.effect("counts only children deleted by the project cascade", () => const allowCascadeLock = yield* Deferred.make(); const withLock = executor.withLock; let threadLockCalls = 0; - const lockSpy = vi.spyOn(executor, "withLock").mockImplementation((key, effect) => { + const interceptThreadLock: ThreadCommandExecutor["Service"]["withLock"] = (key, effect) => { if (key !== threadId || ++threadLockCalls !== 1) { return withLock(key, effect); } @@ -236,7 +242,8 @@ it.effect("counts only children deleted by the project cascade", () => Effect.andThen(Deferred.await(allowCascadeLock)), Effect.andThen(withLock(key, effect)), ); - }); + }; + const lockSpy = vi.spyOn(executor, "withLock").mockImplementation(interceptThreadLock); yield* Effect.gen(function* () { yield* eventSink.write({ events: [nativeThreadCreated(projectId, threadId)] }); diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 0953082ac9e5..58091de8ff18 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -50,7 +50,6 @@ import { settleThread, startThreadTurn, unsettleThread, - updateProject, updateThreadMetadata, } from "./commands.ts"; From e3f2cdaae2c69d7d84ee05baabec32b1b26c4b10 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:50:31 -0700 Subject: [PATCH 19/19] fix(mcp): close project operation races --- .../src/mcp/OrchestratorMcpService.test.ts | 61 +++++++++--- apps/server/src/mcp/OrchestratorMcpService.ts | 1 + apps/server/src/mcp/ProjectMcpService.test.ts | 97 +++++++++++++++++++ apps/server/src/mcp/ProjectMcpService.ts | 44 +++++++-- .../SourceControlRepositoryService.test.ts | 65 +++++++++++++ .../SourceControlRepositoryService.ts | 18 ++-- 6 files changed, 257 insertions(+), 29 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 5e14bce87ae3..546b43fc777e 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -13,7 +13,9 @@ import { type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; 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 Ref from "effect/Ref"; @@ -97,10 +99,25 @@ describe("OrchestratorMcpService", () => { visibleTurnItems: [], updatedAt: now, }; - const archivedParent = { + const replacementRunId = RunId.make("run:mcp-create-admission-parent-replacement"); + const replacementNodeId = NodeId.make("node:mcp-create-admission-parent-replacement"); + const replacedParentRun = { ...activeParent, - thread: { ...activeParent.thread, archivedAt: now }, - } as OrchestrationV2ThreadProjection; + runs: [ + { + ...activeParent.runs[0]!, + status: "completed" as const, + completedAt: now, + }, + { + ...activeParent.runs[0]!, + id: replacementRunId, + ordinal: 2, + userMessageId: MessageId.make("message:mcp-create-admission-parent-replacement"), + rootNodeId: replacementNodeId, + }, + ], + } satisfies OrchestrationV2ThreadProjection; const provider = { instanceId: providerInstanceId, driver: ProviderDriverKind.make("codex"), @@ -115,14 +132,21 @@ describe("OrchestratorMcpService", () => { skills: [], } satisfies ServerProvider; const projectionReads = yield* Ref.make(0); + const currentParent = yield* Ref.make(activeParent); + const admissionEntered = yield* Deferred.make(); + const allowAdmission = yield* Deferred.make(); const dependencies = Layer.mergeAll( NodeServices.layer, Layer.mock(ThreadManagementService)({ getThreadProjection: () => - Ref.updateAndGet(projectionReads, (count) => count + 1).pipe( - Effect.map((count) => (count === 1 ? activeParent : archivedParent)), + Ref.update(projectionReads, (count) => count + 1).pipe( + Effect.andThen(Ref.get(currentParent)), + ), + withProjectCreationAdmission: (_input, effect) => + Deferred.succeed(admissionEntered, undefined).pipe( + Effect.andThen(Deferred.await(allowAdmission)), + Effect.andThen(effect(Option.none())), ), - withProjectCreationAdmission: (_input, effect) => effect(Option.none()), dispatch: () => Effect.die("dispatch must not run after parent admission fails"), }), Layer.mock(ProviderRegistry)({ getProviders: Effect.succeed([provider]) }), @@ -138,14 +162,23 @@ describe("OrchestratorMcpService", () => { }; 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)))); + const creation = 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))), + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(admissionEntered); + yield* Ref.set(currentParent, replacedParentRun); + yield* Deferred.succeed(allowAdmission, undefined); + return yield* Fiber.join(creation); + }).pipe(Effect.ensuring(Deferred.succeed(allowAdmission, undefined))); assert.equal(error.code, "parent_not_active"); assert.equal( diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index f6c7b3528b86..6512f79e4e72 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1467,6 +1467,7 @@ const make = Effect.gen(function* () { freshParent.thread.deletedAt !== null || freshParent.thread.archivedAt !== null || freshParentRun === undefined || + freshParentRun.id !== parentRun.id || freshParentRun.rootNodeId === null || freshParentRun.providerInstanceId !== scope.providerInstanceId ) { diff --git a/apps/server/src/mcp/ProjectMcpService.test.ts b/apps/server/src/mcp/ProjectMcpService.test.ts index c23a51ac843e..3fc1c69bda5a 100644 --- a/apps/server/src/mcp/ProjectMcpService.test.ts +++ b/apps/server/src/mcp/ProjectMcpService.test.ts @@ -465,6 +465,103 @@ it.effect("clones before registration and requires explicit cascading for nonemp }), ); +it.effect("reports an overlapping completed deletion as an idempotent result", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project:mcp:overlapping-delete"); + const project = makeProject({ + id: projectId, + title: "Overlapping deletion", + workspaceRoot: "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/work/overlapping-delete", + }); + const projectState = yield* Ref.make(project); + const deleteAttempts = yield* Ref.make(0); + const firstDeleteEntered = yield* Deferred.make(); + const secondDeleteEntered = yield* Deferred.make(); + const allowFirstDelete = yield* Deferred.make(); + const firstDeleteFinished = yield* Deferred.make(); + const projectService = ProjectService.ProjectService.of({ + create: () => Effect.die("unused"), + bootstrap: () => Effect.die("unused"), + update: () => Effect.die("unused"), + delete: () => Effect.die("unused"), + deleteDetailed: ({ projectId: deletedProjectId }) => + Effect.gen(function* () { + const attempt = yield* Ref.updateAndGet(deleteAttempts, (count) => count + 1); + if (attempt === 1) { + yield* Deferred.succeed(firstDeleteEntered, undefined); + yield* Deferred.await(allowFirstDelete); + const deleted = { ...project, deletedAt: now } satisfies Project; + yield* Ref.set(projectState, deleted); + yield* Deferred.succeed(firstDeleteFinished, undefined); + return { project: deleted, deletedThreadCount: 2 }; + } + yield* Deferred.succeed(secondDeleteEntered, undefined); + yield* Deferred.await(firstDeleteFinished); + return yield* new ProjectService.ProjectNotFoundError({ + projectId: deletedProjectId, + }); + }).pipe(Effect.ensuring(Deferred.succeed(firstDeleteFinished, undefined))), + getById: (_projectId, options) => + Ref.get(projectState).pipe( + Effect.map((current) => + current.deletedAt !== null && options?.includeDeleted !== true + ? Option.none() + : Option.some(current), + ), + ), + getByWorkspaceRoot: () => Effect.die("unused"), + snapshot: Effect.die("unused"), + }); + const testLayer = ProjectMcp.layer.pipe( + Layer.provide(Layer.succeed(ProjectService.ProjectService, projectService)), + Layer.provide(Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({})), + Layer.provide(ServerSettingsService.layerTest({})), + 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 first = yield* service + .delete(scope, { + projectId, + cascadeThreads: true, + clientRequestId: "overlapping-delete-first", + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(firstDeleteEntered); + const second = yield* service + .delete(scope, { + projectId, + cascadeThreads: true, + clientRequestId: "overlapping-delete-second", + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(secondDeleteEntered); + yield* Deferred.succeed(allowFirstDelete, undefined); + + expect(yield* Fiber.join(first)).toMatchObject({ + projectId, + deleted: true, + alreadyDeleted: false, + deletedThreadCount: 2, + }); + expect(yield* Fiber.join(second)).toMatchObject({ + projectId, + deleted: true, + alreadyDeleted: true, + deletedThreadCount: 0, + }); + expect(yield* Ref.get(deleteAttempts)).toBe(2); + }).pipe( + Effect.provide(testLayer), + Effect.ensuring(Deferred.succeed(allowFirstDelete, undefined)), + Effect.ensuring(Deferred.succeed(firstDeleteFinished, undefined)), + ); + }), +); + 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/mcp/ProjectMcpService.ts b/apps/server/src/mcp/ProjectMcpService.ts index 678f38196722..75b54430c5fd 100644 --- a/apps/server/src/mcp/ProjectMcpService.ts +++ b/apps/server/src/mcp/ProjectMcpService.ts @@ -412,25 +412,51 @@ const make = Effect.gen(function* () { ...(input.cascadeThreads === true ? { force: true } : {}), }) .pipe( + Effect.map((result) => ({ type: "deleted" as const, result })), + Effect.catchTag("ProjectNotFoundError", (notFound) => + projects.getById(input.projectId, { includeDeleted: true }).pipe( + redactOperationFailure( + "read-project-after-overlapping-delete", + "Unable to confirm the project deletion.", + ), + Effect.flatMap((reloaded) => + Option.isSome(reloaded) && reloaded.value.deletedAt !== null + ? Effect.succeed({ type: "already_deleted" as const, project: reloaded.value }) + : Effect.fail(notFound), + ), + ), + ), Effect.tapError(() => Effect.logWarning("Project MCP operation failed.", { operation: "delete-project" }), ), Effect.mapError((error) => - error._tag === "ProjectNotEmptyError" - ? failure( - "project_not_empty", - `Project '${input.projectId}' is not empty. Retry with cascadeThreads=true to delete its thread records first. Workspace files will remain untouched.`, - ) - : error._tag === "ProjectNotFoundError" - ? failure("project_not_found", `Project '${input.projectId}' was not found.`) - : failure("operation_failed", "Unable to delete the requested project."), + error._tag === "ProjectMcpFailure" + ? error + : error._tag === "ProjectNotEmptyError" + ? failure( + "project_not_empty", + `Project '${input.projectId}' is not empty. Retry with cascadeThreads=true to delete its thread records first. Workspace files will remain untouched.`, + ) + : error._tag === "ProjectNotFoundError" + ? failure("project_not_found", `Project '${input.projectId}' was not found.`) + : failure("operation_failed", "Unable to delete the requested project."), ), ); + if (deletion.type === "already_deleted") { + return { + projectId: input.projectId, + deleted: true, + alreadyDeleted: true, + deletedThreadCount: 0, + workspaceRoot: deletion.project.workspaceRoot, + workspaceFilesDeleted: false, + } satisfies ProjectMcpDeleteResult; + } return { projectId: input.projectId, deleted: true, alreadyDeleted: false, - deletedThreadCount: deletion.deletedThreadCount, + deletedThreadCount: deletion.result.deletedThreadCount, workspaceRoot: project.workspaceRoot, workspaceFilesDeleted: false, } satisfies ProjectMcpDeleteResult; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index adf2414292e2..3e91adea04e1 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -365,10 +365,75 @@ it.effect("recovers a real completed clone after a lost result and rejects unsaf yield* initializeRepository(sourcePath, "source"); yield* initializeRepository(otherSourcePath, "other source"); + yield* fs.writeFileString(`${sourcePath}/CHANGELOG.md`, "second source commit"); + yield* git.execute({ operation: "test.add.second", cwd: sourcePath, args: ["add", "."] }); + yield* git.execute({ + operation: "test.commit.second", + cwd: sourcePath, + args: ["commit", "-m", "second"], + }); const cloned = yield* service.cloneRepository({ remoteUrl: sourcePath, destinationPath }); assert.strictEqual(cloned.cwd, destinationPath); + const headCommit = (yield* git.execute({ + operation: "test.read.head", + cwd: destinationPath, + args: ["rev-parse", "HEAD"], + })).stdout.trim(); + const previousCommit = (yield* git.execute({ + operation: "test.read.previous", + cwd: destinationPath, + args: ["rev-parse", "HEAD^"], + })).stdout.trim(); + yield* git + .execute({ + operation: "test.remove.remote.head", + cwd: destinationPath, + args: ["symbolic-ref", "-d", "refs/remotes/origin/HEAD"], + }) + .pipe(Effect.ignore); + const existingRemoteRefs = (yield* git.execute({ + operation: "test.list.remote.refs", + cwd: destinationPath, + args: ["for-each-ref", "--format=%(refname)", "refs/remotes/origin"], + })).stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + yield* Effect.forEach( + existingRemoteRefs, + (refName) => + git.execute({ + operation: "test.remove.remote.ref", + cwd: destinationPath, + args: ["update-ref", "-d", refName], + }), + { discard: true }, + ); + const noiseRefCommands = Array.from( + { length: 1_700 }, + (_, index) => + `create refs/remotes/origin/noise-${index.toString().padStart(4, "0")} ${previousCommit}`, + ); + yield* git.execute({ + operation: "test.create.many.remote.refs", + cwd: destinationPath, + args: ["update-ref", "--stdin"], + stdin: [...noiseRefCommands, `create refs/remotes/origin/zzzz-match ${headCommit}`, ""].join( + "\n", + ), + }); + const oversizedInventory = yield* git + .execute({ + operation: "test.verify.old.remote-ref-boundary", + cwd: destinationPath, + args: ["for-each-ref", "--format=%(objectname)", "refs/remotes/origin"], + maxOutputBytes: 64 * 1024, + }) + .pipe(Effect.flip); + assert.instanceOf(oversizedInventory, GitCommandError); + // Simulate the caller losing the successful clone result before project registration. const recovered = yield* service.cloneRepository({ remoteUrl: sourcePath, destinationPath }); assert.strictEqual(recovered.cwd, destinationPath); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index fbffe03ee34f..1b607b6171b8 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -224,19 +224,25 @@ export const make = Effect.gen(function* () { ); if (headCommit === null) return false; - const remoteCommits = yield* git + const remoteCommit = yield* git .execute({ operation: "SourceControlRepositoryService.cloneRepository.verifyRemoteRefs", cwd: destinationPath, - args: ["for-each-ref", "--format=%(objectname)", "refs/remotes/origin"], + args: [ + "for-each-ref", + "--count=1", + "--format=%(objectname)", + `--points-at=${headCommit}`, + "refs/remotes/origin", + ], timeoutMs: 5_000, - maxOutputBytes: 64 * 1024, + maxOutputBytes: 1024, }) .pipe( - Effect.map((result) => new Set(result.stdout.split("\n").map((line) => line.trim()))), - Effect.orElseSucceed(() => new Set()), + Effect.map((result) => result.stdout.trim() || null), + Effect.orElseSucceed(() => null), ); - if (!remoteCommits.has(headCommit)) return false; + if (remoteCommit !== headCommit) return false; return yield* git .execute({