1348 lines
46 KiB
JavaScript
1348 lines
46 KiB
JavaScript
import { spawn, spawnSync } from 'node:child_process';
|
|
import net from 'node:net';
|
|
import { stripAppImageArgv0Leak } from '../inherited-env.js';
|
|
import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './managed-process-registry.js';
|
|
import { applyProviderEnvAliases } from './provider-env-aliases.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;
|
|
const MANAGED_STDERR_TAIL_MAX_BYTES = 32 * 1024;
|
|
const HEALTH_FAILURE_DETAIL_MAX_LENGTH = 256;
|
|
|
|
const getBoundedTextTail = (value, maxBytes) => {
|
|
const buffer = Buffer.from(String(value ?? ''));
|
|
if (buffer.byteLength <= maxBytes) return buffer.toString();
|
|
return buffer.subarray(buffer.byteLength - maxBytes).toString();
|
|
};
|
|
|
|
const sanitizeDiagnosticText = (value) => String(value ?? '')
|
|
.replace(/(https?:\/\/)[^/\s:@]+:[^/\s@]+@/gi, '$1[redacted]@')
|
|
.replace(/\b(Bearer)\s+[^\s,;]+/gi, '$1 [redacted]')
|
|
// Unquoted `Authorization: <scheme> <credential>` values must be handled
|
|
// before the generic key/value rule below: that rule stops at whitespace, so
|
|
// it would redact only the scheme word and leave the credential intact.
|
|
// Scoped to authorization-style keys so ordinary prose using "basic" or
|
|
// "token" is not mangled.
|
|
.replace(
|
|
/(^|[\s,{\[])((?:"|')?[a-z0-9_.-]{0,80}authorization[a-z0-9_.-]{0,80}(?:"|')?\s*[:=]\s*(?:"|')?(?:basic|bearer|token)\s+)[^\s,;"']+/gim,
|
|
'$1$2[redacted]',
|
|
)
|
|
.replace(/([?&][^=&#\s]*(?:token|api[_-]?key|password|secret|authorization|credential|private[_-]?key)[^=&#\s]*=)[^&#\s]+/gi, '$1[redacted]')
|
|
.replace(
|
|
/(^|[\s,{\[])((?:"|')?[a-z0-9_.-]{0,80}(?:token|api[_-]?key|password|secret|authorization|credential|private[_-]?key)[a-z0-9_.-]{0,80}(?:"|')?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;]+)/gim,
|
|
'$1$2[redacted]',
|
|
);
|
|
|
|
const getHealthFailureDetail = (error) => {
|
|
const name = String(error?.name || 'Error');
|
|
const message = String(error?.message || error || 'Unknown error');
|
|
return sanitizeDiagnosticText(`${name}: ${message}`).slice(0, HEALTH_FAILURE_DETAIL_MAX_LENGTH);
|
|
};
|
|
|
|
const classifyHealthProbeError = (error) => {
|
|
const name = String(error?.name || '');
|
|
const code = String(error?.code || '').toUpperCase();
|
|
const message = String(error?.message || error || '');
|
|
const normalizedMessage = message.toLowerCase();
|
|
|
|
if (
|
|
name === 'AbortError'
|
|
|| name === 'TimeoutError'
|
|
|| normalizedMessage.includes('the operation was aborted')
|
|
|| normalizedMessage.includes('abortsignal.timeout')
|
|
) {
|
|
return { class: 'timeout', detail: getHealthFailureDetail(error) };
|
|
}
|
|
if (code === 'ECONNREFUSED' || normalizedMessage.includes('econnrefused')) {
|
|
return { class: 'connection_refused', detail: getHealthFailureDetail(error) };
|
|
}
|
|
if (
|
|
code === 'ECONNRESET'
|
|
|| normalizedMessage.includes('econnreset')
|
|
|| normalizedMessage.includes('socket hang up')
|
|
) {
|
|
return { class: 'connection_reset', detail: getHealthFailureDetail(error) };
|
|
}
|
|
return { class: 'error', detail: getHealthFailureDetail(error) };
|
|
};
|
|
|
|
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 () => [],
|
|
onOpenCodeRestarted = null,
|
|
now = Date.now,
|
|
} = deps;
|
|
|
|
const killProcessOnPortWin32 = (port) => {
|
|
try {
|
|
// Get-NetTCPConnection reads the same locale-independent WinNT API
|
|
// netstat's display layer translates (e.g. "LISTENING" renders as
|
|
// "ABHÖREN"/"ÉCOUTE"/"ESCUTANDO" on non-English Windows), so this
|
|
// works regardless of the OS display language.
|
|
const result = spawnSync(
|
|
'powershell',
|
|
[
|
|
'-NoProfile',
|
|
'-NonInteractive',
|
|
'-Command',
|
|
`Get-NetTCPConnection -State Listen -LocalPort ${Number.parseInt(port, 10)} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess`,
|
|
],
|
|
{ encoding: 'utf8', timeout: 5000, windowsHide: true }
|
|
);
|
|
const output = result.stdout || '';
|
|
const myPid = process.pid;
|
|
const pids = new Set();
|
|
for (const line of output.split(/\r?\n/)) {
|
|
const pid = Number.parseInt(line.trim(), 10);
|
|
if (pid && pid !== myPid) pids.add(pid);
|
|
}
|
|
for (const pid of pids) {
|
|
try {
|
|
spawnSync('taskkill', ['/PID', String(pid), '/F'], { stdio: 'ignore', timeout: 3000, windowsHide: true });
|
|
} catch {
|
|
}
|
|
}
|
|
} catch {
|
|
}
|
|
};
|
|
|
|
const killProcessOnPort = (port) => {
|
|
if (!port) return;
|
|
if (process.platform === 'win32') {
|
|
killProcessOnPortWin32(port);
|
|
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.exitCode !== undefined)
|
|
|| (child.signalCode !== null && child.signalCode !== undefined);
|
|
|
|
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 snapshotManagedOpenCodeProcess = (child = state.openCodeProcess) => {
|
|
if (!child) return null;
|
|
const snapshot = {
|
|
pid: child.pid || null,
|
|
exitCode: child.exitCode ?? null,
|
|
signalCode: child.signalCode ?? null,
|
|
stderrTail: getBoundedTextTail(
|
|
sanitizeDiagnosticText(child.stderrTail ?? ''),
|
|
MANAGED_STDERR_TAIL_MAX_BYTES,
|
|
),
|
|
};
|
|
state.lastManagedOpenCodeProcess = snapshot;
|
|
return snapshot;
|
|
};
|
|
|
|
const captureRestartDiagnostics = (reason) => {
|
|
const processSnapshot = snapshotManagedOpenCodeProcess();
|
|
const diagnostics = {
|
|
reason: sanitizeDiagnosticText(String(reason || 'managed-restart')).slice(0, HEALTH_FAILURE_DETAIL_MAX_LENGTH),
|
|
healthFailure: state.lastOpenCodeHealthFailure ? { ...state.lastOpenCodeHealthFailure } : null,
|
|
process: processSnapshot
|
|
? { ...processSnapshot, alive: isManagedOpenCodeProcessAlive() }
|
|
: null,
|
|
busySessionCount: getActiveSessionCount(),
|
|
at: new Date(now()).toISOString(),
|
|
};
|
|
state.lastOpenCodeRestartDiagnostics = diagnostics;
|
|
console.warn('[lifecycle] managed OpenCode restart diagnostics', diagnostics);
|
|
};
|
|
|
|
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)) {
|
|
await 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'],
|
|
});
|
|
let runtimeStderrTail = '';
|
|
let runtimeStderrAttached = false;
|
|
let observedExitCode = null;
|
|
let observedSignalCode = null;
|
|
|
|
const getManagedProcessSnapshot = () => ({
|
|
pid: child.pid || null,
|
|
exitCode: observedExitCode ?? child.exitCode ?? null,
|
|
signalCode: observedSignalCode ?? child.signalCode ?? null,
|
|
stderrTail: getBoundedTextTail(sanitizeDiagnosticText(runtimeStderrTail), MANAGED_STDERR_TAIL_MAX_BYTES),
|
|
});
|
|
const recordManagedProcessExit = (code, signal) => {
|
|
if (code !== null && code !== undefined) observedExitCode = code;
|
|
if (signal !== null && signal !== undefined) observedSignalCode = signal;
|
|
state.lastManagedOpenCodeProcess = getManagedProcessSnapshot();
|
|
};
|
|
const attachRuntimeStderrCapture = () => {
|
|
if (runtimeStderrAttached) return;
|
|
runtimeStderrAttached = true;
|
|
child.stderr?.on('data', (chunk) => {
|
|
runtimeStderrTail = getBoundedTextTail(
|
|
`${runtimeStderrTail}${chunk.toString()}`,
|
|
MANAGED_STDERR_TAIL_MAX_BYTES,
|
|
);
|
|
});
|
|
};
|
|
child.on('exit', recordManagedProcessExit);
|
|
child.on('close', recordManagedProcessExit);
|
|
|
|
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;
|
|
}
|
|
attachRuntimeStderrCapture();
|
|
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.
|
|
await registerManagedProcess({
|
|
pid: child.pid,
|
|
ownerPid: process.pid,
|
|
port,
|
|
binary,
|
|
runtime: process.env.OPENCHAMBER_RUNTIME || 'web',
|
|
});
|
|
|
|
return {
|
|
url,
|
|
pid: child.pid || null,
|
|
get exitCode() {
|
|
return observedExitCode ?? child.exitCode;
|
|
},
|
|
get signalCode() {
|
|
return observedSignalCode ?? child.signalCode;
|
|
},
|
|
get stderrTail() {
|
|
return getManagedProcessSnapshot().stderrTail;
|
|
},
|
|
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 probeOpenCodeHealthDetailed = async () => {
|
|
if (!state.openCodeProcess || !state.openCodePort) {
|
|
return {
|
|
healthy: false,
|
|
failure: {
|
|
class: 'error',
|
|
detail: 'Managed OpenCode process or port is unavailable',
|
|
},
|
|
};
|
|
}
|
|
|
|
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 {
|
|
healthy: false,
|
|
failure: {
|
|
class: 'invalid_response',
|
|
detail: `Health endpoint returned HTTP ${response.status ?? 'unknown'}`,
|
|
},
|
|
};
|
|
}
|
|
let body;
|
|
try {
|
|
body = await response.json();
|
|
} catch {
|
|
return {
|
|
healthy: false,
|
|
failure: {
|
|
class: 'invalid_response',
|
|
detail: 'Health endpoint returned invalid JSON',
|
|
},
|
|
};
|
|
}
|
|
if (body?.healthy !== true) {
|
|
return {
|
|
healthy: false,
|
|
failure: {
|
|
class: 'invalid_response',
|
|
detail: 'Health endpoint did not report healthy=true',
|
|
},
|
|
};
|
|
}
|
|
return { healthy: true, failure: null };
|
|
} catch (error) {
|
|
return {
|
|
healthy: false,
|
|
failure: classifyHealthProbeError(error),
|
|
};
|
|
}
|
|
};
|
|
|
|
const isOpenCodeProcessHealthy = async () => (await probeOpenCodeHealthDetailed()).healthy;
|
|
|
|
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: stripAppImageArgv0Leak(applyProviderEnvAliases({
|
|
...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 (reason = 'managed-restart') => {
|
|
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_EFFECTIVE_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`);
|
|
state.openCodeBaseUrl = probeOrigin ?? null;
|
|
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;
|
|
}
|
|
|
|
captureRestartDiagnostics(reason);
|
|
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();
|
|
}
|
|
|
|
// The restart may have landed on a NEW port (the old one can remain
|
|
// occupied if killProcessOnPort/waitForPortRelease didn't free it in
|
|
// time, on any platform). Upstream event readers pinned to the old
|
|
// process would keep the UI silent forever, so rebind them to the
|
|
// current port. Best effort: a failure here must not fail the restart
|
|
// itself.
|
|
try {
|
|
onOpenCodeRestarted?.();
|
|
} catch (error) {
|
|
console.warn('Failed to rebind event stream after OpenCode restart:', error?.message ?? error);
|
|
}
|
|
})();
|
|
|
|
try {
|
|
await state.currentRestartPromise;
|
|
} catch (error) {
|
|
console.error(`Failed to restart OpenCode: ${error.message}`);
|
|
state.lastOpenCodeError = error.message;
|
|
if (!env.ENV_EFFECTIVE_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(reason || 'config-change');
|
|
|
|
// 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;
|
|
}
|
|
|
|
if (healthProbePromise) {
|
|
return healthProbePromise;
|
|
}
|
|
|
|
healthProbePromise = probeOpenCodeHealthDetailed()
|
|
.then((result) => {
|
|
lastHealthProbeResult = { at: now(), ...result };
|
|
return lastHealthProbeResult;
|
|
})
|
|
.finally(() => {
|
|
healthProbePromise = null;
|
|
});
|
|
|
|
return healthProbePromise;
|
|
};
|
|
|
|
const shouldSkipRestartForBusySessions = () => {
|
|
const activeCount = getActiveSessionCount();
|
|
if (activeCount === 0) {
|
|
lastUnhealthyWithBusySessionsAt = 0;
|
|
return { skip: false, staleBusy: false };
|
|
}
|
|
|
|
const checkedAt = now();
|
|
if (!lastUnhealthyWithBusySessionsAt) {
|
|
lastUnhealthyWithBusySessionsAt = checkedAt;
|
|
return { skip: true, staleBusy: false };
|
|
}
|
|
|
|
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 { skip: false, staleBusy: true };
|
|
}
|
|
|
|
return { skip: true, staleBusy: false };
|
|
};
|
|
|
|
const runHealthCheckCycle = async (source) => {
|
|
if (!state.openCodeProcess || state.isShuttingDown || state.isRestartingOpenCode) return;
|
|
if (healthCheckCyclePromise) return healthCheckCyclePromise;
|
|
|
|
healthCheckCyclePromise = (async () => {
|
|
const healthResult = await probeOpenCodeHealth();
|
|
if (!healthResult.healthy) {
|
|
if (!isManagedOpenCodeProcessAlive()) {
|
|
console.log(`[lifecycle] ${source} health check: OpenCode process exited, restarting...`);
|
|
consecutiveHealthFailures = 0;
|
|
lastHealthProbeResult = null;
|
|
await restartOpenCode(`${source}-process-exited`);
|
|
return;
|
|
}
|
|
const checkedAt = now();
|
|
if (lastCountedHealthFailureAt && checkedAt - lastCountedHealthFailureAt < healthFailureCountIntervalMs) {
|
|
return;
|
|
}
|
|
lastCountedHealthFailureAt = checkedAt;
|
|
consecutiveHealthFailures += 1;
|
|
const healthFailure = healthResult.failure || {
|
|
class: 'error',
|
|
detail: 'Health check failed without diagnostic detail',
|
|
};
|
|
state.lastOpenCodeHealthFailure = {
|
|
class: healthFailure.class,
|
|
detail: healthFailure.detail,
|
|
at: new Date(checkedAt).toISOString(),
|
|
source,
|
|
};
|
|
console.warn(
|
|
`[lifecycle] ${source} health check failed (${consecutiveHealthFailures}/${HEALTH_CHECK_MAX_CONSECUTIVE_FAILURES}) class=${healthFailure.class}`
|
|
);
|
|
if (consecutiveHealthFailures < HEALTH_CHECK_MAX_CONSECUTIVE_FAILURES) return;
|
|
const busyDecision = shouldSkipRestartForBusySessions();
|
|
if (busyDecision.skip) return;
|
|
console.log(`[lifecycle] ${source} health check failure threshold reached, restarting OpenCode...`);
|
|
consecutiveHealthFailures = 0;
|
|
lastHealthProbeResult = null;
|
|
await restartOpenCode(
|
|
busyDecision.staleBusy
|
|
? `${source}-stale-busy-health-failure`
|
|
: `${source}-health-failure`,
|
|
);
|
|
} 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,
|
|
};
|
|
};
|