diff --git a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts index b020c27e55..03e4d6bdb5 100644 --- a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts +++ b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts @@ -591,6 +591,25 @@ test("malformed configuration cannot turn a readiness timeout into launch cancel } }, 60_000); +test("a background start that fails before the supervisor logs names the cause in a log that exists", async () => { + const f = await fixture(); + const home = f.homes[0]!; + const configPath = path.join(home, "config.json"); + try { + await mkdir(home, { recursive: true }); + await writeFile(configPath, '{"version":1,'); + const failed = await f.run(["daemon", "start", "--home", home, "--timeout", "30"]); + expect(failed.code).toBe(1); + expect(failed.stderr).toContain(`Logs: ${path.join(home, "daemon.log")}`); + expect(failed.stderr).toContain(`Invalid JSON in ${configPath}`); + expect(await readFile(path.join(home, "daemon.log"), "utf8")).toContain( + `Invalid JSON in ${configPath}`, + ); + } finally { + await f.close(); + } +}, 60_000); + test("onboarding preserves an existing MCP choice unless explicitly supplied", async () => { const f = await fixture(); const home = f.homes[0]!; diff --git a/packages/server/scripts/supervisor-entrypoint.ts b/packages/server/scripts/supervisor-entrypoint.ts index c929281525..6b9b534591 100644 --- a/packages/server/scripts/supervisor-entrypoint.ts +++ b/packages/server/scripts/supervisor-entrypoint.ts @@ -1,5 +1,5 @@ import { fileURLToPath } from "url"; -import { existsSync } from "node:fs"; +import { appendFileSync, existsSync, mkdirSync } from "node:fs"; import path from "node:path"; import { acquirePidLock, @@ -9,6 +9,8 @@ import { updatePidLock, } from "../src/server/pid-lock.js"; import { resolvePaseoHome } from "../src/server/paseo-home.js"; +import { daemonLogPath } from "../src/server/daemon-instance.js"; +import { PRIVATE_FILE_MODE } from "../src/server/private-files.js"; import { loadPersistedConfig } from "../src/server/persisted-config.js"; import { runSupervisor } from "./supervisor.js"; import { resolveSupervisorLogFile } from "./supervisor-log-config.js"; @@ -118,9 +120,7 @@ async function main(): Promise { }); } catch (error) { if (error instanceof PidLockError) { - process.stderr.write(`${error.message}\n`); - process.exit(1); - return; + failStartup(error.message, error.message); } throw error; } @@ -181,8 +181,31 @@ async function main(): Promise { requestSupervisorShutdown = supervisor.requestShutdown; } -void main().catch((error) => { - const message = error instanceof Error ? (error.stack ?? error.message) : String(error); - process.stderr.write(`${message}\n`); +// The supervisor opens its log only after config and the PID lock succeed. A background +// launch discards stderr, so earlier failures also go to the log the launcher points at. +function failStartup(detail: string, summary: string): never { + process.stderr.write(`${detail}\n`); + try { + const logPath = daemonLogPath(resolvePaseoHome(process.env)); + mkdirSync(path.dirname(logPath), { recursive: true }); + appendFileSync( + logPath, + `${JSON.stringify({ + level: "fatal", + time: new Date().toISOString(), + pid: process.pid, + name: "DaemonRunner", + msg: summary, + })}\n`, + { mode: PRIVATE_FILE_MODE }, + ); + } catch { + // stderr already carries the failure. + } process.exit(1); +} + +void main().catch((error) => { + if (error instanceof Error) failStartup(error.stack ?? error.message, error.message); + failStartup(String(error), String(error)); });