| ... | @@ -7,7 +7,7 @@ import { | ... | @@ -7,7 +7,7 @@ import { |
| 7 | ProviderInstanceId, | 7 | ProviderInstanceId, |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import * as Clock from "effect/Clock"; | 9 | import * as Clock from "effect/Clock"; |
| 10 | import * as DateTime from "effect/DateTime"; | 10 | import * as Deferred from "effect/Deferred"; |
| 11 | import * as Effect from "effect/Effect"; | 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Exit from "effect/Exit"; | 12 | import * as Exit from "effect/Exit"; |
| 13 | import * as Layer from "effect/Layer"; | 13 | import * as Layer from "effect/Layer"; |
| ... | @@ -140,9 +140,36 @@ describe("ProviderSessionReaper", () => { | ... | @@ -140,9 +140,36 @@ describe("ProviderSessionReaper", () => { |
| 140 | // Shared start sequence so each test adds no manual Effect runners | 140 | // Shared start sequence so each test adds no manual Effect runners |
| 141 | // (no-manual-effect-runtime-in-tests tracks this file's legacy count). | 141 | // (no-manual-effect-runtime-in-tests tracks this file's legacy count). |
| 142 | async function startReaper() { | 142 | async function startReaper() { |
| 143 | const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); | 143 | await runtime!.runPromise( |
| 144 | scope = await Effect.runPromise(Scope.make("sequential")); | 144 | Effect.gen(function* () { |
| 145 | await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); | 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 | async function createHarness(input: { | 175 | async function createHarness(input: { |
| ... | @@ -383,52 +410,111 @@ describe("ProviderSessionReaper", () => { | ... | @@ -383,52 +410,111 @@ describe("ProviderSessionReaper", () => { |
| 383 | expect(Option.isSome(remaining)).toBe(true); | 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 () => { | 413 | it.each(["ready", "interrupted", "error"] as const)( |
| 387 | const threadId = ThreadId.make("thread-reaper-fresh"); | 414 | "gives a long turn a full idle window after becoming %s", |
| 388 | const now = DateTime.formatIso(await Effect.runPromise(DateTime.now)); | 415 | async (status) => { |
| 389 | const harness = await createHarness({ | 416 | const threadId = ThreadId.make(`thread-reaper-long-turn-${status}`); |
| 390 | readModel: makeReadModel([ | 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 | id: threadId, | 422 | id: threadId, |
| 393 | session: { | 423 | session: { |
| 394 | threadId, | 424 | threadId, |
| 395 | status: "ready", | 425 | status: "running", |
| 396 | providerName: "claudeAgent", | 426 | providerName: "claudeAgent", |
| 397 | runtimeMode: "full-access", | 427 | runtimeMode: "full-access", |
| 398 | activeTurnId: null, | 428 | activeTurnId: TurnId.make("turn-reaper-long"), |
| 399 | lastError: null, | 429 | lastError: null, |
| 400 | updatedAt: now, | 430 | updatedAt: startedAt, |
| 401 | }, | 431 | }, |
| 402 | }, | 432 | }, |
| 403 | ]), | 433 | ]); |
| 404 | }); | 434 | const harness = await createHarness({ readModel }); |
| 405 | const repository = await runtime!.runPromise( | 435 | const repository = await runtime!.runPromise( |
| 406 | Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository), | 436 | Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository), |
| 407 | ); | 437 | ); |
| 408 | | 438 | await runtime!.runPromise( |
| 409 | await runtime!.runPromise( | 439 | repository.upsert({ |
| 410 | repository.upsert({ | 440 | threadId, |
| 411 | threadId, | 441 | providerName: "claudeAgent", |
| 412 | providerName: "claudeAgent", | 442 | providerInstanceId: null, |
| 413 | providerInstanceId: null, | 443 | adapterKey: "claudeAgent", |
| 414 | adapterKey: "claudeAgent", | 444 | runtimeMode: "full-access", |
| 415 | runtimeMode: "full-access", | 445 | status: "running", |
| 416 | status: "running", | 446 | lastSeenAt: startedAt, |
| 417 | lastSeenAt: now, | 447 | resumeCursor: { opaque: "resume-long-turn" }, |
| 418 | resumeCursor: { | 448 | runtimePayload: null, |
| 419 | opaque: "resume-fresh", | 449 | }), |
| 420 | }, | 450 | ); |
| 421 | runtimePayload: null, | 451 | |
| 422 | }), | 452 | await sweepAt(completedAtMs); |
| 423 | ); | 453 | expect(harness.stopSession).not.toHaveBeenCalled(); |
| 424 | | 454 | |
| 425 | await startReaper(); | 455 | // Ingestion changes the timestamp and clears the active turn in the same session row. |
| 426 | await Effect.runPromise(drainFibers); | 456 | readModel.threads[0]!.session = { |
| 427 | | 457 | ...readModel.threads[0]!.session!, |
| 428 | expect(harness.stopSession).not.toHaveBeenCalled(); | 458 | status, |
| 429 | const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId })); | 459 | activeTurnId: null, |
| 430 | expect(Option.isSome(remaining)).toBe(true); | 460 | updatedAt: completedAt, |
| 431 | }); | 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 | it("skips persisted sessions that are already marked stopped", async () => { | 519 | it("skips persisted sessions that are already marked stopped", async () => { |
| 434 | const threadId = ThreadId.make("thread-reaper-stopped"); | 520 | const threadId = ThreadId.make("thread-reaper-stopped"); |