diff --git a/crates/goose-server/src/commands/agent.rs b/crates/goose-server/src/commands/agent.rs index 26bb09158516..03f9ac1d5df8 100644 --- a/crates/goose-server/src/commands/agent.rs +++ b/crates/goose-server/src/commands/agent.rs @@ -9,6 +9,10 @@ use goose_server::tls::setup_tls; use tower_http::cors::{Any, CorsLayer}; use tracing::info; +fn boot_marker(message: &str) { + eprintln!("GOOSED_BOOT: {message}"); +} + #[cfg(unix)] async fn shutdown_signal() { use tokio::signal::unix::{signal, SignalKind}; @@ -35,6 +39,7 @@ pub async fn run() -> Result<()> { #[cfg(feature = "rustls-tls")] let _ = rustls::crypto::ring::default_provider().install_default(); + boot_marker("main entered"); crate::logging::setup_logging(Some("goosed"))?; let settings = configuration::Settings::new()?; @@ -42,6 +47,7 @@ pub async fn run() -> Result<()> { let secret_key = std::env::var("GOOSE_SERVER__SECRET_KEY") .unwrap_or_else(|_| hex::encode(rand::random::<[u8; 32]>())); + boot_marker("appstate init start"); let app_state = state::AppState::new(settings.tls).await?; // Share the server secret with the tunnel manager so it uses the same @@ -78,6 +84,7 @@ pub async fn run() -> Result<()> { if settings.tls { #[cfg(any(feature = "rustls-tls", feature = "native-tls"))] { + boot_marker("tls setup start"); let tls_setup = setup_tls( settings.tls_cert_path.as_deref(), settings.tls_key_path.as_deref(), @@ -92,6 +99,7 @@ pub async fn run() -> Result<()> { }); info!("listening on https://{}", addr); + boot_marker("listening"); #[cfg(feature = "rustls-tls")] axum_server::bind_rustls(addr, tls_setup.config) @@ -114,9 +122,11 @@ pub async fn run() -> Result<()> { ); } } else { + boot_marker("tcp bind start"); let listener = tokio::net::TcpListener::bind(addr).await?; info!("listening on http://{}", addr); + boot_marker("listening"); axum::serve(listener, app) .with_graceful_shutdown(async { shutdown_signal().await }) diff --git a/crates/goose-server/src/main.rs b/crates/goose-server/src/main.rs index 66a203694b7f..bbb12fe9489f 100644 --- a/crates/goose-server/src/main.rs +++ b/crates/goose-server/src/main.rs @@ -9,6 +9,7 @@ mod state; mod tunnel; use std::path::PathBuf; +use std::{backtrace::Backtrace, panic::PanicHookInfo}; use clap::{Parser, Subcommand}; use goose::agents::validate_extensions; @@ -42,9 +43,42 @@ enum Commands { }, } +fn boot_marker(message: &str) { + eprintln!("GOOSED_BOOT: {message}"); +} + +fn install_panic_hook() { + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info: &PanicHookInfo<'_>| { + let location = panic_info + .location() + .map(|location| format!("{}:{}", location.file(), location.line())) + .unwrap_or_else(|| "unknown".to_string()); + + let payload = panic_info + .payload() + .downcast_ref::<&str>() + .map(|msg| (*msg).to_string()) + .or_else(|| panic_info.payload().downcast_ref::().cloned()) + .unwrap_or_else(|| "unknown panic payload".to_string()); + + eprintln!("GOOSED_BOOT: panic at {location}: {payload}"); + eprintln!("GOOSED_BOOT: backtrace:\n{}", Backtrace::force_capture()); + + default_hook(panic_info); + })); +} + #[tokio::main] async fn main() -> anyhow::Result<()> { + install_panic_hook(); + boot_marker("main entered"); + let cli = Cli::parse(); + boot_marker(&format!( + "command parsed: {:?}", + std::mem::discriminant(&cli.command) + )); match cli.command { Commands::Agent => { diff --git a/crates/goose-server/src/routes/local_inference.rs b/crates/goose-server/src/routes/local_inference.rs index 1248dff1f462..b7a7dfbde6ab 100644 --- a/crates/goose-server/src/routes/local_inference.rs +++ b/crates/goose-server/src/routes/local_inference.rs @@ -230,7 +230,8 @@ pub async fn sync_featured_models() -> Result { pub async fn list_local_models( axum::extract::State(state): axum::extract::State>, ) -> Result>, ErrorResponse> { - let recommended_id = recommend_local_model(&state.inference_runtime); + let runtime = state.get_inference_runtime()?; + let recommended_id = recommend_local_model(&runtime); let registry = get_registry() .lock() @@ -360,7 +361,8 @@ pub async fn get_repo_files( .await .map_err(|e| ErrorResponse::internal(format!("Failed to fetch repo files: {}", e)))?; - let available_memory = available_inference_memory_bytes(&state.inference_runtime); + let runtime = state.get_inference_runtime()?; + let available_memory = available_inference_memory_bytes(&runtime); let recommended_index = hf_models::recommend_variant(&variants, available_memory); let downloaded_quants = { diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index 73bb1726acad..3be8934adbce 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -5,7 +5,7 @@ use goose::scheduler_trait::SchedulerTrait; use goose::session::SessionManager; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use tokio::sync::Mutex; use tokio::task::JoinHandle; @@ -28,7 +28,7 @@ pub struct AppState { pub gateway_manager: Arc, pub extension_loading_tasks: ExtensionLoadingTasks, #[cfg(feature = "local-inference")] - pub inference_runtime: Arc, + inference_runtime: Arc>>, session_buses: Arc>>>, } @@ -48,11 +48,31 @@ impl AppState { gateway_manager, extension_loading_tasks: Arc::new(Mutex::new(HashMap::new())), #[cfg(feature = "local-inference")] - inference_runtime: InferenceRuntime::get_or_init(), + inference_runtime: Arc::new(OnceLock::new()), session_buses: Arc::new(Mutex::new(HashMap::new())), })) } + #[cfg(feature = "local-inference")] + pub fn get_inference_runtime(&self) -> anyhow::Result> { + if let Some(runtime) = self.inference_runtime.get() { + return Ok(runtime.clone()); + } + + let runtime = InferenceRuntime::get_or_init()?; + + // Another thread may win the race to cache the runtime in AppState. + // In that case, return the already-initialized cached runtime. + match self.inference_runtime.set(runtime.clone()) { + Ok(()) => Ok(runtime), + Err(_) => Ok(self + .inference_runtime + .get() + .expect("inference runtime initialized by another thread") + .clone()), + } + } + pub async fn set_extension_loading_task( &self, session_id: String, diff --git a/crates/goose/src/providers/local_inference.rs b/crates/goose/src/providers/local_inference.rs index 1115c4b2a20f..2f72616d7237 100644 --- a/crates/goose/src/providers/local_inference.rs +++ b/crates/goose/src/providers/local_inference.rs @@ -62,10 +62,10 @@ pub struct InferenceRuntime { static RUNTIME: StdMutex> = StdMutex::new(Weak::new()); impl InferenceRuntime { - pub fn get_or_init() -> Arc { + pub fn get_or_init() -> Result> { let mut guard = RUNTIME.lock().expect("runtime lock poisoned"); if let Some(runtime) = guard.upgrade() { - return runtime; + return Ok(runtime); } // Safety invariant: the Weak::upgrade() check and LlamaBackend::init() // both execute inside this same mutex guard, so there is no window where @@ -80,7 +80,10 @@ impl InferenceRuntime { the mutex guard prevents concurrent re-init" ) } - Err(e) => panic!("Failed to init llama backend: {}", e), + Err(e) => { + tracing::error!(error = %e, "failed to initialize local inference runtime"); + return Err(anyhow::anyhow!("Failed to init llama backend: {}", e)); + } }; llama_cpp_2::send_logs_to_tracing(LogOptions::default()); let runtime = Arc::new(Self { @@ -88,7 +91,7 @@ impl InferenceRuntime { backend, }); *guard = Arc::downgrade(&runtime); - runtime + Ok(runtime) } pub fn backend(&self) -> &LlamaBackend { @@ -357,7 +360,7 @@ pub struct LocalInferenceProvider { impl LocalInferenceProvider { pub async fn from_env(model: ModelConfig, _extensions: Vec) -> Result { - let runtime = InferenceRuntime::get_or_init(); + let runtime = InferenceRuntime::get_or_init()?; let model_slot = runtime.get_or_create_model_slot(&model.model_name); Ok(Self { runtime, diff --git a/documentation/docs/troubleshooting/desktop-startup-debugging.md b/documentation/docs/troubleshooting/desktop-startup-debugging.md new file mode 100644 index 000000000000..928a8dd4599b --- /dev/null +++ b/documentation/docs/troubleshooting/desktop-startup-debugging.md @@ -0,0 +1,73 @@ +--- +title: Debug Desktop Startup Failures +sidebar_label: Debug Desktop Startup Failures +description: Find the desktop startup diagnostics log, understand the key fields, and share the right artifacts when goose fails to start. +--- + +When goose Desktop fails before the backend becomes ready, the normal server log may be empty or incomplete. In that case, the most useful artifact is the startup diagnostics JSON written by the desktop app. + +## Find the Startup Diagnostics Log + +goose Desktop writes one startup diagnostics file per launch attempt. + +Typical locations: + +- macOS: `~/Library/Application Support/Goose/logs/startup/` +- Windows: `%APPDATA%\Goose\logs\startup\` +- Linux: `~/.config/Goose/logs/startup/` + +The files are named like: + +```text +goosed-startup-2026-04-21T01-24-03.149Z-23416.json +``` + +If several files exist, use the newest one. + +## What To Share + +When reporting a desktop startup failure, share: + +- the newest `goosed-startup-*.json` +- your goose version +- your operating system and version + +For Windows native crashes, also attach the Windows crash report for `goosed.exe` if available. + +Common places to find the Windows crash report: + +- Event Viewer: `Windows Logs` → `Application` +- Reliability Monitor: `View technical details` +- WER files on disk: + - `%LOCALAPPDATA%\Microsoft\Windows\WER\ReportArchive\` + - `%LOCALAPPDATA%\Microsoft\Windows\WER\ReportQueue\` + +Look for a `Report.wer` related to `goosed.exe`. + +If you are filing a GitHub issue or asking for support, this is usually enough: + +- the newest `goosed-startup-*.json` +- your goose version +- your operating system and version +- on Windows, `Report.wer` for `goosed.exe` if Windows created one + +## What The Startup Log Contains + +In most cases, sharing the newest startup log is enough. + +If you want a quick high-level read, focus on these fields: + +- `childExitCode` or `childExitSignal` + Shows whether the backend process exited during startup. +- `certFingerprintSeen` + Shows whether the backend reached the TLS startup stage. +- `healthCheckSucceeded` + Shows whether the desktop app ever observed the backend as ready. +- `stderrTail` + Shows the most recent startup output captured from the backend, including major startup stage markers when available. +- `events` + Shows the order of major startup steps like process spawn, health check, and child exit. + +## Related Diagnostics + +For session or in-app issues after goose has started, use the normal diagnostics bundle described in [Diagnostics and Reporting](/docs/troubleshooting/diagnostics-and-reporting). diff --git a/documentation/docs/troubleshooting/index.mdx b/documentation/docs/troubleshooting/index.mdx index e5113f552ca8..2314fd231231 100644 --- a/documentation/docs/troubleshooting/index.mdx +++ b/documentation/docs/troubleshooting/index.mdx @@ -20,6 +20,11 @@ import styles from '@site/src/components/Card/styles.module.css'; description="Use built-in diagnostics, report bugs, and request new features. Includes step-by-step guides for generating troubleshooting data." link="/docs/troubleshooting/diagnostics-and-reporting" /> + TOOLS_MAX_SUGGESTED) { addAlert({ type: AlertType.Warning, - message: intl.formatMessage(i18n.tooManyTools, { toolCount, recommended: TOOLS_MAX_SUGGESTED }), + message: intl.formatMessage(i18n.tooManyTools, { + toolCount, + recommended: TOOLS_MAX_SUGGESTED, + }), action: { text: intl.formatMessage(i18n.viewExtensions), onClick: () => setView('extensions'), @@ -1567,7 +1571,9 @@ export default function ChatInput({

