947 lines
30 KiB
JavaScript
947 lines
30 KiB
JavaScript
import { spawn, spawnSync } from 'node:child_process';
|
|
import net from 'node:net';
|
|
|
|
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';
|
|
|
|
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,
|
|
getActiveSessionCount = () => 0,
|
|
} = 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 closeManagedOpenCodeChild = 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 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';
|
|
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(),
|
|
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);
|
|
});
|
|
|
|
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 () => {
|
|
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();
|
|
const openCodePassword = await ensureLocalOpenCodeServerPassword({ rotateManaged: true });
|
|
const envPath = typeof buildManagedOpenCodePath === 'function'
|
|
? buildManagedOpenCodePath()
|
|
: typeof buildAugmentedPath === 'function'
|
|
? buildAugmentedPath()
|
|
: process.env.PATH;
|
|
const shellEnv = typeof getManagedOpenCodeShellEnvSnapshot === 'function'
|
|
? getManagedOpenCodeShellEnvSnapshot() || {}
|
|
: {};
|
|
|
|
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,
|
|
PATH: envPath,
|
|
OPENCODE_SERVER_PASSWORD: openCodePassword,
|
|
},
|
|
});
|
|
|
|
if (!serverInstance || !serverInstance.url) {
|
|
throw new Error('OpenCode server started but URL is missing');
|
|
}
|
|
|
|
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;
|
|
|
|
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();
|
|
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();
|
|
} 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();
|
|
|
|
try {
|
|
await waitForOpenCodeReady();
|
|
state.isOpenCodeReady = true;
|
|
state.openCodeNotReadySince = 0;
|
|
|
|
if (agentName) {
|
|
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;
|
|
}
|
|
};
|
|
|
|
const bootstrapOpenCodeAtStartup = async () => {
|
|
try {
|
|
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 if (!env.ENV_EFFECTIVE_PORT && await probeExternalOpenCode(4096)) {
|
|
console.log('Auto-detected existing OpenCode server on default port 4096');
|
|
setOpenCodePort(4096);
|
|
state.isOpenCodeReady = true;
|
|
state.isExternalOpenCode = true;
|
|
state.lastOpenCodeError = null;
|
|
state.openCodeNotReadySince = 0;
|
|
syncToHmrState();
|
|
} else {
|
|
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) {
|
|
console.error(`OpenCode readiness check failed: ${error.message}`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to start OpenCode: ${error.message}`);
|
|
console.log('Continuing without OpenCode integration...');
|
|
state.lastOpenCodeError = error.message;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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 healthProbePromise = null;
|
|
let healthCheckCyclePromise = null;
|
|
let lastHealthProbeResult = null;
|
|
|
|
const resetHealthFailureState = () => {
|
|
consecutiveHealthFailures = 0;
|
|
lastUnhealthyWithBusySessionsAt = 0;
|
|
};
|
|
|
|
const probeOpenCodeHealth = async () => {
|
|
const now = Date.now();
|
|
if (lastHealthProbeResult && now - lastHealthProbeResult.at < HEALTH_CHECK_RESULT_CACHE_MS) {
|
|
return lastHealthProbeResult.healthy;
|
|
}
|
|
|
|
if (healthProbePromise) {
|
|
return healthProbePromise;
|
|
}
|
|
|
|
healthProbePromise = isOpenCodeProcessHealthy()
|
|
.then((healthy) => {
|
|
lastHealthProbeResult = { at: Date.now(), healthy };
|
|
return healthy;
|
|
})
|
|
.finally(() => {
|
|
healthProbePromise = null;
|
|
});
|
|
|
|
return healthProbePromise;
|
|
};
|
|
|
|
const shouldSkipRestartForBusySessions = () => {
|
|
const activeCount = getActiveSessionCount();
|
|
if (activeCount === 0) {
|
|
lastUnhealthyWithBusySessionsAt = 0;
|
|
return false;
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (!lastUnhealthyWithBusySessionsAt) {
|
|
lastUnhealthyWithBusySessionsAt = now;
|
|
return true;
|
|
}
|
|
|
|
if (now - 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;
|
|
}
|
|
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;
|
|
|
|
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,
|
|
};
|
|
};
|