From 378e40f919af15d1ec5f3b3bf99a2b7c8dc7a9a4 Mon Sep 17 00:00:00 2001 From: jieyuexing Date: Thu, 8 Oct 2026 14:03:21 +0800 Subject: [PATCH 1/2] fix(server): T3 tools work again after a thread switches back to a reused provider session A thread keeps its old provider session attached after switching providers, and the new provider's attach rotates the thread's single MCP credential slot to its own scope. When the thread switched back, the reused session skipped credential preparation, so the returning provider ran with a credential scoped to the other provider and every live-caller check failed with parent_not_active (#16565). ensureThreadAttached now also prepares the credential when the thread is already attached but the slot belongs to another provider instance, and records the new credential on the session so release revokes the right one. Co-Authored-By: Claude Opus 5.5 (1M context) Rebased onto current main (history rewritten since the PR opened): the slot read now goes through the McpProviderSessions service, which replaced the readMcpProviderSession/setMcpProviderSession helpers. Co-Authored-By: Claude Opus 5.5 --- .../ProviderSessionManager.test.ts | 56 +++++++++++++++++++ .../ProviderSessionManager.ts | 42 +++++++++----- 2 files changed, 83 insertions(+), 15 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 3c635be6753df..391e9d911a0b5 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -2107,6 +2107,62 @@ it.effect( }), ); +it.effect( + "ProviderSessionManagerV2 takes the thread's MCP credential back when a run returns to a still-attached session", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const mcpSessions = yield* McpProviderSessions.McpProviderSessions; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-returning-provider"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + const tokenOf = (config: McpProviderSession.McpProviderSessionConfig | undefined) => + config?.authorizationHeader.replace(/^Bearer\s+/, "") ?? ""; + + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + + // The thread switches to another provider, whose attach replaces the + // thread's credential while this session stays attached. + yield* registry.revokeThread(threadId); + const otherProvider = yield* registry.issue({ + threadId, + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + }); + yield* mcpSessions.set(otherProvider.config); + + // The thread switches back and its next turn reuses this session. + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + assert.equal((yield* Ref.get(state)).openCount, 1); + + const reclaimed = yield* mcpSessions.read(threadId); + assert.equal(reclaimed?.providerInstanceId, modelSelection.instanceId); + assert.equal( + (yield* registry.resolve(tokenOf(reclaimed)))?.thread.providerInstanceId, + modelSelection.instanceId, + ); + assert.isUndefined(yield* registry.resolve(tokenOf(otherProvider.config))); + + // The session's record follows the new credential, so release revokes it. + yield* manager.close(providerSessionId); + assert.isUndefined(yield* registry.resolve(tokenOf(reclaimed))); + assert.isUndefined(yield* mcpSessions.read(threadId)); + }); + + yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1_000 }))); + }), +); + it.effect( "ProviderSessionManagerV2 protects a reused credential from a predecessor release during open", () => diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 00b42d510bd91..f83c318626775 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -1328,22 +1328,34 @@ export const layerWithOptions = ( Effect.tap((runtime) => Effect.sync(() => (attachedTo = runtime))), Effect.uninterruptible, ); + // A thread stays attached to the session it ran on before switching + // providers, and the other provider's attach took over the thread's + // credential slot. A run returning to this session must take it + // back, or the process calls T3 tools scoped to the other provider. + const reclaimsCredential = + attached === undefined && + (yield* mcpSessions.read(input.threadId))?.providerInstanceId !== + input.providerInstanceId && + (yield* Ref.get(sessions)) + .get(sessionKey(input.providerSessionId)) + ?.attachedThreadIds.has(input.threadId) === true; + if (attached === undefined && !reclaimsCredential) return; + const prepared = yield* prepareMcpSession(input.threadId, input.providerInstanceId); + preparedForCleanup = prepared; + if (prepared.mcpCredentialId !== undefined) { + const mcpCredentialId = prepared.mcpCredentialId; + yield* Ref.update(sessions, (current) => { + const key = sessionKey(input.providerSessionId); + const entry = current.get(key); + if (entry === undefined) return current; + const mcpCredentialIdByThread = new Map(entry.mcpCredentialIdByThread); + mcpCredentialIdByThread.set(input.threadId, mcpCredentialId); + const updated = new Map(current); + updated.set(key, { ...entry, mcpCredentialIdByThread }); + return updated; + }); + } if (attached !== undefined) { - const prepared = yield* prepareMcpSession(input.threadId, input.providerInstanceId); - preparedForCleanup = prepared; - if (prepared.mcpCredentialId !== undefined) { - const mcpCredentialId = prepared.mcpCredentialId; - yield* Ref.update(sessions, (current) => { - const key = sessionKey(input.providerSessionId); - const entry = current.get(key); - if (entry === undefined) return current; - const mcpCredentialIdByThread = new Map(entry.mcpCredentialIdByThread); - mcpCredentialIdByThread.set(input.threadId, mcpCredentialId); - const updated = new Map(current); - updated.set(key, { ...entry, mcpCredentialIdByThread }); - return updated; - }); - } const entry = (yield* Ref.get(sessions)).get(sessionKey(input.providerSessionId)); if (entry !== undefined) { yield* withActivityError( From 7c69e728e5919801fc83a2076421862ffcdbd001 Mon Sep 17 00:00:00 2001 From: "Abhi (onFire)" Date: Mon, 26 Oct 2026 14:57:39 +0545 Subject: [PATCH 2/2] fix(server): revoke MCP credential issued during interrupted reclaim Handle failed reclaims in ensureThreadAttached separately from failed new attachments: revoke the credential issued by the current call when it has no other holder, instead of skipping cleanup because attachedTo is undefined. Co-Authored-By: Claude Haiku 5.5 --- .../ProviderSessionManager.ts | 72 +++++++++++-------- 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index f83c318626775..9f86fca43fbdc 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -1370,37 +1370,49 @@ export const layerWithOptions = ( } } }).pipe( - Effect.onError(() => - attachedTo === undefined - ? Effect.void - : removeThreadAttachment({ ...input, runtime: attachedTo }).pipe( - Effect.andThen( - Effect.suspend(() => { - dropReservation(); - // Other live processes hold reused MCP credentials; revocation would break their clients. - if (preparedForCleanup?.issued !== true) return Effect.void; - const mcpCredentialId = preparedForCleanup.mcpCredentialId; - const attachedRuntime = attachedTo; - return Ref.get(sessions).pipe( - Effect.flatMap((current) => - (mcpCredentialId !== undefined && - isMcpCredentialReserved(input.threadId, mcpCredentialId)) || - Array.from(current.values()).some( - (other) => - other.runtime !== attachedRuntime && - (other.attachedThreadIds.has(input.threadId) || - (mcpCredentialId !== undefined && - other.mcpCredentialIdByThread.get(input.threadId) === - mcpCredentialId)), - ) - ? Effect.void - : clearMcpSession(input.threadId, mcpCredentialId), - ), - ); + Effect.onError(() => { + // A reclaim (thread already attached, credential rotated) has + // attachedTo === undefined but still issued a credential that must + // be cleaned up on failure. Only skip when nothing was prepared. + const isReclaim = attachedTo === undefined && preparedForCleanup !== undefined; + if (attachedTo === undefined && !isReclaim) return Effect.void; + const removeAttachment = + attachedTo === undefined + ? Effect.void + : removeThreadAttachment({ ...input, runtime: attachedTo }); + return removeAttachment.pipe( + Effect.andThen( + Effect.suspend(() => { + dropReservation(); + // Other live processes hold reused MCP credentials; revocation would break their clients. + if (preparedForCleanup?.issued !== true) return Effect.void; + const mcpCredentialId = preparedForCleanup.mcpCredentialId; + // For a reclaim, exclude the session being reused (not torn + // down) from the holder check, mirroring new attachments. + return Ref.get(sessions).pipe( + Effect.flatMap((current) => { + const attachedRuntime = + attachedTo ?? current.get(sessionKey(input.providerSessionId))?.runtime; + const held = + (mcpCredentialId !== undefined && + isMcpCredentialReserved(input.threadId, mcpCredentialId)) || + Array.from(current.values()).some( + (other) => + other.runtime !== attachedRuntime && + (other.attachedThreadIds.has(input.threadId) || + (mcpCredentialId !== undefined && + other.mcpCredentialIdByThread.get(input.threadId) === + mcpCredentialId)), + ); + return held + ? Effect.void + : clearMcpSession(input.threadId, mcpCredentialId); }), - ), - ), - ), + ); + }), + ), + ); + }), ); return threadAttachment .withLock(threadAttachmentKey(input), attach)