diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 6f4508cb0b0a..b67a6a2fdb9d 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -784,26 +784,27 @@ export const RunCommand = effectCmd({ if (result.error) { if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error)) process.exitCode = 1 - return - } - await finish() - return - } - - const model = pick(args.model) - const result = await client.session.prompt({ - sessionID, - agent, - model, - variant: args.variant, - parts: [...files, { type: "text", text: message }], - }) - if (result.error) { - if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error)) - process.exitCode = 1 - return + } else await finish() + } else { + const model = pick(args.model) + const result = await client.session.prompt({ + sessionID, + agent, + model, + variant: args.variant, + parts: [...files, { type: "text", text: message }], + }) + if (result.error) { + if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error)) + process.exitCode = 1 + } else await finish() } - await finish() + // The server keeps handles alive (db connections, sockets, timers), + // so a non-interactive run would otherwise hang with an idle event + // loop instead of terminating. Exit explicitly once it finishes, + // matching the error paths above. Attach mode drives a separate + // server, so leave it untouched. + if (!args.attach) process.exit(process.exitCode ?? 0) return } diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index 00d2e64b377e..46ef8adf0e9d 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -58,6 +58,23 @@ describe("opencode run (non-interactive subprocess)", () => { 60_000, ) + // Regression for #32335: after a successful prompt the process used to stay + // alive with an idle event loop (server db connections, sockets and timers + // keep handles open), so scheduled `opencode run` jobs leaked and piled up. + // A successful run must terminate promptly on its own; a hang would expire + // the harness timeout and surface as a signal-killed failure instead. + cliIt.concurrent( + "exits promptly after a successful prompt instead of hanging (regression for #32335)", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.text("done") + const result = yield* opencode.run("say hi", { timeoutMs: 20_000 }) + opencode.expectExit(result, 0) + expect(result.durationMs).toBeLessThan(20_000) + }), + 30_000, + ) + // --format json puts one JSON object per line on stdout for each emitted // event. Consumers (CI scripts, tooling) parse this stream. Asserts the // shape so a future event-emit change has to update this expectation.