diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 3927f615a0..a1798667b3 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -123,6 +123,20 @@ async function toolError(part: ToolPart) { } } +async function embeddedServer() { + const { Server } = await import("@/server/server") + const server = Server.Default().app + const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + const headers = new Headers(request.headers) + const { ServerAuth } = await import("@/server/auth") + const auth = ServerAuth.header() + if (auth) headers.set("Authorization", auth) + return server.fetch(new Request(request, { headers })) + }) as typeof globalThis.fetch + return { fetch, dispose: server.dispose } +} + export const RunCommand = effectCmd({ command: "run [message..]", describe: "run opencode with a message", @@ -902,19 +916,12 @@ export const RunCommand = effectCmd({ if (interactive && !args.attach && !args.session && !args.continue) { const model = pick(args.model) const { runInteractiveLocalMode } = await import("./run/runtime") - const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { - const { Server } = await import("@/server/server") - const request = new Request(input, init) - const headers = new Headers(request.headers) - const auth = ServerAuth.header() - if (auth) headers.set("Authorization", auth) - return Server.Default().app.fetch(new Request(request, { headers })) - }) as typeof globalThis.fetch + const server = await embeddedServer() try { return await runInteractiveLocalMode({ directory: directory ?? root, - fetch: fetchFn, + fetch: server.fetch, resolveAgent: localAgent, session, share, @@ -932,6 +939,8 @@ export const RunCommand = effectCmd({ }) } catch (error) { dieInteractive(error) + } finally { + await server.dispose() } } @@ -940,20 +949,17 @@ export const RunCommand = effectCmd({ return await execute(sdk) } - const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { - const { Server } = await import("@/server/server") - const request = new Request(input, init) - const headers = new Headers(request.headers) - const auth = ServerAuth.header() - if (auth) headers.set("Authorization", auth) - return Server.Default().app.fetch(new Request(request, { headers })) - }) as typeof globalThis.fetch - const sdk = createOpencodeClient({ - baseUrl: "http://opencode.internal", - fetch: fetchFn, - directory, - }) - await execute(sdk) + const server = await embeddedServer() + try { + const sdk = createOpencodeClient({ + baseUrl: "http://opencode.internal", + fetch: server.fetch, + directory, + }) + await execute(sdk) + } finally { + await server.dispose() + } }) }), }) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index 9ac2bd3555..66475f84ad 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -60,6 +60,8 @@ interface EffectCmdOpts { * * Errors propagate to the existing top-level handler in `src/index.ts`; use * `fail("...")` for user-visible domain failures (clean exit, formatted message). + * The AppRuntime is disposed after the command finishes so its scoped resources + * are released without making the CLI entrypoint import the full application graph. * * Handlers are typically `Effect.fn("Cli.")(function*(args) { ... })`, * which adds a named tracing span per CLI invocation. Once all commands use @@ -74,23 +76,27 @@ export const effectCmd = (opts: EffectCmdOpts) => builder: opts.builder as never, async handler(rawArgs) { const { AppRuntime } = await import("@/effect/app-runtime") - // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. - const args = rawArgs as unknown as WithDoubleDash - const useInstance = typeof opts.instance === "function" ? opts.instance(args) : opts.instance !== false - if (!useInstance) { - await AppRuntime.runPromise(opts.handler(args)) - return - } - const { InstanceStore } = await import("@/project/instance-store") - const { InstanceRef } = await import("@/effect/instance-ref") - const directory = opts.directory?.(args) ?? process.cwd() - const { store, ctx } = await AppRuntime.runPromise( - InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))), - ) try { - await AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))) + // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. + const args = rawArgs as unknown as WithDoubleDash + const useInstance = typeof opts.instance === "function" ? opts.instance(args) : opts.instance !== false + if (!useInstance) { + await AppRuntime.runPromise(opts.handler(args)) + return + } + const { InstanceStore } = await import("@/project/instance-store") + const { InstanceRef } = await import("@/effect/instance-ref") + const directory = opts.directory?.(args) ?? process.cwd() + const { store, ctx } = await AppRuntime.runPromise( + InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))), + ) + try { + await AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))) + } finally { + await AppRuntime.runPromise(store.dispose(ctx)) + } } finally { - await AppRuntime.runPromise(store.dispose(ctx)) + await AppRuntime.dispose().catch(() => {}) } }, }) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 440b992c15..62f778b8ad 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -27,6 +27,7 @@ export type Listener = { type ServerApp = { fetch(request: Request): Response | Promise request(input: string | URL | Request, init?: RequestInit): Response | Promise + dispose(): Promise } type ListenOptions = CorsOptions & { @@ -54,12 +55,13 @@ class ListenerServerService extends Context.Service { - const handler = HttpApiApp.webHandler().handler + const web = HttpApiApp.webHandler() const app: ServerApp = { - fetch: (request: Request) => handler(request, HttpApiApp.context), + fetch: (request: Request) => web.handler(request, HttpApiApp.context), request(input, init) { return app.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) }, + dispose: web.dispose, } return { app } }) diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index bd5847e272..20a5971259 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -23,6 +23,35 @@ describe("opencode run (non-interactive subprocess)", () => { 60_000, ) + cliIt.concurrent( + "flushes server telemetry before exiting", + ({ llm, opencode }) => + Effect.gen(function* () { + const requests: string[] = [] + const collector = yield* Effect.acquireRelease( + Effect.sync(() => + Bun.serve({ + port: 0, + fetch(request) { + requests.push(new URL(request.url).pathname) + return new Response(null, { status: 200 }) + }, + }), + ), + (server) => Effect.sync(() => server.stop(true)), + ) + yield* llm.text("telemetry response") + + const result = yield* opencode.run("say hi", { + env: { OTEL_EXPORTER_OTLP_ENDPOINT: collector.url.toString().replace(/\/$/, "") }, + }) + + opencode.expectExit(result, 0) + expect(requests).toContain("/v1/traces") + }), + 60_000, + ) + cliIt.concurrent( "prints each completed text part in order around a tool continuation", ({ llm, opencode }) =>