瀏覽代碼

feat: unwrap effect namespaces to flat exports + barrel (#22745)

Kit Langton 17 小時之前
父節點
當前提交
d4cfbd020d

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

@@ -1,6 +1,6 @@
 import { Layer, ManagedRuntime } from "effect"
 import { attach, memoMap } from "./run-service"
-import { Observability } from "./observability"
+import { Observability } from "."
 
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { Bus } from "@/bus"

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

@@ -10,7 +10,7 @@ import { File } from "@/file"
 import { Vcs } from "@/project"
 import { Snapshot } from "@/snapshot"
 import { Bus } from "@/bus"
-import { Observability } from "./observability"
+import { Observability } from "."
 
 export const BootstrapLayer = Layer.mergeAll(
   Plugin.defaultLayer,

+ 3 - 0
packages/opencode/src/effect/index.ts

@@ -1,2 +1,5 @@
 export * as InstanceState from "./instance-state"
 export * as EffectBridge from "./bridge"
+export * as Runner from "./runner"
+export * as Observability from "./observability"
+export * as EffectLogger from "./logger"

+ 1 - 1
packages/opencode/src/effect/instance-state.ts

@@ -1,5 +1,5 @@
 import { Effect, Fiber, ScopedCache, Scope, Context } from "effect"
-import { EffectLogger } from "@/effect/logger"
+import { EffectLogger } from "@/effect"
 import { Instance, type InstanceContext } from "@/project/instance"
 import { LocalContext } from "@/util"
 import { InstanceRef, WorkspaceRef } from "./instance-ref"

+ 53 - 55
packages/opencode/src/effect/logger.ts

@@ -1,67 +1,65 @@
 import { Cause, Effect, Logger, References } from "effect"
 import { Log } from "@/util"
 
-export namespace EffectLogger {
-  type Fields = Record<string, unknown>
+type Fields = Record<string, unknown>
 
-  export interface Handle {
-    readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
-    readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
-    readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
-    readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
-    readonly with: (extra: Fields) => Handle
-  }
+export interface Handle {
+  readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
+  readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
+  readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
+  readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
+  readonly with: (extra: Fields) => Handle
+}
 
-  const clean = (input?: Fields): Fields =>
-    Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null))
+const clean = (input?: Fields): Fields =>
+  Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null))
 
-  const text = (input: unknown): string => {
-    if (Array.isArray(input)) return input.map((item) => String(item)).join(" ")
-    return input === undefined ? "" : String(input)
-  }
+const text = (input: unknown): string => {
+  if (Array.isArray(input)) return input.map((item) => String(item)).join(" ")
+  return input === undefined ? "" : String(input)
+}
 
-  const call = (run: (msg?: unknown) => Effect.Effect<void>, base: Fields, msg?: unknown, extra?: Fields) => {
-    const ann = clean({ ...base, ...extra })
-    const fx = run(msg)
-    return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx
-  }
+const call = (run: (msg?: unknown) => Effect.Effect<void>, base: Fields, msg?: unknown, extra?: Fields) => {
+  const ann = clean({ ...base, ...extra })
+  const fx = run(msg)
+  return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx
+}
 
-  export const logger = Logger.make((opts) => {
-    const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations))
-    const now = opts.date.getTime()
-    for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) {
-      extra[`logSpan.${key}`] = `${now - start}ms`
-    }
-    if (opts.cause.reasons.length > 0) {
-      extra.cause = Cause.pretty(opts.cause)
-    }
+export const logger = Logger.make((opts) => {
+  const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations))
+  const now = opts.date.getTime()
+  for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) {
+    extra[`logSpan.${key}`] = `${now - start}ms`
+  }
+  if (opts.cause.reasons.length > 0) {
+    extra.cause = Cause.pretty(opts.cause)
+  }
 
-    const svc = typeof extra.service === "string" ? extra.service : undefined
-    if (svc) delete extra.service
-    const log = svc ? Log.create({ service: svc }) : Log.Default
-    const msg = text(opts.message)
+  const svc = typeof extra.service === "string" ? extra.service : undefined
+  if (svc) delete extra.service
+  const log = svc ? Log.create({ service: svc }) : Log.Default
+  const msg = text(opts.message)
 