{file.name}

-

{file.type || intl.formatMessage(i18n.unknownType)}

+

+ {file.type || intl.formatMessage(i18n.unknownType)} +

)} @@ -1683,7 +1689,9 @@ export default function ChatInput({ - {recipe ? intl.formatMessage(i18n.viewEditRecipe) : intl.formatMessage(i18n.createRecipeFromSession)} + {recipe + ? intl.formatMessage(i18n.viewEditRecipe) + : intl.formatMessage(i18n.createRecipeFromSession)} diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index f676deb1405e..df6b435556fd 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -88,16 +88,19 @@ export const ConfigProvider: React.FC = ({ children }) => { [reloadConfig] ); - const read = useCallback(async (key: string, is_secret: boolean = false, options?: { throwOnError?: boolean }) => { - const query: ConfigKeyQuery = { key: key, is_secret: is_secret }; - const response = await readConfig({ - body: query, - }); - if (options?.throwOnError && response.error) { - throw response.error; - } - return response.data; - }, []); + const read = useCallback( + async (key: string, is_secret: boolean = false, options?: { throwOnError?: boolean }) => { + const query: ConfigKeyQuery = { key: key, is_secret: is_secret }; + const response = await readConfig({ + body: query, + }); + if (options?.throwOnError && response.error) { + throw response.error; + } + return response.data; + }, + [] + ); const remove = useCallback( async (key: string, is_secret: boolean) => { diff --git a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx index 6ad2559c5055..17eecae4726c 100644 --- a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx +++ b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx @@ -68,7 +68,9 @@ const CustomProviderCard = memo(function CustomProviderCard({ onClick }: { onCli
{intl.formatMessage(i18n.addProvider)}
-
{intl.formatMessage(i18n.fromTemplateOrManual)}
+
+ {intl.formatMessage(i18n.fromTemplateOrManual)} +
} @@ -277,7 +279,9 @@ function ProviderCards({ const editable = editingProvider ? editingProvider.isEditable : true; const title = editingProvider - ? (editable ? intl.formatMessage(i18n.editProvider) : intl.formatMessage(i18n.configureProvider)) + ? editable + ? intl.formatMessage(i18n.editProvider) + : intl.formatMessage(i18n.configureProvider) : intl.formatMessage(i18n.addProviderTitle); return ( <> diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index cf74bb31c946..340a0767d63a 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -6,6 +6,11 @@ import { createServer } from 'net'; import { Buffer } from 'node:buffer'; import { status } from './api'; import { Client, createClient, createConfig } from './api/client'; +import { + appendTail, + createStartupDiagnostics, + type StartupDiagnostics, +} from './startupDiagnostics'; export interface Logger { info: (...args: unknown[]) => void; @@ -77,24 +82,36 @@ export const findGoosedBinaryPath = (options: FindBinaryOptions = {}): string => ); }; -export const checkServerStatus = async (client: Client, errorLog: string[]): Promise => { +export interface CheckServerStatusOptions { + onEvent?: (name: string, details?: Record) => void; +} + +export const checkServerStatus = async ( + client: Client, + errorLog: string[], + options: CheckServerStatusOptions = {} +): Promise => { const timeout = 30000; const interval = 100; const maxAttempts = Math.ceil(timeout / interval); + options.onEvent?.('healthcheck_start', { timeoutMs: timeout, intervalMs: interval }); for (let attempt = 1; attempt <= maxAttempts; attempt++) { if (errorLog.some(isFatalError)) { + options.onEvent?.('healthcheck_fatal_error', { attempt }); return false; } try { await status({ client, throwOnError: true }); + options.onEvent?.('healthcheck_success', { attempt }); return true; } catch { await new Promise((resolve) => setTimeout(resolve, interval)); } } + options.onEvent?.('healthcheck_timeout', { timeoutMs: timeout }); return false; }; @@ -153,6 +170,7 @@ export interface StartGoosedOptions { isPackaged?: boolean; resourcesPath?: string; logger?: Logger; + diagnosticsDir?: string; } export interface GoosedResult { @@ -164,6 +182,9 @@ export interface GoosedResult { cleanup: () => Promise; client: Client; certFingerprint: string | null; + startupDiagnosticsPath: string | null; + getStartupDiagnostics: () => StartupDiagnostics | null; + recordStartupEvent: (name: string, details?: Record) => void; } const goosedClientForUrlAndSecret = (url: string, secret: string): Client => { @@ -187,14 +208,19 @@ export const startGoosed = async (options: StartGoosedOptions): Promise startupTrace?.diagnostics ?? null, + recordStartupEvent: (name, details) => startupTrace?.record(name, details), }; } @@ -214,6 +243,9 @@ export const startGoosed = async (options: StartGoosedOptions): Promise startupTrace?.diagnostics ?? null, + recordStartupEvent: (name, details) => startupTrace?.record(name, details), }; } @@ -235,6 +270,11 @@ export const startGoosed = async (options: StartGoosedOptions): Promise = { ...process.env, @@ -273,6 +313,10 @@ export const startGoosed = async (options: StartGoosedOptions): Promise((resolve) => { @@ -288,6 +332,10 @@ export const startGoosed = async (options: StartGoosedOptions): Promise { const lines = data.toString().split('\n'); + const nonEmptyLines = lines.filter((line) => line.trim()); + appendTail(startupTrace?.diagnostics.stderrTail ?? [], nonEmptyLines); for (const line of lines) { if (line.trim()) { errorLog.push(line); @@ -334,13 +384,19 @@ export const startGoosed = async (options: StartGoosedOptions): Promise { + goosedProcess.on('exit', (code, signal) => { logger.info(`goosed process exited with code ${code} for port ${port} and dir ${workingDir}`); + if (startupTrace) { + startupTrace.diagnostics.childExitCode = code; + startupTrace.diagnostics.childExitSignal = signal; + startupTrace.record('child_exit', { code, signal }); + } }); goosedProcess.on('error', (err) => { logger.error(`Failed to start goosed on port ${port} and dir ${workingDir}`, err); errorLog.push(err.message); + startupTrace?.record('spawn_error', { message: err.message, name: err.name }); }); const cleanup = async (): Promise => { @@ -387,5 +443,8 @@ export const startGoosed = async (options: StartGoosedOptions): Promise startupTrace?.diagnostics ?? null, + recordStartupEvent: (name, details) => startupTrace?.record(name, details), }; }; diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 28332b8c2c29..9c7a99cac421 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -61,6 +61,7 @@ function shouldSetupUpdater(): boolean { // Settings management const SETTINGS_FILE = path.join(app.getPath('userData'), 'settings.json'); +const STARTUP_LOGS_DIR = path.join(app.getPath('userData'), 'logs', 'startup'); function getSettings(): Settings { if (fsSync.existsSync(SETTINGS_FILE)) { @@ -617,6 +618,7 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { isPackaged: app.isPackaged, resourcesPath: app.isPackaged ? process.resourcesPath : undefined, logger: log, + diagnosticsDir: STARTUP_LOGS_DIR, }); // For locally-spawned goosed, pin using the fingerprint from stdout. @@ -637,6 +639,9 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { process: goosedProcess, errorLog, stopErrorLogCollection, + startupDiagnosticsPath, + getStartupDiagnostics, + recordStartupEvent, } = goosedResult; const mainWindowState = windowStateKeeper({ @@ -705,9 +710,27 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { ); goosedClients.set(mainWindow.id, goosedClient); - const serverReady = await checkServerStatus(goosedClient, errorLog); + const serverReady = await checkServerStatus(goosedClient, errorLog, { + onEvent: recordStartupEvent, + }); if (!serverReady) { const isUsingExternalBackend = settings.externalGoosed?.enabled; + const diagnostics = getStartupDiagnostics(); + const stderrTail = diagnostics?.stderrTail ?? []; + const failureDetailParts = [ + diagnostics?.childExitCode !== null || diagnostics?.childExitSignal + ? `Child exit: code=${diagnostics?.childExitCode ?? 'null'} signal=${diagnostics?.childExitSignal ?? 'null'}` + : 'Child exit: unavailable', + diagnostics?.certFingerprintSeen + ? 'TLS fingerprint observed: yes' + : 'TLS fingerprint observed: no', + diagnostics?.healthCheckSucceeded + ? 'Health check observed: yes' + : 'Health check observed: no', + startupDiagnosticsPath ? `Startup diagnostics: ${startupDiagnosticsPath}` : '', + errorLog.length > 0 ? `Startup errors:\n${errorLog.join('\n')}` : '', + stderrTail.length > 0 ? `Captured startup stderr:\n${stderrTail.join('\n')}` : '', + ].filter(Boolean); if (isUsingExternalBackend) { const response = dialog.showMessageBoxSync({ @@ -734,7 +757,7 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { type: 'error', title: 'Goose Failed to Start', message: 'The backend server failed to start.', - detail: errorLog.join('\n'), + detail: failureDetailParts.join('\n\n'), buttons: ['OK'], }); } diff --git a/ui/desktop/src/startupDiagnostics.ts b/ui/desktop/src/startupDiagnostics.ts new file mode 100644 index 000000000000..3c797b2ae006 --- /dev/null +++ b/ui/desktop/src/startupDiagnostics.ts @@ -0,0 +1,119 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export interface StartupTraceEvent { + name: string; + at: string; + elapsedMs: number; + details?: Record; +} + +export interface StartupDiagnostics { + attemptId: string; + startedAt: string; + goosedPath: string | null; + workingDir: string; + baseUrl: string | null; + pid: number | null; + certFingerprintSeen: boolean; + healthCheckSucceeded: boolean; + childExitCode: number | null; + childExitSignal: string | null; + stderrTail: string[]; + events: StartupTraceEvent[]; +} + +export interface StartupTrace { + diagnosticsPath: string; + diagnostics: StartupDiagnostics; + record: (name: string, details?: Record) => void; + flush: () => void; +} + +const STARTUP_TAIL_LIMIT = 80; +const STARTUP_LOGS_TO_KEEP = 20; + +export const appendTail = (target: string[], lines: string[]) => { + target.push(...lines.filter((line) => line.trim())); + if (target.length > STARTUP_TAIL_LIMIT) { + target.splice(0, target.length - STARTUP_TAIL_LIMIT); + } +}; + +const cleanupStartupDiagnostics = (diagnosticsDir: string) => { + const startupLogs = fs + .readdirSync(diagnosticsDir, { withFileTypes: true }) + .filter( + (entry) => + entry.isFile() && entry.name.startsWith('goosed-startup-') && entry.name.endsWith('.json') + ) + .map((entry) => { + const filePath = path.join(diagnosticsDir, entry.name); + return { + filePath, + modifiedMs: fs.statSync(filePath).mtimeMs, + }; + }) + .sort((a, b) => b.modifiedMs - a.modifiedMs); + + for (const startupLog of startupLogs.slice(STARTUP_LOGS_TO_KEEP)) { + fs.unlinkSync(startupLog.filePath); + } +}; + +export const createStartupDiagnostics = ( + diagnosticsDir: string | undefined, + workingDir: string +): StartupTrace | null => { + if (!diagnosticsDir) { + return null; + } + + fs.mkdirSync(diagnosticsDir, { recursive: true }); + cleanupStartupDiagnostics(diagnosticsDir); + const startedAt = new Date(); + const attemptId = `goosed-startup-${startedAt.toISOString().replace(/:/g, '-')}-${process.pid}.json`; + const diagnosticsPath = path.join(diagnosticsDir, attemptId); + const monotonicStart = Date.now(); + + const diagnostics: StartupDiagnostics = { + attemptId, + startedAt: startedAt.toISOString(), + goosedPath: null, + workingDir, + baseUrl: null, + pid: null, + certFingerprintSeen: false, + healthCheckSucceeded: false, + childExitCode: null, + childExitSignal: null, + stderrTail: [], + events: [], + }; + + const flush = () => { + fs.writeFileSync(diagnosticsPath, `${JSON.stringify(diagnostics, null, 2)}\n`); + }; + + const record = (name: string, details?: Record) => { + if (name === 'healthcheck_success') { + diagnostics.healthCheckSucceeded = true; + } + diagnostics.events.push({ + name, + at: new Date().toISOString(), + elapsedMs: Date.now() - monotonicStart, + ...(details ? { details } : {}), + }); + flush(); + }; + + flush(); + + return { + diagnosticsPath, + diagnostics, + record, + flush, + }; +};