authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-09-08 09:27:35+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 00:27:35-07:00
log430fbd1ffc98378f6a6a41027ddc6004a79b2e7b
tree9dce8b6e05e09efe2fe4b59be2402c0a46079a18
parent991526383f95eefdd66ffd2853136c6dd34ae008
signature Signed by PGP key B5690EEEBB952194

fix(server): give completed turns a full session idle window (#10689)


2 files changed, 139 insertions(+), 43 deletions(-)

apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+127-41
......@@ -7,7 +7,7 @@ import {
77 ProviderInstanceId,
88} from "@t3tools/contracts";
99import * as Clock from "effect/Clock";
10import * as DateTime from "effect/DateTime";
10import * as Deferred from "effect/Deferred";
1111import * as Effect from "effect/Effect";
1212import * as Exit from "effect/Exit";
1313import * as Layer from "effect/Layer";
......@@ -140,9 +140,36 @@ describe("ProviderSessionReaper", () => {
140140 // Shared start sequence so each test adds no manual Effect runners
141141 // (no-manual-effect-runtime-in-tests tracks this file's legacy count).
142142 async function startReaper() {
143 const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper));
144 scope = await Effect.runPromise(Scope.make("sequential"));
145 await Effect.runPromise(reaper.start().pipe(Scope.provide(scope)));
143 await runtime!.runPromise(
144 Effect.gen(function* () {
145 const reaper = yield* ProviderSessionReaper;
146 scope = yield* Scope.make("sequential");
147 yield* reaper.start().pipe(Scope.provide(scope));
148 }),
149 );
150 }
151
152 async function sweepAt(nowMs: number) {
153 await runtime!.runPromise(
154 Effect.gen(function* () {
155 const reaper = yield* ProviderSessionReaper;
156 const clock = yield* Clock.Clock;
157 const swept = yield* Deferred.make<void>();
158 yield* reaper.start().pipe(
159 Effect.provideService(Clock.Clock, {
160 currentTimeMillis: Effect.succeed(nowMs),
161 currentTimeMillisUnsafe: () => nowMs,
162 currentTimeNanos: Effect.succeed(BigInt(nowMs) * 1_000_000n),
163 currentTimeNanosUnsafe: () => BigInt(nowMs) * 1_000_000n,
164 monotonicTimeNanos: clock.monotonicTimeNanos,
165 monotonicTimeNanosUnsafe: () => clock.monotonicTimeNanosUnsafe(),
166 // Reaching the next scheduled sleep proves this sweep has finished.
167 sleep: () => Deferred.succeed(swept, undefined).pipe(Effect.andThen(Effect.never)),
168 }),
169 );
170 yield* Deferred.await(swept);
171 }).pipe(Effect.scoped),
172 );
146173 }
147174
148175 async function createHarness(input: {
......@@ -383,52 +410,111 @@ describe("ProviderSessionReaper", () => {
383410 expect(Option.isSome(remaining)).toBe(true);
384411 });
385412
386 it("does not reap sessions that are still within the inactivity threshold", async () => {
387 const threadId = ThreadId.make("thread-reaper-fresh");
388 const now = DateTime.formatIso(await Effect.runPromise(DateTime.now));
389 const harness = await createHarness({
390 readModel: makeReadModel([
413 it.each(["ready", "interrupted", "error"] as const)(
414 "gives a long turn a full idle window after becoming %s",
415 async (status) => {
416 const threadId = ThreadId.make(`thread-reaper-long-turn-${status}`);
417 const startedAt = "2026-04-14T00:00:00.000Z";
418 const completedAt = "2026-04-14T01:00:00.000Z";
419 const completedAtMs = Date.parse(completedAt);
420 const readModel = makeReadModel([
391421 {
392422 id: threadId,
393423 session: {
394424 threadId,
395 status: "ready",
425 status: "running",
396426 providerName: "claudeAgent",
397427 runtimeMode: "full-access",
398 activeTurnId: null,
428 activeTurnId: TurnId.make("turn-reaper-long"),
399429 lastError: null,
400 updatedAt: now,
430 updatedAt: startedAt,
401431 },
402432 },
403 ]),
404 });
405 const repository = await runtime!.runPromise(
406 Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository),
407 );
408
409 await runtime!.runPromise(
410 repository.upsert({
411 threadId,
412 providerName: "claudeAgent",
413 providerInstanceId: null,
414 adapterKey: "claudeAgent",
415 runtimeMode: "full-access",
416 status: "running",
417 lastSeenAt: now,
418 resumeCursor: {
419 opaque: "resume-fresh",
420 },
421 runtimePayload: null,
422 }),
423 );
424
425 await startReaper();
426 await Effect.runPromise(drainFibers);
427
428 expect(harness.stopSession).not.toHaveBeenCalled();
429 const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId }));
430 expect(Option.isSome(remaining)).toBe(true);
431 });
433 ]);
434 const harness = await createHarness({ readModel });
435 const repository = await runtime!.runPromise(
436 Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository),
437 );
438 await runtime!.runPromise(
439 repository.upsert({
440 threadId,
441 providerName: "claudeAgent",
442 providerInstanceId: null,
443 adapterKey: "claudeAgent",
444 runtimeMode: "full-access",
445 status: "running",
446 lastSeenAt: startedAt,
447 resumeCursor: { opaque: "resume-long-turn" },
448 runtimePayload: null,
449 }),
450 );
451
452 await sweepAt(completedAtMs);
453 expect(harness.stopSession).not.toHaveBeenCalled();
454
455 // Ingestion changes the timestamp and clears the active turn in the same session row.
456 readModel.threads[0]!.session = {
457 ...readModel.threads[0]!.session!,
458 status,
459 activeTurnId: null,
460 updatedAt: completedAt,
461 };
462 await sweepAt(completedAtMs);
463 expect(harness.stopSession).not.toHaveBeenCalled();
464 await sweepAt(completedAtMs + 999);
465 expect(harness.stopSession).not.toHaveBeenCalled();
466 await sweepAt(completedAtMs + 1_000);
467 expect(harness.stopSession).toHaveBeenCalledExactlyOnceWith({ threadId });
468 },
469 );
470
471 it.each([true, false])(
472 "uses the binding idle window when the session is older or missing, hasSession=%s",
473 async (hasSession) => {
474 const threadId = ThreadId.make("thread-reaper-fresh");
475 const now = "2026-04-14T01:00:00.000Z";
476 const nowMs = Date.parse(now);
477 const harness = await createHarness({
478 readModel: makeReadModel([
479 {
480 id: threadId,
481 session: hasSession
482 ? {
483 threadId,
484 status: "ready",
485 providerName: "claudeAgent",
486 runtimeMode: "full-access",
487 activeTurnId: null,
488 lastError: null,
489 updatedAt: "2026-04-14T00:00:00.000Z",
490 }
491 : null,
492 },
493 ]),
494 });
495 const repository = await runtime!.runPromise(
496 Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository),
497 );
498 await runtime!.runPromise(
499 repository.upsert({
500 threadId,
501 providerName: "claudeAgent",
502 providerInstanceId: null,
503 adapterKey: "claudeAgent",
504 runtimeMode: "full-access",
505 status: "running",
506 lastSeenAt: now,
507 resumeCursor: { opaque: "resume-fresh" },
508 runtimePayload: null,
509 }),
510 );
511
512 await sweepAt(nowMs + 999);
513 expect(harness.stopSession).not.toHaveBeenCalled();
514 await sweepAt(nowMs + 1_000);
515 expect(harness.stopSession).toHaveBeenCalledExactlyOnceWith({ threadId });
516 },
517 );
432518
433519 it("skips persisted sessions that are already marked stopped", async () => {
434520 const threadId = ThreadId.make("thread-reaper-stopped");
apps/server/src/provider/Layers/ProviderSessionReaper.ts+12-2
......@@ -54,14 +54,24 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) =
5454 continue;
5555 }
5656
57 const idleDurationMs = now - lastSeenMs;
58 if (idleDurationMs < inactivityThresholdMs) {
57 if (now - lastSeenMs < inactivityThresholdMs) {
5958 continue;
6059 }
6160
6261 const thread = yield* projectionSnapshotQuery
6362 .getThreadShellById(binding.threadId)
6463 .pipe(Effect.map(Option.getOrUndefined));
64 // Ingestion updates this timestamp alongside activeTurnId when a turn
65 // settles. Long turns must get a full idle window after that transition,
66 // even though the binding was last touched when the turn was sent.
67 const lastActivityMs = Math.max(
68 lastSeenMs,
69 Date.parse(thread?.session?.updatedAt ?? binding.lastSeenAt),
70 );
71 const idleDurationMs = now - lastActivityMs;
72 if (idleDurationMs < inactivityThresholdMs) {
73 continue;
74 }
6575 if (thread?.session?.activeTurnId != null) {
6676 yield* Effect.logDebug("provider.session.reaper.skipped-active-turn", {
6777 threadId: binding.threadId,