feat(mcp): target thread launches by project#8678

closed18
agents/mcp-projects/management
agents/mcp-projects/targeted-threads
openedAug 29, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 4d ago
20 unresolved threads ยท Jump to file
Selected thread: apps/server/src/mcp/OrchestratorMcpService.ts:973 โ€” ๐ŸŸก **Medium** `mcp/OrchestratorMcpService.ts:973` When no branch is requested, `resolveLaunchWorkspace` leaves `expectedBranch` unset, so `ThreadLaunchService.launch` skips its `currentBranch` validation and can persist the snapshot from a checkout that has since changed. Thread details and `worktree.status` then report a branch different from the checkout used by the run. Populate `expectedBranch` with the observed `workspace.branch` when no explicit branch was supplied for both root and existing-worktree strategies. <details> <summary>๐Ÿš€ Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> ```text In file @apps/server/src/mcp/OrchestratorMcpService.ts around line 973: When no branch is requested, `resolveLaunchWorkspace` leaves `expectedBranch` unset, so `ThreadLaunchService.launch` skips its `currentBranch` validation and can persist the snapshot from a checkout that has since changed. Thread details and `worktree.status` then report a branch different from the checkout used by the run. Populate `expectedBranch` with the observed `workspace.branch` when no explicit branch was supplied for both root and existing-worktree strategies. ``` </details> <!-- macroscope-meta: {"kind":"code_review","variant":"f57f0b61c1d8f13d673564c863408287"} -->
0 / 21 viewed21 files changedLine totals unavailable

apps/server/src/mcp/OrchestratorMcpService.ts ยท Earlier revision 661a3b18 ยท right 1790

Original diff
@@ -1341,139 +1625,245 @@ const make = Effect.gen(function* () {
           );
         }
         const parentNodeId = parentRun.rootNodeId;