-    switch (opts.logLevel) {
-      case "Trace":
-      case "Debug":
-        return log.debug(msg, extra)
-      case "Warn":
-        return log.warn(msg, extra)
-      case "Error":
-      case "Fatal":
-        return log.error(msg, extra)
-      default:
-        return log.info(msg, extra)
-    }
-  })
+  switch (opts.logLevel) {
+    case "Trace":
+    case "Debug":
+      return log.debug(msg, extra)
+    case "Warn":
+      return log.warn(msg, extra)
+    case "Error":
+    case "Fatal":
+      return log.error(msg, extra)
+    default:
+      return log.info(msg, extra)
+  }
+})
 
-  export const layer = Logger.layer([logger], { mergeWithExisting: false })
+export const layer = Logger.layer([logger], { mergeWithExisting: false })
 
-  export const create = (base: Fields = {}): Handle => ({
-    debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra),
-    info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra),
-    warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra),
-    error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra),
-    with: (extra) => create({ ...base, ...extra }),
-  })
-}
+export const create = (base: Fields = {}): Handle => ({
+  debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra),
+  info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra),
+  warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra),
+  error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra),
+  with: (extra) => create({ ...base, ...extra }),
+})

+ 65 - 67
packages/opencode/src/effect/observability.ts

@@ -1,80 +1,78 @@
 import { Effect, Layer, Logger } from "effect"
 import { FetchHttpClient } from "effect/unstable/http"
 import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
-import { EffectLogger } from "@/effect/logger"
+import { EffectLogger } from "@/effect"
 import { Flag } from "@/flag/flag"
 import { CHANNEL, VERSION } from "@/installation/meta"
 
-export namespace Observability {
-  const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
-  export const enabled = !!base
+const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
+export const enabled = !!base
 
-  const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
-    ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
-        (acc, x) => {
-          const [key, ...value] = x.split("=")
-          acc[key] = value.join("=")
-          return acc
-        },
-        {} as Record<string, string>,
-      )
-    : undefined
+const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
+  ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
+      (acc, x) => {
+        const [key, ...value] = x.split("=")
+        acc[key] = value.join("=")
+        return acc
+      },
+      {} as Record<string, string>,
+    )
+  : undefined
 
-  const resource = {
-    serviceName: "opencode",
-    serviceVersion: VERSION,
-    attributes: {
-      "deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
-      "opencode.client": Flag.OPENCODE_CLIENT,
-    },
-  }
-
-  const logs = Logger.layer(
-    [
-      EffectLogger.logger,
-      OtlpLogger.make({
-        url: `${base}/v1/logs`,
-        resource,
-        headers,
-      }),
-    ],
-    { mergeWithExisting: false },
-  ).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
+const resource = {
+  serviceName: "opencode",
+  serviceVersion: VERSION,
+  attributes: {
+    "deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
+    "opencode.client": Flag.OPENCODE_CLIENT,
+  },
+}
 
