| ... | ... | @@ -7,7 +7,7 @@ import { |
| 7 | 7 | ProviderInstanceId, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | import * as Clock from "effect/Clock"; |
| 10 | | import * as DateTime from "effect/DateTime"; |
| 10 | import * as Deferred from "effect/Deferred"; |
| 11 | 11 | import * as Effect from "effect/Effect"; |
| 12 | 12 | import * as Exit from "effect/Exit"; |
| 13 | 13 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -140,9 +140,36 @@ describe("ProviderSessionReaper", () => { |
| 140 | 140 | // Shared start sequence so each test adds no manual Effect runners |
| 141 | 141 | // (no-manual-effect-runtime-in-tests tracks this file's legacy count). |
| 142 | 142 | 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 | ); |
| 146 | 173 | } |
| 147 | 174 | |
| 148 | 175 | async function createHarness(input: { |
| ... | ... | @@ -383,52 +410,111 @@ describe("ProviderSessionReaper", () => { |
| 383 | 410 | expect(Option.isSome(remaining)).toBe(true); |
| 384 | 411 | }); |
| 385 | 412 | |
| 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([ |
| 391 | 421 | { |
| 392 | 422 | id: threadId, |
| 393 | 423 | session: { |
| 394 | 424 | threadId, |
| 395 | | status: "ready", |
| 425 | status: "running", |
| 396 | 426 | providerName: "claudeAgent", |
| 397 | 427 | runtimeMode: "full-access", |
| 398 | | activeTurnId: null, |
| 428 | activeTurnId: TurnId.make("turn-reaper-long"), |
| 399 | 429 | lastError: null, |
| 400 | | updatedAt: now, |
| 430 | updatedAt: startedAt, |
| 401 | 431 | }, |
| 402 | 432 | }, |
| 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 | ); |
| 432 | 518 | |
| 433 | 519 | it("skips persisted sessions that are already marked stopped", async () => { |
| 434 | 520 | const threadId = ThreadId.make("thread-reaper-stopped"); |