Files
Michael Ramos a79e6b0efe fix(ai): stop leaking opencode serve processes (#1445)
* fix(ai): stop leaking opencode serve processes

Every server boot with the opencode CLI on PATH eagerly called the
provider's fetchModels() to fill the Ask AI dropdown, which spawned (or
attached to) an 'opencode serve' on the shared default port 4096. Dispose
only ran on the clean decision path, so Ctrl-C orphaned the child, and
every later session attached to the orphan and piled unevictable
per-directory instances into it (multi-GB over a day of normal use).

Three changes:
- Lazy start: opencode model discovery moves onto the same deferred
  provider initializer Codex uses. Nothing spawns until the user
  activates opencode in Ask AI (?activate= from the model picker, or the
  first opencode session). The picker still lists the provider with an
  empty model list pre-activation, exactly like Codex.
- Own server per process: spawn with port 0 (OS-assigned; the SDK reads
  the real URL from the child's listening line) and never attach to a
  server we did not spawn. An explicitly configured port is honored.
- Exit cleanup: a process 'exit' handler closes the spawned server
  (SIGINT/SIGTERM are routed through process.exit by the CLI), removed
  again on dispose. No SIGHUP listener, preserving nohup.

Both runtimes; regression tests mock the SDK so no real server spawns.

* fix(ai): close review findings on the opencode lifecycle

Independent review of the leak fix found two holes, both now closed and
regression-tested against the mocked SDK:

- A failure after the spawn (client construction) left the child running
  and its exit handler registered, and because the handler read
  this.server late instead of capturing its own server, a retry's second
  spawn made the first unreachable by any cleanup. doStart now captures
  the server in its handler closure and reaps child + handler on any
  post-spawn failure.
- dispose() during an in-flight spawn was a no-op the completing spawn
  then undid, resurrecting a disposed provider with a live child and a
  fresh exit handler. dispose() now bumps a start epoch; a spawn that
  completes past its epoch reaps its own server and rejects, and the
  provider remains restartable afterwards.

Also documents the OpenCode transport (per-process server, deferred
discovery) beside the Codex note in AGENTS.md.
2026-09-01 10:55:17 -07:00

133 lines
3.7 KiB
TypeScript

import {
createAIEndpoints,
createBestEffortOnce,
createProvider,
ProviderRegistry,
SessionManager,
type AIEndpoints,
type PiSDKConfig,
} from "@plannotator/ai";
import { resolveWindowsCommandShim } from "@plannotator/ai/providers/command-path";
export interface AIRuntime {
endpoints: AIEndpoints;
dispose: () => void;
}
export const AI_QUERY_ENDPOINT = "/api/ai/query";
interface CreateAIRuntimeOptions {
cwd?: string;
getCwd?: () => string;
}
export async function createAIRuntime(options: CreateAIRuntimeOptions = {}): Promise<AIRuntime> {
const cwd = options.cwd ?? process.cwd();
const registry = new ProviderRegistry();
const sessionManager = new SessionManager();
const modelDiscovery: Promise<void>[] = [];
const providerInitializers = new Map<string, () => Promise<void>>();
try {
await import("@plannotator/ai/providers/claude-agent-sdk");
const claudePath = Bun.which("claude");
const provider = await createProvider({
type: "claude-agent-sdk",
cwd,
...(claudePath && { claudeExecutablePath: claudePath }),
});
registry.register(provider);
} catch {
// Claude SDK not available.
}
try {
await import("@plannotator/ai/providers/codex-app-server");
const codexPath = Bun.which("codex");
if (codexPath) {
const provider = await createProvider({
type: "codex-sdk",
cwd,
...(codexPath ? { codexExecutablePath: codexPath } : {}),
});
const providerId = registry.register(provider);
if ("fetchModels" in provider) {
providerInitializers.set(
providerId,
createBestEffortOnce(
() => (provider as { fetchModels: () => Promise<void> }).fetchModels(),
),
);
}
}
} catch {
// Codex not available.
}
try {
const { PiSDKProvider } = await import("@plannotator/ai/providers/pi-sdk");
const rawPiPath = Bun.which("pi");
if (rawPiPath) {
const piPath = resolveWindowsCommandShim(rawPiPath);
const provider = await createProvider({
type: "pi-sdk",
cwd,
piExecutablePath: piPath,
} as PiSDKConfig);
if (provider instanceof PiSDKProvider) {
modelDiscovery.push(provider.fetchModels().catch(() => {}));
}
registry.register(provider);
}
} catch {
// Pi not available.
}
try {
await import("@plannotator/ai/providers/opencode-sdk");
const opencodePath = Bun.which("opencode");
if (opencodePath) {
const provider = await createProvider({
type: "opencode-sdk",
cwd,
});
const providerId = registry.register(provider);
// Deferred like Codex: fetchModels spawns `opencode serve`, so it must
// NOT run eagerly at startup — that spawned a server on every session
// for every user with opencode installed, and interrupted sessions
// orphaned it. The initializer runs on first explicit activation
// (?activate= from the model picker) or first opencode session.
if ("fetchModels" in provider) {
providerInitializers.set(
providerId,
createBestEffortOnce(
() => (provider as { fetchModels: () => Promise<void> }).fetchModels(),
),
);
}
}
} catch {
// OpenCode not available.
}
const endpoints = createAIEndpoints({
registry,
sessionManager,
getCwd: options.getCwd,
beforeCapabilities: async () => {
await Promise.allSettled(modelDiscovery);
},
beforeProviderSession: async (providerId) => {
await providerInitializers.get(providerId)?.();
},
});
return {
endpoints,
dispose: () => {
sessionManager.disposeAll();
registry.disposeAll();
},
};
}