Просмотр исходного кода

refactor(question): effectify QuestionService (#17432)

Kit Langton 1 месяц назад
Родитель
Сommit
cec1255b36

+ 4 - 1
packages/opencode/src/effect/runtime.ts

@@ -1,5 +1,8 @@
 import { Layer, ManagedRuntime } from "effect"
 import { AccountService } from "@/account/service"
 import { AuthService } from "@/auth/service"
+import { QuestionService } from "@/question/service"
 
-export const runtime = ManagedRuntime.make(Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer))
+export const runtime = ManagedRuntime.make(
+  Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer, QuestionService.layer),
+)

+ 11 - 12
packages/opencode/src/provider/auth-service.ts

@@ -79,18 +79,17 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
     ProviderAuthService,
     Effect.gen(function* () {
       const auth = yield* Auth.AuthService
-      const state = yield* InstanceState.make({
-        lookup: () =>
-          Effect.promise(async () => {
-            const methods = pipe(
-              await Plugin.list(),
-              filter((x) => x.auth?.provider !== undefined),
-              map((x) => [x.auth!.provider, x.auth!] as const),
-              fromEntries(),
-            )
-            return { methods, pending: new Map<ProviderID, AuthOuathResult>() }
-          }),
-      })
+      const state = yield* InstanceState.make(() =>
+        Effect.promise(async () => {
+          const methods = pipe(
+            await Plugin.list(),
+            filter((x) => x.auth?.provider !== undefined),
+            map((x) => [x.auth!.provider, x.auth!] as const),
+            fromEntries(),
+          )
+          return { methods, pending: new Map<ProviderID, AuthOuathResult>() }
+        }),
+      )
 
       const methods = Effect.fn("ProviderAuthService.methods")(function* () {
         const x = yield* InstanceState.get(state)

+ 26 - 149
packages/opencode/src/question/index.ts

@@ -1,167 +1,44 @@
-import { Bus } from "@/bus"
-import { BusEvent } from "@/bus/bus-event"
-import { SessionID, MessageID } from "@/session/schema"
-import { Instance } from "@/project/instance"
-import { Log } from "@/util/log"
-import z from "zod"
-import { QuestionID } from "./schema"
+import { Effect } from "effect"
+import { runtime } from "@/effect/runtime"
+import * as S from "./service"
+import type { QuestionID } from "./schema"
+import type { SessionID, MessageID } from "@/session/schema"
+
+function runPromise<A>(f: (service: S.QuestionService.Service) => Effect.Effect<A, S.QuestionServiceError>) {
+  return runtime.runPromise(S.QuestionService.use(f))
+}
 
 export namespace Question {
-  const log = Log.create({ service: "question" })
-
-  export const Option = z
-    .object({
-      label: z.string().describe("Display text (1-5 words, concise)"),
-      description: z.string().describe("Explanation of choice"),
-    })
-    .meta({
-      ref: "QuestionOption",
-    })
-  export type Option = z.infer<typeof Option>
-
-  export const Info = z
-    .object({
-      question: z.string().describe("Complete question"),
-      header: z.string().describe("Very short label (max 30 chars)"),
-      options: z.array(Option).describe("Available choices"),
-      multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
-      custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
-    })
-    .meta({
-      ref: "QuestionInfo",
-    })
-  export type Info = z.infer<typeof Info>
-
-  export const Request = z
-    .object({
-      id: QuestionID.zod,
-      sessionID: SessionID.zod,
-      questions: z.array(Info).describe("Questions to ask"),
-      tool: z
-        .object({
-          messageID: MessageID.zod,
-          callID: z.string(),
-        })
-        .optional(),
-    })
-    .meta({
-      ref: "QuestionRequest",
-    })
-  export type Request = z.infer<typeof Request>
-
-  export const Answer = z.array(z.string()).meta({
-    ref: "QuestionAnswer",
-  })
-  export type Answer = z.infer<typeof Answer>
-
-  export const Reply = z.object({
-    answers: z
-      .array(Answer)
-      .describe("User answers in order of questions (each answer is an array of selected labels)"),
-  })
-  export type Reply = z.infer<typeof Reply>
-
-  export const Event = {
-    Asked: BusEvent.define("question.asked", Request),
-    Replied: BusEvent.define(
-      "question.replied",
-      z.object({
-        sessionID: SessionID.zod,
-        requestID: QuestionID.zod,
-        answers: z.array(Answer),
-      }),
-    ),
-    Rejected: BusEvent.define(
-      "question.rejected",
-      z.object({
-        sessionID: SessionID.zod,
-        requestID: QuestionID.zod,
-      }),
-    ),
-  }
-
-  interface PendingEntry {
-    info: Request
-    resolve: (answers: Answer[]) => void
-    reject: (e: any) => void
-  }
-
-  const state = Instance.state(async () => ({
-    pending: new Map<QuestionID, PendingEntry>(),
-  }))
+  export const Option = S.Option
+  export type Option = S.Option
+  export const Info = S.Info
+  export type Info = S.Info
+  export const Request = S.Request
+  export type Request = S.Request
+  export const Answer = S.Answer
+  export type Answer = S.Answer
+  export const Reply = S.Reply
+  export type Reply = S.Reply
+  export const Event = S.Event
+  export const RejectedError = S.RejectedError
 
   export async function ask(input: {
     sessionID: SessionID
     questions: Info[]
     tool?: { messageID: MessageID; callID: string }
   }): Promise<Answer[]> {
-    const s = await state()
-    const id = QuestionID.ascending()
-
-    log.info("asking", { id, questions: input.questions.length })
-
-    return new Promise<Answer[]>((resolve, reject) => {
-      const info: Request = {
-        id,
-        sessionID: input.sessionID,
-        questions: input.questions,
-        tool: input.tool,
-      }
-      s.pending.set(id, {
-        info,
-        resolve,
-        reject,
-      })
-      Bus.publish(Event.Asked, info)
-    })
+    return runPromise((service) => service.ask(input))
   }
 
   export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
-    const s = await state()
-    const existing = s.pending.get(input.requestID)
-    if (!existing) {
-      log.warn("reply for unknown request", { requestID: input.requestID })
-      return
-    }
-    s.pending.delete(input.requestID)
-
-    log.info("replied", { requestID: input.requestID, answers: input.answers })
-
-    Bus.publish(Event.Replied, {
-      sessionID: existing.info.sessionID,
-      requestID: existing.info.id,
-      answers: input.answers,
-    })
-
-    existing.resolve(input.answers)
+    return runPromise((service) => service.reply(input))
   }
 
   export async function reject(requestID: QuestionID): Promise<void> {
-    const s = await state()
-    const existing = s.pending.get(requestID)
-    if (!existing) {
-      log.warn("reject for unknown request", { requestID })
-      return
-    }
-    s.pending.delete(requestID)
-
-    log.info("rejected", { requestID })
-
-    Bus.publish(Event.Rejected, {
-      sessionID: existing.info.sessionID,
-      requestID: existing.info.id,
-    })
-
-    existing.reject(new RejectedError())
-  }
-
-  export class RejectedError extends Error {
-    constructor() {
-      super("The user dismissed this question")
-    }
+    return runPromise((service) => service.reject(requestID))
   }
 
-  export async function list() {
-    return state().then((x) => Array.from(x.pending.values(), (x) => x.info))
+  export async function list(): Promise<Request[]> {
+    return runPromise((service) => service.list())
   }
 }

+ 10 - 10
packages/opencode/src/question/schema.ts

@@ -2,16 +2,16 @@ import { Schema } from "effect"
 import z from "zod"
 
 import { Identifier } from "@/id/id"
-import { withStatics } from "@/util/schema"
+import { Newtype } from "@/util/schema"
 
-const questionIdSchema = Schema.String.pipe(Schema.brand("QuestionID"))
+export class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) {
+  static make(id: string): QuestionID {
+    return this.makeUnsafe(id)
+  }
 
-export type QuestionID = typeof questionIdSchema.Type
+  static ascending(id?: string): QuestionID {
+    return this.makeUnsafe(Identifier.ascending("question", id))
+  }
 
-export const QuestionID = questionIdSchema.pipe(
-  withStatics((schema: typeof questionIdSchema) => ({
-    make: (id: string) => schema.makeUnsafe(id),
-    ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("question", id)),
-    zod: Identifier.schema("question").pipe(z.custom<QuestionID>()),
-  })),
-)
+  static readonly zod = Identifier.schema("question") as unknown as z.ZodType<QuestionID>
+}

+ 181 - 0
packages/opencode/src/question/service.ts

@@ -0,0 +1,181 @@
+import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
+import { Bus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
+import { SessionID, MessageID } from "@/session/schema"
+import { InstanceState } from "@/util/instance-state"
+import { Log } from "@/util/log"
+import z from "zod"
+import { QuestionID } from "./schema"
+
+const log = Log.create({ service: "question" })
+
+// --- Zod schemas (re-exported by facade) ---
+
+export const Option = z
+  .object({
+    label: z.string().describe("Display text (1-5 words, concise)"),
+    description: z.string().describe("Explanation of choice"),
+  })
+  .meta({ ref: "QuestionOption" })
+export type Option = z.infer<typeof Option>
+
+export const Info = z
+  .object({
+    question: z.string().describe("Complete question"),
+    header: z.string().describe("Very short label (max 30 chars)"),
+    options: z.array(Option).describe("Available choices"),
+    multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
+    custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
+  })
+  .meta({ ref: "QuestionInfo" })
+export type Info = z.infer<typeof Info>
+
+export const Request = z
+  .object({
+    id: QuestionID.zod,
+    sessionID: SessionID.zod,
+    questions: z.array(Info).describe("Questions to ask"),
+    tool: z
+      .object({
+        messageID: MessageID.zod,
+        callID: z.string(),
+      })
+      .optional(),
+  })
+  .meta({ ref: "QuestionRequest" })
+export type Request = z.infer<typeof Request>
+
+export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
+export type Answer = z.infer<typeof Answer>
+
+export const Reply = z.object({
+  answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"),
+})
+export type Reply = z.infer<typeof Reply>
+
+export const Event = {
+  Asked: BusEvent.define("question.asked", Request),
+  Replied: BusEvent.define(
+    "question.replied",
+    z.object({
+      sessionID: SessionID.zod,
+      requestID: QuestionID.zod,
+      answers: z.array(Answer),
+    }),
+  ),
+  Rejected: BusEvent.define(
+    "question.rejected",
+    z.object({
+      sessionID: SessionID.zod,
+      requestID: QuestionID.zod,
+    }),
+  ),
+}
+
+export class RejectedError extends Error {
+  constructor() {
+    super("The user dismissed this question")
+  }
+}
+
+// --- Effect service ---
+
+export class QuestionServiceError extends Schema.TaggedErrorClass<QuestionServiceError>()("QuestionServiceError", {
+  message: Schema.String,
+  cause: Schema.optional(Schema.Defect),
+}) {}
+
+interface PendingEntry {
+  info: Request
+  deferred: Deferred.Deferred<Answer[]>
+}
+
+export namespace QuestionService {
+  export interface Service {
+    readonly ask: (input: {
+      sessionID: SessionID
+      questions: Info[]
+      tool?: { messageID: MessageID; callID: string }
+    }) => Effect.Effect<Answer[], QuestionServiceError>
+    readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void, QuestionServiceError>
+    readonly reject: (requestID: QuestionID) => Effect.Effect<void, QuestionServiceError>
+    readonly list: () => Effect.Effect<Request[], QuestionServiceError>
+  }
+}
+
+export class QuestionService extends ServiceMap.Service<QuestionService, QuestionService.Service>()(
+  "@opencode/Question",
+) {
+  static readonly layer = Layer.effect(
+    QuestionService,
+    Effect.gen(function* () {
+      const instanceState = yield* InstanceState.make<Map<QuestionID, PendingEntry>, QuestionServiceError>(() =>
+        Effect.succeed(new Map<QuestionID, PendingEntry>()),
+      )
+
+      const getPending = InstanceState.get(instanceState)
+
+      const ask = Effect.fn("QuestionService.ask")(function* (input: {
+        sessionID: SessionID
+        questions: Info[]
+        tool?: { messageID: MessageID; callID: string }
+      }) {
+        const pending = yield* getPending
+        const id = QuestionID.ascending()
+        log.info("asking", { id, questions: input.questions.length })
+
+        const deferred = yield* Deferred.make<Answer[]>()
+        const info: Request = {
+          id,
+          sessionID: input.sessionID,
+          questions: input.questions,
+          tool: input.tool,
+        }
+        pending.set(id, { info, deferred })
+        Bus.publish(Event.Asked, info)
+
+        return yield* Deferred.await(deferred)
+      })
+
+      const reply = Effect.fn("QuestionService.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
+        const pending = yield* getPending
+        const existing = pending.get(input.requestID)
+        if (!existing) {
+          log.warn("reply for unknown request", { requestID: input.requestID })
+          return
+        }
+        pending.delete(input.requestID)
+        log.info("replied", { requestID: input.requestID, answers: input.answers })
+        Bus.publish(Event.Replied, {
+          sessionID: existing.info.sessionID,
+          requestID: existing.info.id,
+          answers: input.answers,
+        })
+        yield* Deferred.succeed(existing.deferred, input.answers)
+      })
+
+      const reject = Effect.fn("QuestionService.reject")(function* (requestID: QuestionID) {
+        const pending = yield* getPending
+        const existing = pending.get(requestID)
+        if (!existing) {
+          log.warn("reject for unknown request", { requestID })
+          return
+        }
+        pending.delete(requestID)
+        log.info("rejected", { requestID })
+        Bus.publish(Event.Rejected, {
+          sessionID: existing.info.sessionID,
+          requestID: existing.info.id,
+        })
+        yield* Deferred.die(existing.deferred, new RejectedError())
+      })
+
+      const list = Effect.fn("QuestionService.list")(function* () {
+        const pending = yield* getPending
+        return Array.from(pending.values(), (x) => x.info)
+      })
+
+      return QuestionService.of({ ask, reply, reject, list })
+    }),
+  )
+}

+ 36 - 26
packages/opencode/src/util/instance-state.ts

@@ -2,34 +2,39 @@ import { Effect, ScopedCache, Scope } from "effect"
 
 import { Instance } from "@/project/instance"
 
-const TypeId = Symbol.for("@opencode/InstanceState")
-
-type Task = (key: string) => Effect.Effect<void>
-
-const tasks = new Set<Task>()
+type Disposer = (directory: string) => Effect.Effect<void>
+const disposers = new Set<Disposer>()
+
+const TypeId = "~opencode/InstanceState"
+
+/**
+ * Effect version of `Instance.state` — lazily-initialized, per-directory
+ * cached state for Effect services.
+ *
+ * Values are created on first access for a given directory and cached for
+ * subsequent reads. Concurrent access shares a single initialization —
+ * no duplicate work or races. Use `Effect.acquireRelease` in `init` if
+ * the value needs cleanup on disposal.
+ */
+export interface InstanceState<A, E = never, R = never> {
+  readonly [TypeId]: typeof TypeId
+  readonly cache: ScopedCache.ScopedCache<string, A, E, R>
+}
 
 export namespace InstanceState {
-  export interface State<A, E = never, R = never> {
-    readonly [TypeId]: typeof TypeId
-    readonly cache: ScopedCache.ScopedCache<string, A, E, R>
-  }
-
-  export const make = <A, E = never, R = never>(input: {
-    lookup: (key: string) => Effect.Effect<A, E, R>
-    release?: (value: A, key: string) => Effect.Effect<void>
-  }): Effect.Effect<State<A, E, R>, never, R | Scope.Scope> =>
+  /** Create a new InstanceState with the given initializer. */
+  export const make = <A, E = never, R = never>(
+    init: (directory: string) => Effect.Effect<A, E, R | Scope.Scope>,
+  ): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
     Effect.gen(function* () {
       const cache = yield* ScopedCache.make<string, A, E, R>({
         capacity: Number.POSITIVE_INFINITY,
-        lookup: (key) =>
-          Effect.acquireRelease(input.lookup(key), (value) =>
-            input.release ? input.release(value, key) : Effect.void,
-          ),
+        lookup: init,
       })
 
-      const task: Task = (key) => ScopedCache.invalidate(cache, key)
-      tasks.add(task)
-      yield* Effect.addFinalizer(() => Effect.sync(() => void tasks.delete(task)))
+      const disposer: Disposer = (directory) => ScopedCache.invalidate(cache, directory)
+      disposers.add(disposer)
+      yield* Effect.addFinalizer(() => Effect.sync(() => void disposers.delete(disposer)))
 
       return {
         [TypeId]: TypeId,
@@ -37,15 +42,20 @@ export namespace InstanceState {
       }
     })
 
-  export const get = <A, E, R>(self: State<A, E, R>) => ScopedCache.get(self.cache, Instance.directory)
+  /** Get the cached value for the current directory, initializing it if needed. */
+  export const get = <A, E, R>(self: InstanceState<A, E, R>) => ScopedCache.get(self.cache, Instance.directory)
 
-  export const has = <A, E, R>(self: State<A, E, R>) => ScopedCache.has(self.cache, Instance.directory)
+  /** Check whether a value exists for the current directory. */
+  export const has = <A, E, R>(self: InstanceState<A, E, R>) => ScopedCache.has(self.cache, Instance.directory)
 
-  export const invalidate = <A, E, R>(self: State<A, E, R>) => ScopedCache.invalidate(self.cache, Instance.directory)
+  /** Invalidate the cached value for the current directory. */
+  export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
+    ScopedCache.invalidate(self.cache, Instance.directory)
 
-  export const dispose = (key: string) =>
+  /** Invalidate the given directory across all InstanceState caches. */
+  export const dispose = (directory: string) =>
     Effect.all(
-      [...tasks].map((task) => task(key)),
+      [...disposers].map((disposer) => disposer(directory)),
       { concurrency: "unbounded" },
     )
 }

+ 37 - 0
packages/opencode/src/util/schema.ts

@@ -15,3 +15,40 @@ export const withStatics =
   <S extends object, M extends Record<string, unknown>>(methods: (schema: S) => M) =>
   (schema: S): S & M =>
     Object.assign(schema, methods(schema))
+
+declare const NewtypeBrand: unique symbol
+type NewtypeBrand<Tag extends string> = { readonly [NewtypeBrand]: Tag }
+
+/**
+ * Nominal wrapper for scalar types. The class itself is a valid schema —
+ * pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc.
+ *
+ * @example
+ *   class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) {
+ *     static make(id: string): QuestionID {
+ *       return this.makeUnsafe(id)
+ *     }
+ *   }
+ *
+ *   Schema.decodeEffect(QuestionID)(input)
+ */
+export function Newtype<Self>() {
+  return <const Tag extends string, S extends Schema.Top>(tag: Tag, schema: S) => {
+    type Branded = NewtypeBrand<Tag>
+
+    abstract class Base {
+      declare readonly [NewtypeBrand]: Tag
+
+      static makeUnsafe(value: Schema.Schema.Type<S>): Self {
+        return value as unknown as Self
+      }
+    }
+
+    Object.setPrototypeOf(Base, schema)
+
+    return Base as unknown as
+      & (abstract new (_: never) => Branded)
+      & { readonly makeUnsafe: (value: Schema.Schema.Type<S>) => Self }
+      & Omit<Schema.Opaque<Self, S, {}>, "makeUnsafe">
+  }
+}

+ 20 - 4
packages/opencode/test/question/question.test.ts

@@ -5,6 +5,14 @@ import { QuestionID } from "../../src/question/schema"
 import { tmpdir } from "../fixture/fixture"
 import { SessionID } from "../../src/session/schema"
 
+/** Reject all pending questions so dangling Deferred fibers don't hang the test. */
+async function rejectAll() {
+  const pending = await Question.list()
+  for (const req of pending) {
+    await Question.reject(req.id)
+  }
+}
+
 test("ask - returns pending promise", async () => {
   await using tmp = await tmpdir({ git: true })
   await Instance.provide({
@@ -24,6 +32,8 @@ test("ask - returns pending promise", async () => {
         ],
       })
       expect(promise).toBeInstanceOf(Promise)
+      await rejectAll()
+      await promise.catch(() => {})
     },
   })
 })
@@ -44,7 +54,7 @@ test("ask - adds to pending list", async () => {
         },
       ]
 
-      Question.ask({
+      const askPromise = Question.ask({
         sessionID: SessionID.make("ses_test"),
         questions,
       })
@@ -52,6 +62,8 @@ test("ask - adds to pending list", async () => {
       const pending = await Question.list()
       expect(pending.length).toBe(1)
       expect(pending[0].questions).toEqual(questions)
+      await rejectAll()
+      await askPromise.catch(() => {})
     },
   })
 })
