authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-10 21:42:38-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 21:42:38-03:00
loge4ef8d36bad346e0b8c38da276329cc54a1cce6f
tree9d210172dfbf9957573076715435822c0ca9baac
parent477282263a9115f911380b5aec7d4973267e4edc
signature Signed by PGP key B5690EEEBB952194

fix(server): keep Claude workflows alive while they report progress (#17715)


2 files changed, 81 insertions(+), 4 deletions(-)

apps/server/src/orchestration-v2/ProviderSessionManager.test.ts+67
......@@ -2839,6 +2839,73 @@ it.effect("ProviderSessionManagerV2 releases pinned idle sessions once the pin c
28392839 }),
28402840);
28412841
2842it.effect("ProviderSessionManagerV2 keeps pinned work that reports progress", () =>
2843 Effect.gen(function* () {
2844 const state = yield* Ref.make(emptyState);
2845 const effect = Effect.gen(function* () {
2846 const eventSink = yield* EventSink.EventSinkV2;
2847 const idAllocator = yield* IdAllocator.IdAllocatorV2;
2848 const manager = yield* ProviderSessionManager.ProviderSessionManagerV2;
2849 const now = yield* DateTime.now;
2850 const threadId = yield* idAllocator.allocate.thread({
2851 fixtureName: "provider-session-manager-pin-progress",
2852 projectId: yield* idAllocator.allocate.project({
2853 fixtureName: "provider-session-manager-pin-progress",
2854 }),
2855 });
2856 const providerSessionId = yield* idAllocator.allocate.providerSession({
2857 providerInstanceId: modelSelection.instanceId,
2858 threadId,
2859 });
2860 yield* eventSink.write({
2861 events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })],
2862 });
2863 const runtime = yield* manager.open({
2864 threadId,
2865 providerSessionId,
2866 modelSelection,
2867 runtimePolicy,
2868 });
2869 const adapterQueue = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId));
2870 assert.isDefined(adapterQueue);
2871 const step = (duration: Parameters<typeof TestClock.adjust>[0]) =>
2872 Effect.gen(function* () {
2873 yield* TestClock.adjust(duration);
2874 for (let i = 0; i < 20; i += 1) yield* Effect.yieldNow;
2875 });
2876
2877 // A long workflow keeps reporting progress well past the pin cap.
2878 for (let tick = 0; tick < 12; tick += 1) {
2879 yield* step("1500 millis");
2880 yield* Queue.offer(adapterQueue!, {
2881 type: "provider_session.updated",
2882 driver: CODEX_DRIVER,
2883 providerSession: runtime.providerSession,
2884 });
2885 for (let i = 0; i < 20; i += 1) yield* Effect.yieldNow;
2886 }
2887 assert.equal((yield* Ref.get(state)).closeCount, 0);
2888
2889 // Work that goes silent past the cap is still stopped.
2890 for (let tick = 0; tick < 6; tick += 1) {
2891 yield* step("1 second");
2892 }
2893 assert.equal((yield* Ref.get(state)).closeCount, 1);
2894 });
2895
2896 yield* effect.pipe(
2897 Effect.provide(
2898 layerTest({
2899 state,
2900 idleTimeoutMs: 1000,
2901 maxIdlePinMs: 3000,
2902 hasPendingBackgroundWork: Effect.succeed(true),
2903 }),
2904 ),
2905 );
2906 }),
2907);
2908
28422909it.effect(
28432910 "ProviderSessionManagerV2 does not idle-release a session that turns busy during the pending-work check",
28442911 () =>
apps/server/src/orchestration-v2/ProviderSessionManager.ts+14-4
......@@ -1141,7 +1141,13 @@ export const layerWithOptions = (
11411141 ),
11421142 );
11431143
1144 const scheduleIdleReleaseInternal = (providerSessionId: ProviderSessionId) =>
1144 // Provider activity restarts the pin budget, so background work that keeps
1145 // reporting progress (a long Claude workflow) is never cut off; only work
1146 // that went silent for maxIdlePinMs is released.
1147 const scheduleIdleReleaseInternal = (
1148 providerSessionId: ProviderSessionId,
1149 options?: { readonly providerActivity?: boolean },
1150 ) =>
11451151 Effect.gen(function* () {
11461152 const key = sessionKey(providerSessionId);
11471153 const current = yield* Ref.get(sessions);
......@@ -1168,6 +1174,7 @@ export const layerWithOptions = (
11681174 idleGeneration: generation,
11691175 idleFiber,
11701176 lastActivityAtMs,
1177 ...(options?.providerActivity === true ? { pinnedSinceMs: null } : {}),
11711178 });
11721179 return updated;
11731180 });
......@@ -1176,7 +1183,10 @@ export const layerWithOptions = (
11761183 const scheduleIdleRelease = (providerSessionId: ProviderSessionId) =>
11771184 withActivityError(providerSessionId, scheduleIdleReleaseInternal(providerSessionId));
11781185
1179 const touchActivity = (providerSessionId: ProviderSessionId) =>
1186 const touchActivity = (
1187 providerSessionId: ProviderSessionId,
1188 options?: { readonly providerActivity?: boolean },
1189 ) =>
11801190 withActivityError(
11811191 providerSessionId,
11821192 Effect.gen(function* () {
......@@ -1193,7 +1203,7 @@ export const layerWithOptions = (
11931203 });
11941204 return updated;
11951205 });
1196 yield* scheduleIdleReleaseInternal(providerSessionId);
1206 yield* scheduleIdleReleaseInternal(providerSessionId, options);
11971207 }),
11981208 );
11991209
......@@ -1897,7 +1907,7 @@ export const layerWithOptions = (
18971907 event.providerThreadId,
18981908 event.runOrdinal,
18991909 )
1900 : touchActivity(entry.runtime.providerSessionId),
1910 : touchActivity(entry.runtime.providerSessionId, { providerActivity: true }),
19011911 ).pipe(
19021912 Effect.andThen(
19031913 event.type === "provider_session.updated"