/** * A2A Server Lifecycle Management Module * * Handles process spawning, stdout readiness detection, stderr auth error parsing, * exit event handling, ring buffer diagnostics, concurrent startup lock, and * search counter with forced restart at 1000 searches. */ import { spawn, type ChildProcess } from 'node:child_process'; import { join } from 'node:path'; import { homedir } from 'node:os'; import type { A2AServerState, SearchError } from './types.js'; import { getA2APackageRoot } from './a2a-path.js'; import { checkA2APatched } from './availability.js'; import { isPortInUse, isServerHealthy } from './port-check.js'; import { debugLog } from './logger.js'; // ============================================================================ // Constants // ============================================================================ const A2A_PORT = 41242; const STARTUP_TIMEOUT_MS = 30000; // 30s timeout (12s boot + generous margin) const RING_BUFFER_MAX = 50; const SEARCH_COUNT_RESTART_THRESHOLD = 1000; const READY_MARKER = 'Agent Server started'; // ============================================================================ // State // ============================================================================ /** Singleton state machine */ let serverState: A2AServerState = { status: 'idle', port: A2A_PORT, uptime: null, searchCount: 0, lastError: null, exitCode: null, stdoutBuffer: [], stderrBuffer: [], }; /** Child process reference */ let childProcess: ChildProcess | null = null; /** Start time for uptime calculation */ let startTime: number | null = null; /** Concurrent startup lock - prevents duplicate spawns */ let startupPromise: Promise | null = null; /** Uptime timer reference */ let uptimeTimer: NodeJS.Timeout | null = null; // ============================================================================ // Ring Buffer Implementation // ============================================================================ /** * Pushes a line to a ring buffer, maintaining max length. * When buffer exceeds max length, oldest item is removed. */ function pushToRingBuffer(buffer: string[], line: string, maxLength: number = RING_BUFFER_MAX): void { buffer.push(line); while (buffer.length > maxLength) { buffer.shift(); } } // ============================================================================ // Helper Functions // ============================================================================ /** * Logs a debug message (hidden unless GCS_DEBUG=1, writes to file) */ function log(message: string): void { debugLog('lifecycle', message); } /** * Creates a SearchError object */ function createSearchError(type: SearchError['type'], message: string): SearchError { return { type, message }; } /** * Updates server state atomically */ function updateState(updates: Partial): void { serverState = { ...serverState, ...updates }; } /** * Clears the uptime timer if running */ function clearUptimeTimer(): void { if (uptimeTimer) { clearInterval(uptimeTimer); uptimeTimer = null; } } // ============================================================================ // Core Lifecycle Functions // ============================================================================ /** * Starts the A2A server with fire-and-forget auto-start behavior. * * Implements: * - Concurrent startup lock (rejects if already starting/running) * - Patch verification before spawn * - Stdout readiness detection ("Agent Server started" marker) * - Stderr auth error parsing (FatalAuthenticationError, Interactive terminal required) * - 5s timeout for readiness * - Exit event handling with code capture * - Ring buffer diagnostics (last 50 stdout/stderr lines) * * @returns Promise that resolves when server is running, rejects on error */ export async function startServer(): Promise { // Check if there's an ongoing startup promise (concurrent lock) if (startupPromise) { log('Waiting for ongoing startup to complete'); return startupPromise; } // Check concurrent lock - reject if already running (but not starting - that's handled by promise) if (serverState.status === 'running') { log('Server already running, rejecting duplicate start request'); throw createSearchError('SEARCH_FAILED', 'Server is already running'); } // Fix 10: Check for existing A2A server before spawning log(`Checking for existing A2A server on port ${A2A_PORT}...`); // Health check first - authoritative test for A2A server const healthy = await isServerHealthy(A2A_PORT); if (healthy) { log('Port check result: Healthy A2A server detected, reusing existing server'); // Mark as running without spawning startTime = Date.now(); updateState({ status: 'running', uptime: 0, }); // Start uptime timer uptimeTimer = setInterval(() => { if (startTime && serverState.status === 'running') { updateState({ uptime: Date.now() - startTime }); } }, 1000); return; // Reuse existing server } // Health check failed, but port might still be in use by foreign process const portInUse = await isPortInUse(A2A_PORT); log(`Port check result: Port ${portInUse ? 'IN USE' : 'available'}`); if (portInUse) { log(`Port ${A2A_PORT} is in use but server is not healthy. User should kill the process manually.`); throw createSearchError('A2A_PORT_CONFLICT', `Port ${A2A_PORT} is already in use by another process. Please kill the process using this port and retry.`); } // Create startup promise for concurrent lock startupPromise = (async () => { try { log('Starting A2A server...'); updateState({ status: 'starting', lastError: null, exitCode: null }); // Verify patches and get bundle path const packageRoot = getA2APackageRoot(); const serverPath = packageRoot + '/dist/a2a-server.mjs'; if (!checkA2APatched(serverPath)) { throw createSearchError('A2A_NOT_PATCHED', `A2A patch not found at ${serverPath}`); } // Spawn the server process using node directly with the bundle path // This avoids the isMainModule check failure that occurs when spawning // via 'gemini-cli-a2a-server' symlink (basenames don't match) childProcess = spawn('node', [serverPath], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, USE_CCPA: '1', CODER_AGENT_PORT: String(A2A_PORT), CODER_AGENT_WORKSPACE_PATH: join(homedir(), '.pi', 'agent', 'extensions', 'gemini-cli-search', 'a2a-workspace'), GEMINI_YOLO_MODE: 'true', }, }); let readyResolved = false; let startupTimeoutId: NodeJS.Timeout | null = null; // Set up 30s timeout for readiness (12s boot + generous margin) const timeoutPromise = new Promise((_, reject) => { startupTimeoutId = setTimeout(() => { if (!readyResolved) { log('Startup timeout reached, killing process'); if (childProcess && childProcess.pid) { childProcess.kill('SIGKILL'); } updateState({ status: 'error', lastError: createSearchError('A2A_STARTUP_TIMEOUT', `Server did not start within ${STARTUP_TIMEOUT_MS}ms`) }); reject(createSearchError('A2A_STARTUP_TIMEOUT', `Server did not start within ${STARTUP_TIMEOUT_MS}ms`)); } }, STARTUP_TIMEOUT_MS); }); // Promise that resolves when ready marker is seen const readyPromise = new Promise((resolve, reject) => { // Ensure childProcess is set before attaching listeners if (!childProcess) { reject(createSearchError('A2A_STARTUP_TIMEOUT', 'Child process not created')); return; } // Handle stdout - look for ready marker childProcess.stdout?.on('data', (data: Buffer) => { const lines = data.toString().split('\n'); for (const line of lines) { if (line.trim()) { pushToRingBuffer(serverState.stdoutBuffer, line); log(`stdout: ${line}`); // Check for ready marker if (line.includes(READY_MARKER) && !readyResolved) { readyResolved = true; if (startupTimeoutId) clearTimeout(startupTimeoutId); // Mark as running startTime = Date.now(); updateState({ status: 'running', uptime: 0, }); // Start uptime timer uptimeTimer = setInterval(() => { if (startTime && serverState.status === 'running') { updateState({ uptime: Date.now() - startTime }); } }, 1000); // Attach persistent exit handler for runtime crashes if (childProcess) { childProcess.on('exit', (code, signal) => { if (serverState.status === 'running') { log(`Server crashed unexpectedly with code ${code}, signal ${signal}`); handleExit(code, signal); } }); } log('Server is now running'); resolve(); } } } }); // Handle stderr - look for auth errors childProcess.stderr?.on('data', (data: Buffer) => { const lines = data.toString().split('\n'); for (const line of lines) { if (line.trim()) { pushToRingBuffer(serverState.stderrBuffer, line); log(`stderr: ${line}`); // Check for authentication errors if (line.includes('FatalAuthenticationError') || line.includes('OAuth token expired')) { if (!readyResolved) { readyResolved = true; if (startupTimeoutId) clearTimeout(startupTimeoutId); updateState({ status: 'error', lastError: createSearchError('A2A_AUTH_EXPIRED', `Authentication error: ${line}`) }); reject(createSearchError('A2A_AUTH_EXPIRED', `Authentication error: ${line}`)); } } else if (line.includes('Interactive terminal required')) { if (!readyResolved) { readyResolved = true; if (startupTimeoutId) clearTimeout(startupTimeoutId); updateState({ status: 'error', lastError: createSearchError('A2A_HEADLESS_MISSING', `Headless mode error: ${line}`) }); reject(createSearchError('A2A_HEADLESS_MISSING', `Headless mode error: ${line}`)); } } } } }); // Handle process exit during startup childProcess.on('exit', (code, signal) => { if (!readyResolved) { readyResolved = true; if (startupTimeoutId) clearTimeout(startupTimeoutId); updateState({ status: 'stopped', exitCode: code, lastError: createSearchError('A2A_CRASHED', `Server exited with code ${code}, signal ${signal}`) }); reject(createSearchError('A2A_CRASHED', `Server exited with code ${code}, signal ${signal}`)); } }); // Handle spawn errors (e.g., binary not found) childProcess.on('error', (err: NodeJS.ErrnoException) => { if (!readyResolved) { readyResolved = true; if (startupTimeoutId) clearTimeout(startupTimeoutId); let errorType: SearchError['type'] = 'A2A_STARTUP_TIMEOUT'; let errorMessage = `Failed to spawn server: ${err.message}`; if (err.code === 'ENOENT') { errorType = 'A2A_NOT_INSTALLED'; errorMessage = 'gemini-cli-a2a-server binary not found. Please install with: npm install -g @google/gemini-cli-a2a-server'; } updateState({ status: 'error', lastError: createSearchError(errorType, errorMessage) }); reject(createSearchError(errorType, errorMessage)); } }); }); // Race between ready and timeout await Promise.race([readyPromise, timeoutPromise]); } catch (error) { // Update state to error updateState({ status: 'error', lastError: error as SearchError }); // Clean up on error clearUptimeTimer(); if (childProcess && childProcess.pid) { childProcess.kill('SIGKILL'); } childProcess = null; throw error; } finally { // Release concurrent lock startupPromise = null; } })(); return startupPromise; } /** * Stops the A2A server gracefully. * * Sends SIGTERM first, then SIGKILL if needed. * Updates state to 'stopped' with null exitCode (graceful stop). * * @returns Promise that resolves when server is stopped */ export async function stopServer(): Promise { log('Stopping A2A server...'); clearUptimeTimer(); if (!childProcess) { log('No server process to stop'); updateState({ status: 'stopped' }); return; } const processToStop = childProcess; return new Promise((resolve) => { let resolved = false; const finishStop = () => { if (!resolved) { resolved = true; updateState({ status: 'stopped', exitCode: null }); childProcess = null; startTime = null; log('Server stopped'); resolve(); } }; // Set up exit handler processToStop.once('exit', () => { finishStop(); }); // Send SIGTERM for graceful shutdown processToStop.kill('SIGTERM'); // Escalate to SIGKILL after 3s if still running setTimeout(() => { if (childProcess && !childProcess.killed) { log('Graceful shutdown timed out, sending SIGKILL'); childProcess.kill('SIGKILL'); } }, 3000); // Force resolve after 5s regardless setTimeout(() => { finishStop(); }, 5000); }); } /** * Returns the current A2A server state. * * Provides complete diagnostic visibility including: * - Current status (idle|starting|running|stopped|error) * - Port number * - Uptime in milliseconds * - Search count * - Last error (if any) * - Exit code (if stopped) * - Last 50 stdout lines * - Last 50 stderr lines * * @returns Current A2AServerState */ export function getServerState(): A2AServerState { return { ...serverState }; } /** * Returns the current search count. * * @returns Number of searches processed since session start */ export function getSearchCount(): number { return serverState.searchCount; } /** * Increments the search counter and triggers auto-restart at 1000. * * When search count reaches 1000: * 1. Calls stopServer() to gracefully shut down * 2. Resets search count to 0 * 3. Calls startServer() to restart * * @returns Promise that resolves after increment (and restart if needed) */ export async function incrementSearchCount(): Promise { const newCount = serverState.searchCount + 1; updateState({ searchCount: newCount }); log(`Search count incremented to ${newCount}`); // Check if restart is needed if (newCount >= SEARCH_COUNT_RESTART_THRESHOLD) { log(`Search count reached ${SEARCH_COUNT_RESTART_THRESHOLD}, triggering restart`); // Stop the server await stopServer(); // Reset counter updateState({ searchCount: 0 }); // Restart the server await startServer(); log('Server restarted with search count reset to 0'); } } /** * Resets the search counter to 0 without restarting. * Useful for manual resets or testing. */ export function resetSearchCount(): void { updateState({ searchCount: 0 }); log('Search count reset to 0'); } /** * Handles child process exit events. * Captures exit code, updates state to 'stopped', preserves buffers. * Called automatically by the spawn logic, exposed for testing. * * @param code - Exit code from child process * @param signal - Signal that caused termination */ export function handleExit(code: number | null, signal: NodeJS.Signals | null): void { log(`Child process exited with code ${code}, signal ${signal}`); clearUptimeTimer(); // Check if this was an unexpected crash (non-zero exit while running) const wasRunning = serverState.status === 'running'; const crashed = wasRunning && code !== null && code !== 0; updateState({ status: 'stopped', exitCode: code, }); // Set error if it was a crash if (crashed) { updateState({ lastError: createSearchError('A2A_CRASHED', `Server crashed with exit code ${code}`) }); } childProcess = null; startTime = null; } /** * Exports for testing - allows tests to mock internal state */ export const __testing__ = { getState: () => serverState, setState: (state: A2AServerState) => { serverState = state; }, getChildProcess: () => childProcess, setChildProcess: (child: ChildProcess | null) => { childProcess = child; }, getStartTime: () => startTime, setStartTime: (time: number | null) => { startTime = time; }, getStartupPromise: () => startupPromise, setStartupPromise: (promise: Promise | null) => { startupPromise = promise; }, clearUptimeTimer, pushToRingBuffer, handleExit, resetSearchCount, };