@@ -98,7 +110,7 @@ test("reply - removes from pending list", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      Question.ask({
+      const askPromise = Question.ask({
         sessionID: SessionID.make("ses_test"),
         questions: [
           {
@@ -119,6 +131,7 @@ test("reply - removes from pending list", async () => {
         requestID: pending[0].id,
         answers: [["Option 1"]],
       })
+      await askPromise
 
       const pendingAfter = await Question.list()
       expect(pendingAfter.length).toBe(0)
@@ -262,7 +275,7 @@ test("list - returns all pending requests", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      Question.ask({
+      const p1 = Question.ask({
         sessionID: SessionID.make("ses_test1"),
         questions: [
           {
@@ -273,7 +286,7 @@ test("list - returns all pending requests", async () => {
         ],
       })
 
-      Question.ask({
+      const p2 = Question.ask({
         sessionID: SessionID.make("ses_test2"),
         questions: [
           {
@@ -286,6 +299,9 @@ test("list - returns all pending requests", async () => {
 
       const pending = await Question.list()
       expect(pending.length).toBe(2)
+      await rejectAll()
+      p1.catch(() => {})
+      p2.catch(() => {})
     },
   })
 })

+ 22 - 29
packages/opencode/test/util/instance-state.test.ts

@@ -5,7 +5,7 @@ import { Instance } from "../../src/project/instance"
 import { InstanceState } from "../../src/util/instance-state"
 import { tmpdir } from "../fixture/fixture"
 
-async function access<A, E>(state: InstanceState.State<A, E>, dir: string) {
+async function access<A, E>(state: InstanceState<A, E>, dir: string) {
   return Instance.provide({
     directory: dir,
     fn: () => Effect.runPromise(InstanceState.get(state)),
@@ -23,9 +23,7 @@ test("InstanceState caches values for the same instance", async () => {
   await Effect.runPromise(
     Effect.scoped(
       Effect.gen(function* () {
-        const state = yield* InstanceState.make({
-          lookup: () => Effect.sync(() => ({ n: ++n })),
-        })
+        const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n })))
 
         const a = yield* Effect.promise(() => access(state, tmp.path))
         const b = yield* Effect.promise(() => access(state, tmp.path))
@@ -45,9 +43,7 @@ test("InstanceState isolates values by directory", async () => {
   await Effect.runPromise(
     Effect.scoped(
       Effect.gen(function* () {
-        const state = yield* InstanceState.make({
-          lookup: (dir) => Effect.sync(() => ({ dir, n: ++n })),
-        })
+        const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n })))
 
         const x = yield* Effect.promise(() => access(state, a.path))
         const y = yield* Effect.promise(() => access(state, b.path))
@@ -69,13 +65,12 @@ test("InstanceState is disposed on instance reload", async () => {
   await Effect.runPromise(
     Effect.scoped(
       Effect.gen(function* () {
-        const state = yield* InstanceState.make({
-          lookup: () => Effect.sync(() => ({ n: ++n })),
-          release: (value) =>
-            Effect.sync(() => {
-              seen.push(String(value.n))
-            }),
-        })
+        const state = yield* InstanceState.make(() =>
+          Effect.acquireRelease(
+            Effect.sync(() => ({ n: ++n })),
+            (value) => Effect.sync(() => { seen.push(String(value.n)) }),
+          ),
+        )
 
         const a = yield* Effect.promise(() => access(state, tmp.path))
         yield* Effect.promise(() => Instance.reload({ directory: tmp.path }))
@@ -96,13 +91,12 @@ test("InstanceState is disposed on disposeAll", async () => {
   await Effect.runPromise(
     Effect.scoped(
       Effect.gen(function* () {
-        const state = yield* InstanceState.make({
-          lookup: (dir) => Effect.sync(() => ({ dir })),
-          release: (value) =>
-            Effect.sync(() => {
-              seen.push(value.dir)
-            }),
-        })
+        const state = yield* InstanceState.make((dir) =>
+          Effect.acquireRelease(
+            Effect.sync(() => ({ dir })),
+            (value) => Effect.sync(() => { seen.push(value.dir) }),
+          ),
+        )
 
         yield* Effect.promise(() => access(state, a.path))
         yield* Effect.promise(() => access(state, b.path))
@@ -121,14 +115,13 @@ test("InstanceState dedupes concurrent lookups for the same directory", async ()
   await Effect.runPromise(
     Effect.scoped(
       Effect.gen(function* () {
-        const state = yield* InstanceState.make({
-          lookup: () =>
-            Effect.promise(async () => {
-              n += 1
-              await Bun.sleep(10)
-              return { n }
-            }),
-        })
+        const state = yield* InstanceState.make(() =>
+          Effect.promise(async () => {
+            n += 1
+            await Bun.sleep(10)
+            return { n }
+          }),
+        )
 
         const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)]))
         expect(a).toBe(b)