-  const traces = async () => {
-    const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
-    const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
-    const SdkBase = await import("@opentelemetry/sdk-trace-base")
+const logs = Logger.layer(
+  [
+    EffectLogger.logger,
+    OtlpLogger.make({
+      url: `${base}/v1/logs`,
+      resource,
+      headers,
+    }),
+  ],
+  { mergeWithExisting: false },
+).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
 
-    // @effect/opentelemetry creates a NodeTracerProvider but never calls
-    // register(), so the global @opentelemetry/api context manager stays
-    // as the no-op default. Non-Effect code (like the AI SDK) that calls
-    // tracer.startActiveSpan() relies on context.active() to find the
-    // parent span — without a real context manager every span starts a
-    // new trace. Registering AsyncLocalStorageContextManager fixes this.
-    const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
-    const { context } = await import("@opentelemetry/api")
-    const mgr = new AsyncLocalStorageContextManager()
-    mgr.enable()
-    context.setGlobalContextManager(mgr)
+const traces = async () => {
+  const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
+  const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
+  const SdkBase = await import("@opentelemetry/sdk-trace-base")
 
-    return NodeSdk.layer(() => ({
-      resource,
-      spanProcessor: new SdkBase.BatchSpanProcessor(
-        new OTLP.OTLPTraceExporter({
-          url: `${base}/v1/traces`,
-          headers,
-        }),
-      ),
-    }))
-  }
+  // @effect/opentelemetry creates a NodeTracerProvider but never calls
+  // register(), so the global @opentelemetry/api context manager stays
+  // as the no-op default. Non-Effect code (like the AI SDK) that calls
+  // tracer.startActiveSpan() relies on context.active() to find the
+  // parent span — without a real context manager every span starts a
+  // new trace. Registering AsyncLocalStorageContextManager fixes this.
+  const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
+  const { context } = await import("@opentelemetry/api")
+  const mgr = new AsyncLocalStorageContextManager()
+  mgr.enable()
+  context.setGlobalContextManager(mgr)
 
-  export const layer = !base
-    ? EffectLogger.layer
-    : Layer.unwrap(
-        Effect.gen(function* () {
-          const trace = yield* Effect.promise(traces)
-          return Layer.mergeAll(trace, logs)
-        }),
-      )
+  return NodeSdk.layer(() => ({
+    resource,
+    spanProcessor: new SdkBase.BatchSpanProcessor(
+      new OTLP.OTLPTraceExporter({
+        url: `${base}/v1/traces`,
+        headers,
+      }),
+    ),
+  }))
 }
+
+export const layer = !base
+  ? EffectLogger.layer
+  : Layer.unwrap(
+      Effect.gen(function* () {
+        const trace = yield* Effect.promise(traces)
+        return Layer.mergeAll(trace, logs)
+      }),
+    )

+ 1 - 1
packages/opencode/src/effect/run-service.ts

@@ -3,7 +3,7 @@ import * as Context from "effect/Context"
 import { Instance } from "@/project/instance"
 import { LocalContext } from "@/util"
 import { InstanceRef, WorkspaceRef } from "./instance-ref"
-import { Observability } from "./observability"
+import { Observability } from "."
 import { WorkspaceContext } from "@/control-plane/workspace-context"
 import type { InstanceContext } from "@/project/instance"
 

+ 184 - 186
packages/opencode/src/effect/runner.ts

@@ -1,208 +1,206 @@
 import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope, SynchronizedRef } from "effect"
 
 export interface Runner<A, E = never> {
-  readonly state: Runner.State<A, E>
+  readonly state: State<A, E>
   readonly busy: boolean
   readonly ensureRunning: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
   readonly startShell: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
   readonly cancel: Effect.Effect<void>
 }
 
