| @@ -19,6 +19,7 @@ import { · 18 unmodified lines |
| 19 | 19 | OrchestratorMcpThreadReadResult, |
| 20 | 20 | OrchestratorMcpThreadSendResult, |
| 21 | 21 | OrchestratorMcpThreadWaitResult, |
| 22 | + type Project, |
| 22 | 23 | ProjectId, |
| 23 | 24 | ProviderDriverKind, |
| 24 | 25 | ProviderInstanceId, |
| @@ -37,6 +38,7 @@ import * as DateTime from "effect/DateTime"; · 12 unmodified lines |
| 37 | 38 | import * as Deferred from "effect/Deferred"; |
| 38 | 39 | import * as Effect from "effect/Effect"; |
| 39 | 40 | import * as FileSystem from "effect/FileSystem"; |
| 41 | +import * as Fiber from "effect/Fiber"; |
| 40 | 42 | import * as Layer from "effect/Layer"; |
| 41 | 43 | import * as Option from "effect/Option"; |
| 42 | 44 | import * as PubSub from "effect/PubSub"; |
| @@ -48,8 +50,9 @@ import { McpSchema, McpServer } from "effect/unstable/ai"; · 5 unmodified lines |
| 48 | 50 | import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; |
| 49 | 51 | import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; |
| 50 | 52 | import { CodexOrchestratorReplayHarness } from "../orchestration-v2/Adapters/CodexAdapterV2.testkit.ts"; |
| 53 | +import * as ClientCommandDispatch from "../orchestration-v2/ClientCommandDispatch.ts"; |
| 51 | 54 | import { OrchestratorV2, type OrchestratorV2Shape } from "../orchestration-v2/Orchestrator.ts"; |
| 52 | | −import { layer as threadManagementServiceLayer } from "../orchestration-v2/ThreadManagementService.ts"; |
| 55 | +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; |
| 53 | 56 | import { |
| 54 | 57 | type ProviderAdapterV2Event, |
| 55 | 58 | ProviderAdapterProtocolError, |
| @@ -71,13 +74,18 @@ import { · 15 unmodified lines |
| 71 | 74 | materializeReplayTranscriptWorkspace, |
| 72 | 75 | } from "../orchestration-v2/testkit/ReplayTranscriptNdjson.ts"; |
| 73 | 76 | import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts"; |
| 77 | +import * as ProjectService from "../project/ProjectService.ts"; |
| 74 | 78 | import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts"; |
| 79 | +import * as ServerSettings from "../serverSettings.ts"; |
| 80 | +import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; |
| 75 | 81 | import * as McpHttpServer from "./McpHttpServer.ts"; |
| 76 | 82 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 77 | 83 | import { delegatedTaskRun, hasPendingChildRuns } from "./OrchestratorMcpService.ts"; |
| 78 | 84 | |
| 79 | 85 | const parentThreadId = ThreadId.make("thread:mcp-orchestrator-parent"); |
| 80 | 86 | const projectId = ProjectId.make("project:mcp-orchestrator"); |
| 87 | +const deletionRaceProjectId = ProjectId.make("project:mcp-orchestrator-delete-race"); |
| 88 | +const deletionRaceParentThreadId = ThreadId.make("thread:mcp-orchestrator-delete-race-parent"); |
| 81 | 89 | const codexInstanceId = ProviderInstanceId.make("codex"); |
| 82 | 90 | const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); |
| 83 | 91 | const codexModel = "gpt-5.4"; |
| @@ -472,6 +480,28 @@ describe("orchestrator MCP toolkit", () => { · 388 unmodified lines |
| 472 | 480 | Effect.scoped( |
| 473 | 481 | Effect.gen(function* () { |
| 474 | 482 | const cwd = yield* checkpointWorkspace("orchestrator-mcp-toolkit"); |
| 483 | + const deletionRaceProject = { |
| 484 | + id: deletionRaceProjectId, |
| 485 | + title: "MCP deletion race project", |
| 486 | + workspaceRoot: cwd, |
| 487 | + repositoryIdentity: null, |
| 488 | + faviconPath: null, |
| 489 | + defaultModelSelection: codexSelection, |
| 490 | + defaultThreadEnvMode: "local", |
| 491 | + scripts: [], |
| 492 | + createdAt: "2026-08-30T00:00:00.000Z", |
| 493 | + updatedAt: "2026-08-30T00:00:00.000Z", |
| 494 | + deletedAt: null, |
| 495 | + } satisfies Project; |
| 496 | + const deletionRaceProjectState = yield* Ref.make<Project>(deletionRaceProject); |
| 497 | + const creationAdmissionEntered = yield* Deferred.make<void>(); |
| 498 | + const allowCreationAdmission = yield* Deferred.make<void>(); |
| 499 | + const deletionLockAttempted = yield* Deferred.make<void>(); |
| 500 | + const deletionLockAcquired = yield* Deferred.make<void>(); |
| 501 | + const allowDeletion = yield* Deferred.make<void>(); |
| 502 | + const deletionRaceCommandId = CommandId.make( |
| 503 | + "command:mcp:mcp-provider-session-delete-race:create-thread:delete-create-race:0", |
| 504 | + ); |
| 475 | 505 | const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]); |
| 476 | 506 | const parentTerminalGates = new Map<ThreadId, Deferred.Deferred<void>>(); |
| 477 | 507 | const deliveryTerminalGates = new Map<ThreadId, Deferred.Deferred<void>>(); |
| @@ -482,7 +512,9 @@ describe("orchestrator MCP toolkit", () => { · 4 unmodified lines |
| 482 | 512 | capabilities: CodexProviderCapabilitiesV2, |
| 483 | 513 | capturedTurns, |
| 484 | 514 | shouldComplete: (turn) => |
| 485 | | − turn.threadId !== parentThreadId && turn.message.text !== cancellationPrompt, |
| 515 | + turn.threadId !== parentThreadId && |
| 516 | + turn.threadId !== deletionRaceParentThreadId && |
| 517 | + turn.message.text !== cancellationPrompt, |
| 486 | 518 | terminalGate: (turn) => |
| 487 | 519 | turn.message.text.startsWith("Delegated task") || |
| 488 | 520 | turn.message.text.startsWith("Delegated tasks") |
| @@ -554,10 +586,42 @@ describe("orchestrator MCP toolkit", () => { · 65 unmodified lines |
| 554 | 586 | }, |
| 555 | 587 | registryLayer, |
| 556 | 588 | ).pipe(Layer.provide(continuationProbeLayer)); |
| 557 | | − const orchestrationLayer = Layer.merge( |
| 558 | | − orchestratorLayer, |
| 559 | | − threadManagementServiceLayer.pipe(Layer.provide(orchestratorLayer)), |
| 589 | + const threadManagementLayer = ThreadManagement.layer.pipe( |
| 590 | + Layer.provide(orchestratorLayer), |
| 560 | 591 | ); |
| 592 | + const gatedThreadManagementLayer = Layer.effect( |
| 593 | + ThreadManagement.ThreadManagementService, |
| 594 | + Effect.gen(function* () { |
| 595 | + const actual = yield* ThreadManagement.ThreadManagementService; |
| 596 | + return ThreadManagement.ThreadManagementService.of({ |
| 597 | + ...actual, |
| 598 | + withProjectCreationAdmission: (input, effect) => |
| 599 | + actual.withProjectCreationAdmission(input, (receipt) => |
| 600 | + input.commandId === deletionRaceCommandId && Option.isNone(receipt) |
| 601 | + ? Deferred.succeed(creationAdmissionEntered, undefined).pipe( |
| 602 | + Effect.andThen(Deferred.await(allowCreationAdmission)), |
| 603 | + Effect.andThen(effect(receipt)), |
| 604 | + ) |
| 605 | + : effect(receipt), |
| 606 | + ), |
| 607 | + withProjectMutationLock: (lockedProjectId, effect) => |
| 608 | + lockedProjectId === deletionRaceProjectId |
| 609 | + ? Deferred.succeed(deletionLockAttempted, undefined).pipe( |
| 610 | + Effect.andThen( |
| 611 | + actual.withProjectMutationLock( |
| 612 | + lockedProjectId, |
| 613 | + Deferred.succeed(deletionLockAcquired, undefined).pipe( |
| 614 | + Effect.andThen(Deferred.await(allowDeletion)), |
| 615 | + Effect.andThen(effect), |
| 616 | + ), |
| 617 | + ), |
| 618 | + ), |
| 619 | + ) |
| 620 | + : actual.withProjectMutationLock(lockedProjectId, effect), |
| 621 | + }); |
| 622 | + }), |
| 623 | + ).pipe(Layer.provide(threadManagementLayer)); |
| 624 | + const orchestrationLayer = Layer.merge(orchestratorLayer, gatedThreadManagementLayer); |
| 561 | 625 | const providerRegistryLayer = makeProviderRegistryLayer([ |
| 562 | 626 | makeProviderSnapshot({ |
| 563 | 627 | instanceId: codexInstanceId, |
| @@ -613,11 +677,40 @@ describe("orchestrator MCP toolkit", () => { · 49 unmodified lines |
| 613 | 677 | runNow: () => Effect.die("ScheduledTaskService.runNow is unused in this test"), |
| 614 | 678 | }), |
| 615 | 679 | ); |
| 616 | | − const testLayer = McpHttpServer.OrchestratorToolkitRegistrationLive.pipe( |
| 680 | + const projectLayer = Layer.mock(ProjectService.ProjectService)({ |
| 681 | + getById: (requestedProjectId, options) => |
| 682 | + requestedProjectId === deletionRaceProjectId |
| 683 | + ? Ref.get(deletionRaceProjectState).pipe( |
| 684 | + Effect.map((project) => |
| 685 | + project.deletedAt === null || options?.includeDeleted === true |
| 686 | + ? Option.some(project) |
| 687 | + : Option.none(), |
| 688 | + ), |
| 689 | + ) |
| 690 | + : Effect.succeed(Option.none()), |
| 691 | + delete: ({ projectId: deletedProjectId }) => |
| 692 | + deletedProjectId === deletionRaceProjectId |
| 693 | + ? Ref.updateAndGet(deletionRaceProjectState, (project) => ({ |
| 694 | + ...project, |
| 695 | + deletedAt: "2026-08-30T00:01:00.000Z", |
| 696 | + updatedAt: "2026-08-30T00:01:00.000Z", |
| 697 | + })) |
| 698 | + : Effect.die(`Unexpected project deletion: ${deletedProjectId}`), |
| 699 | + }); |
| 700 | + const toolkitRegistrationLayer = Layer.merge( |
| 701 | + McpHttpServer.OrchestratorToolkitRegistrationLive, |
| 702 | + McpHttpServer.ProjectToolkitRegistrationLive, |
| 703 | + ); |
| 704 | + const testLayer = toolkitRegistrationLayer.pipe( |
| 617 | 705 | Layer.provideMerge(McpServer.McpServer.layer), |
| 618 | 706 | Layer.provideMerge(orchestrationLayer), |
| 619 | 707 | Layer.provide(providerRegistryLayer), |
| 620 | 708 | Layer.provide(scheduledTaskStubLayer), |
| 709 | + Layer.provideMerge(projectLayer), |
| 710 | + Layer.provide( |
| 711 | + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({}), |
| 712 | + ), |
| 713 | + Layer.provide(ServerSettings.layerTest({})), |
| 621 | 714 | Layer.provide(NodeServices.layer), |
| 622 | 715 | ); |
| 623 | 716 | |
| @@ -2876,6 +2969,144 @@ describe("orchestrator MCP toolkit", () => { · 2252 unmodified lines |
| 2876 | 2969 | removedDelivery.subagents.find((task) => task.id === removeTask.id), |
| 2877 | 2970 | ).toMatchObject({ result: expect.any(String), status: "completed" }); |
| 2878 | 2971 | yield* expectOffersToStay(0); |
| 2972 | + |
| 2973 | + yield* orchestrator.dispatch({ |
| 2974 | + type: "thread.create", |
| 2975 | + createdBy: "user", |
| 2976 | + creationSource: "web", |
| 2977 | + commandId: CommandId.make("command:mcp-delete-race-parent:create"), |
| 2978 | + threadId: deletionRaceParentThreadId, |
| 2979 | + projectId: deletionRaceProjectId, |
| 2980 | + title: "MCP deletion race parent", |
| 2981 | + modelSelection: codexSelection, |
| 2982 | + runtimeMode: "full-access", |
| 2983 | + interactionMode: "default", |
| 2984 | + branch: null, |
| 2985 | + worktreePath: cwd, |
| 2986 | + }); |
| 2987 | + yield* orchestrator.dispatch({ |
| 2988 | + type: "message.dispatch", |
| 2989 | + createdBy: "user", |
| 2990 | + creationSource: "web", |
| 2991 | + commandId: CommandId.make("command:mcp-delete-race-parent:start"), |
| 2992 | + threadId: deletionRaceParentThreadId, |
| 2993 | + messageId: MessageId.make("message:mcp-delete-race-parent:start"), |
| 2994 | + text: "Stay active while project deletion races thread creation.", |
| 2995 | + attachments: [], |
| 2996 | + modelSelection: codexSelection, |
| 2997 | + dispatchMode: { type: "start_immediately" }, |
| 2998 | + }); |
| 2999 | + yield* waitForProjection(orchestrator, deletionRaceParentThreadId, (projection) => |
| 3000 | + projection.runs.some((run) => run.status === "running"), |
| 3001 | + ); |
| 3002 | + |
| 3003 | + const deletionRaceInvocation: McpInvocationContext.McpInvocationScope = { |
| 3004 | + ...invocation, |
| 3005 | + threadId: deletionRaceParentThreadId, |
| 3006 | + providerSessionId: "mcp-provider-session-delete-race", |
| 3007 | + }; |
| 3008 | + const invokeDeletionRace = (name: string, args: Record<string, unknown>) => |
| 3009 | + server |
| 3010 | + .callTool({ name, arguments: args }) |
| 3011 | + .pipe( |
| 3012 | + Effect.provideService( |
| 3013 | + McpInvocationContext.McpInvocationContext, |
| 3014 | + deletionRaceInvocation, |
| 3015 | + ), |
| 3016 | + Effect.provideService(McpSchema.McpServerClient, client), |
| 3017 | + ); |
| 3018 | + const creation = yield* Effect.forkChild( |
| 3019 | + invokeDeletionRace("create_threads", { |
| 3020 | + clientRequestId: "delete-create-race", |
| 3021 | + threads: [{ title: "Racing child" }], |
| 3022 | + }), |
| 3023 | + { startImmediately: true }, |
| 3024 | + ); |
| 3025 | + yield* Deferred.await(creationAdmissionEntered); |
| 3026 | + const deletion = yield* Effect.forkChild( |
| 3027 | + invokeDeletionRace("t3_project_delete", { |
| 3028 | + projectId: deletionRaceProjectId, |
| 3029 | + cascadeThreads: true, |
| 3030 | + clientRequestId: "delete-create-race-project", |
| 3031 | + }), |
| 3032 | + { startImmediately: true }, |
| 3033 | + ); |
| 3034 | + yield* Deferred.await(deletionLockAttempted); |
| 3035 | + expect(yield* Deferred.isDone(deletionLockAcquired)).toBe(false); |
| 3036 | + |
| 3037 | + yield* Deferred.succeed(allowCreationAdmission, undefined); |
| 3038 | + yield* Deferred.await(deletionLockAcquired); |
| 3039 | + const creationCall = yield* Fiber.join(creation); |
| 3040 | + expect(creationCall.isError).toBe(false); |
| 3041 | + const creationResult = yield* decodeCreateThreadsResult( |
| 3042 | + creationCall.structuredContent, |
| 3043 | + ).pipe(Effect.orDie); |
| 3044 | + const createdThread = creationResult.threads[0]; |
| 3045 | + if (createdThread === undefined) { |
| 3046 | + return yield* Effect.die(new Error("Racing MCP thread was not created.")); |
| 3047 | + } |
| 3048 | + expect( |
| 3049 | + (yield* orchestrator.getThreadProjection(createdThread.threadId)).thread.projectId, |
| 3050 | + ).toBe(deletionRaceProjectId); |
| 3051 | + |
| 3052 | + yield* Deferred.succeed(allowDeletion, undefined); |
| 3053 | + const deletionCall = yield* Fiber.join(deletion); |
| 3054 | + expect(deletionCall.isError).toBe(false); |
| 3055 | + expect(deletionCall.structuredContent).toMatchObject({ |
| 3056 | + projectId: deletionRaceProjectId, |
| 3057 | + deleted: true, |
| 3058 | + alreadyDeleted: false, |
| 3059 | + deletedThreadCount: 2, |
| 3060 | + workspaceFilesDeleted: false, |
| 3061 | + }); |
| 3062 | + expect( |
| 3063 | + (yield* orchestrator.getThreadProjection(createdThread.threadId)).thread.deletedAt, |
| 3064 | + ).not.toBeNull(); |
| 3065 | + expect( |
| 3066 | + (yield* orchestrator.getThreadProjection(deletionRaceParentThreadId)).thread |
| 3067 | + .deletedAt, |
| 3068 | + ).not.toBeNull(); |
| 3069 | + |
| 3070 | + const threads = yield* ThreadManagement.ThreadManagementService; |
| 3071 | + const dispatchClientCommand = yield* ClientCommandDispatch.make; |
| 3072 | + const acceptedCreateCommand = { |
| 3073 | + type: "thread.create", |
| 3074 | + createdBy: "agent", |
| 3075 | + creationSource: "mcp", |
| 3076 | + commandId: deletionRaceCommandId, |
| 3077 | + threadId: createdThread.threadId, |
| 3078 | + projectId: deletionRaceProjectId, |
| 3079 | + title: "Racing child", |
| 3080 | + modelSelection: codexSelection, |
| 3081 | + runtimeMode: "full-access", |
| 3082 | + interactionMode: "default", |
| 3083 | + branch: null, |
| 3084 | + worktreePath: cwd, |
| 3085 | + } as const; |
| 3086 | + const sequenceBeforeReplay = yield* threads.getThreadEventSequence( |
| 3087 | + createdThread.threadId, |
| 3088 | + ); |
| 3089 | + const replayed = yield* dispatchClientCommand(acceptedCreateCommand); |
| 3090 | + expect( |
| 3091 | + replayed.storedEvents.some((stored) => stored.event.type === "thread.created"), |
| 3092 | + ).toBe(true); |
| 3093 | + expect(yield* threads.getThreadEventSequence(createdThread.threadId)).toBe( |
| 3094 | + sequenceBeforeReplay, |
| 3095 | + ); |
| 3096 | + expect( |
| 3097 | + (yield* orchestrator.getThreadProjection(createdThread.threadId)).thread.deletedAt, |
| 3098 | + ).not.toBeNull(); |
| 3099 | + |
| 3100 | + const freshThreadId = ThreadId.make("thread:mcp-delete-race:fresh-after-delete"); |
| 3101 | + const freshFailure = yield* dispatchClientCommand({ |
| 3102 | + ...acceptedCreateCommand, |
| 3103 | + commandId: CommandId.make("command:mcp-delete-race:fresh-after-delete"), |
| 3104 | + threadId: freshThreadId, |
| 3105 | + }).pipe(Effect.flip); |
| 3106 | + expect(freshFailure).toMatchObject({ _tag: "ProjectMutationError" }); |
| 3107 | + expect( |
| 3108 | + Option.isNone(yield* Effect.option(orchestrator.getThreadProjection(freshThreadId))), |
| 3109 | + ).toBe(true); |
| 2879 | 3110 | }).pipe(Effect.provide(testLayer)); |
| 2880 | 3111 | }), |
| 2881 | 3112 | ), |