-        const providers = yield* loadProviders;
         const key = yield* requestKey(input.clientRequestId);
         const created = yield* Effect.forEach(
           input.threads,
           (request, index) =>
             Effect.gen(function* () {
-              const target = yield* resolveTarget({
-                parent,
-                target: request.target,
-                providers,
-              });
-              const runtimeMode = yield* resolveRuntimeMode(
-                parent.thread.runtimeMode,
-                request.runtimeMode,
-              );
-              const interactionMode = yield* resolveInteractionMode(
-                parent.thread.interactionMode,
-                request.interactionMode,
-              );
-              const threadId = stableThreadId({
+              const projectId = yield* resolveProjectId(parent, request.projectId);
+              const commandId = stableCommandId({
                 scope,
                 requestKey: key,
+                operation: "create-thread",
                 index,
               });
-              const title = threadTitle({
-                parentTitle: parent.thread.title,
-                prompt: request.prompt,
-                title: request.title,
-                index,
-              });
-              const createCommandId = stableCommandId({
+              const threadId = stableThreadId({
                 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
-                .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);
-                    }),
+              const initialMessageId = stableMessageId({ scope, requestKey: key, index });
+              const launchReceipt = yield* threadManagement
+                .withProjectCreationAdmission({ projectId, commandId }, (receipt) =>
+                  Effect.succeed(receipt),
                 )
                 .pipe(
-                  Effect.mapError((error) =>
-                    isOrchestratorMcpFailure(error)
-                      ? error
-                      : failure(
-                          "orchestration_error",
-                          `Unable to create thread ${index + 1}: ${errorMessage(error)}`,
-                        ),
+                  Effect.mapError(() =>
+                    failure(
+                      "orchestration_error",
+                      `Unable to read the accepted launch for thread ${index + 1}.`,
+                    ),
                   ),
                 );
-              if (request.prompt !== undefined) {
-                yield* threadManagement
-                  .dispatch({
-                    type: "message.dispatch",
-                    createdBy: "agent",
-                    creationSource: "mcp",
-                    commandId: stableCommandId({
-                      scope,
-                      requestKey: key,
-                      operation: "dispatch-thread",
-                      index,
-                    }),
-                    threadId,
-                    messageId: stableMessageId({
-                      scope,
-                      requestKey: key,
-                      index,
-                    }),
-                    text: request.prompt,
-                    attachments: [],
-                    modelSelection: target.modelSelection,
-                    dispatchMode: { type: "start_immediately" },
-                  })
+              if (
+                Option.isSome(launchReceipt) &&
+                (launchReceipt.value.commandType !== "thread.create" ||
+                  launchReceipt.value.threadId !== threadId)
+              ) {
+                return yield* failure(
+                  "invalid_request",
+                  `clientRequestId conflicts with an existing command for thread ${index + 1}.`,
+                );
+              }
+              const acceptedProjection =
+                Option.isSome(launchReceipt) && launchReceipt.value.status === "accepted"
+                  ? Option.some(yield* loadProjection(threadId))
+                  : Option.none<OrchestrationV2ThreadProjection>();
+              if (
+                Option.isSome(acceptedProjection) &&
+                (acceptedProjection.value.thread.id !== threadId ||
+                  acceptedProjection.value.thread.projectId !== projectId)
+              ) {
+                return yield* failure(
+                  "invalid_request",
+                  `clientRequestId was already accepted for a different project at thread ${index + 1}.`,
+                );
+              }
+              const mayReplayDurableRoot = yield* Effect.gen(function* () {
+                if (
+                  Option.isNone(acceptedProjection) ||
+                  acceptedProjection.value.thread.worktreePath !== null ||
+                  request.workspaceStrategy?.type === "new_worktree"
+                ) {
+                  return false;
+                }
+                if (request.prompt === undefined) return true;
+                const messageCommandId = CommandId.make(`${commandId}:initial-message`);
+                const messageReceipt = yield* threadManagement
+                  .withProjectCreationAdmission(
+                    { projectId, commandId: messageCommandId },
+                    (receipt) => Effect.succeed(receipt),
+                  )
                   .pipe(
-                    Effect.mapError((error) =>
+                    Effect.mapError(() =>
                       failure(
                         "orchestration_error",
-                        `Unable to start thread ${index + 1}: ${errorMessage(error)}`,
+                        `Unable to read the accepted initial message for thread ${index + 1}.`,
                       ),
                     ),
                   );
+                if (
+                  Option.isSome(messageReceipt) &&
+                  (messageReceipt.value.commandType !== "message.dispatch" ||
+                    messageReceipt.value.threadId !== threadId)
+                ) {
+                  return yield* failure(
+                    "invalid_request",
+                    `clientRequestId conflicts with an existing initial message for thread ${index + 1}.`,
+                  );
+                }
+                if (Option.isNone(messageReceipt) || messageReceipt.value.status !== "accepted") {
+                  return false;
+                }
+                const initialMessage = acceptedProjection.value.messages.find(
+                  (message) => message.id === initialMessageId,
+                );
+                if (initialMessage?.runId === null || initialMessage?.runId === undefined) {
+                  return false;
+                }
+                const initialRun = acceptedProjection.value.runs.find(
+                  (run) => run.id === initialMessage.runId,
+                );
+                return initialRun !== undefined && initialRun.status !== "preparing";
+              });
+              const target = Option.isSome(acceptedProjection)
+                ? { modelSelection: acceptedProjection.value.thread.modelSelection }
+                : Option.isSome(launchReceipt)
+                  ? { modelSelection: parent.thread.modelSelection }
+                  : yield* resolveTarget({
+                      parent,
+                      target: request.target,
+                      providers: yield* loadProviders,
+                    });
+              const runtimeMode = Option.isSome(acceptedProjection)
+                ? acceptedProjection.value.thread.runtimeMode
+                : resolveLaunchRuntimeMode(parent.thread.runtimeMode, request.runtimeMode);
+              const interactionMode = Option.isSome(acceptedProjection)
+                ? acceptedProjection.value.thread.interactionMode
+                : resolveLaunchInteractionMode(
+                    parent.thread.interactionMode,
+                    request.interactionMode,
+                  );
+              const title = Option.isSome(acceptedProjection)
+                ? acceptedProjection.value.thread.title
+                : threadTitle({
+                    parentTitle: parent.thread.title,
+                    prompt: request.prompt,
+                    title: request.title,
+                    index,
+                  });
+              const workspaceStrategy: ThreadLaunch.ThreadLaunchWorkspaceStrategy = Option.isSome(
+                acceptedProjection,
+              )
+                ? acceptedProjection.value.thread.worktreePath !== null
+                  ? {
+                      type: "existing_worktree",
+                      worktreePath: acceptedProjection.value.thread.worktreePath,
+                      ...(acceptedProjection.value.thread.branch === null
+                        ? {}
+                        : { branch: acceptedProjection.value.thread.branch }),
+                    }
+                  : request.workspaceStrategy?.type === "new_worktree"
+                    ? {
+                        type: "worktree",
+                        baseRef: request.workspaceStrategy.baseRef,
+                        ...(acceptedProjection.value.thread.branch === null
+                          ? request.workspaceStrategy.branch === undefined
+                            ? {}
+                            : { branch: request.workspaceStrategy.branch }
+                          : { branch: acceptedProjection.value.thread.branch }),
+                        ...(request.workspaceStrategy.startFromOrigin === undefined
+                          ? {}
+                          : { startFromOrigin: request.workspaceStrategy.startFromOrigin }),
+                      }
+                    : mayReplayDurableRoot
+                      ? {
+                          type: "root",
+                          ...(acceptedProjection.value.thread.branch === null
+                            ? {}
+                            : { branch: acceptedProjection.value.thread.branch }),
+                        }
+                      : yield* resolveLaunchWorkspace(
+                          parent,
+                          yield* loadLaunchProject(projectId),
+                          request.workspaceStrategy,
+                        )
C
cursorBotAug 30, 2026

Partial root replay rechecks workspace

Medium Severity

When a root thread is already accepted but the initial message is still preparing or missing, mayReplayDurableRoot is false and createThreads re-runs resolveLaunchWorkspace. That re-reads the live checkout and can fail the retry if the branch or path changed, even though ThreadLaunchService skips expectedBranch once a create receipt exists. Existing-worktree replays avoid this by using the stored path.

Reviewed by Cursor Bugbot for commit 661a3b184abb70fd97afd2951af28b8f547a6c41. Configure here.

J
Julius MarmingeAug 30, 2026

Fixed in 8f61ffb24b3b03854cc4f20dda37d7cbb039cc1b. Accepted durable non-null paths still replay as existing_worktree, and a retry marked new_worktree retains provisioning recovery. Accepted null-path root/omitted retries now reconstruct root directly from the durable branch, leaving ThreadLaunch in charge of message receipts, fresh missing-message ceilings, and preparation reservation. The real setup-gated test removes the root while exact R1 is preparing and proves the same thread/R1/status, one message, null binding, and unchanged branch; the pre-fix planning path fails that replay.

packages/contracts/src/orchestrationV2.ts ยท Earlier revision 06b60599 ยท right 2014โ€“2049

Original diff
@@ -2000,6 +2011,7 @@ export const OrchestrationV2Command = Schema.Union([
     modelSelection: ModelSelection,
     runtimeMode: RuntimeMode,
     interactionMode: ProviderInteractionMode,
+    policyCeiling: Schema.optional(OrchestrationV2PolicyCeiling),

packages/contracts/src/orchestrationV2.ts ยท right 2014โ€“2049

M
macroscopeappBotAug 30, 2026

๐ŸŸ  High src/orchestrationV2.ts:2014

An authenticated WebSocket client can set thread.create.policyCeiling.callerThreadId to another active thread, causing dispatch to acquire that thread's threadDispatch lock and block its dispatches without authorization. Because policyCeiling is decoded from the public command, derive this server-internal value server-side instead of accepting it here.

suggestion
1
๐Ÿš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @packages/contracts/src/orchestrationV2.ts around line 2014:
2
3An authenticated WebSocket client can set `thread.create.policyCeiling.callerThreadId` to another active thread, causing dispatch to acquire that thread's `threadDispatch` lock and block its dispatches without authorization. Because `policyCeiling` is decoded from the public command, derive this server-internal value server-side instead of accepting it here.
J
Julius MarmingeAug 30, 2026

Verified against the actual RPC boundary. dispatchCommand requires the environment-wide orchestration operate scope in RpcAuthorization before ws dispatch, and that principal may already dispatch commands to arbitrary thread IDs in the environment. policyCeiling only adds caller lifecycle/mode restrictions; its sorted caller/target locks do not grant additional authority. MCP constructs the ceiling from its authenticated invocation scope. No change.

apps/server/src/orchestration-v2/Orchestrator.ts ยท Earlier revision 06b60599 ยท right 289โ€“292

Original diff
@@ -224,6 +282,23 @@ function nextRunOrdinal(projection: OrchestrationV2ThreadProjection): number {
   return projection.runs.length + 1;
 }
 
+function runtimeModeRank(mode: OrchestrationV2AppThread["runtimeMode"]): number {
+  switch (mode) {
+    case "approval-required":
+      return 0;
+    case "auto-accept-edits":
+      return 1;
+    case "auto":
+      return 2;

apps/server/src/orchestration-v2/Orchestrator.ts ยท right 289โ€“292

M
macroscopeappBotAug 30, 2026

๐ŸŸ  High orchestration-v2/Orchestrator.ts:289

A caller capped at "auto" can launch a child in "auto-accept-edits", allowing edits that should still require approval. runtimeModeRank assigns "auto-accept-edits" rank 1 and "auto" rank 2, so the ceiling check incorrectly considers the less restrictive mode valid; assign "auto" the lower rank.

diff
1- return 1;
2+ return 2;
3@@
4- return 2;
5+ return 1;
๐Ÿš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/orchestration-v2/Orchestrator.ts around lines 289-292:
2
3A caller capped at `"auto"` can launch a child in `"auto-accept-edits"`, allowing edits that should still require approval. `runtimeModeRank` assigns `"auto-accept-edits"` rank 1 and `"auto"` rank 2, so the ceiling check incorrectly considers the less restrictive mode valid; assign `"auto"` the lower rank.
J
Julius MarmingeAug 30, 2026

Verified against the V2 adapters: this ordering is intentional. Codex maps auto-accept-edits to workspace-write/on-request with user review, while auto uses the same filesystem boundary with auto_review; Claude likewise maps them to acceptEdits versus auto. Swapping the ranks would let the narrower auto-accept-edits ceiling authorize the more autonomous auto mode. No change.

apps/server/src/mcp/OrchestratorMcpService.ts ยท Earlier revision 6b41e5d3 ยท right 1486

Original diff
@@ -1370,67 +1441,46 @@ const make = Effect.gen(function* () {
                 title: request.title,
                 index,
               });
-              yield* threadManagement
-                .dispatch({
-                  type: "thread.create",
-                  createdBy: "agent",
-                  creationSource: "mcp",
+              const launched = yield* threadLaunch
+                .launch({
                   commandId: stableCommandId({
                     scope,
                     requestKey: key,
                     operation: "create-thread",
                     index,
                   }),
                   threadId,
-                  projectId: parent.thread.projectId,
+                  projectId,
                   title,
                   modelSelection: target.modelSelection,
                   runtimeMode,
                   interactionMode,
-                  branch: parent.thread.branch,
-                  worktreePath: parent.thread.worktreePath,
+                  workspaceStrategy: resolveLaunchWorkspace(
+                    parent,
+                    projectId,
+                    request.workspaceStrategy,
+                  ),
+                  ...(request.prompt === undefined
+                    ? {}
+                    : {
+                        initialMessage: {
+                          messageId: stableMessageId({ scope, requestKey: key, index }),
+                          text: request.prompt,
+                          attachments: [],
+                        },
+                      }),
+                  createdBy: "agent",
+                  creationSource: "mcp",
                 })
                 .pipe(
                   Effect.mapError((error) =>
                     failure(
                       "orchestration_error",
-                      `Unable to create thread ${index + 1}: ${errorMessage(error)}`,
+                      `Unable to launch thread ${index + 1}: ${errorMessage(error)}`,
                     ),
                   ),
                 );
-              if (request.prompt !== undefined) {
-                yield* threadManagement
-                  .dispatch({
-                    type: "message.dispatch",
-                    createdBy: "agent",
-                    creationSource: "mcp",
-                    commandId: stableCommandId({
-                      scope,
-                      requestKey: key,
-                      operation: "dispatch-thread",
-                      index,
-                    }),
-                    threadId,
-                    messageId: stableMessageId({
-                      scope,
-                      requestKey: key,
-                      index,
-                    }),
-                    text: request.prompt,
-                    attachments: [],
-                    modelSelection: target.modelSelection,
-                    dispatchMode: { type: "start_immediately" },
-                  })
-                  .pipe(
-                    Effect.mapError((error) =>
-                      failure(
-                        "orchestration_error",
-                        `Unable to start thread ${index + 1}: ${errorMessage(error)}`,
-                      ),
-                    ),
-                  );
-              }
-              const projection = yield* loadProjection(threadId);
+              const projection = launched.projection;
               const run = projection.runs.at(-1);
               yield* threadManagement
                 .dispatch({
M
macroscopeappBotAug 29, 2026

๐ŸŸ  High mcp/OrchestratorMcpService.ts:1486

Cross-project createThreads returns an orchestration_error after already creating and potentially starting the target thread, leaving an unreported orphan thread. thread.created.record rejects targets whose project differs from the parent, so make the recording path accept cross-project targets or otherwise prevent the launch from committing before recording succeeds.

โœ… Resolved in 192e70c07f29260141b44f5d6ae4d76dadae0815

J
Julius MarmingeAug 29, 2026

Fixed in baad86f8cc. thread.created.record now carries the explicitly authorized targetProjectId, and the orchestrator verifies it against the durable target-thread projection while retaining the legacy same-project check when the field is omitted. The integration test now exercises the actual MCP service, ThreadLaunchService, and event-sourced orchestrator for a cross-project launch and parent timeline record.

apps/server/src/orchestration-v2/Orchestrator.ts ยท Earlier revision baad86f8 ยท right 4910

Original diff
@@ -4896,13 +4896,26 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
           cause: `Parent node ${command.parentNodeId} is not the root of run ${command.parentRunId}.`,
         });
       }
-      if (parentProjection.thread.projectId !== targetProjection.thread.projectId) {
+      if (
+        command.targetProjectId === undefined &&
+        parentProjection.thread.projectId !== targetProjection.thread.projectId
+      ) {
         return yield* new OrchestratorDispatchError({
           commandId: command.commandId,
           commandType: command.type,
           cause: `Target thread ${command.targetThreadId} belongs to another project.`,
         });
       }
+      if (
+        command.targetProjectId !== undefined &&
M
macroscopeappBotAug 29, 2026

๐ŸŸ  High orchestration-v2/Orchestrator.ts:4910

A client can submit thread.created.record with an arbitrary targetProjectId and record a cross-project thread in the parent timeline. Because the branch treats the caller-supplied field as authorization, it bypasses the same-project check and copies the target's title, project ID, run ID, provider, and model into the parent thread; authorization must come from a trusted boundary rather than this untrusted command field.

โœ… Resolved in 1f905034f7c91d154ad77f5c0fba447ef694f573

J
Julius MarmingeAug 29, 2026

Fixed in 1f905034f7. Cross-project authorization is no longer part of the public orchestration command. The MCP path calls a server-owned recordServerCreatedThread method with the authorized target project supplied out-of-band; public dispatch retains the same-project check. The real MCP/ThreadLaunch/Orchestrator test also asserts that an untrusted public cross-project record command is rejected.

apps/server/src/mcp/OrchestratorMcpService.ts ยท Earlier revision a5870511 ยท right 958

Original diff
@@ -705,17 +763,219 @@ const make = Effect.gen(function* () {
       .getProjectThread({ projectId, threadId })
       .pipe(Effect.mapError(threadManagementFailure));
 
-  const loadScopedThread = (scope: McpInvocationScope, threadId: ThreadId) =>
+  const resolveProjectId = (
+    parent: OrchestrationV2ThreadProjection,
+    requestedProjectId: ProjectId | undefined,
+  ): Effect.Effect<ProjectId, OrchestratorMcpFailure> =>
+    Effect.gen(function* () {
+      const projectId = requestedProjectId ?? parent.thread.projectId;
+      if (projectId === parent.thread.projectId) return projectId;
+      const project = yield* projects
+        .getById(projectId)
+        .pipe(
+          Effect.mapError((error) =>
+            failure(
+              "orchestration_error",
+              `Unable to read project ${projectId}: ${errorMessage(error)}`,
+            ),
+          ),
+        );
+      if (Option.isNone(project)) {
+        return yield* failure("project_not_found", `Project ${projectId} was not found.`);
+      }
+      return projectId;
+    });
+
+  const loadLaunchProject = (projectId: ProjectId) =>
+    projects.getById(projectId).pipe(
+      Effect.mapError((error) =>
+        failure(
+          "orchestration_error",
+          `Unable to read project ${projectId}: ${errorMessage(error)}`,
+        ),
+      ),
+      Effect.flatMap(
+        Option.match({
+          onNone: () =>
+            Effect.fail(failure("project_not_found", `Project ${projectId} was not found.`)),
+          onSome: Effect.succeed,
+        }),
+      ),
+    );
+
+  const loadScopedThread = (
+    scope: McpInvocationScope,
+    threadId: ThreadId,
+    requestedProjectId?: ProjectId,
+  ) =>
     Effect.gen(function* () {
       yield* requireCapability(scope);
       const parent = yield* loadProjection(scope.threadId);
+      const projectId = yield* resolveProjectId(parent, requestedProjectId);
       const target =
-        threadId === scope.threadId
+        threadId === scope.threadId && projectId === parent.thread.projectId
           ? parent
-          : yield* loadProjectThread(parent.thread.projectId, threadId);
-      return { parent, target } as const;
+          : yield* loadProjectThread(projectId, threadId);
+      return { parent, projectId, target } as const;
     });
 
+  const canonicalPath = (value: string, label: string) =>
+    fileSystem
+      .realPath(value)
+      .pipe(
+        Effect.mapError((error) =>
+          failure("invalid_request", `Unable to resolve ${label}: ${errorMessage(error)}`),
+        ),
+      );
+
+  const detectRepository = (cwd: string, label: string) =>
+    vcsRegistry
+      .detect({ cwd })
+      .pipe(
+        Effect.mapError((error) =>
+          failure("invalid_request", `Unable to inspect ${label}: ${errorMessage(error)}`),
+        ),
+      );
+
+  const canonicalRepositoryMetadata = Effect.fn(
+    "OrchestratorMcpService.canonicalRepositoryMetadata",
+  )(function* (input: { readonly detectionCwd: string; readonly metadataPath: string | null }) {
+    if (input.metadataPath === null) return null;
+    return yield* canonicalPath(
+      path.isAbsolute(input.metadataPath)
+        ? input.metadataPath
+        : path.resolve(input.detectionCwd, input.metadataPath),
+      "repository metadata",
+    );
+  });
+
+  const actualGitBranch = Effect.fn("OrchestratorMcpService.actualGitBranch")(function* (
+    handle: VcsDriverRegistry.VcsDriverHandle,
+    cwd: string,
+  ) {
+    const result = yield* handle.driver
+      .execute({
+        operation: "OrchestratorMcpService.actualGitBranch",
+        cwd,
+        args: ["symbolic-ref", "--quiet", "--short", "HEAD"],
+        allowNonZeroExit: true,
+        timeoutMs: 5_000,
+        maxOutputBytes: 16 * 1024,
+      })
+      .pipe(
+        Effect.mapError((error) =>
+          failure("invalid_request", `Unable to read the workspace branch: ${errorMessage(error)}`),
+        ),
+      );
+    const branch = result.exitCode === 0 ? result.stdout.trim() : "";
+    return branch.length === 0 ? null : branch;
+  });
+
+  const inspectLaunchWorkspace = Effect.fn("OrchestratorMcpService.inspectLaunchWorkspace")(
+    function* (project: Project, candidatePath: string, requireWorktreeRoot: boolean) {
+      const projectRepository = yield* detectRepository(project.workspaceRoot, "project root");
+      if (projectRepository === null) {
+        if (requireWorktreeRoot) {
+          return yield* failure(
+            "invalid_request",
+            `Project ${project.id} does not have a repository with linked worktrees.`,
+          );
+        }
+        return {
+          canonicalExecutionPath: yield* canonicalPath(project.workspaceRoot, "project root"),
+          canonicalWorktreeRoot: null,
+          branch: null,
+        } as const;
+      }
+      if (projectRepository.kind !== "git") {
+        return yield* failure(
+          "invalid_request",
+          `Project ${project.id} uses ${projectRepository.kind}; this launch strategy requires Git.`,
+        );
+      }
+
+      const candidateRepository = yield* detectRepository(candidatePath, "workspace path");
+      if (candidateRepository === null || candidateRepository.kind !== "git") {
+        return yield* failure(
+          "invalid_request",
+          `Workspace path '${candidatePath}' is not a Git workspace for project ${project.id}.`,
+        );
+      }
+      const [canonicalCandidate, canonicalCandidateRoot, projectMetadata, candidateMetadata] =
+        yield* Effect.all([
+          canonicalPath(candidatePath, "workspace path"),
+          canonicalPath(candidateRepository.repository.rootPath, "workspace root"),
+          canonicalRepositoryMetadata({
+            detectionCwd: project.workspaceRoot,
+            metadataPath: projectRepository.repository.metadataPath,
+          }),
+          canonicalRepositoryMetadata({
+            detectionCwd: candidatePath,
+            metadataPath: candidateRepository.repository.metadataPath,
+          }),
+        ]);
+      if (
+        (requireWorktreeRoot && canonicalCandidate !== canonicalCandidateRoot) ||
+        projectMetadata === null ||
+        candidateMetadata === null ||
+        projectMetadata !== candidateMetadata
+      ) {
+        return yield* failure(
+          "invalid_request",
+          `Workspace path '${candidatePath}' is not a workspace of project ${project.id}.`,
+        );
+      }
+      return {
+        canonicalExecutionPath: canonicalCandidate,
+        canonicalWorktreeRoot: canonicalCandidateRoot,
+        branch: yield* actualGitBranch(candidateRepository, canonicalCandidate),
+      } as const;
+    },
+  );
+
+  const resolveLaunchWorkspace = Effect.fn("OrchestratorMcpService.resolveLaunchWorkspace")(
+    function* (
+      parent: OrchestrationV2ThreadProjection,
+      project: Project,
+      requested: OrchestratorMcpCreateThreadsInput["threads"][number]["workspaceStrategy"],
+    ): Effect.fn.Return<ThreadLaunch.ThreadLaunchWorkspaceStrategy, OrchestratorMcpFailure> {
+      if (requested?.type === "new_worktree") {
+        return {
+          type: "worktree",
+          baseRef: requested.baseRef,
+          ...(requested.branch === undefined ? {} : { branch: requested.branch }),
+          ...(requested.startFromOrigin === undefined
+            ? {}
+            : { startFromOrigin: requested.startFromOrigin }),
+        };
+      }
+      const explicitExisting = requested?.type === "existing_worktree" ? requested : undefined;
+      const useParentWorktree =
+        requested === undefined &&
+        project.id === parent.thread.projectId &&
+        parent.thread.worktreePath !== null;
+      if (explicitExisting !== undefined || useParentWorktree) {
+        const requestedPath = explicitExisting?.worktreePath ?? parent.thread.worktreePath!;
M
macroscopeappBotAug 30, 2026

๐ŸŸก Medium mcp/OrchestratorMcpService.ts:958

A retry with the same clientRequestId returns invalid_request instead of replaying the accepted thread when an existing_worktree has been deleted or is otherwise unresolvable. resolveLaunchWorkspace calls inspectLaunchWorkspace before ThreadLaunchService.launch can read the accepted command receipt, so workspace validation fails before idempotent recovery; resolve the receipt before validating the workspace.

๐Ÿš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/mcp/OrchestratorMcpService.ts around line 958:
2
3A retry with the same `clientRequestId` returns `invalid_request` instead of replaying the accepted thread when an `existing_worktree` has been deleted or is otherwise unresolvable. `resolveLaunchWorkspace` calls `inspectLaunchWorkspace` before `ThreadLaunchService.launch` can read the accepted command receipt, so workspace validation fails before idempotent recovery; resolve the receipt before validating the workspace.
J
Julius MarmingeAug 30, 2026

Fixed in 661a3b184abb70fd97afd2951af28b8f547a6c41. createThreads now validates the stable create receipt before mutable workspace planning. An accepted durable existing-worktree binding is replayed at its persisted path without filesystem inspection while ThreadLaunch still handles the exact initial-message receipt and unfinished preparation. The gated integration removes the physical checkout while R1 is preparing and proves the same child/R1 returns without rewriting its binding to root.

apps/server/src/mcp/OrchestratorMcpService.ts ยท Earlier revision 661a3b18 ยท right 220

Original diff
@@ -181,6 +194,32 @@ function errorMessage(error: unknown): string {
   return error instanceof Error ? error.message : String(error);
 }
 
+function threadLaunchPolicyFailure(
+  error: ThreadLaunch.ThreadLaunchError,
+): OrchestratorMcpFailure | null {
+  if (!isOrchestratorDispatchError(error.cause)) return null;
+  const cause = error.cause.cause;
+  if (isRuntimeModeCeilingError(cause)) {
+    return failure(
+      "runtime_mode_escalation_denied",
+      `Child runtime mode ${cause.targetMode} exceeds the caller ceiling (captured ${cause.capturedCallerMode}; current ${cause.currentCallerMode}).`,
+    );
+  }
+  if (isInteractionModeCeilingError(cause)) {
+    return failure(
+      "interaction_mode_escalation_denied",
+      `Child interaction mode ${cause.targetMode} exceeds the caller ceiling (captured ${cause.capturedCallerMode}; current ${cause.currentCallerMode}).`,
+    );
+  }
+  if (isCallerRunCeilingError(cause)) {
+    return failure(
+      "parent_not_active",
+      "Thread creation requires an active run owned by this MCP provider session.",
+    );
+  }
+  return null;
M
macroscopeappBotAug 30, 2026

๐ŸŸก Medium mcp/OrchestratorMcpService.ts:220

When the caller thread is archived or deleted after MCP preflight, createThreads reports generic orchestration_error instead of documented parent_not_active. enforcePolicyCeiling wraps the inactive-thread message as a string in OrchestratorDispatchError, so this function only recognizes the typed OrchestratorCallerRunCeilingError and returns null; handle that string-wrapped lifecycle error as parent_not_active too.

๐Ÿš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/mcp/OrchestratorMcpService.ts around line 220:
2
3When the caller thread is archived or deleted after MCP preflight, `createThreads` reports generic `orchestration_error` instead of documented `parent_not_active`. `enforcePolicyCeiling` wraps the inactive-thread message as a string in `OrchestratorDispatchError`, so this function only recognizes the typed `OrchestratorCallerRunCeilingError` and returns `null`; handle that string-wrapped lifecycle error as `parent_not_active` too.
J
Julius MarmingeAug 30, 2026

Fixed in 8f61ffb24b3b03854cc4f20dda37d7cbb039cc1b without string matching. When the ceiling carries callerRunId and callerProviderInstanceId, an archived/deleted caller now retains the existing typed OrchestratorCallerRunCeilingError, which the MCP boundary maps to parent_not_active. Legacy ceiling-only callers retain the generic cause. A real gated MCPโ†’ThreadLaunchโ†’V2 archive race proves the typed result and no child creation; restoring the old string cause made that assertion fail with orchestration_error.

apps/server/src/mcp/OrchestratorMcpService.ts ยท Earlier revision a5870511 ยท right 973

Original diff
@@ -705,17 +763,219 @@ const make = Effect.gen(function* () {
       .getProjectThread({ projectId, threadId })
       .pipe(Effect.mapError(threadManagementFailure));
 
-  const loadScopedThread = (scope: McpInvocationScope, threadId: ThreadId) =>
+  const resolveProjectId = (
+    parent: OrchestrationV2ThreadProjection,
+    requestedProjectId: ProjectId | undefined,
+  ): Effect.Effect<ProjectId, OrchestratorMcpFailure> =>
+    Effect.gen(function* () {
+      const projectId = requestedProjectId ?? parent.thread.projectId;
+      if (projectId === parent.thread.projectId) return projectId;
+      const project = yield* projects
+        .getById(projectId)
+        .pipe(
+          Effect.mapError((error) =>
+            failure(
+              "orchestration_error",
+              `Unable to read project ${projectId}: ${errorMessage(error)}`,
+            ),
+          ),
+        );
+      if (Option.isNone(project)) {
+        return yield* failure("project_not_found", `Project ${projectId} was not found.`);
+      }
+      return projectId;
+    });
+
+  const loadLaunchProject = (projectId: ProjectId) =>
+    projects.getById(projectId).pipe(
+      Effect.mapError((error) =>
+        failure(
+          "orchestration_error",
+          `Unable to read project ${projectId}: ${errorMessage(error)}`,
+        ),
+      ),
+      Effect.flatMap(
+        Option.match({
+          onNone: () =>
+            Effect.fail(failure("project_not_found", `Project ${projectId} was not found.`)),
+          onSome: Effect.succeed,
+        }),
+      ),
+    );
+
+  const loadScopedThread = (
+    scope: McpInvocationScope,
+    threadId: ThreadId,
+    requestedProjectId?: ProjectId,
+  ) =>
     Effect.gen(function* () {
       yield* requireCapability(scope);
       const parent = yield* loadProjection(scope.threadId);
+      const projectId = yield* resolveProjectId(parent, requestedProjectId);
       const target =
-        threadId === scope.threadId
+        threadId === scope.threadId && projectId === parent.thread.projectId
           ? parent
-          : yield* loadProjectThread(parent.thread.projectId, threadId);
-      return { parent, target } as const;
+          : yield* loadProjectThread(projectId, threadId);
+      return { parent, projectId, target } as const;
     });
 
+  const canonicalPath = (value: string, label: string) =>
+    fileSystem
+      .realPath(value)
+      .pipe(
+        Effect.mapError((error) =>
+          failure("invalid_request", `Unable to resolve ${label}: ${errorMessage(error)}`),
+        ),
+      );
+
+  const detectRepository = (cwd: string, label: string) =>
+    vcsRegistry
+      .detect({ cwd })
+      .pipe(
+        Effect.mapError((error) =>
+          failure("invalid_request", `Unable to inspect ${label}: ${errorMessage(error)}`),
+        ),
+      );
+
+  const canonicalRepositoryMetadata = Effect.fn(
+    "OrchestratorMcpService.canonicalRepositoryMetadata",
+  )(function* (input: { readonly detectionCwd: string; readonly metadataPath: string | null }) {
+    if (input.metadataPath === null) return null;
+    return yield* canonicalPath(
+      path.isAbsolute(input.metadataPath)
+        ? input.metadataPath
+        : path.resolve(input.detectionCwd, input.metadataPath),
+      "repository metadata",
+    );
+  });
+
+  const actualGitBranch = Effect.fn("OrchestratorMcpService.actualGitBranch")(function* (
+    handle: VcsDriverRegistry.VcsDriverHandle,
+    cwd: string,
+  ) {
+    const result = yield* handle.driver
+      .execute({
+        operation: "OrchestratorMcpService.actualGitBranch",
+        cwd,
+        args: ["symbolic-ref", "--quiet", "--short", "HEAD"],
+        allowNonZeroExit: true,
+        timeoutMs: 5_000,
+        maxOutputBytes: 16 * 1024,
+      })
+      .pipe(
+        Effect.mapError((error) =>
+          failure("invalid_request", `Unable to read the workspace branch: ${errorMessage(error)}`),
+        ),
+      );
+    const branch = result.exitCode === 0 ? result.stdout.trim() : "";
+    return branch.length === 0 ? null : branch;
+  });
+
+  const inspectLaunchWorkspace = Effect.fn("OrchestratorMcpService.inspectLaunchWorkspace")(
+    function* (project: Project, candidatePath: string, requireWorktreeRoot: boolean) {
+      const projectRepository = yield* detectRepository(project.workspaceRoot, "project root");
+      if (projectRepository === null) {
+        if (requireWorktreeRoot) {
+          return yield* failure(
+            "invalid_request",
+            `Project ${project.id} does not have a repository with linked worktrees.`,
+          );
+        }
+        return {
+          canonicalExecutionPath: yield* canonicalPath(project.workspaceRoot, "project root"),
+          canonicalWorktreeRoot: null,
+          branch: null,
+        } as const;
+      }
+      if (projectRepository.kind !== "git") {
+        return yield* failure(
+          "invalid_request",
+          `Project ${project.id} uses ${projectRepository.kind}; this launch strategy requires Git.`,
+        );
+      }
+
+      const candidateRepository = yield* detectRepository(candidatePath, "workspace path");
+      if (candidateRepository === null || candidateRepository.kind !== "git") {
+        return yield* failure(
+          "invalid_request",
+          `Workspace path '${candidatePath}' is not a Git workspace for project ${project.id}.`,
+        );
+      }
+      const [canonicalCandidate, canonicalCandidateRoot, projectMetadata, candidateMetadata] =
+        yield* Effect.all([
+          canonicalPath(candidatePath, "workspace path"),
+          canonicalPath(candidateRepository.repository.rootPath, "workspace root"),
+          canonicalRepositoryMetadata({
+            detectionCwd: project.workspaceRoot,
+            metadataPath: projectRepository.repository.metadataPath,
+          }),
+          canonicalRepositoryMetadata({
+            detectionCwd: candidatePath,
+            metadataPath: candidateRepository.repository.metadataPath,
+          }),
+        ]);
+      if (
+        (requireWorktreeRoot && canonicalCandidate !== canonicalCandidateRoot) ||
+        projectMetadata === null ||
+        candidateMetadata === null ||
+        projectMetadata !== candidateMetadata
+      ) {
+        return yield* failure(
+          "invalid_request",
+          `Workspace path '${candidatePath}' is not a workspace of project ${project.id}.`,
+        );
+      }
+      return {
+        canonicalExecutionPath: canonicalCandidate,
+        canonicalWorktreeRoot: canonicalCandidateRoot,
+        branch: yield* actualGitBranch(candidateRepository, canonicalCandidate),
+      } as const;
+    },
+  );
+
+  const resolveLaunchWorkspace = Effect.fn("OrchestratorMcpService.resolveLaunchWorkspace")(
+    function* (
+      parent: OrchestrationV2ThreadProjection,
+      project: Project,
+      requested: OrchestratorMcpCreateThreadsInput["threads"][number]["workspaceStrategy"],
+    ): Effect.fn.Return<ThreadLaunch.ThreadLaunchWorkspaceStrategy, OrchestratorMcpFailure> {
+      if (requested?.type === "new_worktree") {
+        return {
+          type: "worktree",
+          baseRef: requested.baseRef,
+          ...(requested.branch === undefined ? {} : { branch: requested.branch }),
+          ...(requested.startFromOrigin === undefined
+            ? {}
+            : { startFromOrigin: requested.startFromOrigin }),
+        };
+      }
+      const explicitExisting = requested?.type === "existing_worktree" ? requested : undefined;
+      const useParentWorktree =
+        requested === undefined &&
+        project.id === parent.thread.projectId &&
+        parent.thread.worktreePath !== null;
+      if (explicitExisting !== undefined || useParentWorktree) {
+        const requestedPath = explicitExisting?.worktreePath ?? parent.thread.worktreePath!;
+        const workspace = yield* inspectLaunchWorkspace(project, requestedPath, true);
+        return {
+          type: "existing_worktree",
+          worktreePath: workspace.canonicalWorktreeRoot!,
+          ...(workspace.branch === null ? {} : { branch: workspace.branch }),
+          ...(explicitExisting?.branch === undefined
+            ? {}
+            : { expectedBranch: explicitExisting.branch }),
+        };
+      }
+      const workspace = yield* inspectLaunchWorkspace(project, project.workspaceRoot, false);
+      const expectedBranch = requested?.type === "root" ? requested.branch : undefined;
+      return {
+        type: "root",
+        ...(workspace.branch === null ? {} : { branch: workspace.branch }),
M
macroscopeappBotAug 30, 2026

๐ŸŸก Medium mcp/OrchestratorMcpService.ts:973

When no branch is requested, resolveLaunchWorkspace leaves expectedBranch unset, so ThreadLaunchService.launch skips its currentBranch validation and can persist the snapshot from a checkout that has since changed. Thread details and worktree.status then report a branch different from the checkout used by the run. Populate expectedBranch with the observed workspace.branch when no explicit branch was supplied for both root and existing-worktree strategies.

๐Ÿš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/mcp/OrchestratorMcpService.ts around line 973:
2
3When no branch is requested, `resolveLaunchWorkspace` leaves `expectedBranch` unset, so `ThreadLaunchService.launch` skips its `currentBranch` validation and can persist the snapshot from a checkout that has since changed. Thread details and `worktree.status` then report a branch different from the checkout used by the run. Populate `expectedBranch` with the observed `workspace.branch` when no explicit branch was supplied for both root and existing-worktree strategies.
J
Julius MarmingeAug 30, 2026

Fixed in 661a3b184abb70fd97afd2951af28b8f547a6c41. Fresh root and existing-worktree launches carry the observed branch, including detached HEAD as null, into ThreadLaunch's receipt-aware acceptance check. Non-Git roots remain unconstrained only when branch is omitted; explicit branches reject. Tests cover a fresh branch race, detached transitions, and accepted retry after the checkout later changes.

apps/server/src/mcp/OrchestratorMcpService.targeting.test.ts ยท Earlier revision baad86f8 ยท right 28โ€“29

Original diff
@@ -0,0 +1,524 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { assert, describe, it } from "@effect/vitest";
+import {
+  EnvironmentId,
+  NodeId,
+  ProjectId,
+  ProviderDriverKind,
+  ProviderInstanceId,
+  RunId,
+  ThreadId,
+  type OrchestrationV2ThreadProjection,
+  type Project,
+  type ServerProvider,
+} from "@t3tools/contracts";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as Ref from "effect/Ref";
+
+import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts";
+import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts";
+import * as ProjectService from "../project/ProjectService.ts";
+import { ProviderRegistry } from "../provider/Services/ProviderRegistry.ts";
+import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts";
+import * as VcsDriverRegistryModule from "../vcs/VcsDriverRegistry.ts";
+import { VcsDriverRegistry, type VcsDriverHandle } from "../vcs/VcsDriverRegistry.ts";

apps/server/src/mcp/OrchestratorMcpService.targeting.test.ts ยท right 28โ€“29

M
macroscopeappBotAug 29, 2026

mcp/OrchestratorMcpService.targeting.test.ts:29

The same service module is imported twice, and the namespace copy has to be aliased to VcsDriverRegistryModule because the named import took the module's own name โ€” that alias erases the module namespace this repo uses everywhere else for this module. Consider a single import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; and referencing VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.layer, and VcsDriverRegistry.VcsDriverHandle at the use sites.

No longer relevant as of 192e70c07f29260141b44f5d6ae4d76dadae0815

apps/server/src/mcp/OrchestratorMcpService.ts ยท Earlier revision 8c7a4106 ยท right 1589โ€“1596

Original diff
@@ -1351,14 +1586,21 @@ const make = Effect.gen(function* () {
                 target: request.target,
                 providers,
               });
-              const runtimeMode = yield* resolveRuntimeMode(
+              const runtimeMode = resolveLaunchRuntimeMode(
                 parent.thread.runtimeMode,
                 request.runtimeMode,
               );
-              const interactionMode = yield* resolveInteractionMode(
+              const interactionMode = resolveLaunchInteractionMode(
                 parent.thread.interactionMode,
                 request.interactionMode,
               );

apps/server/src/mcp/OrchestratorMcpService.ts ยท right 1589โ€“1596

M
macroscopeappBotAug 30, 2026

Swapping resolveRuntimeMode/resolveInteractionMode for the new non-failing resolveLaunch* helpers drops the caller-visible runtime_mode_escalation_denied / interaction_mode_escalation_denied failure codes on create_threads: an ordinary (non-racing) escalation request now surfaces as a generic orchestration_error wrapping the orchestrator ceiling message, even though those codes remain in OrchestratorMcpFailure and in docs/orchestration-v2/orchestrator-mcp-server.md. Consider keeping the eager typed check here โ€” the policyCeiling still covers the concurrent-downgrade race โ€” and dropping the duplicated resolveLaunchRuntimeMode/resolveLaunchInteractionMode helpers.

suggestion
1 const runtimeMode = yield* resolveRuntimeMode(
2 parent.thread.runtimeMode,
3 request.runtimeMode,
4 );
5 const interactionMode = yield* resolveInteractionMode(
6 parent.thread.interactionMode,
7 request.interactionMode,
8 );

Posted via Macroscope โ€” Effect Service Conventions

J
Julius MarmingeAug 30, 2026

Addressed in 0e9b8981986fff1e2557d067e2a94611f7b363dc without restoring eager checks, because eager mutable-mode validation would reject an already accepted retry after caller or target modes changed. Fresh launches enforce captured and current runtime/interaction ceilings under serialized caller/target locks. Accepted receipts preserve authenticated scope and key ownership but skip mutable mode revalidation. ThreadLaunch policy failures map to runtime_mode_escalation_denied / interaction_mode_escalation_denied, and Deferred tests assert the typed code plus truthful captured/current ceiling messages.

packages/contracts/src/orchestratorMcp.ts ยท Earlier revision 6b41e5d3 ยท right 212

Original diff
@@ -202,10 +202,35 @@ export const OrchestratorMcpTaskCancelResult = Schema.Struct({
 });
 export type OrchestratorMcpTaskCancelResult = typeof OrchestratorMcpTaskCancelResult.Type;
 
+export const OrchestratorMcpThreadWorkspaceStrategy = Schema.Union([
+  Schema.Struct({
+    type: Schema.Literal("root"),
+    branch: Schema.optional(TrimmedNonEmptyString),
+  }),
+  Schema.Struct({
+    type: Schema.Literal("existing_worktree"),
+    worktreePath: TrimmedNonEmptyString,
M
macroscopeappBotAug 29, 2026

๐ŸŸ  High src/orchestratorMcp.ts:212

existing_worktree.worktreePath accepts any non-empty path, so a project-scoped thread can launch with cwd set to /etc or another project's checkout. The value is passed unchanged through resolveLaunchWorkspace into ThreadLaunchService, which persists it and uses it for setup scripts; validate that the path belongs to the selected project before accepting the strategy.

Also found in 1 other location(s)

apps/server/src/mcp/OrchestratorMcpService.ts:766

resolveLaunchWorkspace returns an explicit existing_worktree strategy without verifying that its worktreePath belongs to the selected project. A caller can select any existing local directory (for example another project's checkout) and ThreadLaunchService persists that path and uses it as the launched run's cwd; thus a credential scoped to one project can start an agent against an arbitrary local checkout, bypassing the project scope introduced by this change.

๐Ÿš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @packages/contracts/src/orchestratorMcp.ts around line 212:
2
3`existing_worktree.worktreePath` accepts any non-empty path, so a project-scoped thread can launch with `cwd` set to `/etc` or another project's checkout. The value is passed unchanged through `resolveLaunchWorkspace` into `ThreadLaunchService`, which persists it and uses it for setup scripts; validate that the path belongs to the selected project before accepting the strategy.
4
5Also found in 1 other location(s):
6- apps/server/src/mcp/OrchestratorMcpService.ts:766 -- `resolveLaunchWorkspace` returns an explicit `existing_worktree` strategy without verifying that its `worktreePath` belongs to the selected project. A caller can select any existing local directory (for example another project's checkout) and `ThreadLaunchService` persists that path and uses it as the launched run's `cwd`; thus a credential scoped to one project can start an agent against an arbitrary local checkout, bypassing the project scope introduced by this change.
J
Julius MarmingeAug 29, 2026

Fixed in baad86f8cc. Existing-worktree paths are resolved canonically, detected through the VCS registry, required to be a repository root, and compared by canonical Git common-dir identity with the selected project. The accepted checkoutโ€™s actual branch/path are passed to ThreadLaunchService. Real Git tests cover a sibling worktree, symlink canonicalization, another repository, a plain directory, and branch mismatches.

apps/server/src/orchestration-v2/ThreadLaunchService.ts ยท Earlier revision 8c7a4106 ยท right 475

Original diff
@@ -452,7 +462,30 @@ export const make = Effect.gen(function* () {
               yield* validateReusableThread(input, candidateThreadId);
             }
 
-            const initialBranch = input.workspaceStrategy.branch ?? null;
+            let initialBranch = input.workspaceStrategy.branch ?? null;
+            if (
+              Option.isNone(launchReceipt) &&
+              input.workspaceStrategy.type !== "worktree" &&
+              input.workspaceStrategy.expectedBranch !== undefined
+            ) {
+              const cwd =
+                input.workspaceStrategy.type === "existing_worktree"
+                  ? input.workspaceStrategy.worktreePath
+                  : project.workspaceRoot;
+              const currentBranch = yield* git
M
macroscopeappBotAug 30, 2026

๐ŸŸก Medium orchestration-v2/ThreadLaunchService.ts:475

git.currentBranch reports repository errors such as an unreadable or corrupt .git/HEAD as null, so this validation returns an expected-branch mismatch claiming the workspace is detached instead of propagating the GitCommandError. Because git symbolic-ref --quiet --short HEAD uses status 1 for a detached HEAD but status 128 for errors, currentBranch should return null only for status 1 and preserve other failures.

๐Ÿš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/orchestration-v2/ThreadLaunchService.ts around line 475:
2
3`git.currentBranch` reports repository errors such as an unreadable or corrupt `.git/HEAD` as `null`, so this validation returns an expected-branch mismatch claiming the workspace is detached instead of propagating the `GitCommandError`. Because `git symbolic-ref --quiet --short HEAD` uses status `1` for a detached HEAD but status `128` for errors, `currentBranch` should return `null` only for status `1` and preserve other failures.
J
Julius MarmingeAug 30, 2026

Fixed in 0e9b8981986fff1e2557d067e2a94611f7b363dc. GitWorkflowService.currentBranch now maps only symbolic-ref exit code 1 to detached HEAD and propagates every other GitCommandError. Expected-branch validation remains inside receipt-aware launch admission. Tests cover detached HEAD, non-1 failure propagation, accepted retry after branch change, and fresh mismatch rejection.

docs/orchestration-v2/orchestrator-mcp-server.md ยท Earlier revision 661a3b18 ยท right 289โ€“291

Original diff
@@ -261,31 +262,56 @@ type CreateThreadsInput = {
       driverKind?: string;
       model?: string;
     };
+    projectId?: string;
+    workspaceStrategy?:
+      | { type: "root"; branch?: string }
+      | { type: "existing_worktree"; worktreePath: string; branch?: string }
+      | { type: "new_worktree"; baseRef: string; branch?: string; startFromOrigin?: boolean };
     runtimeMode?: "inherit" | "approval-required" | "auto-accept-edits" | "full-access";
     interactionMode?: "inherit" | "plan" | "default";
   }>;
   clientRequestId?: string;
 };
 ```
 
-Each entry independently resolves provider, model, and modes. The new threads
-inherit the parent's project, branch, and worktree path, but they have no
-sub-agent lineage. Entries with a prompt immediately dispatch a run; entries
-without a prompt remain idle.
+Each entry independently resolves its project, workspace, provider, model, and modes. With no
+`projectId`, a thread uses the parent's project. With no `workspaceStrategy`, a thread in the
+parent's project reuses the parent's checkout, while a thread in another project uses that
+project's root and never inherits the parent's worktree path. An explicit strategy can use the
+project root, register a known existing worktree for the new thread, or ask `ThreadLaunchService`
+to provision a new worktree. Root and existing-worktree launches inspect the checkout before
+launch. A supplied branch is an expected-branch constraint, not a request to switch branches, and
+the call fails when the checkout is detached or on another branch. Existing-worktree paths are
+canonicalized and must resolve to the physical root of a Git worktree belonging to the selected
+project's repository. A root launch still uses the project's configured workspace root, including
+when that execution directory is a subdirectory of the physical Git worktree.
+
+Project selection does not raise the caller's authority. Provider, model, runtime mode, and
+interaction mode still inherit from the caller, and requested modes cannot exceed its permission
+ceiling. Project model and workspace-mode defaults remain client defaults; the MCP request's

docs/orchestration-v2/orchestrator-mcp-server.md ยท right 289โ€“291

M
macroscopeappBotAug 30, 2026

๐ŸŸข Low orchestration-v2/orchestrator-mcp-server.md:289

The documentation incorrectly says provider and model inherit from the caller, so clients targeting another available provider or model through target.providerInstanceId, target.driverKind, or target.model are told the launch will retain the caller's settings. resolveTarget selects the explicitly requested enabled target and only falls back when those fields are omitted; document that resolution behavior instead.

suggestion
1Project selection does not raise the caller's authority. Provider and model resolve from the
2requested `target` when supplied, otherwise inherit from the caller; requested modes cannot exceed
3its permission ceiling.
๐Ÿš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @docs/orchestration-v2/orchestrator-mcp-server.md around lines 289-291:
2
3The documentation incorrectly says `provider` and `model` inherit from the caller, so clients targeting another available provider or model through `target.providerInstanceId`, `target.driverKind`, or `target.model` are told the launch will retain the caller's settings. `resolveTarget` selects the explicitly requested enabled target and only falls back when those fields are omitted; document that resolution behavior instead.
J
Julius MarmingeAug 30, 2026

Fixed in 8f61ffb24b3b03854cc4f20dda37d7cbb039cc1b. The docs now state that an explicit target selects provider/model and omitted target fields inherit from the caller; runtime and interaction modes still inherit by default and remain bounded by the caller ceiling.

apps/server/src/mcp/OrchestratorMcpService.ts ยท Earlier revision a5870511 ยท right 1725

Original diff
@@ -1485,7 +1724,8 @@ const make = Effect.gen(function* () {
                 );
               return {
M
macroscopeappBotAug 30, 2026

๐ŸŸก Medium mcp/OrchestratorMcpService.ts:1725

An idempotent createThreads replay can return a thread with the wrong projectId, providerInstanceId, and model: these fields come from the new request even though ThreadLaunchService returns the previously persisted projection. Clients can therefore route follow-up operations to the wrong project or provider. Serialize these values from projection.thread (or reject mismatched replays).

โœ… Resolved in 661a3b184abb70fd97afd2951af28b8f547a6c41

J
Julius MarmingeAug 30, 2026

Fixed in 661a3b184abb70fd97afd2951af28b8f547a6c41. Accepted retries derive project, provider/model, modes, title, and returned/recorded thread identity from the durable projection, with both pre-launch and post-launch stable thread/project guards. The real MCP/V2 regression inserts a later R2 and proves the retry and parent record still return the exact durable R1.

apps/server/src/mcp/OrchestratorMcpService.ts ยท Earlier revision 7f96f2bc ยท right 950

Original diff
@@ -704,17 +730,231 @@ const make = Effect.gen(function* () {
       .getProjectThread({ projectId, threadId })
       .pipe(Effect.mapError(threadManagementFailure));
 
-  const loadScopedThread = (scope: McpInvocationScope, threadId: ThreadId) =>
+  const resolveProjectId = (
+    parent: OrchestrationV2ThreadProjection,
+    requestedProjectId: ProjectId | undefined,
+  ): Effect.Effect<ProjectId, OrchestratorMcpFailure> =>
+    Effect.gen(function* () {
+      const projectId = requestedProjectId ?? parent.thread.projectId;
+      if (projectId === parent.thread.projectId) return projectId;
+      const project = yield* projects
+        .getById(projectId)
+        .pipe(
+          Effect.mapError((error) =>
+            failure(
+              "orchestration_error",
+              `Unable to read project ${projectId}: ${errorMessage(error)}`,
+            ),
+          ),
+        );
+      if (Option.isNone(project)) {
+        return yield* failure("project_not_found", `Project ${projectId} was not found.`);
+      }
+      return projectId;
+    });
+
+  const loadLaunchProject = (projectId: ProjectId) =>
+    projects.getById(projectId).pipe(
+      Effect.mapError((error) =>
+        failure(
+          "orchestration_error",
+          `Unable to read project ${projectId}: ${errorMessage(error)}`,
+        ),
+      ),
+      Effect.flatMap(
+        Option.match({
+          onNone: () =>
+            Effect.fail(failure("project_not_found", `Project ${projectId} was not found.`)),
+          onSome: Effect.succeed,
+        }),
+      ),
+    );
+
+  const loadScopedThread = (
+    scope: McpInvocationScope,
+    threadId: ThreadId,
+    requestedProjectId?: ProjectId,
+  ) =>
     Effect.gen(function* () {
       yield* requireCapability(scope);
       const parent = yield* loadProjection(scope.threadId);
+      const projectId = yield* resolveProjectId(parent, requestedProjectId);
       const target =
-        threadId === scope.threadId
+        threadId === scope.threadId && projectId === parent.thread.projectId
           ? parent
-          : yield* loadProjectThread(parent.thread.projectId, threadId);
-      return { parent, target } as const;
+          : yield* loadProjectThread(projectId, threadId);
+      return { parent, projectId, target } as const;
     });
 
+  const canonicalPath = (value: string, label: string) =>
+    fileSystem
+      .realPath(value)
+      .pipe(
+        Effect.mapError((error) =>
+          failure("invalid_request", `Unable to resolve ${label}: ${errorMessage(error)}`),
+        ),
+      );
+
+  const detectRepository = (cwd: string, label: string) =>
+    vcsRegistry
+      .detect({ cwd })
+      .pipe(
+        Effect.mapError((error) =>
+          failure("invalid_request", `Unable to inspect ${label}: ${errorMessage(error)}`),
+        ),
+      );
+
+  const canonicalRepositoryMetadata = Effect.fn(
+    "OrchestratorMcpService.canonicalRepositoryMetadata",
+  )(function* (input: { readonly detectionCwd: string; readonly metadataPath: string | null }) {
+    if (input.metadataPath === null) return null;
+    return yield* canonicalPath(
+      path.isAbsolute(input.metadataPath)
+        ? input.metadataPath
+        : path.resolve(input.detectionCwd, input.metadataPath),
+      "repository metadata",
+    );
+  });
+
+  const actualGitBranch = Effect.fn("OrchestratorMcpService.actualGitBranch")(function* (
+    handle: VcsDriverRegistry.VcsDriverHandle,
+    cwd: string,
+  ) {
+    const result = yield* handle.driver
+      .execute({
+        operation: "OrchestratorMcpService.actualGitBranch",
+        cwd,
+        args: ["symbolic-ref", "--quiet", "--short", "HEAD"],
+        allowNonZeroExit: true,
+        timeoutMs: 5_000,
+        maxOutputBytes: 16 * 1024,
+      })
+      .pipe(
+        Effect.mapError((error) =>
+          failure("invalid_request", `Unable to read the workspace branch: ${errorMessage(error)}`),
+        ),
+      );
+    const branch = result.exitCode === 0 ? result.stdout.trim() : "";
+    return branch.length === 0 ? null : branch;
+  });
+
+  const inspectLaunchWorkspace = Effect.fn("OrchestratorMcpService.inspectLaunchWorkspace")(
+    function* (project: Project, candidatePath: string, requireWorktreeRoot: boolean) {
+      const projectRepository = yield* detectRepository(project.workspaceRoot, "project root");
+      if (projectRepository === null) {
+        if (requireWorktreeRoot) {
+          return yield* failure(
+            "invalid_request",
+            `Project ${project.id} does not have a repository with linked worktrees.`,
+          );
+        }
+        return {
+          canonicalExecutionPath: yield* canonicalPath(project.workspaceRoot, "project root"),
+          canonicalWorktreeRoot: null,
+          branch: null,
+        } as const;
+      }
+      if (projectRepository.kind !== "git") {
+        return yield* failure(
+          "invalid_request",
+          `Project ${project.id} uses ${projectRepository.kind}; this launch strategy requires Git.`,
+        );
+      }
+
+      const candidateRepository = yield* detectRepository(candidatePath, "workspace path");
+      if (candidateRepository === null || candidateRepository.kind !== "git") {
+        return yield* failure(
+          "invalid_request",
+          `Workspace path '${candidatePath}' is not a Git workspace for project ${project.id}.`,
+        );
+      }
+      const [canonicalCandidate, canonicalCandidateRoot, projectMetadata, candidateMetadata] =
+        yield* Effect.all([
+          canonicalPath(candidatePath, "workspace path"),
+          canonicalPath(candidateRepository.repository.rootPath, "workspace root"),
+          canonicalRepositoryMetadata({
+            detectionCwd: project.workspaceRoot,
+            metadataPath: projectRepository.repository.metadataPath,
+          }),
+          canonicalRepositoryMetadata({
+            detectionCwd: candidatePath,
+            metadataPath: candidateRepository.repository.metadataPath,
+          }),
+        ]);
+      if (
+        (requireWorktreeRoot && canonicalCandidate !== canonicalCandidateRoot) ||
+        projectMetadata === null ||
+        candidateMetadata === null ||
+        projectMetadata !== candidateMetadata
+      ) {
+        return yield* failure(
+          "invalid_request",
+          `Workspace path '${candidatePath}' is not a workspace of project ${project.id}.`,
+        );
+      }
+      return {
+        canonicalExecutionPath: canonicalCandidate,
+        canonicalWorktreeRoot: canonicalCandidateRoot,
+        branch: yield* actualGitBranch(candidateRepository, canonicalCandidate),
+      } as const;
+    },
+  );
+
+  const enforceExpectedBranch = (
+    expected: string | undefined,
+    actual: string | null,
+    cwd: string,
+  ): Effect.Effect<void, OrchestratorMcpFailure> =>
+    expected === undefined || expected === actual
+      ? Effect.void
+      : Effect.fail(
+          failure(
+            "invalid_request",
+            `Workspace '${cwd}' is on ${actual === null ? "a detached HEAD" : `branch '${actual}'`}, not requested branch '${expected}'.`,
+          ),
+        );
+
+  const resolveLaunchWorkspace = Effect.fn("OrchestratorMcpService.resolveLaunchWorkspace")(
+    function* (
+      parent: OrchestrationV2ThreadProjection,
+      project: Project,
+      requested: OrchestratorMcpCreateThreadsInput["threads"][number]["workspaceStrategy"],
+    ): Effect.fn.Return<ThreadLaunch.ThreadLaunchWorkspaceStrategy, OrchestratorMcpFailure> {
+      if (requested?.type === "new_worktree") {
+        return {
+          type: "worktree",
+          baseRef: requested.baseRef,
+          ...(requested.branch === undefined ? {} : { branch: requested.branch }),
+          ...(requested.startFromOrigin === undefined
+            ? {}
+            : { startFromOrigin: requested.startFromOrigin }),
+        };
+      }
+      const explicitExisting = requested?.type === "existing_worktree" ? requested : undefined;
+      const useParentWorktree =
+        requested === undefined &&
+        project.id === parent.thread.projectId &&
+        parent.thread.worktreePath !== null;
+      if (explicitExisting !== undefined || useParentWorktree) {
+        const requestedPath = explicitExisting?.worktreePath ?? parent.thread.worktreePath!;
+        const workspace = yield* inspectLaunchWorkspace(project, requestedPath, true);
+        yield* enforceExpectedBranch(explicitExisting?.branch, workspace.branch, requestedPath);
+        return {
+          type: "existing_worktree",
+          worktreePath: workspace.canonicalWorktreeRoot!,
+          ...(workspace.branch === null ? {} : { branch: workspace.branch }),
+        };
+      }
+      const workspace = yield* inspectLaunchWorkspace(project, project.workspaceRoot, false);
+      const expectedBranch = requested?.type === "root" ? requested.branch : undefined;
+      yield* enforceExpectedBranch(expectedBranch, workspace.branch, project.workspaceRoot);
M
macroscopeappBotAug 30, 2026

๐ŸŸ  High mcp/OrchestratorMcpService.ts:950

createThreads can launch on a different branch than the requested one while still reporting the branch validated at line 950. A concurrent git checkout between enforceExpectedBranch and the asynchronous ThreadLaunchService.launch changes the root or existing worktree after validation, but launch receives only the stale observed branch and never rechecks it. Move the branch check into the launch/setup critical section (or otherwise revalidate immediately before setup) so the expected-branch constraint is enforced atomically.

๐Ÿš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/mcp/OrchestratorMcpService.ts around line 950:
2
3`createThreads` can launch on a different branch than the requested one while still reporting the branch validated at line 950. A concurrent `git checkout` between `enforceExpectedBranch` and the asynchronous `ThreadLaunchService.launch` changes the root or existing worktree after validation, but launch receives only the stale observed branch and never rechecks it. Move the branch check into the launch/setup critical section (or otherwise revalidate immediately before setup) so the expected-branch constraint is enforced atomically.
J
Julius MarmingeAug 30, 2026

Fixed in 8c7a4106baa159cc39c2be3ae4c68454193e0bbd. Canonical repository/project authorization remains in MCP preflight, while expected-branch enforcement now re-reads symbolic-ref inside the receipt-aware ThreadLaunch boundary immediately before a fresh launch. Accepted retries consult the durable receipt first and return the original launch even if the checkout later changes; a fresh mismatched key rejects before thread creation. The real MCP and direct ThreadLaunch regressions cover both cases. This is the strongest truthful in-process acceptance guarantee: an arbitrary external Git writer is not governed by T3โ€™s service lock and can still change a checkout after acceptance.

apps/server/src/mcp/OrchestratorMcpService.ts ยท Earlier revision abf52e5b ยท right 1651โ€“1654

Original diff
@@ -1341,139 +1625,192 @@ const make = Effect.gen(function* () {
           );
         }
         const parentNodeId = parentRun.rootNodeId;
-        const providers = yield* loadProviders;
         const key = yield* requestKey(input.clientRequestId);
         const created = yield* Effect.forEach(
           input.threads,
           (request, index) =>
             Effect.gen(function* () {
-              const target = yield* resolveTarget({
-                parent,
-                target: request.target,
-                providers,
-              });
-              const runtimeMode = yield* resolveRuntimeMode(
-                parent.thread.runtimeMode,
-                request.runtimeMode,
-              );
-              const interactionMode = yield* resolveInteractionMode(
-                parent.thread.interactionMode,
-                request.interactionMode,
-              );
-              const threadId = stableThreadId({
+              const projectId = yield* resolveProjectId(parent, request.projectId);
+              const commandId = stableCommandId({
                 scope,
                 requestKey: key,
+                operation: "create-thread",
                 index,
               });
-              const title = threadTitle({
-                parentTitle: parent.thread.title,
-                prompt: request.prompt,
-                title: request.title,
-                index,
-              });
-              const createCommandId = stableCommandId({
+              const threadId = stableThreadId({
                 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
-                .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);
-                    }),
+              const initialMessageId = stableMessageId({ scope, requestKey: key, index });
+              const launchReceipt = yield* threadManagement
+                .withProjectCreationAdmission({ projectId, commandId }, (receipt) =>
+                  Effect.succeed(receipt),
                 )
                 .pipe(
-                  Effect.mapError((error) =>
-                    isOrchestratorMcpFailure(error)
-                      ? error
-                      : failure(
-                          "orchestration_error",
-                          `Unable to create thread ${index + 1}: ${errorMessage(error)}`,
-                        ),
+                  Effect.mapError(() =>
+                    failure(
+                      "orchestration_error",
+                      `Unable to read the accepted launch for thread ${index + 1}.`,

apps/server/src/mcp/OrchestratorMcpService.ts ยท right 1651โ€“1654

M
macroscopeappBotAug 30, 2026

This translation boundary drops the underlying failure entirely: the callback ignores its argument, and OrchestratorMcpFailure has no cause field, so a ThreadManagementError/OrchestratorV2Error from withProjectCreationAdmission becomes an opaque, context-free message. The previous code preserved it via errorMessage(error). Consider keeping the upstream detail, as every other mapError in this module does.

suggestion
1 Effect.mapError((error) =>
2 failure(
3 "orchestration_error",
4 `Unable to read the accepted launch for thread ${index + 1}: ${errorMessage(error)}`,

Posted via Macroscope โ€” Effect Service Conventions

J
Julius MarmingeAug 30, 2026

Verified against exact head abf52e5ba2eb5644b1fe134e9291b88425d2c18d. No source change: this is an intentionally redacted caller-facing MCP boundary. withProjectCreationAdmission can carry nested ThreadManagement or Orchestrator failures whose messages include internal project, repository, command, or provider details. Appending errorMessage(error) would serialize those details through OrchestratorMcpFailure. The public response keeps the safe operation and thread index; known safe structured failures are mapped separately. The c01 source-cleared head used this exact mapping, and the refresh range-diff is unchanged. Internal diagnostics can retain a cause at a non-wire boundary, but the proposed public message is not safe.

apps/server/src/mcp/OrchestratorMcpService.ts ยท Earlier revision baad86f8 ยท right 80โ€“80

Original diff
@@ -70,7 +75,9 @@ import {
   ThreadManagementService,
 } from "../orchestration-v2/ThreadManagementService.ts";
 import { ProviderRegistry } from "../provider/Services/ProviderRegistry.ts";
+import * as ProjectService from "../project/ProjectService.ts";
 import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts";
+import { VcsDriverRegistry, type VcsDriverHandle } from "../vcs/VcsDriverRegistry.ts";

apps/server/src/mcp/OrchestratorMcpService.ts ยท right 80โ€“80

M
macroscopeappBotAug 29, 2026

VcsDriverRegistry.ts is a service module and every other consumer in apps/server imports it as a namespace (import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts", then VcsDriverRegistry.VcsDriverRegistry / .layer). Consider matching that here, as this PR already does for the other new service imports (ThreadLaunch, ProjectService), and referencing the handle type as VcsDriverRegistry.VcsDriverHandle.

Posted via Macroscope โ€” Effect Service Conventions

J
Julius MarmingeAug 29, 2026

Fixed in 1f905034f7. The service and focused tests now use the VcsDriverRegistry module namespace consistently for the service tag, layer, and handle type.

apps/server/src/mcp/OrchestratorMcpService.ts ยท Earlier revision 0e9b8981 ยท right 1641

Original diff
@@ -1370,107 +1638,72 @@ 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
-                .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);
-                    }),
-                )
+              const launched = yield* threadLaunch
M
macroscopeappBotAug 30, 2026

๐ŸŸ  High mcp/OrchestratorMcpService.ts:1641

createThreads can launch and start a child thread after the scoped parent run has finished or changed provider ownership. The initial check occurs before target, project, and workspace resolution, and ThreadLaunchService.launch does not revalidate that active-run/session condition; recheck it immediately before launching, as the previous withProjectCreationAdmission path did.

๐Ÿš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/mcp/OrchestratorMcpService.ts around line 1641:
2
3`createThreads` can launch and start a child thread after the scoped parent run has finished or changed provider ownership. The initial check occurs before target, project, and workspace resolution, and `ThreadLaunchService.launch` does not revalidate that active-run/session condition; recheck it immediately before launching, as the previous `withProjectCreationAdmission` path did.
J
Julius MarmingeAug 30, 2026

Fixed in 4dd8caf935f836ecd4ee8c5fe3eb681619285293. create_threads carries the captured caller run and provider identity on the existing optional policy ceiling. Fresh thread.create and initial message.dispatch revalidate that exact active, rooted, provider-owned run under the existing sorted caller-and-target locks. Accepted receipts still replay before mutable checks, and cross-project authorization is unchanged. Real MCP/V2 stored-event and Deferred tests cover the parent becoming terminal before create (no child) and between create and message dispatch (no child turn).

apps/server/src/mcp/OrchestratorMcpService.ts ยท Earlier revision 0e9b8981 ยท right 1680

Original diff
@@ -1370,107 +1638,72 @@ 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
-                .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);
-                    }),
-                )
+              const launched = yield* threadLaunch
+                .launch({
+                  commandId: stableCommandId({
+                    scope,
+                    requestKey: key,
+                    operation: "create-thread",
+                    index,
+                  }),
+                  threadId,
+                  projectId,
+                  title,
+                  modelSelection: target.modelSelection,
+                  runtimeMode,
+                  interactionMode,
+                  policyCeiling: {
+                    callerThreadId: scope.threadId,
+                    runtimeMode: parent.thread.runtimeMode,
+                    interactionMode: parent.thread.interactionMode,
+                  },
+                  workspaceStrategy,
+                  ...(request.prompt === undefined
+                    ? {}
+                    : {
+                        initialMessage: {
+                          messageId: stableMessageId({ scope, requestKey: key, index }),
+                          text: request.prompt,
+                          attachments: [],
+                        },
+                      }),
+                  createdBy: "agent",
+                  creationSource: "mcp",
+                })
                 .pipe(
-                  Effect.mapError((error) =>
-                    failure(
-                      "orchestration_error",
-                      `Unable to create thread ${index + 1}: ${errorMessage(error)}`,
-                    ),
+                  Effect.mapError(
+                    (error) =>
+                      threadLaunchPolicyFailure(error) ??
+                      failure(
+                        "orchestration_error",
+                        `Unable to launch thread ${index + 1}: ${errorMessage(error)}`,
+                      ),
C
cursorBotAug 30, 2026

Stale parent run still creates threads

Medium Severity

create_threads now checks that the caller run is active only at request start, then launches through ThreadLaunchService without the previous admission-time recheck. A parent run that completes during workspace inspect or provisioning still creates the target thread, and recordServerCreatedThread accepts a completed parent run, so the child can land after the MCP turn has already ended.

Reviewed by Cursor Bugbot for commit 0e9b8981986fff1e2557d067e2a94611f7b363dc. Configure here.

J
Julius MarmingeAug 30, 2026

Fixed in 4dd8caf935f836ecd4ee8c5fe3eb681619285293. create_threads carries the captured caller run and provider identity on the existing optional policy ceiling. Fresh thread.create and initial message.dispatch revalidate that exact active, rooted, provider-owned run under the existing sorted caller-and-target locks. Accepted receipts still replay before mutable checks, and cross-project authorization is unchanged. Real MCP/V2 stored-event and Deferred tests cover the parent becoming terminal before create (no child) and between create and message dispatch (no child turn).

packages/contracts/src/orchestratorMcp.ts ยท Earlier revision 13bd326f ยท right 224

Original diff
@@ -202,10 +202,35 @@ export const OrchestratorMcpTaskCancelResult = Schema.Struct({
 });
 export type OrchestratorMcpTaskCancelResult = typeof OrchestratorMcpTaskCancelResult.Type;
 
+export const OrchestratorMcpThreadWorkspaceStrategy = Schema.Union([
+  Schema.Struct({
+    type: Schema.Literal("root"),
+    branch: Schema.optional(TrimmedNonEmptyString),
+  }),
+  Schema.Struct({
+    type: Schema.Literal("existing_worktree"),
+    worktreePath: TrimmedNonEmptyString,
+    branch: Schema.optional(TrimmedNonEmptyString),
+  }),
+  Schema.Struct({
+    type: Schema.Literal("new_worktree"),
+    baseRef: TrimmedNonEmptyString,
+    branch: Schema.optional(TrimmedNonEmptyString),
+    startFromOrigin: Schema.optional(Schema.Boolean),
+  }),
+]).annotate({
+  description:
+    "Workspace for the new thread. Omit to reuse the caller's checkout in the current project, or the target project's root when projectId selects another project.",
+});
C
cursorBotAug 29, 2026

Shared MCP schema type carries description annotation

Low Severity

OrchestratorMcpThreadWorkspaceStrategy is a shared/reusable schema type used in MCP tool input parameters (OrchestratorMcpCreateThreadsInput and OrchestratorMcpThreadStartInput), and it carries a .annotate({ description: ... }). Per project conventions, shared schema types in MCP tool inputs need to be description-free to avoid compiled JSON schema issues with some LLM providers. The description text belongs on the concrete field annotations in OrchestratorMcpCreateThreadRequest.workspaceStrategy and OrchestratorMcpThreadStartInput.workspaceStrategy instead.

Triggered by learned rule: MCP tool input schemas: only one description per allOf branch in Effect Schema compositions

Reviewed by Cursor Bugbot for commit 13bd326fdcbe6765fbbdd5a8793fdfcd0dd7901b. Configure here.

J
Julius MarmingeAug 29, 2026

Fixed in f17378fdad. The reusable OrchestratorMcpThreadWorkspaceStrategy is now description-free; the same guidance is attached only to the concrete create_threads and t3_thread_start workspaceStrategy fields. Contracts typecheck passes and the real MCP toolkit integration test passes.