diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts index 78f8caa1..c9710deb 100644 --- a/backend/src/routes/health.ts +++ b/backend/src/routes/health.ts @@ -79,18 +79,22 @@ export function createHealthRoutes(db: Database, openCodeSupervisor?: OpenCodeSu : null const opencodeHealthy = lifecycle?.healthy ?? await opencodeServerManager.checkHealth() const startupError = lifecycle?.lastError ?? opencodeServerManager.getLastStartupError() + const ready = lifecycle ? lifecycle.ready : opencodeHealthy const status = lifecycle?.state === 'recovering' ? 'degraded' : startupError && !opencodeHealthy ? 'unhealthy' + : opencodeHealthy && !ready + ? 'degraded' : (dbCheck && opencodeHealthy ? 'healthy' : 'degraded') const response: Record = { status, timestamp: new Date().toISOString(), database: dbCheck ? 'connected' : 'disconnected', - opencode: opencodeHealthy ? 'healthy' : 'unhealthy', + opencode: !opencodeHealthy ? 'unhealthy' : (ready ? 'healthy' : 'busy'), + opencodeReady: ready, opencodePort: opencodeServerManager.getPort(), opencodeVersion: opencodeServerManager.getVersion(), opencodeMinVersion: opencodeServerManager.getMinVersion(), diff --git a/backend/src/services/opencode-single-server.ts b/backend/src/services/opencode-single-server.ts index 49827ef3..0edf9aa2 100644 --- a/backend/src/services/opencode-single-server.ts +++ b/backend/src/services/opencode-single-server.ts @@ -28,9 +28,10 @@ import { writeFileContent } from './file-operations' const MIN_OPENCODE_VERSION = '1.0.137' const MAX_STDERR_SIZE = 10240 -const HEALTH_CHECK_TIMEOUT_MS = 3000 const DEPRECATED_PLUGIN_PACKAGES = ['opencode-openai-codex-auth', 'opencode-copilot-auth'] +export interface ServerExitInfo { code: number | null; signal: NodeJS.Signals | null } + type StartupValidationIssue = { path: string message: string @@ -96,6 +97,7 @@ const getOpenCodeServerPort = () => ENV.OPENCODE.PORT const getOpenCodeServerHost = () => ENV.OPENCODE.HOST const getOpenCodeServerPublicUrl = () => ENV.OPENCODE.PUBLIC_URL const getOpenCodeServerUsername = () => ENV.OPENCODE.SERVER_USERNAME +const getHealthProbeTimeoutMs = () => ENV.OPENCODE.HEALTH_PROBE_TIMEOUT_MS ?? 10000 class OpenCodeServerManager { private static instance: OpenCodeServerManager @@ -107,6 +109,8 @@ class OpenCodeServerManager { private lastStartupError: string | null = null private opInProgress: boolean = false private openCodeClient: OpenCodeClient | null = null + private expectedExit = false + private exitListeners = new Set<(info: ServerExitInfo) => void>() private constructor() {} @@ -173,6 +177,22 @@ class OpenCodeServerManager { return this.opInProgress } + isProcessAlive(): boolean { + if (this.serverPid === null) return false + try { + process.kill(this.serverPid, 0) + return true + } catch (error) { + const code = error && typeof error === 'object' && 'code' in error ? (error as { code: string }).code : '' + return code === 'EPERM' + } + } + + onUnexpectedExit(listener: (info: ServerExitInfo) => void): () => void { + this.exitListeners.add(listener) + return () => { this.exitListeners.delete(listener) } + } + async start(retryAfterPluginInstall = true, allowNested = false): Promise { const acquired = this.acquireOp() if (!acquired && !allowNested) { @@ -257,6 +277,23 @@ class OpenCodeServerManager { return } } else { + const isAlive = existingProcesses.some(proc => { + try { + process.kill(proc.pid, 0) + return true + } catch (error) { + const code = error && typeof error === 'object' && 'code' in error ? (error as { code: string }).code : '' + return code === 'EPERM' + } + }) + if (isAlive) { + logger.warn('OpenCode server is alive but not answering readiness probe (busy); keeping existing process') + this.isHealthy = true + if (existingProcesses[0]) { + this.serverPid = existingProcesses[0].pid + } + return + } logger.warn('Killing unhealthy OpenCode server') for (const proc of existingProcesses) { try { @@ -373,7 +410,10 @@ class OpenCodeServerManager { }) } + const child = this.serverProcess this.serverProcess.on('exit', (code, signal) => { + if (this.serverProcess !== child) return + const wasExpected = this.expectedExit if (code !== null && code !== 0) { const fallback = `Server exited with code ${code}${stderrOutput ? `: ${stderrOutput.slice(-500)}` : ''}` this.lastStartupError = formatStartupError(stderrOutput, fallback) @@ -382,15 +422,27 @@ class OpenCodeServerManager { this.lastStartupError = `Server terminated by signal ${signal}` logger.error('OpenCode server process terminated:', this.lastStartupError) } + this.isHealthy = false + this.serverPid = null + if (!wasExpected) { + this.exitListeners.forEach((listener) => { try { listener({ code, signal }) } catch { /* ignore */ } }) + } }) this.serverPid = this.serverProcess.pid ?? null + this.expectedExit = false logger.info(`OpenCode server started with PID ${this.serverPid}`) const healthTimeoutMs = configuredPluginCount > 0 ? 120000 : 30000 const healthy = await this.waitForHealth(healthTimeoutMs) if (!healthy) { + if (this.isProcessAlive()) { + logger.warn(`OpenCode server process is alive but not answering readiness probe after ${Math.round(healthTimeoutMs / 1000)}s; treating as busy`) + this.isHealthy = true + return + } + const fallback = `Server failed to become healthy after ${Math.round(healthTimeoutMs / 1000)}s${stderrOutput ? `. Last error: ${stderrOutput.slice(-500)}` : ''}` this.lastStartupError = formatStartupError(stderrOutput, fallback) if (configuredPluginCount > 0 && retryAfterPluginInstall) { @@ -429,6 +481,7 @@ class OpenCodeServerManager { if (!this.serverPid) return logger.info('Stopping OpenCode server') + this.expectedExit = true try { process.kill(this.serverPid, 'SIGTERM') } catch (error) { @@ -705,7 +758,7 @@ class OpenCodeServerManager { const response = await this.openCodeClient.forward({ method: 'GET', path: '/doc', - signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), + signal: AbortSignal.timeout(getHealthProbeTimeoutMs()), }) return response.ok } catch { diff --git a/backend/src/services/opencode-supervisor.ts b/backend/src/services/opencode-supervisor.ts index fb0404e6..313b0ab6 100644 --- a/backend/src/services/opencode-supervisor.ts +++ b/backend/src/services/opencode-supervisor.ts @@ -34,10 +34,12 @@ export type OpenCodeOperationReason = | 'settings_restart' | 'settings_reload' | 'manual' + | 'crash' export interface OpenCodeLifecycleStatus { state: OpenCodeLifecycleState healthy: boolean + ready: boolean port: number version: string | null minVersion: string @@ -66,6 +68,8 @@ export class OpenCodeSupervisor { private attemptedRecoveryActions: OpenCodeRecoveryAction[] = [] private consecutiveFailures = 0 private operationInProgress = false + private ready = false + private exitListenerRegistered = false private updatedAt = new Date().toISOString() constructor( @@ -88,17 +92,27 @@ export class OpenCodeSupervisor { } async start(): Promise { + if (!this.exitListenerRegistered) { + this.exitListenerRegistered = true + this.openCodeServerManager.onUnexpectedExit(() => { void this.handleUnexpectedExit() }) + } + await this.runLifecycleOperation(async () => { this.setState('starting') try { await this.openCodeServerManager.start() - const healthy = await this.openCodeServerManager.checkHealth() - if (healthy) { + const ready = await this.openCodeServerManager.checkHealth() + if (ready) { this.markHealthy() return this.getStatus() } + if (this.openCodeServerManager.isProcessAlive()) { + this.markBusy() + return this.getStatus() + } + this.recordFailure('OpenCode server failed to become healthy during startup') } catch (error) { this.recordFailure(error) @@ -161,6 +175,7 @@ export class OpenCodeSupervisor { await this.runLifecycleOperation(async () => { this.setState('stopping') + this.ready = false await this.openCodeServerManager.stop() this.setState('stopped') return this.getStatus() @@ -177,6 +192,7 @@ export class OpenCodeSupervisor { return { state: this.state, healthy: this.state === 'healthy', + ready: this.ready, port: this.openCodeServerManager.getPort(), version: this.openCodeServerManager.getVersion(), minVersion: this.openCodeServerManager.getMinVersion(), @@ -206,15 +222,22 @@ export class OpenCodeSupervisor { } private async refreshHealthOrRecover(reason: OpenCodeOperationReason, respectThreshold = false): Promise { - const healthy = await this.openCodeServerManager.checkHealth() - if (healthy) { + const ready = await this.openCodeServerManager.checkHealth() + if (ready) { this.markHealthy() return this.getStatus() } + if (this.openCodeServerManager.isProcessAlive()) { + logger.warn('OpenCode is alive but not answering readiness probe (busy); skipping restart') + this.markBusy() + return this.getStatus() + } + this.consecutiveFailures += 1 + this.ready = false this.setState('unhealthy') - this.lastError = this.openCodeServerManager.getLastStartupError() ?? 'OpenCode health check failed' + this.lastError = this.openCodeServerManager.getLastStartupError() ?? 'OpenCode process is not running' if (respectThreshold && this.consecutiveFailures < this.failureThreshold) { return this.getStatus() @@ -253,6 +276,20 @@ export class OpenCodeSupervisor { return this.getStatus() } + private async handleUnexpectedExit(): Promise { + if (!this.isWatchEnabled()) return + if (this.operationInProgress || this.openCodeServerManager.isOperationInProgress()) return + + logger.warn('OpenCode server exited unexpectedly; starting recovery') + await this.runLifecycleOperation(async () => { + this.consecutiveFailures += 1 + this.ready = false + this.setState('unhealthy') + this.lastError = this.openCodeServerManager.getLastStartupError() ?? 'OpenCode server exited unexpectedly' + return this.recover('crash') + }) + } + private async runRecoveryAction(action: OpenCodeRecoveryAction): Promise { if (action === 'restart') { await this.openCodeServerManager.restart() @@ -352,6 +389,17 @@ export class OpenCodeSupervisor { private markHealthy(): void { this.state = 'healthy' + this.ready = true + this.lastError = null + this.activeRecoveryAction = null + this.attemptedRecoveryActions = [] + this.consecutiveFailures = 0 + this.touch() + } + + private markBusy(): void { + this.state = 'healthy' + this.ready = false this.lastError = null this.activeRecoveryAction = null this.attemptedRecoveryActions = [] @@ -361,6 +409,7 @@ export class OpenCodeSupervisor { private recordFailure(error: unknown): void { this.consecutiveFailures += 1 + this.ready = false this.lastError = error instanceof Error ? error.message : this.openCodeServerManager.getLastStartupError() ?? 'Unknown OpenCode lifecycle error' diff --git a/backend/test/routes/health.test.ts b/backend/test/routes/health.test.ts index 5f4bc479..c0df246c 100644 --- a/backend/test/routes/health.test.ts +++ b/backend/test/routes/health.test.ts @@ -99,6 +99,40 @@ describe('Health Routes', () => { expect(json.database).toBe('disconnected') }) + it('should return degraded status with opencode busy when supervisor reports healthy but not ready', async () => { + mockDb.prepare().get.mockReturnValue({ 1: 1 }) + ;(opencodeServerManager.getLastStartupError as ReturnType).mockReturnValueOnce(null) + + const fakeSupervisor = { + checkNow: vi.fn().mockResolvedValueOnce({ + state: 'healthy', + healthy: true, + ready: false, + port: 5551, + version: '1.0.0', + minVersion: '1.0.137', + versionSupported: true, + lastError: null, + activeRecoveryAction: null, + attemptedRecoveryActions: [], + nextRecoveryAction: null, + failureCount: 0, + watching: true, + updatedAt: new Date().toISOString(), + }), + } + + const healthAppWithSupervisor = createHealthRoutes(mockDb, fakeSupervisor as any) + const req = new Request('http://localhost/') + const res = await healthAppWithSupervisor.fetch(req) + const json = await res.json() as Record + + expect(res.status).toBe(200) + expect(json.status).toBe('degraded') + expect(json.opencode).toBe('busy') + expect(json.opencodeReady).toBe(false) + }) + it('should return 503 when health check throws an error', async () => { mockDb.prepare().get.mockImplementationOnce(() => { throw new Error('Database error') diff --git a/backend/test/services/opencode-single-server.test.ts b/backend/test/services/opencode-single-server.test.ts index 6429d809..dd28748f 100644 --- a/backend/test/services/opencode-single-server.test.ts +++ b/backend/test/services/opencode-single-server.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest' +const spawnExitHandlers: Record void> = {} + const createOpenCodeClientMock = vi.hoisted(() => vi.fn(() => ({ forward: vi.fn().mockResolvedValue(new Response(null, { status: 200 })), forwardRaw: vi.fn(), @@ -14,7 +16,7 @@ const createOpenCodeClientMock = vi.hoisted(() => vi.fn(() => ({ const spawnMock = vi.hoisted(() => vi.fn(() => ({ pid: 1234, stderr: null, - on: vi.fn(), + on: (event: string, cb: (code: number | null, signal: NodeJS.Signals | null) => void) => { spawnExitHandlers[event] = cb }, }))) vi.mock('bun:sqlite', () => ({ @@ -32,7 +34,7 @@ vi.mock('@opencode-manager/shared/config/env', () => ({ SERVER: { PORT: 5003, HOST: '0.0.0.0', NODE_ENV: 'test' }, AUTH: { TRUSTED_ORIGINS: 'http://localhost:5173', SECRET: 'test-secret-for-encryption-key-32c' }, WORKSPACE: { BASE_PATH: '/test/workspace', REPOS_DIR: 'repos', CONFIG_DIR: 'config', AUTH_FILE: 'auth.json' }, - OPENCODE: { PORT: 5551, HOST: '127.0.0.1', SERVER_PASSWORD: '', SERVER_USERNAME: 'opencode', PUBLIC_URL: '' }, + OPENCODE: { PORT: 5551, HOST: '127.0.0.1', SERVER_PASSWORD: '', SERVER_USERNAME: 'opencode', PUBLIC_URL: '', HEALTH_PROBE_TIMEOUT_MS: 100 }, DATABASE: { PATH: ':memory:' }, FILE_LIMITS: { MAX_SIZE_BYTES: 1024 * 1024, @@ -413,3 +415,159 @@ describe('OpenCodeServerManager - checkHealth', () => { expect(aborted).toBe(true) }, 5000) }) + +describe('OpenCodeServerManager - isProcessAlive', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns false when serverPid is null', async () => { + const mod = await import('../../src/services/opencode-single-server') + mod.OpenCodeServerManager.resetInstance() + const freshManager = mod.OpenCodeServerManager.getInstance() + expect(freshManager.isProcessAlive()).toBe(false) + }) + + it('returns true when process.kill(pid, 0) succeeds', async () => { + const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true) + const { opencodeServerManager } = await import('../../src/services/opencode-single-server') + await opencodeServerManager.start() + expect(opencodeServerManager.isProcessAlive()).toBe(true) + killSpy.mockRestore() + }) + + it('returns false when process.kill throws ESRCH', async () => { + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { throw { code: 'ESRCH' } }) + const { opencodeServerManager } = await import('../../src/services/opencode-single-server') + await opencodeServerManager.start() + expect(opencodeServerManager.isProcessAlive()).toBe(false) + killSpy.mockRestore() + }) + + it('returns true when process.kill throws EPERM', async () => { + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { throw { code: 'EPERM' } }) + const { opencodeServerManager } = await import('../../src/services/opencode-single-server') + await opencodeServerManager.start() + expect(opencodeServerManager.isProcessAlive()).toBe(true) + killSpy.mockRestore() + }) +}) + +describe('OpenCodeServerManager - spawned alive but not ready', () => { + let previousExitHandler: ((code: number | null, signal: NodeJS.Signals | null) => void) | undefined + + beforeEach(async () => { + // Save and clear the shared exit handler to prevent cross-test contamination + previousExitHandler = spawnExitHandlers.exit + delete spawnExitHandlers.exit + vi.clearAllMocks() + const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') + OpenCodeServerManager.resetInstance() + }) + + afterEach(() => { + // Restore the previous exit handler so subsequent tests are unaffected + if (previousExitHandler) { + spawnExitHandlers.exit = previousExitHandler + } + vi.clearAllMocks() + }) + + it('does not throw when spawned process is alive but not ready after waitForHealth timeout', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout'] }) + + const mod = await import('../../src/services/opencode-single-server') + const freshManager = mod.OpenCodeServerManager.getInstance() + + // Spy on checkHealth to return false (not ready) + vi.spyOn(freshManager, 'checkHealth').mockResolvedValue(false) + + // Make process.kill succeed (process is alive) + const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true) + + const startPromise = freshManager.start() + + // Advance time past the 30s health check timeout + await vi.advanceTimersByTimeAsync(31000) + + await startPromise + + // Process should be tracked as alive and healthy + expect(freshManager.isProcessAlive()).toBe(true) + // checkHealth still returns false (not serving yet) + expect(await freshManager.checkHealth()).toBe(false) + + vi.useRealTimers() + killSpy.mockRestore() + }) + + it('throws when spawned process is dead and not healthy after waitForHealth timeout', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout'] }) + + const mod = await import('../../src/services/opencode-single-server') + mod.OpenCodeServerManager.resetInstance() + const freshManager = mod.OpenCodeServerManager.getInstance() + + // Spy on checkHealth to return false (not ready) + vi.spyOn(freshManager, 'checkHealth').mockResolvedValue(false) + + // Make process.kill throw ESRCH (process dead) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { throw { code: 'ESRCH' } }) + + const startPromise = freshManager.start() + // Attach rejection handler BEFORE advancing timers to avoid unhandled rejection + const rejectionAssertion = expect(startPromise).rejects.toThrow('OpenCode server failed to become healthy') + + await vi.advanceTimersByTimeAsync(31000) + + await rejectionAssertion + + vi.useRealTimers() + killSpy.mockRestore() + }) +}) + +describe('OpenCodeServerManager - unexpected exit notification', () => { + beforeEach(async () => { + vi.clearAllMocks() + // Make execSync throw so findProcessesByPort returns empty and start() spawns + execSyncMock.mockImplementation(() => { throw new Error('no process') }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('notifies listeners on unexpected exit', async () => { + const { opencodeServerManager } = await import('../../src/services/opencode-single-server') + const listener = vi.fn() + opencodeServerManager.onUnexpectedExit(listener) + // Start will capture the exit handler in spawnExitHandlers + await opencodeServerManager.start() + + spawnExitHandlers.exit!(1, null) + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith({ code: 1, signal: null }) + expect(opencodeServerManager.isProcessAlive()).toBe(false) + }) + + it('does not notify on expected exit after stop', async () => { + vi.useFakeTimers() + const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true) + const { opencodeServerManager } = await import('../../src/services/opencode-single-server') + const listener = vi.fn() + opencodeServerManager.onUnexpectedExit(listener) + await opencodeServerManager.start() + + const stopPromise = opencodeServerManager.stop() + await vi.advanceTimersByTimeAsync(2000) + await stopPromise + + spawnExitHandlers.exit!(null, 'SIGKILL') + + expect(listener).not.toHaveBeenCalled() + vi.useRealTimers() + killSpy.mockRestore() + }) +}) diff --git a/backend/test/services/opencode-supervisor.test.ts b/backend/test/services/opencode-supervisor.test.ts index 03c96722..d86c6cbd 100644 --- a/backend/test/services/opencode-supervisor.test.ts +++ b/backend/test/services/opencode-supervisor.test.ts @@ -31,6 +31,8 @@ interface FakeManager { start: ReturnType stop: ReturnType isOperationInProgress: ReturnType + isProcessAlive: ReturnType + onUnexpectedExit: ReturnType checkHealth: ReturnType restart: ReturnType reloadConfig: ReturnType @@ -55,6 +57,8 @@ describe('OpenCodeSupervisor', () => { start: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined), isOperationInProgress: vi.fn(() => false), + isProcessAlive: vi.fn(() => false), + onUnexpectedExit: vi.fn(), checkHealth: vi.fn().mockResolvedValue(true), restart: vi.fn().mockResolvedValue(undefined), reloadConfig: vi.fn().mockResolvedValue(undefined), @@ -131,6 +135,7 @@ describe('OpenCodeSupervisor', () => { }) manager.checkHealth.mockResolvedValueOnce(false) + manager.isProcessAlive.mockReturnValue(false) const status = await supervisor.checkNow('api_probe') @@ -147,6 +152,7 @@ describe('OpenCodeSupervisor', () => { watchEnabled: false, }) + manager.isProcessAlive.mockReturnValue(false) manager.checkHealth .mockResolvedValueOnce(false) .mockResolvedValueOnce(false) @@ -170,5 +176,92 @@ describe('OpenCodeSupervisor', () => { await supervisor.checkNow('api_probe') expect(manager.checkHealth).not.toHaveBeenCalled() + expect(manager.isProcessAlive).not.toHaveBeenCalled() + }) + + it('does not restart a busy-but-alive server', async () => { + const manager = createManager() + const settings = createSettings() + const supervisor = new OpenCodeSupervisor(manager as unknown as never, settings as unknown as never, { + failureThreshold: 1, + watchEnabled: false, + }) + + manager.checkHealth.mockResolvedValue(false) + manager.isProcessAlive.mockReturnValue(true) + + await supervisor.checkNow('api_probe') + await supervisor.checkNow('api_probe') + const status3 = await supervisor.checkNow('api_probe') + + expect(manager.restart).not.toHaveBeenCalled() + expect(manager.stop).not.toHaveBeenCalled() + expect(status3.ready).toBe(false) + expect(status3.healthy).toBe(true) + expect(status3.failureCount).toBe(0) + }) + + it('recovers when the process is actually dead', async () => { + const manager = createManager() + const settings = createSettings() + const supervisor = new OpenCodeSupervisor(manager as unknown as never, settings as unknown as never, { + failureThreshold: 1, + watchEnabled: false, + }) + + manager.checkHealth + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + manager.isProcessAlive.mockReturnValue(false) + + await supervisor.checkNow('api_probe') + + expect(manager.restart).toHaveBeenCalled() + }) + + it('restarts immediately on unexpected exit', async () => { + const manager = createManager() + const settings = createSettings() + const supervisor = new OpenCodeSupervisor(manager as unknown as never, settings as unknown as never, { + failureThreshold: 1, + watchEnabled: true, + }) + + manager.checkHealth.mockResolvedValue(true) + await supervisor.start() + + const exitCallback = manager.onUnexpectedExit.mock.calls[0]?.[0] + expect(exitCallback).toBeDefined() + + manager.restart.mockClear() + manager.checkHealth.mockClear() + + exitCallback({ code: null, signal: 'SIGTERM' }) + + await vi.waitFor(() => { + expect(supervisor.getStatus().healthy).toBe(true) + }) + + expect(manager.restart).toHaveBeenCalled() + expect(supervisor.getStatus().ready).toBe(true) + }) + + it('reports ready=false after being stopped', async () => { + const manager = createManager() + const settings = createSettings() + const supervisor = new OpenCodeSupervisor(manager as unknown as never, settings as unknown as never, { + failureThreshold: 1, + watchEnabled: false, + }) + + manager.checkHealth.mockResolvedValue(true) + await supervisor.start() + + expect(supervisor.getStatus().ready).toBe(true) + + await supervisor.stop() + + expect(supervisor.getStatus().ready).toBe(false) }) }) diff --git a/shared/src/config/defaults.ts b/shared/src/config/defaults.ts index d0a3a330..35200c1a 100644 --- a/shared/src/config/defaults.ts +++ b/shared/src/config/defaults.ts @@ -17,6 +17,7 @@ export const DEFAULTS = { HEALTH_WATCH_ENABLED: true, HEALTH_POLL_MS: 30000, HEALTH_FAILURE_THRESHOLD: 2, + HEALTH_PROBE_TIMEOUT_MS: 10000, }, DATABASE: { diff --git a/shared/src/config/env.ts b/shared/src/config/env.ts index 0c8b8f3d..3260bd40 100644 --- a/shared/src/config/env.ts +++ b/shared/src/config/env.ts @@ -59,6 +59,7 @@ export const ENV = { HEALTH_WATCH_ENABLED: getEnvBoolean('OPENCODE_HEALTH_WATCH_ENABLED', defaultHealthWatchEnabled), HEALTH_POLL_MS: getEnvNumber('OPENCODE_HEALTH_POLL_MS', DEFAULTS.OPENCODE.HEALTH_POLL_MS), HEALTH_FAILURE_THRESHOLD: getEnvNumber('OPENCODE_HEALTH_FAILURE_THRESHOLD', DEFAULTS.OPENCODE.HEALTH_FAILURE_THRESHOLD), + HEALTH_PROBE_TIMEOUT_MS: getEnvNumber('OPENCODE_HEALTH_PROBE_TIMEOUT_MS', DEFAULTS.OPENCODE.HEALTH_PROBE_TIMEOUT_MS), SERVER_PASSWORD: getEnvString('OPENCODE_SERVER_PASSWORD', ''), SERVER_USERNAME: getEnvString('OPENCODE_SERVER_USERNAME', 'opencode'), },