733 lines · 23.6 KB · TypeScript
RawDownload
import * as NodeServices from "@effect/platform-node/NodeServices";import {  ProjectId,  ThreadId,  TurnId,  ProviderDriverKind,  ProviderInstanceId,} from "@t3tools/contracts";import * as Clock from "effect/Clock";import * as Deferred from "effect/Deferred";import * as Effect from "effect/Effect";import * as Exit from "effect/Exit";import * as Layer from "effect/Layer";import * as ManagedRuntime from "effect/ManagedRuntime";import * as Option from "effect/Option";import * as Scope from "effect/Scope";import * as Stream from "effect/Stream";import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts";import { ProviderValidationError } from "../Errors.ts";import { ProviderSessionReaper } from "../Services/ProviderSessionReaper.ts";import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts";import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";import { makeProviderSessionReaperLive } from "./ProviderSessionReaper.ts"; const defaultModelSelection = {  instanceId: ProviderInstanceId.make("codex"),  model: "gpt-5-codex",} as const; async function waitFor(  predicate: () => boolean | Promise<boolean>,  timeoutMs = 2_000,): Promise<void> {  const deadline = (await Effect.runPromise(Clock.currentTimeMillis)) + timeoutMs;  const poll = async (): Promise<void> => {    if (await predicate()) {      return;    }    if ((await Effect.runPromise(Clock.currentTimeMillis)) >= deadline) {      throw new Error("Timed out waiting for expectation.");    }    await Effect.runPromise(Effect.yieldNow);    return poll();  };   return poll();} const drainFibers = Effect.forEach(Array.from({ length: 10 }), () => Effect.yieldNow, {  discard: true,}); const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; function makeReadModel(  threads: ReadonlyArray<{    readonly id: ThreadId;    readonly session: {      readonly threadId: ThreadId;      readonly status: "starting" | "running" | "ready" | "interrupted" | "stopped" | "error";      readonly providerName: "codex" | "claudeAgent";      readonly runtimeMode: "approval-required" | "full-access" | "auto-accept-edits";      readonly activeTurnId: TurnId | null;      readonly lastError: string | null;      readonly updatedAt: string;    } | null;    readonly backgroundLiveness?: "working" | "monitoring" | null;  }>,) {  const now = "2026-01-01T00:00:00.000Z";  const projectId = ProjectId.make("project-provider-session-reaper");   return {    snapshotSequence: 0,    updatedAt: now,    projects: [      {        id: projectId,        title: "Provider Reaper Project",        workspaceRoot: "/tmp/provider-reaper-project",        defaultModelSelection,        scripts: [],        createdAt: now,        updatedAt: now,        deletedAt: null,      },    ],    threads: threads.map((thread) => ({      id: thread.id,      projectId,      title: `Thread ${thread.id}`,      modelSelection: defaultModelSelection,      interactionMode: "default" as const,      runtimeMode: "full-access" as const,      branch: null,      worktreePath: null,      pullRequests: [],      createdAt: now,      updatedAt: now,      archivedAt: null,      settledOverride: null,      settledAt: null,      latestUserMessageAt: null,      hasPendingApprovals: false,      hasPendingUserInput: false,      hasActionableProposedPlan: false,      latestTurn: null,      messages: [],      session: thread.session,      backgroundLiveness: thread.backgroundLiveness ?? null,      activities: [],      proposedPlans: [],      checkpoints: [],      deletedAt: null,    })),  };} describe("ProviderSessionReaper", () => {  let runtime: ManagedRuntime.ManagedRuntime<    ProviderSessionReaper | ProviderSessionRuntime.ProviderSessionRuntimeRepository,    unknown  > | null = null;  let scope: Scope.Closeable | null = null;   afterEach(async () => {    if (scope) {      await Effect.runPromise(Scope.close(scope, Exit.void));    }    scope = null;    if (runtime) {      await runtime.dispose();    }    runtime = null;  });   // Shared start sequence so each test adds no manual Effect runners  // (no-manual-effect-runtime-in-tests tracks this file's legacy count).  async function startReaper() {    await runtime!.runPromise(      Effect.gen(function* () {        const reaper = yield* ProviderSessionReaper;        scope = yield* Scope.make("sequential");        yield* reaper.start().pipe(Scope.provide(scope));      }),    );  }   async function sweepAt(nowMs: number) {    await runtime!.runPromise(      Effect.gen(function* () {        const reaper = yield* ProviderSessionReaper;        const clock = yield* Clock.Clock;        const swept = yield* Deferred.make<void>();        yield* reaper.start().pipe(          Effect.provideService(Clock.Clock, {            currentTimeMillis: Effect.succeed(nowMs),            currentTimeMillisUnsafe: () => nowMs,            currentTimeNanos: Effect.succeed(BigInt(nowMs) * 1_000_000n),            currentTimeNanosUnsafe: () => BigInt(nowMs) * 1_000_000n,            monotonicTimeNanos: clock.monotonicTimeNanos,            monotonicTimeNanosUnsafe: () => clock.monotonicTimeNanosUnsafe(),            // Reaching the next scheduled sleep proves this sweep has finished.            sleep: () => Deferred.succeed(swept, undefined).pipe(Effect.andThen(Effect.never)),          }),        );        yield* Deferred.await(swept);      }).pipe(Effect.scoped),    );  }   async function createHarness(input: {    readonly readModel: ReturnType<typeof makeReadModel>;    readonly stopSessionImplementation?: (input: {      readonly threadId: ThreadId;    }) => ReturnType<ProviderServiceShape["stopSession"]>;  }) {    const stoppedThreadIds = new Set<ThreadId>();    const stopSession = vi.fn<ProviderServiceShape["stopSession"]>(      (request) =>        (input.stopSessionImplementation          ? input.stopSessionImplementation(request)          : Effect.sync(() => {              stoppedThreadIds.add(request.threadId);            })) as ReturnType<ProviderServiceShape["stopSession"]>,    );     const providerService: ProviderServiceShape = {      startSession: () => unsupported(),      sendTurn: () => unsupported(),      compactThread: () => unsupported(),      interruptTurn: () => unsupported(),      respondToRequest: () => unsupported(),      respondToUserInput: () => unsupported(),      stopSession,      listSessions: () => Effect.succeed([]),      getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),      assertConversationRollbackSupported: () => unsupported(),      getInstanceInfo: (instanceId) => {        const driverKind = ProviderDriverKind.make(String(instanceId));        return Effect.succeed({          instanceId,          driverKind,          displayName: undefined,          enabled: true,          continuationIdentity: {            driverKind,            continuationKey: `${driverKind}:instance:${instanceId}`,          },        });      },      rollbackConversation: () => unsupported(),      uploadFeedback: () => unsupported(),      streamEvents: Stream.empty,    };     const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(      Layer.provide(SqlitePersistenceMemory),    );    const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(      Layer.provide(runtimeRepositoryLayer),    );    const layer = makeProviderSessionReaperLive({      inactivityThresholdMs: 1_000,      sweepIntervalMs: 60_000,    }).pipe(      Layer.provideMerge(providerSessionDirectoryLayer),      Layer.provideMerge(runtimeRepositoryLayer),      Layer.provideMerge(Layer.succeed(ProviderService, providerService)),      Layer.provideMerge(        Layer.succeed(ProjectionSnapshotQuery, {          getUserInputActivity: () => Effect.die("unused"),          getCommandReadModel: () => Effect.die("unused"),          getSnapshot: () => Effect.die("unused"),          getShellSnapshot: () => Effect.die("unused"),          getArchivedShellSnapshot: () => Effect.die("unused"),          getSnapshotSequence: () =>            Effect.succeed({ snapshotSequence: input.readModel.snapshotSequence }),          getCounts: () => Effect.die("unused"),          getEventReplayStats: () => Effect.die("unused"),          getActiveProjectByWorkspaceRoot: () => Effect.die("unused"),          getProjectShellById: () => Effect.die("unused"),          getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),          getImportedAgentSessionSources: () => Effect.die("unused"),          getThreadCheckpointContext: () => Effect.die("unused"),          getFullThreadDiffContext: () => Effect.die("unused"),          getThreadRuntimeContext: () => Effect.die("unused"),          getTurnStartMessage: () => Effect.die("unused"),          getThreadShellById: (threadId) =>            Effect.succeed(              input.readModel.threads.find((thread) => thread.id === threadId)                ? Option.some(input.readModel.threads.find((thread) => thread.id === threadId)!)                : Option.none(),            ),          getThreadDetailById: () => Effect.die("unused"),          getThreadDetailSnapshot: () => Effect.die("unused"),          searchThreads: () => Effect.succeed({ matches: [] }),        }),      ),      Layer.provideMerge(NodeServices.layer),    );     runtime = ManagedRuntime.make(layer);    return { stopSession, stoppedThreadIds };  }   it("reaps stale persisted sessions without active turns", async () => {    const threadId = ThreadId.make("thread-reaper-stale");    const now = "2026-01-01T00:00:00.000Z";    const harness = await createHarness({      readModel: makeReadModel([        {          id: threadId,          session: {            threadId,            status: "ready",            providerName: "claudeAgent",            runtimeMode: "full-access",            activeTurnId: null,            lastError: null,            updatedAt: now,          },        },      ]),    });    const repository = await runtime!.runPromise(      Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository),    );     await runtime!.runPromise(      repository.upsert({        threadId,        providerName: "claudeAgent",        providerInstanceId: null,        adapterKey: "claudeAgent",        runtimeMode: "full-access",        status: "running",        lastSeenAt: "2026-04-14T00:00:00.000Z",        resumeCursor: {          opaque: "resume-stale",        },        runtimePayload: null,      }),    );     await startReaper();     await waitFor(() => harness.stopSession.mock.calls.length === 1);     expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ threadId });    expect(harness.stoppedThreadIds.has(threadId)).toBe(true);  });   it("skips stale sessions when the thread still has an active turn", async () => {    const threadId = ThreadId.make("thread-reaper-active-turn");    const turnId = TurnId.make("turn-reaper-active");    const now = "2026-01-01T00:00:00.000Z";    const harness = await createHarness({      readModel: makeReadModel([        {          id: threadId,          session: {            threadId,            status: "running",            providerName: "claudeAgent",            runtimeMode: "full-access",            activeTurnId: turnId,            lastError: null,            updatedAt: now,          },        },      ]),    });    const repository = await runtime!.runPromise(      Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository),    );     await runtime!.runPromise(      repository.upsert({        threadId,        providerName: "claudeAgent",        providerInstanceId: null,        adapterKey: "claudeAgent",        runtimeMode: "full-access",        status: "running",        lastSeenAt: "2026-04-14T00:00:00.000Z",        resumeCursor: {          opaque: "resume-active-turn",        },        runtimePayload: null,      }),    );     await startReaper();    await Effect.runPromise(drainFibers);     expect(harness.stopSession).not.toHaveBeenCalled();    const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId }));    expect(Option.isSome(remaining)).toBe(true);  });   it("skips stale sessions while background work is still live", async () => {    const threadId = ThreadId.make("thread-reaper-background-work");    const now = "2026-01-01T00:00:00.000Z";    const harness = await createHarness({      readModel: makeReadModel([        {          id: threadId,          session: {            threadId,            status: "ready",            providerName: "claudeAgent",            runtimeMode: "full-access",            activeTurnId: null,            lastError: null,            updatedAt: now,          },          backgroundLiveness: "working",        },      ]),    });    const repository = await runtime!.runPromise(      Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository),    );     await runtime!.runPromise(      repository.upsert({        threadId,        providerName: "claudeAgent",        providerInstanceId: null,        adapterKey: "claudeAgent",        runtimeMode: "full-access",        status: "running",        lastSeenAt: "2026-04-14T00:00:00.000Z",        resumeCursor: {          opaque: "resume-background-work",        },        runtimePayload: null,      }),    );     await startReaper();    await Effect.runPromise(drainFibers);     expect(harness.stopSession).not.toHaveBeenCalled();    const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId }));    expect(Option.isSome(remaining)).toBe(true);  });   it.each(["ready", "interrupted", "error"] as const)(    "gives a long turn a full idle window after becoming %s",    async (status) => {      const threadId = ThreadId.make(`thread-reaper-long-turn-${status}`);      const startedAt = "2026-04-14T00:00:00.000Z";      const completedAt = "2026-04-14T01:00:00.000Z";      const completedAtMs = Date.parse(completedAt);      const readModel = makeReadModel([        {          id: threadId,          session: {            threadId,            status: "running",            providerName: "claudeAgent",            runtimeMode: "full-access",            activeTurnId: TurnId.make("turn-reaper-long"),            lastError: null,            updatedAt: startedAt,          },        },      ]);      const harness = await createHarness({ readModel });      const repository = await runtime!.runPromise(        Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository),      );      await runtime!.runPromise(        repository.upsert({          threadId,          providerName: "claudeAgent",          providerInstanceId: null,          adapterKey: "claudeAgent",          runtimeMode: "full-access",          status: "running",          lastSeenAt: startedAt,          resumeCursor: { opaque: "resume-long-turn" },          runtimePayload: null,        }),      );       await sweepAt(completedAtMs);      expect(harness.stopSession).not.toHaveBeenCalled();       // Ingestion changes the timestamp and clears the active turn in the same session row.      readModel.threads[0]!.session = {        ...readModel.threads[0]!.session!,        status,        activeTurnId: null,        updatedAt: completedAt,      };      await sweepAt(completedAtMs);      expect(harness.stopSession).not.toHaveBeenCalled();      await sweepAt(completedAtMs + 999);      expect(harness.stopSession).not.toHaveBeenCalled();      await sweepAt(completedAtMs + 1_000);      expect(harness.stopSession).toHaveBeenCalledExactlyOnceWith({ threadId });    },  );   it.each([true, false])(    "uses the binding idle window when the session is older or missing, hasSession=%s",    async (hasSession) => {      const threadId = ThreadId.make("thread-reaper-fresh");      const now = "2026-04-14T01:00:00.000Z";      const nowMs = Date.parse(now);      const harness = await createHarness({        readModel: makeReadModel([          {            id: threadId,            session: hasSession              ? {                  threadId,                  status: "ready",                  providerName: "claudeAgent",                  runtimeMode: "full-access",                  activeTurnId: null,                  lastError: null,                  updatedAt: "2026-04-14T00:00:00.000Z",                }              : null,          },        ]),      });      const repository = await runtime!.runPromise(        Effect.service(ProviderSessionRuntime.ProviderSessionRuntimeRepository),      );      await runtime!.runPromise(        repository.upsert({

Lines 1–500 of 733.