From e4ef8d36bad346e0b8c38da276329cc54a1cce6f Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 10 Oct 2026 21:42:38 -0300 Subject: [PATCH] fix(server): keep Claude workflows alive while they report progress (#17715) --- .../ProviderSessionManager.test.ts | 67 +++++++++++++++++++ .../ProviderSessionManager.ts | 18 +++-- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 04310257a71e6a2cdff09270092ba9058966ba73..ceb230b00894095db7889b718d0d60e3f8646d8b 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -2839,6 +2839,73 @@ it.effect("ProviderSessionManagerV2 releases pinned idle sessions once the pin c }), ); +it.effect("ProviderSessionManagerV2 keeps pinned work that reports progress", () => + 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 now = yield* DateTime.now; + const threadId = yield* idAllocator.allocate.thread({ + fixtureName: "provider-session-manager-pin-progress", + projectId: yield* idAllocator.allocate.project({ + fixtureName: "provider-session-manager-pin-progress", + }), + }); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const runtime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const adapterQueue = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); + assert.isDefined(adapterQueue); + const step = (duration: Parameters[0]) => + Effect.gen(function* () { + yield* TestClock.adjust(duration); + for (let i = 0; i < 20; i += 1) yield* Effect.yieldNow; + }); + + // A long workflow keeps reporting progress well past the pin cap. + for (let tick = 0; tick < 12; tick += 1) { + yield* step("1500 millis"); + yield* Queue.offer(adapterQueue!, { + type: "provider_session.updated", + driver: CODEX_DRIVER, + providerSession: runtime.providerSession, + }); + for (let i = 0; i < 20; i += 1) yield* Effect.yieldNow; + } + assert.equal((yield* Ref.get(state)).closeCount, 0); + + // Work that goes silent past the cap is still stopped. + for (let tick = 0; tick < 6; tick += 1) { + yield* step("1 second"); + } + assert.equal((yield* Ref.get(state)).closeCount, 1); + }); + + yield* effect.pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 1000, + maxIdlePinMs: 3000, + hasPendingBackgroundWork: Effect.succeed(true), + }), + ), + ); + }), +); + it.effect( "ProviderSessionManagerV2 does not idle-release a session that turns busy during the pending-work check", () => diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 0dec68dc1ec9ce6712e70a21339980a8172d1daa..f0ec5282ef5b58fdd28b546ae825f46502c93d91 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -1141,7 +1141,13 @@ export const layerWithOptions = ( ), ); - const scheduleIdleReleaseInternal = (providerSessionId: ProviderSessionId) => + // Provider activity restarts the pin budget, so background work that keeps + // reporting progress (a long Claude workflow) is never cut off; only work + // that went silent for maxIdlePinMs is released. + const scheduleIdleReleaseInternal = ( + providerSessionId: ProviderSessionId, + options?: { readonly providerActivity?: boolean }, + ) => Effect.gen(function* () { const key = sessionKey(providerSessionId); const current = yield* Ref.get(sessions); @@ -1168,6 +1174,7 @@ export const layerWithOptions = ( idleGeneration: generation, idleFiber, lastActivityAtMs, + ...(options?.providerActivity === true ? { pinnedSinceMs: null } : {}), }); return updated; }); @@ -1176,7 +1183,10 @@ export const layerWithOptions = ( const scheduleIdleRelease = (providerSessionId: ProviderSessionId) => withActivityError(providerSessionId, scheduleIdleReleaseInternal(providerSessionId)); - const touchActivity = (providerSessionId: ProviderSessionId) => + const touchActivity = ( + providerSessionId: ProviderSessionId, + options?: { readonly providerActivity?: boolean }, + ) => withActivityError( providerSessionId, Effect.gen(function* () { @@ -1193,7 +1203,7 @@ export const layerWithOptions = ( }); return updated; }); - yield* scheduleIdleReleaseInternal(providerSessionId); + yield* scheduleIdleReleaseInternal(providerSessionId, options); }), ); @@ -1897,7 +1907,7 @@ export const layerWithOptions = ( event.providerThreadId, event.runOrdinal, ) - : touchActivity(entry.runtime.providerSessionId), + : touchActivity(entry.runtime.providerSessionId, { providerActivity: true }), ).pipe( Effect.andThen( event.type === "provider_session.updated" -- 2.54.0