From 1e00c59a530e50958f0010286ad7bd68006e82a3 Mon Sep 17 00:00:00 2001 From: bcode Date: Sat, 9 May 2026 22:27:02 +0000 Subject: [PATCH] fix(laminar): drain OTel processors before process.exit() to stop losing final agent spans Eval traces were missing the final LLM span on ~27% of runs (gemini-3-flash 76%, glm-5.1 34%, mimo 14%, gpt-5.5 1%, claude 0%). Root cause is a process-exit race: the plugin event hook in packages/opencode/src/plugin/index.ts:249 is invoked fire-and-forget (`void hook["event"]?.(...)`), so the bcode-laminar `session.idle` handler's `processor.forceFlush()` Promise is discarded, and the unconditional `process.exit()` in the top-level `finally` (index.ts:252) kills in-flight gRPC exports. Model-dependence comes from emit shape: tool-only-then-final-text models (glm-5.1, gemini-3-flash) make one extra tool-less LLM round at the end whose lone `ai.streamText.doStream` span ends 50-200ms before idle and is the freshest unflushed thing in the BatchSpanProcessor queue at exit. Tool-call+ text-in-same-step models (claude-opus, gpt-5.5) fold the final answer into a step that ended seconds earlier and was already flushed in a prior batch. Fix: in the top-level `finally` of index.ts, before `process.exit()`, fetch the global OTel TracerProvider via `@opentelemetry/api`, duck-check `forceFlush`, and race it against a 3 s timeout so a wedged exporter cannot hang bcode on exit. Generic to any OTel-based plugin. Does not touch the deeper bug (the fire-and-forget `event` hook); that is the proper upstream fix to anomalyco/opencode. --- packages/opencode/src/index.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 272df358a..1134c9a0c 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -45,6 +45,7 @@ import { PluginCommand } from "./cli/cmd/plug" import { Heap } from "./cli/heap" import { drizzle } from "drizzle-orm/bun-sqlite" import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" +import { trace } from "@opentelemetry/api" const processMetadata = ensureProcessMetadata("main") @@ -245,6 +246,20 @@ try { } process.exitCode = 1 } finally { + // Drain any registered OTel span processors (e.g. bcode-laminar) before + // exiting. The plugin's `session.idle` event handler is invoked + // fire-and-forget (`packages/opencode/src/plugin/index.ts:249`), so its + // `processor.forceFlush()` Promise was never awaited — without this drain, + // `process.exit()` kills any in-flight gRPC export and the final agent + // span is lost. Bounded with a 3 s race so a wedged exporter cannot hang + // bcode on exit. Generic to any OTel-based plugin, not laminar-specific. + const provider = trace.getTracerProvider() as { forceFlush?: () => Promise } + if (provider.forceFlush) { + await Promise.race([ + provider.forceFlush().catch(() => {}), + new Promise((resolve) => setTimeout(resolve, 3000)), + ]) + } // Some subprocesses don't react properly to SIGTERM and similar signals. // Most notably, some docker-container-based MCP servers don't handle such signals unless // run using `docker run --init`.