-export namespace Runner {
-  export class Cancelled extends Schema.TaggedErrorClass<Cancelled>()("RunnerCancelled", {}) {}
+export class Cancelled extends Schema.TaggedErrorClass<Cancelled>()("RunnerCancelled", {}) {}
 
-  interface RunHandle<A, E> {
-    id: number
-    done: Deferred.Deferred<A, E | Cancelled>
-    fiber: Fiber.Fiber<A, E>
-  }
-
-  interface ShellHandle<A, E> {
-    id: number
-    fiber: Fiber.Fiber<A, E>
-  }
+interface RunHandle<A, E> {
+  id: number
+  done: Deferred.Deferred<A, E | Cancelled>
+  fiber: Fiber.Fiber<A, E>
+}
 
-  interface PendingHandle<A, E> {
-    id: number
-    done: Deferred.Deferred<A, E | Cancelled>
-    work: Effect.Effect<A, E>
-  }
+interface ShellHandle<A, E> {
+  id: number
+  fiber: Fiber.Fiber<A, E>
+}
 
-  export type State<A, E> =
-    | { readonly _tag: "Idle" }
-    | { readonly _tag: "Running"; readonly run: RunHandle<A, E> }
-    | { readonly _tag: "Shell"; readonly shell: ShellHandle<A, E> }
-    | { readonly _tag: "ShellThenRun"; readonly shell: ShellHandle<A, E>; readonly run: PendingHandle<A, E> }
-
-  export const make = <A, E = never>(
-    scope: Scope.Scope,
-    opts?: {
-      onIdle?: Effect.Effect<void>
-      onBusy?: Effect.Effect<void>
-      onInterrupt?: Effect.Effect<A, E>
-      busy?: () => never
-    },
-  ): Runner<A, E> => {
-    const ref = SynchronizedRef.makeUnsafe<State<A, E>>({ _tag: "Idle" })
-    const idle = opts?.onIdle ?? Effect.void
-    const busy = opts?.onBusy ?? Effect.void
-    const onInterrupt = opts?.onInterrupt
-    let ids = 0
-
-    const state = () => SynchronizedRef.getUnsafe(ref)
-    const next = () => {
-      ids += 1
-      return ids
-    }
+interface PendingHandle<A, E> {
+  id: number
+  done: Deferred.Deferred<A, E | Cancelled>
+  work: Effect.Effect<A, E>
+}
 
-    const complete = (done: Deferred.Deferred<A, E | Cancelled>, exit: Exit.Exit<A, E>) =>
-      Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)
-        ? Deferred.fail(done, new Cancelled()).pipe(Effect.asVoid)
-        : Deferred.done(done, exit).pipe(Effect.asVoid)
-
-    const idleIfCurrent = () =>
-      SynchronizedRef.modify(ref, (st) => [st._tag === "Idle" ? idle : Effect.void, st] as const).pipe(Effect.flatten)
-
-    const finishRun = (id: number, done: Deferred.Deferred<A, E | Cancelled>, exit: Exit.Exit<A, E>) =>
-      SynchronizedRef.modify(
-        ref,
-        (st) =>
-          [
-            Effect.gen(function* () {
-              if (st._tag === "Running" && st.run.id === id) yield* idle
-              yield* complete(done, exit)
-            }),
-            st._tag === "Running" && st.run.id === id ? ({ _tag: "Idle" } as const) : st,
-          ] as const,
-      ).pipe(Effect.flatten)
+export type State<A, E> =
+  | { readonly _tag: "Idle" }
+  | { readonly _tag: "Running"; readonly run: RunHandle<A, E> }
+  | { readonly _tag: "Shell"; readonly shell: ShellHandle<A, E> }
+  | { readonly _tag: "ShellThenRun"; readonly shell: ShellHandle<A, E>; readonly run: PendingHandle<A, E> }
+
+export const make = <A, E = never>(
+  scope: Scope.Scope,
+  opts?: {
+    onIdle?: Effect.Effect<void>
+    onBusy?: Effect.Effect<void>
+    onInterrupt?: Effect.Effect<A, E>
+    busy?: () => never
+  },
+): Runner<A, E> => {
+  const ref = SynchronizedRef.makeUnsafe<State<A, E>>({ _tag: "Idle" })
+  const idle = opts?.onIdle ?? Effect.void
+  const busy = opts?.onBusy ?? Effect.void
+  const onInterrupt = opts?.onInterrupt
+  let ids = 0
+
+  const state = () => SynchronizedRef.getUnsafe(ref)
+  const next = () => {
+    ids += 1
+    return ids
+  }
 
-    const startRun = (work: Effect.Effect<A, E>, done: Deferred.Deferred<A, E | Cancelled>) =>
-      Effect.gen(function* () {
-        const id = next()
-        const fiber = yield* work.pipe(
-          Effect.onExit((exit) => finishRun(id, done, exit)),
-          Effect.forkIn(scope),
-        )
-        return { id, done, fiber } satisfies RunHandle<A, E>
-      })
-
-    const finishShell = (id: number) =>
-      SynchronizedRef.modifyEffect(
-        ref,
-        Effect.fnUntraced(function* (st) {
-          if (st._tag === "Shell" && st.shell.id === id) return [idle, { _tag: "Idle" }] as const
-          if (st._tag === "ShellThenRun" && st.shell.id === id) {
-            const run = yield* startRun(st.run.work, st.run.done)
-            return [Effect.void, { _tag: "Running", run }] as const
-          }
-          return [Effect.void, st] as const
-        }),
-      ).pipe(Effect.flatten)
-
-    const stopShell = (shell: ShellHandle<A, E>) => Fiber.interrupt(shell.fiber)
-
-    const ensureRunning = (work: Effect.Effect<A, E>) =>
-      SynchronizedRef.modifyEffect(
-        ref,
-        Effect.fnUntraced(function* (st) {
-          switch (st._tag) {
-            case "Running":
-            case "ShellThenRun":
-              return [Deferred.await(st.run.done), st] as const
-            case "Shell": {
-              const run = {
-                id: next(),
-                done: yield* Deferred.make<A, E | Cancelled>(),
-                work,
-              } satisfies PendingHandle<A, E>
-              return [Deferred.await(run.done), { _tag: "ShellThenRun", shell: st.shell, run }] as const
-            }
-            case "Idle": {
-              const done = yield* Deferred.make<A, E | Cancelled>()
-              const run = yield* startRun(work, done)
-              return [Deferred.await(done), { _tag: "Running", run }] as const
-            }
-          }
-        }),
-      ).pipe(
-        Effect.flatten,
-        Effect.catch(
-          (e): Effect.Effect<A, E> => (e instanceof Cancelled ? (onInterrupt ?? Effect.die(e)) : Effect.fail(e as E)),
-        ),
+  const complete = (done: Deferred.Deferred<A, E | Cancelled>, exit: Exit.Exit<A, E>) =>
+    Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)
+      ? Deferred.fail(done, new Cancelled()).pipe(Effect.asVoid)
+      : Deferred.done(done, exit).pipe(Effect.asVoid)
+
+  const idleIfCurrent = () =>
+    SynchronizedRef.modify(ref, (st) => [st._tag === "Idle" ? idle : Effect.void, st] as const).pipe(Effect.flatten)
+
+  const finishRun = (id: number, done: Deferred.Deferred<A, E | Cancelled>, exit: Exit.Exit<A, E>) =>
+    SynchronizedRef.modify(
+      ref,
+      (st) =>
+        [
+          Effect.gen(function* () {
+            if (st._tag === "Running" && st.run.id === id) yield* idle
+            yield* complete(done, exit)
+          }),
+          st._tag === "Running" && st.run.id === id ? ({ _tag: "Idle" } as const) : st,
+        ] as const,
+    ).pipe(Effect.flatten)
+
+  const startRun = (work: Effect.Effect<A, E>, done: Deferred.Deferred<A, E | Cancelled>) =>
+    Effect.gen(function* () {
+      const id = next()
+      const fiber = yield* work.pipe(
+        Effect.onExit((exit) => finishRun(id, done, exit)),
+        Effect.forkIn(scope),
       )
-
-    const startShell = (work: Effect.Effect<A, E>) =>
-      SynchronizedRef.modifyEffect(
-        ref,
-        Effect.fnUntraced(function* (st) {
-          if (st._tag !== "Idle") {
-            return [
-              Effect.sync(() => {
-                if (opts?.busy) opts.busy()
-                throw new Error("Runner is busy")
-              }),
-              st,
-            ] as const
+      return { id, done, fiber } satisfies RunHandle<A, E>
+    })
+
+  const finishShell = (id: number) =>
+    SynchronizedRef.modifyEffect(
+      ref,
+      Effect.fnUntraced(function* (st) {
+        if (st._tag === "Shell" && st.shell.id === id) return [idle, { _tag: "Idle" }] as const
+        if (st._tag === "ShellThenRun" && st.shell.id === id) {
+          const run = yield* startRun(st.run.work, st.run.done)
+          return [Effect.void, { _tag: "Running", run }] as const
+        }
+        return [Effect.void, st] as const
+      }),
+    ).pipe(Effect.flatten)
+
+  const stopShell = (shell: ShellHandle<A, E>) => Fiber.interrupt(shell.fiber)
+
+  const ensureRunning = (work: Effect.Effect<A, E>) =>
+    SynchronizedRef.modifyEffect(
+      ref,
+      Effect.fnUntraced(function* (st) {
+        switch (st._tag) {
+          case "Running":
+          case "ShellThenRun":
+            return [Deferred.await(st.run.done), st] as const
+          case "Shell": {
+            const run = {
+              id: next(),
+              done: yield* Deferred.make<A, E | Cancelled>(),
+              work,
+            } satisfies PendingHandle<A, E>
+            return [Deferred.await(run.done), { _tag: "ShellThenRun", shell: st.shell, run }] as const
           }
-          yield* busy
-          const id = next()
-          const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
-          const shell = { id, fiber } satisfies ShellHandle<A, E>
-          return [
-            Effect.gen(function* () {
-              const exit = yield* Fiber.await(fiber)
-              if (Exit.isSuccess(exit)) return exit.value
-              if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt
-              return yield* Effect.failCause(exit.cause)
-            }),
-            { _tag: "Shell", shell },
-          ] as const
-        }),
-      ).pipe(Effect.flatten)
-
-    const cancel = SynchronizedRef.modify(ref, (st) => {
-      switch (st._tag) {
-        case "Idle":
-          return [Effect.void, st] as const
-        case "Running":
-          return [
-            Effect.gen(function* () {
-              yield* Fiber.interrupt(st.run.fiber)
-              yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid)
-              yield* idleIfCurrent()
-            }),
-            { _tag: "Idle" } as const,
-          ] as const
-        case "Shell":
-          return [
-            Effect.gen(function* () {
-              yield* stopShell(st.shell)
-              yield* idleIfCurrent()
-            }),
-            { _tag: "Idle" } as const,
-          ] as const
-        case "ShellThenRun":
+          case "Idle": {
+            const done = yield* Deferred.make<A, E | Cancelled>()
+            const run = yield* startRun(work, done)
+            return [Deferred.await(done), { _tag: "Running", run }] as const
+          }
+        }
+      }),
+    ).pipe(
+      Effect.flatten,
+      Effect.catch(
+        (e): Effect.Effect<A, E> => (e instanceof Cancelled ? (onInterrupt ?? Effect.die(e)) : Effect.fail(e as E)),
+      ),
+    )
+
+  const startShell = (work: Effect.Effect<A, E>) =>
+    SynchronizedRef.modifyEffect(
+      ref,
+      Effect.fnUntraced(function* (st) {
+        if (st._tag !== "Idle") {
           return [
-            Effect.gen(function* () {
-              yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid)
-              yield* stopShell(st.shell)
-              yield* idleIfCurrent()
+            Effect.sync(() => {
+              if (opts?.busy) opts.busy()
+              throw new Error("Runner is busy")
             }),
-            { _tag: "Idle" } as const,
+            st,
           ] as const
-      }
-    }).pipe(Effect.flatten)
-
-    return {
-      get state() {
-        return state()
-      },
-      get busy() {
-        return state()._tag !== "Idle"
-      },
-      ensureRunning,
-      startShell,
-      cancel,
+        }
+        yield* busy
+        const id = next()
+        const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
+        const shell = { id, fiber } satisfies ShellHandle<A, E>
+        return [
+          Effect.gen(function* () {
+            const exit = yield* Fiber.await(fiber)
+            if (Exit.isSuccess(exit)) return exit.value
+            if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt
+            return yield* Effect.failCause(exit.cause)
+          }),
+          { _tag: "Shell", shell },
+        ] as const
+      }),
+    ).pipe(Effect.flatten)
+
+  const cancel = SynchronizedRef.modify(ref, (st) => {
+    switch (st._tag) {
+      case "Idle":
+        return [Effect.void, st] as const
+      case "Running":
+        return [
+          Effect.gen(function* () {
+            yield* Fiber.interrupt(st.run.fiber)
+            yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid)
+            yield* idleIfCurrent()
+          }),
+          { _tag: "Idle" } as const,
+        ] as const
+      case "Shell":
+        return [
+          Effect.gen(function* () {
+            yield* stopShell(st.shell)
+            yield* idleIfCurrent()
+          }),
+          { _tag: "Idle" } as const,
+        ] as const
+      case "ShellThenRun":
+        return [
+          Effect.gen(function* () {
+            yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid)
+            yield* stopShell(st.shell)
+            yield* idleIfCurrent()
+          }),
+          { _tag: "Idle" } as const,
+        ] as const
     }
+  }).pipe(Effect.flatten)
+
+  return {
+    get state() {
+      return state()
+    },
+    get busy() {
+      return state()._tag !== "Idle"
+    },
+    ensureRunning,
+    startShell,
+    cancel,
   }
 }

+ 1 - 1
packages/opencode/src/server/instance/httpapi/server.ts

@@ -3,7 +3,7 @@ import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/unsta
 import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
 import { AppRuntime } from "@/effect/app-runtime"
 import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
-import { Observability } from "@/effect/observability"
+import { Observability } from "@/effect"
 import { memoMap } from "@/effect/run-service"
 import { Flag } from "@/flag/flag"
 import { InstanceBootstrap } from "@/project/bootstrap"

+ 1 - 1
packages/opencode/src/session/message-v2.ts

@@ -15,7 +15,7 @@ import type { SystemError } from "bun"
 import type { Provider } from "@/provider"
 import { ModelID, ProviderID } from "@/provider/schema"
 import { Effect } from "effect"
-import { EffectLogger } from "@/effect/logger"
+import { EffectLogger } from "@/effect"
 
 /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
 interface FetchDecompressionError extends Error {

+ 1 - 1
packages/opencode/src/session/prompt.ts

@@ -44,7 +44,7 @@ import { Truncate } from "@/tool/truncate"
 import { decodeDataUrl } from "@/util/data-url"
 import { Process } from "@/util"
 import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect"
-import { EffectLogger } from "@/effect/logger"
+import { EffectLogger } from "@/effect"
 import { InstanceState } from "@/effect"
 import { TaskTool, type TaskPromptOps } from "@/tool/task"
 import { SessionRunState } from "./run-state"

+ 2 - 2
packages/opencode/src/session/run-state.ts

@@ -1,5 +1,5 @@
 import { InstanceState } from "@/effect"
-import { Runner } from "@/effect/runner"
+import { Runner } from "@/effect"
 import { Effect, Layer, Scope, Context } from "effect"
 import { Session } from "."
 import { MessageV2 } from "./message-v2"
@@ -32,7 +32,7 @@ export namespace SessionRunState {
       const state = yield* InstanceState.make(
         Effect.fn("SessionRunState.state")(function* () {
           const scope = yield* Scope.Scope
-          const runners = new Map<SessionID, Runner<MessageV2.WithParts>>()
+          const runners = new Map<SessionID, Runner.Runner<MessageV2.WithParts>>()
           yield* Effect.addFinalizer(
             Effect.fnUntraced(function* () {
               yield* Effect.forEach(runners.values(), (runner) => runner.cancel, {

+ 1 - 1
packages/opencode/src/tool/external-directory.ts

@@ -1,6 +1,6 @@
 import path from "path"
 import { Effect } from "effect"
-import { EffectLogger } from "@/effect/logger"
+import { EffectLogger } from "@/effect"
 import { InstanceState } from "@/effect"
 import type { Tool } from "./tool"
 import { Instance } from "../project/instance"

+ 1 - 1
packages/opencode/src/tool/skill.ts

@@ -3,7 +3,7 @@ import { pathToFileURL } from "url"
 import z from "zod"
 import { Effect } from "effect"
 import * as Stream from "effect/Stream"
-import { EffectLogger } from "@/effect/logger"
+import { EffectLogger } from "@/effect"
 import { Ripgrep } from "../file/ripgrep"
 import { Skill } from "../skill"
 import { Tool } from "./tool"

+ 1 - 1
packages/opencode/test/effect/app-runtime-logger.test.ts

@@ -3,7 +3,7 @@ import { Context, Effect, Layer, Logger } from "effect"
 import { AppRuntime } from "../../src/effect/app-runtime"
 import { EffectBridge } from "../../src/effect"
 import { InstanceRef } from "../../src/effect/instance-ref"
-import { EffectLogger } from "../../src/effect/logger"
+import { EffectLogger } from "../../src/effect"
 import { makeRuntime } from "../../src/effect/run-service"
 import { Instance } from "../../src/project/instance"
 import { tmpdir } from "../fixture/fixture"

+ 1 - 1
packages/opencode/test/effect/runner.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import { Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect"
-import { Runner } from "../../src/effect/runner"
+import { Runner } from "../../src/effect"
 import { it } from "../lib/effect"
 
 describe("Runner", () => {