mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
a79e6b0efe
* 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.
133 lines
3.7 KiB
TypeScript
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();
|
|
},
|
|
};
|
|
}
|