From 23c0a56f2c76df47bdcdb6890413356137a7d09c Mon Sep 17 00:00:00 2001 From: jieyuexing Date: Thu, 8 Oct 2026 14:03:21 +0800 Subject: [PATCH] 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) --- .../ProviderSessionManager.test.ts | 55 +++++++++++++++++++ .../ProviderSessionManager.ts | 42 +++++++++----- 2 files changed, 82 insertions(+), 15 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 2f2b0402f6205..abd1ba30ce5be 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -2406,6 +2406,61 @@ 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 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"), + }); + McpProviderSession.setMcpProviderSession(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 = McpProviderSession.readMcpProviderSession(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(McpProviderSession.readMcpProviderSession(threadId)); + }); + + yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1_000 }))); + }), +); + it.effect( "ProviderSessionManagerV2 revokes a rotated credential despite a stale record on another live session", () => diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index d181497ec2754..f5ac61ca5475d 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -1308,22 +1308,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 && + McpProviderSession.readMcpProviderSession(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(