| @@ -101,6 +101,7 @@ const projectId = ProjectId.make("project:mcp-orchestrator"); · 100 unmodified lines |
| 101 | 101 | const deletionRaceProjectId = ProjectId.make("project:mcp-orchestrator-delete-race"); |
| 102 | 102 | const deletionRaceParentThreadId = ThreadId.make("thread:mcp-orchestrator-delete-race-parent"); |
| 103 | 103 | const ownershipRaceParentThreadId = ThreadId.make("thread:mcp-orchestrator-ownership-race-parent"); |
| 104 | +const lifecycleRaceParentThreadId = ThreadId.make("thread:mcp-orchestrator-lifecycle-race-parent"); |
| 104 | 105 | const targetProjectId = ProjectId.make("project:mcp-orchestrator-target"); |
| 105 | 106 | const codexInstanceId = ProviderInstanceId.make("codex"); |
| 106 | 107 | const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); |
| @@ -525,6 +526,7 @@ describe("orchestrator MCP toolkit", () => { · 418 unmodified lines |
| 525 | 526 | const allowDeletion = yield* Deferred.make<void>(); |
| 526 | 527 | const deletionRaceProviderStarted = yield* Deferred.make<ProviderAdapterV2TurnInput>(); |
| 527 | 528 | const ownershipRaceProviderStarted = yield* Deferred.make<ProviderAdapterV2TurnInput>(); |
| 529 | + const lifecycleRaceProviderStarted = yield* Deferred.make<ProviderAdapterV2TurnInput>(); |
| 528 | 530 | const deletionRaceCommandId = CommandId.make( |
| 529 | 531 | "command:mcp:mcp-provider-session-delete-race:create-thread:delete-create-race:0", |
| 530 | 532 | ); |
| @@ -547,13 +549,16 @@ describe("orchestrator MCP toolkit", () => { · 16 unmodified lines |
| 547 | 549 | turn.threadId !== parentThreadId && |
| 548 | 550 | turn.threadId !== deletionRaceParentThreadId && |
| 549 | 551 | turn.threadId !== ownershipRaceParentThreadId && |
| 552 | + turn.threadId !== lifecycleRaceParentThreadId && |
| 550 | 553 | turn.message.text !== cancellationPrompt, |
| 551 | 554 | startedSignal: (turn) => |
| 552 | 555 | turn.threadId === deletionRaceParentThreadId |
| 553 | 556 | ? deletionRaceProviderStarted |
| 554 | 557 | : turn.threadId === ownershipRaceParentThreadId |
| 555 | 558 | ? ownershipRaceProviderStarted |
| 556 | | − : undefined, |
| 559 | + : turn.threadId === lifecycleRaceParentThreadId |
| 560 | + ? lifecycleRaceProviderStarted |
| 561 | + : undefined, |
| 557 | 562 | terminalGate: (turn) => |
| 558 | 563 | turn.message.text.startsWith("Delegated task") || |
| 559 | 564 | turn.message.text.startsWith("Delegated tasks") |
| @@ -3736,6 +3741,90 @@ describe("orchestrator MCP toolkit", () => { · 3176 unmodified lines |
| 3736 | 3741 | ).toBe(true); |
| 3737 | 3742 | }).pipe(Effect.ensuring(Deferred.succeed(inactiveParentGate.release, undefined))); |
| 3738 | 3743 | |
| 3744 | + yield* orchestrator.dispatch({ |
| 3745 | + type: "thread.create", |
| 3746 | + createdBy: "user", |
| 3747 | + creationSource: "web", |
| 3748 | + commandId: CommandId.make("command:mcp-lifecycle-race-parent:create"), |
| 3749 | + threadId: lifecycleRaceParentThreadId, |
| 3750 | + projectId, |
| 3751 | + title: "MCP lifecycle race parent", |
| 3752 | + modelSelection: codexSelection, |
| 3753 | + runtimeMode: "full-access", |
| 3754 | + interactionMode: "default", |
| 3755 | + branch: null, |
| 3756 | + worktreePath: cwd, |
| 3757 | + }); |
| 3758 | + yield* orchestrator.dispatch({ |
| 3759 | + type: "message.dispatch", |
| 3760 | + createdBy: "user", |
| 3761 | + creationSource: "web", |
| 3762 | + commandId: CommandId.make("command:mcp-lifecycle-race-parent:start"), |
| 3763 | + threadId: lifecycleRaceParentThreadId, |
| 3764 | + messageId: MessageId.make("message:mcp-lifecycle-race-parent:start"), |
| 3765 | + text: "Stay active until the caller lifecycle is rechecked.", |
| 3766 | + attachments: [], |
| 3767 | + modelSelection: codexSelection, |
| 3768 | + dispatchMode: { type: "start_immediately" }, |
| 3769 | + }); |
| 3770 | + const startedLifecycleRaceTurn = yield* Deferred.await(lifecycleRaceProviderStarted); |
| 3771 | + expect(startedLifecycleRaceTurn.threadId).toBe(lifecycleRaceParentThreadId); |
| 3772 | + const lifecycleRaceInvocation: McpInvocationContext.McpInvocationScope = { |
| 3773 | + ...invocation, |
| 3774 | + threadId: lifecycleRaceParentThreadId, |
| 3775 | + providerSessionId: "mcp-provider-session-lifecycle-race", |
| 3776 | + }; |
| 3777 | + const invokeLifecycleRace = (name: string, args: Record<string, unknown>) => |
| 3778 | + server |
| 3779 | + .callTool({ name, arguments: args }) |
| 3780 | + .pipe( |
| 3781 | + Effect.provideService( |
| 3782 | + McpInvocationContext.McpInvocationContext, |
| 3783 | + lifecycleRaceInvocation, |
| 3784 | + ), |
| 3785 | + Effect.provideService(McpSchema.McpServerClient, client), |
| 3786 | + ); |
| 3787 | + const archivedParentKey = "create-after-parent-archived"; |
| 3788 | + const archivedParentGate = yield* gateLaunchReceipt( |
| 3789 | + launchCommandId(archivedParentKey, lifecycleRaceInvocation.providerSessionId), |
| 3790 | + ); |
| 3791 | + yield* Effect.gen(function* () { |
| 3792 | + const creation = yield* Effect.forkChild( |
| 3793 | + invokeLifecycleRace("create_threads", { |
| 3794 | + clientRequestId: archivedParentKey, |
| 3795 | + threads: [ |
| 3796 | + { |
| 3797 | + projectId: targetProjectId, |
| 3798 | + title: "Must not outlive its archived caller", |
| 3799 | + prompt: "Do not launch after the caller is archived.", |
| 3800 | + }, |
| 3801 | + ], |
| 3802 | + }), |
| 3803 | + { startImmediately: true }, |
| 3804 | + ); |
| 3805 | + yield* Deferred.await(archivedParentGate.entered); |
| 3806 | + yield* orchestrator.dispatch({ |
| 3807 | + type: "thread.archive", |
| 3808 | + commandId: CommandId.make("command:mcp-lifecycle-race-parent:archive"), |
| 3809 | + threadId: lifecycleRaceParentThreadId, |
| 3810 | + }); |
| 3811 | + yield* Deferred.succeed(archivedParentGate.release, undefined); |
| 3812 | + const creationCall = yield* Fiber.join(creation); |
| 3813 | + expect(creationCall.structuredContent).toMatchObject({ |
| 3814 | + code: "parent_not_active", |
| 3815 | + message: |
| 3816 | + "Thread creation requires an active run owned by this MCP provider session.", |
| 3817 | + }); |
| 3818 | + const childThreadId = ThreadId.make( |
| 3819 | + `thread:mcp:${lifecycleRaceInvocation.providerSessionId}:${archivedParentKey}:0`, |
| 3820 | + ); |
| 3821 | + expect( |
| 3822 | + Option.isNone( |
| 3823 | + yield* Effect.option(orchestrator.getThreadProjection(childThreadId)), |
| 3824 | + ), |
| 3825 | + ).toBe(true); |
| 3826 | + }).pipe(Effect.ensuring(Deferred.succeed(archivedParentGate.release, undefined))); |
| 3827 | + |
| 3739 | 3828 | yield* orchestrator.dispatch({ |
| 3740 | 3829 | type: "thread.create", |
| 3741 | 3830 | createdBy: "user", |