Files
openchamber/packages/web/server/lib/opencode/lifecycle.js
T
𝖎𝖚𝖑𝖎𝖎𝖆andBohdan Triapitsyn aae889b904 perf: optimize session loading and desktop startup (#2545)
* perf: optimize session loading and startup

* fix(chat): stabilize history prepend virtualization

* perf: unblock first session open from startup network contention

Opening the first session after app start waited seconds for its message
fetch. Three independent contributors, each measured via CDP network
capture and Chromium net-log against the packaged desktop app:

- The active-session watchdog fired an uncapped per-directory status poll
  and child-session discovery burst at startup, and other subsystems
  (git checks, global session pages, command/skill discovery) fanned out
  alongside it, saturating the browser's ~6 HTTP/1.1 sockets per origin.
  Add a shared background-network gate (concurrency 3) and route the
  watchdog, poll-shaped git reads (also priority: low), global session
  pages, command/skill loads, and the background update check through it.

- The packaged renderer is cross-origin to the loopback backend, so every
  API call needs a CORS preflight; a few slow OpenCode-proxied requests
  held the whole pool while preflights and interactive traffic queued
  behind them. Lift Chromium's per-host connection cap for loopback via
  ignore-connections-limit in the Electron shell.

- OpenCode initializes each directory lazily on its first request, so the
  first click paid that cost interactively. Warm the last-used directory
  and the three most recently opened projects right after OpenCode
  readiness, sequentially and best-effort, overlapping UI startup.

Validation: new background-network tests, lifecycle warmup test, focused
store/sync tests, UI type-check and lint, dead-code report, node --check
plus electron type-check/lint, and CDP first-open measurements on the
packaged app (message fetch socket queue 5.4s -> 0.03s).

* fix(ui): keep interactive git reads out of background queue

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-07-31 12:51:15 +03:00

1111 lines
37 KiB
JavaScript

import { spawn, spawnSync } from 'node:child_process';
import net from 'node:net';
import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './managed-process-registry.js';
import { recordStartupPerformance } from './startup-performance.js';
const parsePositiveInt = (value, fallback) => {
const parsed = Number.parseInt(String(value ?? ''), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
const HEALTH_CHECK_TIMEOUT_MS = parsePositiveInt(process.env.OPENCHAMBER_OPENCODE_HEALTH_TIMEOUT_MS, 5000);
const HEALTH_CHECK_MAX_CONSECUTIVE_FAILURES = parsePositiveInt(
process.env.OPENCHAMBER_OPENCODE_HEALTH_CONSECUTIVE_FAILURES,
20
);
const HEALTH_CHECK_INTERVAL_OVERRIDE_MS = parsePositiveInt(process.env.OPENCHAMBER_OPENCODE_HEALTH_INTERVAL_MS, 0);
const HEALTH_CHECK_RESULT_CACHE_MS = parsePositiveInt(process.env.OPENCHAMBER_OPENCODE_HEALTH_CACHE_MS, 750);
const OPENCODE_HEALTH_PATH = '/global/health';
// Last-used directory plus the three most recently opened projects — deeper
// tails are unlikely to be the user's first click and just add background work.
const WARMUP_DIRECTORY_LIMIT = 4;
const WARMUP_REQUEST_TIMEOUT_MS = 30000;
export const createOpenCodeLifecycleRuntime = (deps) => {
const {
state,
env,
syncToHmrState,
syncFromHmrState,
getOpenCodeAuthHeaders,
buildOpenCodeUrl,
waitForReady,
normalizeApiPrefix,
applyOpencodeBinaryFromSettings,
ensureOpencodeCliEnv,
ensureLocalOpenCodeServerPassword,
resolveManagedOpenCodeLaunchSpec,
setOpenCodePort,
setDetectedOpenCodeApiPrefix,
setupProxy,
ensureOpenCodeApiPrefix,
clearResolvedOpenCodeBinary,
buildAugmentedPath,
buildManagedOpenCodePath,
getManagedOpenCodeShellEnvSnapshot,
getManagedOpenCodeEnv = async () => ({}),
getActiveSessionCount = () => 0,
reapManagedOrphanedProcesses = reapOrphanedProcesses,
getWarmupDirectories = async () => [],
now = Date.now,
} = deps;
const killProcessOnPort = (port) => {
if (!port || process.platform === 'win32') return;
try {
const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000, windowsHide: true });
const output = result.stdout || '';
const myPid = process.pid;
for (const pidStr of output.split(/\s+/)) {
const pid = parseInt(pidStr.trim(), 10);
if (pid && pid !== myPid) {
try {
spawnSync('kill', ['-9', String(pid)], { stdio: 'ignore', timeout: 2000 });
} catch {
}
}
}
} catch {
}
};
const hasChildProcessExited = (child) => !child || child.exitCode !== null || child.signalCode !== null;
const isManagedOpenCodeProcessAlive = () => {
const child = state.openCodeProcess;
if (!child || hasChildProcessExited(child)) return false;
if (!child.pid) return true;
try {
process.kill(child.pid, 0);
return true;
} catch {
return false;
}
};
const waitForChildProcessClose = (child, timeoutMs) => new Promise((resolve) => {
if (!child || hasChildProcessExited(child)) {
resolve(true);
return;
}
let done = false;
const finish = (closed) => {
if (done) return;
done = true;
clearTimeout(timer);
child.off('close', onClose);
child.off('error', onError);
resolve(closed);
};
const onClose = () => finish(true);
const onError = () => finish(hasChildProcessExited(child));
const timer = setTimeout(() => finish(hasChildProcessExited(child)), timeoutMs);
child.once('close', onClose);
child.once('error', onError);
});
const waitForPortRelease = (port, timeoutMs, hostname = env.ENV_CONFIGURED_OPENCODE_HOSTNAME) => {
if (!port) {
return Promise.resolve(true);
}
const probeHost = !hostname || hostname === '0.0.0.0' || hostname === '::' || hostname === '[::]'
? '127.0.0.1'
: hostname;
const deadline = Date.now() + timeoutMs;
return new Promise((resolve) => {
const attempt = () => {
const socket = net.connect({ port, host: probeHost });
let settled = false;
const finish = (released) => {
if (settled) return;
settled = true;
socket.removeAllListeners();
socket.destroy();
if (released || Date.now() >= deadline) {
resolve(released);
return;
}
setTimeout(attempt, 150);
};
socket.once('connect', () => finish(false));
socket.once('timeout', () => finish(true));
socket.once('error', (error) => {
if (error && typeof error === 'object' && (error.code === 'ECONNREFUSED' || error.code === 'EHOSTUNREACH')) {
finish(true);
return;
}
finish(false);
});
socket.setTimeout(500);
};
attempt();
});
};
const terminateChildProcess = async (child) => {
if (!child) {
return;
}
const pid = child.pid;
if (!pid || hasChildProcessExited(child)) {
await waitForChildProcessClose(child, 250);
return;
}
const signalProcessTree = (signal) => {
if (process.platform !== 'win32') {
try {
process.kill(-pid, signal);
} catch {
}
}
try {
child.kill(signal);
} catch {
}
};
if (process.platform === 'win32') {
try {
child.kill();
} catch {
}
if (await waitForChildProcessClose(child, 800)) {
return;
}
try {
spawnSync('taskkill', ['/pid', String(pid), '/t'], {
stdio: 'ignore',
timeout: 3000,
windowsHide: true,
});
} catch {
}
if (await waitForChildProcessClose(child, 1500)) {
return;
}
try {
spawnSync('taskkill', ['/pid', String(pid), '/f', '/t'], {
stdio: 'ignore',
timeout: 5000,
windowsHide: true,
});
} catch {
}
await waitForChildProcessClose(child, 3000);
return;
}
signalProcessTree('SIGTERM');
if (await waitForChildProcessClose(child, 2500)) {
return;
}
signalProcessTree('SIGKILL');
await waitForChildProcessClose(child, 1000);
};
const closeManagedOpenCodeChild = async (child) => {
const pid = child?.pid;
try {
await terminateChildProcess(child);
} finally {
// Drop it from the registry only once it has actually exited, so a child
// that survived teardown stays eligible for the next run's reaper.
if (Number.isInteger(pid) && hasChildProcessExited(child)) {
unregisterManagedProcess(pid);
}
}
};
const formatCapturedOutput = ({ stdout, stderr }) => {
const parts = [];
if (stdout.trim()) {
parts.push(`stdout:\n${stdout.trim()}`);
}
if (stderr.trim()) {
parts.push(`stderr:\n${stderr.trim()}`);
}
return parts.length > 0 ? parts.join('\n\n') : 'No stdout/stderr captured';
};
const createManagedOpenCodeServerProcess = async ({ hostname, port, timeout, cwd, env: processEnv, shellEnvKeysCount = 0 }) => {
let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
const sourceBinary = binary;
let args = ['serve', '--hostname', hostname, '--port', String(port)];
let launchWrapperType = null;
if (process.platform === 'win32' && state.useWslForOpencode) {
throw new Error('Launching OpenCode through WSL is no longer supported. Install OpenCode natively on Windows and configure opencode.cmd or opencode.exe.');
}
if (process.platform === 'win32' && !state.useWslForOpencode) {
const launchSpec = resolveManagedOpenCodeLaunchSpec(binary);
if (launchSpec?.binary) {
if (launchSpec.wrapperType) {
console.log(`Launching OpenCode via ${launchSpec.wrapperType}: ${launchSpec.binary}`);
}
launchWrapperType = launchSpec.wrapperType || null;
binary = launchSpec.binary;
args = [...(Array.isArray(launchSpec.args) ? launchSpec.args : []), ...args];
}
}
const pathValue = typeof processEnv?.PATH === 'string' ? processEnv.PATH : '';
const pathEntryCount = pathValue ? pathValue.split(process.platform === 'win32' ? ';' : ':').filter(Boolean).length : 0;
state.lastOpenCodeLaunchDiagnostics = {
launchedAt: new Date().toISOString(),
sourceBinary,
binary,
args,
cwd,
hostname,
port,
wrapperType: launchWrapperType,
pathEntryCount,
hasShellEnv: shellEnvKeysCount > 0,
shellEnvKeysCount,
};
console.log('[OpenCode] Launching managed server', state.lastOpenCodeLaunchDiagnostics);
const child = spawn(binary, args, {
cwd,
env: processEnv,
detached: process.platform !== 'win32',
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
const url = await new Promise((resolve, reject) => {
let stdout = '';
let stderr = '';
let done = false;
const finish = (handler, value) => {
if (done) return;
done = true;
clearTimeout(timer);
child.stdout?.off('data', onStdout);
child.stderr?.off('data', onStderr);
child.off('exit', onExit);
child.off('error', onError);
handler(value);
};
const onStdout = (chunk) => {
stdout += chunk.toString();
const lines = stdout.split('\n');
for (const line of lines) {
if (!line.startsWith('opencode server listening')) continue;
const match = line.match(/on\s+(https?:\/\/[^\s]+)/);
if (!match) {
finish(reject, new Error(`Failed to parse server url from output: ${line}`));
return;
}
finish(resolve, match[1]);
return;
}
};
const onStderr = (chunk) => {
stderr += chunk.toString();
};
const onExit = (code, signal) => {
const reason = signal ? `signal ${signal}` : `code ${code}`;
const appBundleHint = process.platform === 'darwin' && /\/OpenCode\.app\/Contents\/MacOS\/(?:OpenCode|opencode-cli)$/i.test(binary)
? ' The configured binary appears to point at the macOS desktop app bundle; OpenChamber needs the standalone opencode CLI.'
: '';
finish(reject, new Error(`OpenCode process exited before serving with ${reason}. Binary used: ${binary}.${appBundleHint} ${formatCapturedOutput({ stdout, stderr })}`));
};
const onError = (error) => {
finish(reject, error);
};
const timer = setTimeout(() => {
finish(reject, new Error(`Timeout waiting for OpenCode to start after ${timeout}ms`));
}, timeout);
child.stdout?.on('data', onStdout);
child.stderr?.on('data', onStderr);
child.on('exit', onExit);
child.on('error', onError);
});
// Record this child so a future run can reap it if we crash before teardown.
// The web-server lifecycle runs in-process inside multiple hosts, so tag the
// actual host (Electron sets OPENCHAMBER_RUNTIME='desktop'; the standalone
// web CLI leaves it unset → 'web'; SSH remote → 'ssh-remote') rather than a
// hardcoded label, matching the server's existing runtimeName convention.
registerManagedProcess({
pid: child.pid,
ownerPid: process.pid,
port,
binary,
runtime: process.env.OPENCHAMBER_RUNTIME || 'web',
});
return {
url,
pid: child.pid || null,
async close() {
await closeManagedOpenCodeChild(child);
},
};
};
const resolveManagedOpenCodePort = async (requestedPort, hostname = '127.0.0.1') => {
if (typeof requestedPort === 'number' && Number.isFinite(requestedPort) && requestedPort > 0) {
return requestedPort;
}
return await new Promise((resolve, reject) => {
const server = net.createServer();
const cleanup = () => {
server.removeAllListeners('error');
server.removeAllListeners('listening');
};
server.once('error', (error) => {
cleanup();
reject(error);
});
server.once('listening', () => {
const address = server.address();
const port = address && typeof address === 'object' ? address.port : 0;
server.close(() => {
cleanup();
if (port > 0) {
resolve(port);
return;
}
reject(new Error('Failed to allocate OpenCode port'));
});
});
server.listen(0, hostname);
});
};
const isOpenCodeProcessHealthy = async () => {
if (!state.openCodeProcess || !state.openCodePort) {
return false;
}
try {
const response = await fetch(buildOpenCodeUrl(OPENCODE_HEALTH_PATH, ''), {
method: 'GET',
headers: {
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
});
if (!response.ok) return false;
const body = await response.json().catch(() => null);
return body?.healthy === true;
} catch {
return false;
}
};
const probeExternalOpenCode = async (port, origin) => {
if (!port || port <= 0) {
return false;
}
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const base = origin ?? `http://127.0.0.1:${port}`;
const response = await fetch(`${base}${OPENCODE_HEALTH_PATH}`, {
method: 'GET',
headers: {
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) return false;
const body = await response.json().catch(() => null);
return body?.healthy === true;
} catch {
return false;
}
};
const waitForOpenCodePort = async (timeoutMs = 15000) => {
if (state.openCodePort !== null) {
return state.openCodePort;
}
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 50));
if (state.openCodePort !== null) {
return state.openCodePort;
}
}
throw new Error('Timed out waiting for OpenCode port');
};
const START_OPEN_CODE_MAX_ATTEMPTS = 2;
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const startOpenCodeOnce = async (attempt) => {
const attemptStartedAt = performance.now();
let phaseStartedAt = attemptStartedAt;
recordStartupPerformance('opencode.attempt.start', { attempt });
const desiredPort = env.ENV_CONFIGURED_OPENCODE_PORT ?? 0;
const spawnPort = await resolveManagedOpenCodePort(desiredPort, env.ENV_CONFIGURED_OPENCODE_HOSTNAME);
console.log(
desiredPort > 0
? `Starting OpenCode on requested port ${desiredPort}...`
: `Starting OpenCode on allocated port ${spawnPort}...`
);
await applyOpencodeBinaryFromSettings({ strict: true });
ensureOpencodeCliEnv();
recordStartupPerformance('opencode.binary.ready', {
attempt,
durationMs: performance.now() - phaseStartedAt,
totalDurationMs: performance.now() - attemptStartedAt,
});
phaseStartedAt = performance.now();
const openCodePassword = await ensureLocalOpenCodeServerPassword({ rotateManaged: true });
let envPath = process.env.PATH;
if (typeof buildManagedOpenCodePath === 'function') {
envPath = buildManagedOpenCodePath();
} else if (typeof buildAugmentedPath === 'function') {
envPath = buildAugmentedPath();
}
const shellEnv = typeof getManagedOpenCodeShellEnvSnapshot === 'function'
? getManagedOpenCodeShellEnvSnapshot() || {}
: {};
const managedOpenCodeEnv = await getManagedOpenCodeEnv();
recordStartupPerformance('opencode.environment.ready', {
attempt,
durationMs: performance.now() - phaseStartedAt,
totalDurationMs: performance.now() - attemptStartedAt,
});
phaseStartedAt = performance.now();
try {
const serverInstance = await createManagedOpenCodeServerProcess({
hostname: env.ENV_CONFIGURED_OPENCODE_HOSTNAME,
port: spawnPort,
timeout: 30000,
cwd: state.openCodeWorkingDirectory,
shellEnvKeysCount: Object.keys(shellEnv).length,
env: {
...shellEnv,
...process.env,
...managedOpenCodeEnv,
PATH: envPath,
OPENCODE_SERVER_PASSWORD: openCodePassword,
},
});
if (!serverInstance || !serverInstance.url) {
throw new Error('OpenCode server started but URL is missing');
}
recordStartupPerformance('opencode.process.ready', {
attempt,
durationMs: performance.now() - phaseStartedAt,
totalDurationMs: performance.now() - attemptStartedAt,
});
phaseStartedAt = performance.now();
const url = new URL(serverInstance.url);
const port = parseInt(url.port, 10);
const prefix = normalizeApiPrefix(url.pathname);
if (await waitForReady(serverInstance.url, 10000)) {
setOpenCodePort(port);
setDetectedOpenCodeApiPrefix(prefix);
state.isOpenCodeReady = true;
state.lastOpenCodeError = null;
state.openCodeNotReadySince = 0;
recordStartupPerformance('opencode.health.ready', {
attempt,
durationMs: performance.now() - phaseStartedAt,
totalDurationMs: performance.now() - attemptStartedAt,
outcome: 'ready',
});
return serverInstance;
}
try {
await serverInstance.close();
} catch {
}
throw new Error('Server started but health check failed (timeout)');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
state.lastOpenCodeError = message;
state.openCodePort = null;
syncToHmrState();
recordStartupPerformance('opencode.attempt.error', {
attempt,
totalDurationMs: performance.now() - attemptStartedAt,
outcome: 'error',
});
console.error(`Failed to start OpenCode: ${message}`);
throw error;
}
};
const startOpenCode = async () => {
let lastError = null;
for (let attempt = 1; attempt <= START_OPEN_CODE_MAX_ATTEMPTS; attempt += 1) {
try {
return await startOpenCodeOnce(attempt);
} catch (error) {
lastError = error;
if (error?.code === 'OPENCODE_BINARY_INVALID') {
break;
}
if (attempt >= START_OPEN_CODE_MAX_ATTEMPTS) {
break;
}
const message = error instanceof Error ? error.message : String(error);
console.warn(`[OpenCode] Managed server startup failed on attempt ${attempt}/${START_OPEN_CODE_MAX_ATTEMPTS}; retrying: ${message}`);
state.openCodePort = null;
state.isOpenCodeReady = false;
state.openCodeNotReadySince = Date.now();
syncToHmrState();
await delay(750 * attempt);
}
}
throw lastError;
};
const restartOpenCode = async () => {
if (state.isShuttingDown) return;
if (state.currentRestartPromise) {
await state.currentRestartPromise;
return;
}
state.currentRestartPromise = (async () => {
state.isRestartingOpenCode = true;
state.isOpenCodeReady = false;
state.openCodeNotReadySince = Date.now();
console.log('Restarting OpenCode process...');
if (state.isExternalOpenCode) {
console.log('Re-probing external OpenCode server...');
const probePort = state.openCodePort || env.ENV_CONFIGURED_OPENCODE_PORT || 4096;
const probeOrigin = state.openCodeBaseUrl ?? env.ENV_CONFIGURED_OPENCODE_HOST?.origin;
const healthy = await probeExternalOpenCode(probePort, probeOrigin);
if (healthy) {
console.log(`External OpenCode server on port ${probePort} is healthy`);
setOpenCodePort(probePort);
state.isOpenCodeReady = true;
state.lastOpenCodeError = null;
state.openCodeNotReadySince = 0;
syncToHmrState();
} else {
state.lastOpenCodeError = `External OpenCode server on port ${probePort} is not responding`;
console.error(state.lastOpenCodeError);
throw new Error(state.lastOpenCodeError);
}
if (state.expressApp) {
setupProxy(state.expressApp);
ensureOpenCodeApiPrefix();
}
return;
}
const portToKill = state.openCodePort;
if (state.openCodeProcess) {
console.log('Stopping existing OpenCode process...');
try {
await state.openCodeProcess.close();
} catch (error) {
console.warn('Error closing OpenCode process:', error);
}
state.openCodeProcess = null;
syncToHmrState();
}
killProcessOnPort(portToKill);
if (!(await waitForPortRelease(portToKill, 5000))) {
console.warn(`Timed out waiting for OpenCode port ${portToKill} to be released`);
}
if (env.ENV_CONFIGURED_OPENCODE_PORT) {
console.log(`Using OpenCode port from environment: ${env.ENV_CONFIGURED_OPENCODE_PORT}`);
setOpenCodePort(env.ENV_CONFIGURED_OPENCODE_PORT);
} else {
state.openCodePort = null;
syncToHmrState();
}
state.openCodeApiPrefixDetected = true;
state.openCodeApiPrefix = '';
if (state.openCodeApiDetectionTimer) {
clearTimeout(state.openCodeApiDetectionTimer);
state.openCodeApiDetectionTimer = null;
}
state.lastOpenCodeError = null;
state.openCodeProcess = await startOpenCode();
syncToHmrState();
if (state.expressApp) {
setupProxy(state.expressApp);
ensureOpenCodeApiPrefix();
}
})();
try {
await state.currentRestartPromise;
} catch (error) {
console.error(`Failed to restart OpenCode: ${error.message}`);
state.lastOpenCodeError = error.message;
if (!env.ENV_CONFIGURED_OPENCODE_PORT) {
state.openCodePort = null;
syncToHmrState();
}
state.openCodeApiPrefixDetected = true;
state.openCodeApiPrefix = '';
throw error;
} finally {
state.currentRestartPromise = null;
state.isRestartingOpenCode = false;
}
};
const waitForOpenCodeReady = async (timeoutMs = 20000, intervalMs = 400) => {
if (!state.openCodePort) {
throw new Error('OpenCode port is not available');
}
const deadline = Date.now() + timeoutMs;
let lastError = null;
while (Date.now() < deadline) {
let timeout = null;
try {
const controller = new AbortController();
timeout = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS);
const response = await fetch(buildOpenCodeUrl(OPENCODE_HEALTH_PATH, ''), {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
signal: controller.signal,
});
clearTimeout(timeout);
timeout = null;
if (!response.ok) {
lastError = new Error(`OpenCode health endpoint responded with status ${response.status}`);
await new Promise((resolve) => setTimeout(resolve, intervalMs));
continue;
}
const body = await response.json().catch(() => null);
if (body?.healthy !== true) {
lastError = new Error('OpenCode health endpoint returned unhealthy response');
await new Promise((resolve) => setTimeout(resolve, intervalMs));
continue;
}
state.isOpenCodeReady = true;
state.lastOpenCodeError = null;
return;
} catch (error) {
lastError = error;
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
if (lastError) {
state.lastOpenCodeError = lastError.message || String(lastError);
throw lastError;
}
const timeoutError = new Error('Timed out waiting for OpenCode to become ready');
state.lastOpenCodeError = timeoutError.message;
throw timeoutError;
};
const waitForAgentPresence = async (agentName, timeoutMs = 15000, intervalMs = 300) => {
if (!state.openCodePort) {
throw new Error('OpenCode port is not available');
}
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(buildOpenCodeUrl('/agent'), {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
});
if (response.ok) {
const agents = await response.json();
if (Array.isArray(agents) && agents.some((agent) => agent?.name === agentName)) {
return;
}
}
} catch {
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`Agent "${agentName}" not available after OpenCode restart`);
};
const refreshOpenCodeAfterConfigChange = async (reason, options = {}) => {
const { agentName } = options;
console.log(`Refreshing OpenCode after ${reason}`);
clearResolvedOpenCodeBinary();
await applyOpencodeBinaryFromSettings();
await restartOpenCode();
// A managed OpenCode process is restarted (and thus re-reads config from
// disk) by restartOpenCode(). An external OpenCode server is NOT owned by
// OpenChamber: restartOpenCode() only re-probes its health, so the freshly
// written config is on disk but the running server keeps serving its old,
// startup-cached config until the user restarts it themselves. Report this
// honestly so callers don't claim the change is live.
const external = state.isExternalOpenCode === true;
try {
await waitForOpenCodeReady();
state.isOpenCodeReady = true;
state.openCodeNotReadySince = 0;
// Waiting for the agent to appear only makes sense when we actually
// reloaded config. An external server will never surface it here.
if (agentName && !external) {
await waitForAgentPresence(agentName);
}
state.isOpenCodeReady = true;
state.openCodeNotReadySince = 0;
} catch (error) {
state.isOpenCodeReady = false;
state.openCodeNotReadySince = Date.now();
console.error(`Failed to refresh OpenCode after ${reason}:`, error.message);
throw error;
}
return { reloaded: !external, external };
};
const bootstrapOpenCodeAtStartup = async () => {
const bootstrapStartedAt = performance.now();
let bootstrapError = null;
recordStartupPerformance('opencode.bootstrap.start');
try {
// Before doing anything, reap any OpenCode process WE spawned in a prior
// run that was orphaned by a crash/hard-exit. Verified + scoped to our own
// pids, so it never touches a live instance's or the user's own server.
try {
const orphanReapStartedAt = performance.now();
const { reaped } = await reapManagedOrphanedProcesses({ log: (msg) => console.log(msg) });
recordStartupPerformance('opencode.orphan-reap.ready', {
durationMs: performance.now() - orphanReapStartedAt,
totalDurationMs: performance.now() - bootstrapStartedAt,
});
if (reaped > 0) console.log(`[lifecycle] startup reaped ${reaped} orphaned OpenCode process(es)`);
} catch (error) {
console.warn('[lifecycle] orphan reap failed:', error?.message ?? error);
}
syncFromHmrState();
if (await isOpenCodeProcessHealthy()) {
console.log(`[HMR] Reusing existing OpenCode process on port ${state.openCodePort}`);
} else if (env.ENV_SKIP_OPENCODE_START && env.ENV_EFFECTIVE_PORT) {
const label = env.ENV_CONFIGURED_OPENCODE_HOST ? env.ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${env.ENV_EFFECTIVE_PORT}`;
console.log(`Using external OpenCode server at ${label} (skip-start mode)`);
state.openCodeBaseUrl = env.ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null;
setOpenCodePort(env.ENV_EFFECTIVE_PORT);
state.isOpenCodeReady = true;
state.isExternalOpenCode = true;
state.lastOpenCodeError = null;
state.openCodeNotReadySince = 0;
syncToHmrState();
} else if (env.ENV_EFFECTIVE_PORT && await probeExternalOpenCode(env.ENV_EFFECTIVE_PORT, env.ENV_CONFIGURED_OPENCODE_HOST?.origin)) {
const label = env.ENV_CONFIGURED_OPENCODE_HOST ? env.ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${env.ENV_EFFECTIVE_PORT}`;
console.log(`Auto-detected existing OpenCode server at ${label}`);
state.openCodeBaseUrl = env.ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null;
setOpenCodePort(env.ENV_EFFECTIVE_PORT);
state.isOpenCodeReady = true;
state.isExternalOpenCode = true;
state.lastOpenCodeError = null;
state.openCodeNotReadySince = 0;
syncToHmrState();
} else {
// We never auto-attach to an arbitrary pre-existing OpenCode instance.
// Attaching to an external server requires explicit opt-in via env
// (OPENCODE_HOST / OPENCODE_PORT / OPENCODE_SKIP_START), handled by the
// branches above. Without that opt-in we always start our OWN managed
// instance on a freshly-allocated port. A blind probe of the default
// port 4096 used to hijack a user's separately-running OpenCode (e.g.
// the OpenCode desktop app), coupling our lifecycle to theirs and
// breaking init against an unexpected server version/config.
if (env.ENV_EFFECTIVE_PORT) {
console.log(`Using OpenCode port from environment: ${env.ENV_EFFECTIVE_PORT}`);
setOpenCodePort(env.ENV_EFFECTIVE_PORT);
} else {
state.openCodePort = null;
syncToHmrState();
}
state.lastOpenCodeError = null;
state.openCodeProcess = await startOpenCode();
syncToHmrState();
}
await waitForOpenCodePort();
try {
await waitForOpenCodeReady();
} catch (error) {
bootstrapError = error;
console.error(`OpenCode readiness check failed: ${error.message}`);
}
} catch (error) {
bootstrapError = error;
console.error(`Failed to start OpenCode: ${error.message}`);
console.log('Continuing without OpenCode integration...');
state.lastOpenCodeError = error.message;
}
recordStartupPerformance(
bootstrapError ? 'opencode.bootstrap.error' : 'opencode.bootstrap.ready',
{
totalDurationMs: performance.now() - bootstrapStartedAt,
outcome: bootstrapError ? 'error' : 'ready',
},
);
if (!bootstrapError) {
void warmOpenCodeDirectories();
}
};
// OpenCode initializes each project directory lazily on its first
// directory-scoped request, and that initialization takes seconds on large
// session stores. Without warming, the user's first session open pays it
// interactively (the chat waits on the message fetch until the directory
// finishes initializing). Warm the most recently used directories right
// after readiness so the work overlaps UI startup instead. Sequential and
// best-effort: a failed or slow directory never blocks the others for long,
// and a restart invalidates the pass via the port/readiness guard.
const warmOpenCodeDirectories = async () => {
let directories = [];
try {
directories = await getWarmupDirectories();
} catch {
return;
}
if (!Array.isArray(directories) || directories.length === 0) return;
const warmedPort = state.openCodePort;
for (const directory of directories.slice(0, WARMUP_DIRECTORY_LIMIT)) {
if (typeof directory !== 'string' || !directory) continue;
if (!state.isOpenCodeReady || state.openCodePort !== warmedPort) return;
let timeout = null;
try {
const controller = new AbortController();
timeout = setTimeout(() => controller.abort(), WARMUP_REQUEST_TIMEOUT_MS);
const url = `${buildOpenCodeUrl('/session/status', '')}?directory=${encodeURIComponent(directory)}`;
await fetch(url, {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
signal: controller.signal,
});
} catch {
// Best-effort — the directory stays lazy and the UI's own request warms it.
} finally {
if (timeout) clearTimeout(timeout);
}
}
};
/**
* Perform an immediate (one-shot) health check and restart OpenCode if it's
* not healthy. Callers on the SSE / WS proxy path use this to trigger
* recovery without waiting for the next periodic interval (up to 15 s).
*
* Skips restart when sessions are actively busy — a busy server under
* concurrent load can fail the health check timeout without actually
* being dead (the health endpoint competes with LLM work).
* Forces restart if sessions stay "busy" and the server stays unhealthy
* for over 2 minutes (staleness guard against stuck session state).
*/
const STALE_BUSY_GRACE_MS = 2 * 60 * 1000;
let lastUnhealthyWithBusySessionsAt = 0;
let consecutiveHealthFailures = 0;
let lastCountedHealthFailureAt = 0;
let healthProbePromise = null;
let healthCheckCyclePromise = null;
let lastHealthProbeResult = null;
let healthFailureCountIntervalMs = 15_000;
const resetHealthFailureState = () => {
consecutiveHealthFailures = 0;
lastUnhealthyWithBusySessionsAt = 0;
lastCountedHealthFailureAt = 0;
};
const probeOpenCodeHealth = async () => {
const checkedAt = now();
if (lastHealthProbeResult && checkedAt - lastHealthProbeResult.at < HEALTH_CHECK_RESULT_CACHE_MS) {
return lastHealthProbeResult.healthy;
}
if (healthProbePromise) {
return healthProbePromise;
}
healthProbePromise = isOpenCodeProcessHealthy()
.then((healthy) => {
lastHealthProbeResult = { at: now(), healthy };
return healthy;
})
.finally(() => {
healthProbePromise = null;
});
return healthProbePromise;
};
const shouldSkipRestartForBusySessions = () => {
const activeCount = getActiveSessionCount();
if (activeCount === 0) {
lastUnhealthyWithBusySessionsAt = 0;
return false;
}
const checkedAt = now();
if (!lastUnhealthyWithBusySessionsAt) {
lastUnhealthyWithBusySessionsAt = checkedAt;
return true;
}
if (checkedAt - lastUnhealthyWithBusySessionsAt >= STALE_BUSY_GRACE_MS) {
console.warn(
`[lifecycle] OpenCode unhealthy with ${activeCount} busy session(s) for > 2 min — forcing restart`
);
lastUnhealthyWithBusySessionsAt = 0;
return false;
}
return true;
};
const runHealthCheckCycle = async (source) => {
if (!state.openCodeProcess || state.isShuttingDown || state.isRestartingOpenCode) return;
if (healthCheckCyclePromise) return healthCheckCyclePromise;
healthCheckCyclePromise = (async () => {
const healthy = await probeOpenCodeHealth();
if (!healthy) {
if (!isManagedOpenCodeProcessAlive()) {
console.log(`[lifecycle] ${source} health check: OpenCode process exited, restarting...`);
consecutiveHealthFailures = 0;
lastHealthProbeResult = null;
await restartOpenCode();
return;
}
const checkedAt = now();
if (lastCountedHealthFailureAt && checkedAt - lastCountedHealthFailureAt < healthFailureCountIntervalMs) {
return;
}
lastCountedHealthFailureAt = checkedAt;
consecutiveHealthFailures += 1;
console.warn(
`[lifecycle] ${source} health check failed (${consecutiveHealthFailures}/${HEALTH_CHECK_MAX_CONSECUTIVE_FAILURES})`
);
if (consecutiveHealthFailures < HEALTH_CHECK_MAX_CONSECUTIVE_FAILURES) return;
if (shouldSkipRestartForBusySessions()) return;
console.log(`[lifecycle] ${source} health check failure threshold reached, restarting OpenCode...`);
consecutiveHealthFailures = 0;
lastHealthProbeResult = null;
await restartOpenCode();
} else {
resetHealthFailureState();
}
})().finally(() => {
healthCheckCyclePromise = null;
});
return healthCheckCyclePromise;
};
const triggerHealthCheck = async () => {
try {
await runHealthCheckCycle('immediate');
} catch (error) {
console.error(`[lifecycle] immediate health check error: ${error.message}`);
}
};
const startHealthMonitoring = (healthCheckIntervalMs) => {
if (state.healthCheckInterval) {
clearInterval(state.healthCheckInterval);
}
const effectiveIntervalMs = HEALTH_CHECK_INTERVAL_OVERRIDE_MS || healthCheckIntervalMs;
healthFailureCountIntervalMs = effectiveIntervalMs;
state.healthCheckInterval = setInterval(async () => {
try {
await runHealthCheckCycle('periodic');
} catch (error) {
console.error(`Health check error: ${error.message}`);
}
}, effectiveIntervalMs);
};
return {
killProcessOnPort,
startOpenCode,
restartOpenCode,
waitForOpenCodeReady,
waitForAgentPresence,
refreshOpenCodeAfterConfigChange,
bootstrapOpenCodeAtStartup,
startHealthMonitoring,
triggerHealthCheck,
waitForPortRelease,
};
};