From 4e2fdf168b10321accdc8c05ee0528ef8d21bf16 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:13:39 -0400 Subject: [PATCH 1/9] Stop reporting a slow-starting ADE brain as a broken one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh installs and every auto-update could land on "Updated the app, but the background service couldn't be set up — click Repair" followed by the full-screen "ADE couldn't open this project", and Repair only made it worse. The brain was healthy every time; it just had not answered yet. Three things conspired: - The brain bound `ade.sock` only AFTER the mobile sync-host startup loop (project scope/DB open, sync port band, lease), so desktop reachability was coupled to phone-sync hosting and took seconds even on a fast machine. - The launchd installer gave the replacement 10 s to answer, then reported `replacement_responsive` as a failed install; the desktop turned that into a failed update step and skipped its own connect wait entirely. - Every Repair path killed the booting brain and restarted the same 10 s race, so on a slow machine it could never finish starting. Fixes: - Brain: bind the RPC socket first, start the sync host in the background (0.4 s to socket vs 3.3 s measured); cross-channel conflicts still end the brain and are recorded; a switch superseded by a concurrent RPC caller is adopted, not read as "no project". - Service manager (mac + Windows): 30 s shared handover budget, probe timeout no longer kills a slow-starting probe, a live-but-quiet replacement returns `ok:true, starting:true`, and a young (<120 s) unresponsive brain behind an unchanged definition is waited on rather than restarted — vetoed by a fresh crash-loop record. `restarted` tells the trust-reset caller what happened. - Desktop: parse `starting`, age the streak of installs (not each attempt), wait 90 s for the socket after a (re)install, new `brain_starting` diagnosis with no Repair offered and auto-reopen polling, repair steps streamed live over IPC, restart budget 90 s. - Copy/dead ends: recovery screen keeps a way out and offers a plain reopen, transition banner gets "Try again", CTO failure pane gets "Try again", BrainRepairButton shows the real reason. Co-Authored-By: Claude Fable 5 --- apps/ade-cli/src/cli.ts | 129 ++++++++---- .../ade-cli/src/serviceManager/common.test.ts | 176 ++++++++++++++++ apps/ade-cli/src/serviceManager/common.ts | 61 ++++++ .../src/serviceManager/installLaunchd.ts | 188 +++++++++++++++--- .../src/serviceManager/installWindows.test.ts | 137 +++++++++++++ .../src/serviceManager/installWindows.ts | 97 ++++++++- .../src/serviceManager/windowsSupervisor.ts | 15 +- .../src/services/projects/projectScope.ts | 10 + apps/desktop/src/main/main.ts | 7 +- .../src/main/services/ipc/registerIpc.ts | 15 +- .../localRuntimeConnectionPool.test.ts | 9 + .../localRuntimeConnectionPool.ts | 51 ++++- .../main/services/runtime/lastFailureStore.ts | 3 +- .../runtime/projectRecoveryService.test.ts | 36 +++- .../runtime/projectRecoveryService.ts | 77 ++++--- apps/desktop/src/preload/global.d.ts | 6 +- apps/desktop/src/preload/preload.ts | 12 +- .../app/ProjectRecoveryScreen.test.tsx | 49 +++++ .../components/app/ProjectRecoveryScreen.tsx | 121 ++++++++++- .../app/ProjectTransitionErrorAlert.tsx | 14 ++ .../src/renderer/components/cto/CtoPage.tsx | 31 ++- .../components/settings/BrainRepairButton.tsx | 6 +- .../renderer/hooks/useBrainRepair.test.tsx | 2 +- .../src/renderer/hooks/useBrainRepair.ts | 4 +- .../src/renderer/state/appStore.test.ts | 3 + apps/desktop/src/renderer/state/appStore.ts | 8 +- apps/desktop/src/shared/ipc.ts | 2 + apps/desktop/src/shared/types/core.ts | 14 ++ apps/desktop/src/shared/types/recovery.ts | 22 ++ docs/features/remote-runtime/README.md | 25 ++- 30 files changed, 1198 insertions(+), 132 deletions(-) diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 7b5506aa52..753105c169 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -15409,11 +15409,24 @@ async function repairMachineRuntimeServiceConnection(args: { } return null; } - client = await SocketJsonRpcClient.connect( - args.socketPath, - args.options.timeoutMs, - "ADE runtime endpoint", - ); + // A `starting` result means the supervisor has a live brain that had not + // answered inside the installer's budget. Keep dialing it — the socket + // appears the moment the brain finishes coming up — instead of failing on + // the first connect and leaving the CLI to spawn a rival brain. + const connectDeadline = Date.now() + (result.starting ? 60_000 : 0); + for (;;) { + try { + client = await SocketJsonRpcClient.connect( + args.socketPath, + args.options.timeoutMs, + "ADE runtime endpoint", + ); + break; + } catch (error) { + if (Date.now() >= connectDeadline) throw error; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } const runtimeInfo = await initializeMachineRuntimeDaemon( client, args.options, @@ -17342,6 +17355,26 @@ async function runServe( } else { activeScope = await scopeRegistry.resolveActiveSyncHost(); } + if (!activeScope && scopeRegistry.getRequestedSyncHostProjectId()) { + // A null here can mean "superseded": the RPC socket is already published + // when this loop runs, so a desktop that connected meanwhile may have + // requested its own sync-host switch and bumped the transition past ours. + // That is a project host in progress, not the absence of one — taking + // the projectless lease now would clobber it. Give that switch time to + // land and adopt its result; only if nothing lands does the loop retry. + const adoptDeadline = Date.now() + 30_000; + while (Date.now() < adoptDeadline) { + const activeId = scopeRegistry.getActiveSyncHostProjectId(); + if (activeId) { + activeScope = await scopeRegistry.get(activeId); + break; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + if (!activeScope) { + throw new Error("Sync host switch superseded by a concurrent project switch; retrying."); + } + } if (!activeScope && sharedSyncListener) { // Binding the shared listener IS hosting phone sync, even with no project // scope to attach to it. Take the machine-wide lease first so this path @@ -17470,58 +17503,64 @@ async function runServe( } } - if (syncEnabled) { + // The mobile sync host is started AFTER the RPC socket is bound (see + // `startSyncHostInBackground` below). It used to be awaited here, before the + // bind, which coupled every desktop connection to phone-sync hosting: a + // project scope that was slow to open, a sync port band that was busy, a + // stale lease from a just-killed predecessor — anything the startup loop + // retried — kept `ade.sock` unpublished, and the desktop's service handover + // budget expired against a brain that was alive and healthy. Nothing about + // serving desktop RPC needs the sync host up first. + let syncHostStartupFailure: unknown = null; + const startSyncHostInBackground = async (): Promise => { + if (!syncEnabled) { + clearLastFailure({ kind: "machine" }); + return; + } try { const [{ runSyncHostStartupLoop }, { getRuntimeServiceMainPid }] = await Promise.all([ import("./services/sync/syncHostStartupLoop"), import("./serviceManager"), ]); + // No `abortIf`: the socket-liveness abort guarded against a rival brain + // taking the RPC socket while this one waited for sync (PR #949's + // zombies). The socket is bound before this loop runs now, so a rival + // that dials it finds a live owner and refuses; the bind itself is the + // claim. (A rival could only steal the path by unlinking a socket it + // proved stale, which a bound socket never is.) await runSyncHostStartupLoop({ startSyncHost, isDone: () => done, log: (message) => process.stderr.write(`${message}\n`), getServiceMainPid: getRuntimeServiceMainPid, - // The pre-loop claim above only sees a socket that ALREADY existed. Two - // brains started together on a fresh path both pass it, then one wins - // the lease and binds while the loser waits here forever — never - // reaching its own bind check. Re-check while we wait, and only for a - // provably live owner so a probe hiccup can't make a brain quit on - // itself. - abortIf: async () => { - if (!isAdeRuntimeNamedPipePath(socketPath) && !fs.existsSync(socketPath)) return false; - return await probeLocalSocketForLiveness(socketPath) === "live"; - }, }); + // A recorded sync-host failure is cleared only once the sync host is + // really up; clearing it on the bind would reset the crash-loop counter + // on every restart of a brain that keeps dying right here. + if (!done) clearLastFailure({ kind: "machine" }); } catch (error: unknown) { + if (done) return; // Cross-channel conflict (another build's live brain owns mobile sync): - // real builds never run sync-less, so fail before publishing ade.sock. - const [{ SyncHostSingletonConflictError }, { SyncHostStartupAbortedError }] = await Promise.all([ - import("./services/sync/syncHostSingleton"), - import("./services/sync/syncHostStartupLoop"), - ]); + // real builds never run sync-less, so the brain still refuses to keep + // running. The RPC socket is already published by now; closing it is + // what `finish()` does, and the recorded failure carries the same code + // project recovery keyed on before. + const { SyncHostSingletonConflictError } = await import("./services/sync/syncHostSingleton"); const message = error instanceof Error ? error.message : String(error); - if (error instanceof SyncHostStartupAbortedError) { - await disposeServeResources(); - throw Object.assign(new CliExecutionError("ADE brain socket is already in use.", { - socketPath, - cause: "Another ADE brain took this socket while this one waited for mobile sync.", - nextAction: "Stop the existing ADE brain or choose a different --socket path.", - }), { code: "socket_owned_by_other" as const }); - } if (error instanceof SyncHostSingletonConflictError) { - await disposeServeResources(); - throw new CliExecutionError("ADE brain refusing to run without mobile sync.", { + syncHostStartupFailure = new CliExecutionError("ADE brain refusing to run without mobile sync.", { cause: message, socketPath, nextAction: "Stop the other ADE brain that owns mobile sync, then start this build again.", }); + } else { + process.stderr.write(`ADE brain sync host startup loop failed: ${message}\n`); + syncHostStartupFailure = error; } - process.stderr.write(`ADE brain sync host startup loop failed: ${message}\n`); - await disposeServeResources(); - throw error; + finish(); } - } + }; fs.mkdirSync(layout.adeDir, { recursive: true, mode: 0o700 }); if (isAdeRuntimeNamedPipePath(socketPath)) { @@ -17678,8 +17717,19 @@ async function runServe( process.stderr.write( `ADE brain listening on ${socketPath}${tcpUrl ? ` and ${tcpUrl}` : ""}\n`, ); - clearLastFailure({ kind: "machine" }); serveStarted = true; + // The RPC socket is up: any recorded startup failure that was NOT about the + // sync host is over. Sync-host failures stay recorded until the sync host + // actually comes up (below), so a brain that binds and then dies on a + // cross-channel conflict every time still accumulates a crash-loop count. + if (readLastFailure({ kind: "machine" })?.component !== "sync_host") { + clearLastFailure({ kind: "machine" }); + } + // Started after the account-publisher subscription above so the lease the + // sync host takes is what starts the publisher, and after the socket is + // published so a desktop can already reach this brain while phone sync + // hosting is still coming up (or still retrying). + void startSyncHostInBackground(); // Pinned agent tools are fetched, not bundled — roughly 600 MB across the // three of them. A source checkout still resolves all three out of the repo's @@ -17792,6 +17842,13 @@ async function runServe( fs.unlinkSync(socketPath); } catch {} } + if (syncHostStartupFailure != null) { + // A sync-host startup failure ends the brain even though the socket was + // already published; record it like any other startup failure so project + // recovery diagnoses the conflict instead of an unexplained exit. + serveStarted = false; + throw syncHostStartupFailure; + } return null; } finally { clearSyncRuntimeRpcHandlerFactory?.(); diff --git a/apps/ade-cli/src/serviceManager/common.test.ts b/apps/ade-cli/src/serviceManager/common.test.ts index af4a2a63fd..fb5d058c2f 100644 --- a/apps/ade-cli/src/serviceManager/common.test.ts +++ b/apps/ade-cli/src/serviceManager/common.test.ts @@ -9,6 +9,7 @@ import { buildWindowsParentPidQueryArgs, buildWindowsProcessCommandLineQueryArgs, listStaleChannelServePids, + parsePsElapsedMs, isCurrentProcessDescendantOfPid, isStaleChannelServeCommandLine, PARENT_PID_UNKNOWN, @@ -737,6 +738,21 @@ describe("launchd service rendering", () => { }); }); +describe("parsePsElapsedMs", () => { + it("reads every `ps -o etime=` shape", () => { + expect(parsePsElapsedMs("00:05")).toBe(5_000); + expect(parsePsElapsedMs(" 12:34\n")).toBe((12 * 60 + 34) * 1_000); + expect(parsePsElapsedMs("01:02:03")).toBe(((1 * 60 + 2) * 60 + 3) * 1_000); + expect(parsePsElapsedMs("2-01:02:03")).toBe((((2 * 24 + 1) * 60 + 2) * 60 + 3) * 1_000); + }); + + it("fails open on anything it does not recognise", () => { + expect(parsePsElapsedMs("")).toBeNull(); + expect(parsePsElapsedMs("garbage")).toBeNull(); + expect(parsePsElapsedMs("1:2:3:4")).toBeNull(); + }); +}); + describe("launchd service install", () => { const serviceCommand: AdeServiceCommand = { command: "/Applications/ADE.app/Contents/MacOS/ade", @@ -747,6 +763,10 @@ describe("launchd service install", () => { deps: NonNullable[0]>, ) => installLaunchdService({ responsivenessProbe: () => true, + // Unknown age by default, so a running-but-quiet agent takes the restart + // path these tests were written for; the young-brain tests inject an age. + pidElapsedMs: () => null, + recentCrashLoop: () => false, ...deps, }); @@ -875,6 +895,148 @@ describe("launchd service install", () => { }); }); + it("reports a live replacement that has not answered yet as starting, not failed", async () => { + const homeDir = makeTempHome("ade-launchd-handover-starting-"); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + // The handover poll sees launchd's replacement child running. + { status: 0, stdout: "state = running\npid = 4321\n", stderr: "" }, + ]); + + const result = await install({ + command: serviceCommand, + spawnSync, + homeDir, + responsivenessProbe: () => false, + handoverPidAlive: (pid) => pid === 4321, + handoverTimeoutMs: 0, + }); + + expect(result).toMatchObject({ + ok: true, + starting: true, + action: "install", + }); + expect(result.failureStep).toBeUndefined(); + expect(result.message).toContain("still starting"); + }); + + it("waits for a young unresponsive brain instead of restarting it", async () => { + const homeDir = makeTempHome("ade-launchd-young-brain-"); + const servicePath = launchAgentPath(homeDir); + fs.mkdirSync(path.dirname(servicePath), { recursive: true }); + fs.writeFileSync(servicePath, renderLaunchdPlist(serviceCommand, homeDir), "utf8"); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = runningAgentSpawn(calls, 1234); + // Not answering on the first probe, answering once waited for. + const responsivenessProbe = vi.fn() + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + const kill = vi.fn(); + + const result = await install({ + command: serviceCommand, + spawnSync, + homeDir, + env: { ...process.env, ADE_FORCE_RUNTIME_SERVICE_RESTART: "1" }, + responsivenessProbe, + pidElapsedMs: () => 5_000, + handoverPidAlive: () => true, + terminateDeps: { kill, pidAlive: () => true }, + }); + + expect(result.ok).toBe(true); + expect(result.starting).toBeUndefined(); + expect(kill).not.toHaveBeenCalled(); + // No unload/load of the brain agent: only the watchdog is (re)armed. + expect(calls.filter((call) => call.command === "launchctl" && call.args[1] === servicePath)).toEqual([]); + }); + + it("restarts a young quiet brain anyway when the machine is crash-looping", async () => { + const homeDir = makeTempHome("ade-launchd-young-crashloop-"); + const servicePath = launchAgentPath(homeDir); + fs.mkdirSync(path.dirname(servicePath), { recursive: true }); + fs.writeFileSync(servicePath, renderLaunchdPlist(serviceCommand, homeDir), "utf8"); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "state = running\npid = 1234\n", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + ]); + const kill = vi.fn(); + + const result = await install({ + command: serviceCommand, + spawnSync, + homeDir, + env: { ...process.env, ADE_FORCE_RUNTIME_SERVICE_RESTART: "1" }, + responsivenessProbe: () => true, + pidElapsedMs: () => 5_000, + recentCrashLoop: () => true, + currentPid: 9999, + parentPid: () => null, + terminateDeps: { kill, pidAlive: () => false }, + }); + + expect(result).toMatchObject({ ok: true, restarted: true }); + expect(calls.map((call) => call.args[0])).toContain("load"); + }); + + it("does not restart a young brain that answers, even when a restart was forced", async () => { + const homeDir = makeTempHome("ade-launchd-young-answering-"); + const servicePath = launchAgentPath(homeDir); + fs.mkdirSync(path.dirname(servicePath), { recursive: true }); + fs.writeFileSync(servicePath, renderLaunchdPlist(serviceCommand, homeDir), "utf8"); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = runningAgentSpawn(calls, 1234); + const kill = vi.fn(); + + const result = await install({ + command: serviceCommand, + spawnSync, + homeDir, + env: { ...process.env, ADE_FORCE_RUNTIME_SERVICE_RESTART: "1" }, + responsivenessProbe: () => true, + pidElapsedMs: () => 5_000, + handoverPidAlive: () => true, + terminateDeps: { kill, pidAlive: () => true }, + }); + + expect(result.ok).toBe(true); + // Not a restart: the trust-reset caller must see that and try again later. + expect(result.restarted).toBeUndefined(); + expect(kill).not.toHaveBeenCalled(); + }); + + it("returns starting for a young brain that is still quiet after the wait", async () => { + const homeDir = makeTempHome("ade-launchd-young-brain-quiet-"); + const servicePath = launchAgentPath(homeDir); + fs.mkdirSync(path.dirname(servicePath), { recursive: true }); + fs.writeFileSync(servicePath, renderLaunchdPlist(serviceCommand, homeDir), "utf8"); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = runningAgentSpawn(calls, 1234); + const kill = vi.fn(); + + const result = await install({ + command: serviceCommand, + spawnSync, + homeDir, + responsivenessProbe: () => false, + pidElapsedMs: () => 5_000, + handoverPidAlive: () => true, + handoverTimeoutMs: 0, + terminateDeps: { kill, pidAlive: () => true }, + }); + + expect(result).toMatchObject({ ok: true, starting: true }); + expect(kill).not.toHaveBeenCalled(); + }); + it("reloads an unchanged running launch agent when a packaged trust reset requests it", async () => { const homeDir = makeTempHome("ade-launchd-trust-reset-"); const servicePath = launchAgentPath(homeDir); @@ -1374,6 +1536,20 @@ describe("systemd service install", () => { }); +/** launchd that keeps reporting one running agent child, whatever is asked of it. */ +function runningAgentSpawn( + calls: Array<{ command: string; args: string[] }>, + pid: number, +): ServiceManagerSpawnSync { + return (command, args) => { + calls.push({ command, args }); + if (command === "launchctl" && args[0] === "print") { + return { status: 0, stdout: `state = running\npid = ${pid}\n`, stderr: "" }; + } + return { status: 0, stdout: "", stderr: "" }; + }; +} + function spawnSequence( calls: Array<{ command: string; args: string[] }>, results: ServiceManagerProcessResult[], diff --git a/apps/ade-cli/src/serviceManager/common.ts b/apps/ade-cli/src/serviceManager/common.ts index e19040f928..44e3f5271b 100644 --- a/apps/ade-cli/src/serviceManager/common.ts +++ b/apps/ade-cli/src/serviceManager/common.ts @@ -18,8 +18,69 @@ export type ServiceManagerResult = { selfMutationBlocked?: boolean; /** Typed install verification stage for callers that need repair diagnostics. */ failureStep?: "predecessor_exit" | "replacement_pid" | "replacement_responsive"; + /** + * The service is registered and its brain process is alive, but it had not + * answered on the socket when the install's wait budget ran out. That is a + * brain still coming up (first launch, slow disk, big project database), not + * a broken one — the platform supervisor keeps it, and callers should keep + * waiting for the endpoint rather than restart it. + */ + starting?: boolean; + /** + * The install actually (re)started the service child. Absent/false when the + * install was a no-op (already running and answering) or when it chose to + * wait for a young brain instead of restarting it — callers that need a + * restart to have happened (the one-time trust reset) check this rather than + * `ok`. + */ + restarted?: boolean; }; +/** + * How long a freshly (re)started brain gets to answer before the installer + * stops waiting and reports it as `starting` instead of ready. Generous on + * purpose: this used to be 10s, and a brain that legitimately took longer on a + * cold machine was reported as a failed install, which the desktop turned into + * "couldn't be set up" plus a Repair that killed the brain and started the + * same race over. + */ +export const RUNTIME_SERVICE_HANDOVER_TIMEOUT_MS = 30_000; + +/** + * A brain younger than this that is not answering yet is presumed to still be + * starting. Installers leave it alone and wait for it instead of restarting it: + * restarting a booting brain only resets its clock, and doing so on every + * Repair click is how a slow machine could never finish starting one. + */ +export const RUNTIME_SERVICE_YOUNG_BRAIN_MS = 120_000; + +/** + * Parses `ps -o etime=` output (`[[dd-]hh:]mm:ss`) into milliseconds. + * Returns null for anything it does not recognise so callers fail open. + */ +export function parsePsElapsedMs(raw: string): number | null { + const text = raw.trim(); + const match = text.match(/^(?:(\d+)-)?(?:(\d+):)?(\d{1,2}):(\d{2})$/); + if (!match) return null; + const days = Number(match[1] ?? 0); + const hours = Number(match[2] ?? 0); + const minutes = Number(match[3]); + const seconds = Number(match[4]); + if (![days, hours, minutes, seconds].every((value) => Number.isFinite(value))) return null; + return (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1_000; +} + +/** Milliseconds a POSIX process has been alive, or null when unknown. */ +export function readPidElapsedMs( + pid: number, + run: ServiceManagerSpawnSync = spawnSync, +): number | null { + if (!Number.isFinite(pid) || pid <= 0) return null; + const result = run("ps", ["-o", "etime=", "-p", String(pid)], { encoding: "utf8" }); + if (result.status !== 0) return null; + return parsePsElapsedMs(processOutputRaw(result)); +} + /** * A replacement that reached its readiness phase is already registered with * the platform supervisor. That supervisor owns subsequent retries; starting diff --git a/apps/ade-cli/src/serviceManager/installLaunchd.ts b/apps/ade-cli/src/serviceManager/installLaunchd.ts index 5343273288..77103c0f65 100644 --- a/apps/ade-cli/src/serviceManager/installLaunchd.ts +++ b/apps/ade-cli/src/serviceManager/installLaunchd.ts @@ -7,8 +7,11 @@ import { type AdeServiceCommand, isCurrentProcessDescendantOfPid, listStaleChannelServePids, + readPidElapsedMs, resolveAdeServeCliScriptPath, resolveAdeServeCommand, + RUNTIME_SERVICE_HANDOVER_TIMEOUT_MS, + RUNTIME_SERVICE_YOUNG_BRAIN_MS, serviceManagerResultText, type ServiceManagerResult, type ServiceManagerSpawnSync, @@ -27,6 +30,10 @@ import { isSameChannelSyncHostOwner, type SyncHostSingletonDeps, } from "../services/sync/syncHostSingleton"; +import { + LAST_FAILURE_CRASH_LOOP_WINDOW_MS, + readLastFailure, +} from "../../../desktop/src/main/services/runtime/lastFailureStore"; type LaunchdServiceManagerDeps = { command?: AdeServiceCommand; @@ -47,6 +54,10 @@ type LaunchdServiceManagerDeps = { handoverTimeoutMs?: number; handoverPollMs?: number; handoverPidAlive?: (pid: number) => boolean; + /** Age of a live service pid; tests inject it, production asks `ps`. */ + pidElapsedMs?: (pid: number, run: ServiceManagerSpawnSync) => number | null; + /** Whether the brain has recorded a fresh streak of startup failures; tests inject it. */ + recentCrashLoop?: () => boolean; sleep?: (ms: number) => Promise; }; @@ -258,6 +269,16 @@ function runtimeStatusArgs(command: AdeServiceCommand, socketPath: string): stri return args; } +/** + * The probe is a whole `ade runtime status` child process: Node/Electron + * start-up plus loading the CLI bundle BEFORE it can even dial the socket. Its + * kill timeout therefore has to be the socket wait plus a start-up allowance; + * when the two were equal, a machine where the CLI took longer than the socket + * budget to start killed every probe before it could answer, and a perfectly + * healthy brain read as "never responsive". + */ +const RESPONSIVENESS_PROBE_STARTUP_ALLOWANCE_MS = 8_000; + function defaultResponsivenessProbe(args: { socketPath: string; timeoutMs: number; @@ -274,13 +295,41 @@ function defaultResponsivenessProbe(args: { { encoding: "utf8", env, - timeout: args.timeoutMs, + timeout: args.timeoutMs + RESPONSIVENESS_PROBE_STARTUP_ALLOWANCE_MS, stdio: "ignore", }, ); return result.status === 0 && !result.error; } +/** + * A running service child that is younger than the young-brain window and not + * answering yet is presumed to still be starting. Unknown age (ps failed) + * counts as not young, so a wedged brain is never mistaken for a booting one. + */ +/** + * A fresh failure streak recorded by the brain itself (`last-failure.json`, + * the same record project recovery and the startup backoff read). Two or more + * failures inside the crash-loop window means launchd is respawning a brain + * that dies, and its youth is not a reason to wait for it. + */ +function defaultRecentCrashLoop(): boolean { + const report = readLastFailure({ kind: "machine" }); + if (!report || report.count < 2) return false; + const firstAt = Date.parse(report.firstAt); + return Number.isFinite(firstAt) && Date.now() - firstAt <= LAST_FAILURE_CRASH_LOOP_WINDOW_MS; +} + +function isYoungBrain( + pid: number | null | undefined, + run: ServiceManagerSpawnSync, + elapsedMs: (pid: number, run: ServiceManagerSpawnSync) => number | null, +): boolean { + if (!pid) return false; + const elapsed = elapsedMs(pid, run); + return elapsed != null && elapsed < RUNTIME_SERVICE_YOUNG_BRAIN_MS; +} + function handoverFailure( servicePath: string, failureStep: NonNullable, @@ -321,7 +370,7 @@ export async function installLaunchdService( : null; const plistUnchanged = existingPlist === plist; const forceRestart = env.ADE_FORCE_RUNTIME_SERVICE_RESTART === "1"; - const loaded = getLoadedLaunchdState(run); + let loaded = getLoadedLaunchdState(run); if (!forceRestart && plistUnchanged && loaded?.running === true) { if ( deps.probeResponsiveness === false @@ -340,6 +389,99 @@ export async function installLaunchdService( }; } } + const isAlive = deps.handoverPidAlive ?? deps.terminateDeps?.pidAlive ?? pidAlive; + const sleep = deps.sleep ?? sleepAsync; + const timeoutMs = Math.max(0, deps.handoverTimeoutMs ?? RUNTIME_SERVICE_HANDOVER_TIMEOUT_MS); + const pollMs = Math.max(10, deps.handoverPollMs ?? 100); + const pidElapsedMs = deps.pidElapsedMs ?? readPidElapsedMs; + const responsivenessProbeIntervalMs = 750; + + /** + * Waits for a distinct replacement child to answer on the socket. Shared by + * the young-brain wait (no predecessor to outlive) and the real handover. + */ + // One budget for the whole install: a young-brain wait that gives up and a + // real handover after it share this deadline, so the desktop's child timeout + // can be sized against a known worst case. + const installDeadline = Date.now() + timeoutMs; + const awaitHandover = async (oldPid: number | null): Promise<{ + predecessorGone: boolean; + replacementPid: number | null; + replacementResponsive: boolean; + }> => { + const deadline = installDeadline; + let predecessorGone = oldPid == null || !isAlive(oldPid); + let replacementPid: number | null = null; + let replacementResponsive = false; + let lastProbeAt = 0; + do { + predecessorGone = oldPid == null || !isAlive(oldPid); + const replacement = getLoadedLaunchdState(run); + replacementPid = replacement?.running === true ? replacement.pid : null; + const replacementDiffers = replacementPid != null && replacementPid !== oldPid; + // Each probe is a full CLI child process; on the slow machines this wait + // exists for, running one every poll tick would compete with the very + // brain it is waiting on. Poll launchd cheaply, probe at a slower cadence. + if ( + predecessorGone + && replacementDiffers + && Date.now() - lastProbeAt >= responsivenessProbeIntervalMs + ) { + lastProbeAt = Date.now(); + replacementResponsive = probeResponsiveness({ + socketPath, + timeoutMs: Math.min(1_500, Math.max(1, deadline - Date.now())), + command, + }); + if (replacementResponsive) break; + } + if (Date.now() >= deadline) break; + await sleep(Math.min(pollMs, Math.max(1, deadline - Date.now()))); + } while (Date.now() <= deadline); + return { predecessorGone, replacementPid, replacementResponsive }; + }; + + // The service definition is current and launchd has a live child that is + // simply not answering yet. If that child is young it is still starting — + // first launch, cold disk, big project database — and restarting it (which + // is what a forced install would do next) only resets its clock. Wait for it + // instead. This is what keeps a Repair click from killing the very brain + // that was seconds from being ready. + // A brain that keeps dying is always "young" (launchd just respawned it), so + // the recorded failure streak vetoes the wait: that brain needs the restart + // and the crash-loop diagnosis, not more patience. + const crashLooping = (deps.recentCrashLoop ?? defaultRecentCrashLoop)(); + if ( + plistUnchanged + && loaded?.running === true + && !crashLooping + && isYoungBrain(loaded.pid, run, pidElapsedMs) + ) { + const young = await awaitHandover(null); + if (young.replacementResponsive) { + installLaunchdWatchdogAgent({ command, homeDir, spawnSync: run }); + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: servicePath, + message: "ADE service launchd service is already installed; its background service finished starting.", + }; + } + if (young.replacementPid != null && isAlive(young.replacementPid)) { + installLaunchdWatchdogAgent({ command, homeDir, spawnSync: run }); + return { + ok: true, + starting: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: servicePath, + message: `ADE service launchd service is installed; the background service (pid ${young.replacementPid}) is still starting.`, + }; + } + // The young child died while we waited: fall through and (re)start it. + loaded = getLoadedLaunchdState(run); + } const selfBlock = selfServiceMutationBlock({ action: "install", loadedPid: loaded?.pid, @@ -414,30 +556,7 @@ export async function installLaunchdService( }; } const oldPid = loaded?.pid ?? null; - const isAlive = deps.handoverPidAlive ?? deps.terminateDeps?.pidAlive ?? pidAlive; - const sleep = deps.sleep ?? sleepAsync; - const timeoutMs = Math.max(0, deps.handoverTimeoutMs ?? 10_000); - const pollMs = Math.max(10, deps.handoverPollMs ?? 100); - const deadline = Date.now() + timeoutMs; - let predecessorGone = oldPid == null || !isAlive(oldPid); - let replacementPid: number | null = null; - let replacementResponsive = false; - do { - predecessorGone = oldPid == null || !isAlive(oldPid); - const replacement = getLoadedLaunchdState(run); - replacementPid = replacement?.running === true ? replacement.pid : null; - const replacementDiffers = replacementPid != null && replacementPid !== oldPid; - if (predecessorGone && replacementDiffers) { - replacementResponsive = probeResponsiveness({ - socketPath, - timeoutMs: Math.min(1_500, Math.max(1, deadline - Date.now())), - command, - }); - if (replacementResponsive) break; - } - if (Date.now() >= deadline) break; - await sleep(Math.min(pollMs, Math.max(1, deadline - Date.now()))); - } while (Date.now() <= deadline); + const { predecessorGone, replacementPid, replacementResponsive } = await awaitHandover(oldPid); if (!predecessorGone) { return handoverFailure( @@ -454,6 +573,22 @@ export async function installLaunchdService( ); } if (!replacementResponsive) { + if (isAlive(replacementPid)) { + // launchd owns a live replacement that has not answered yet. That is a + // slow start, not a failed install; the supervisor keeps the child and + // the caller keeps waiting for the endpoint. Reporting this as a failure + // was what made every slow machine read as a broken one. + installLaunchdWatchdogAgent({ command, homeDir, spawnSync: run }); + return { + ok: true, + starting: true, + restarted: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: servicePath, + message: `ADE service launchd service installed; the background service (pid ${replacementPid}) is still starting after ${timeoutMs}ms.`, + }; + } return handoverFailure( servicePath, "replacement_responsive", @@ -470,6 +605,7 @@ export async function installLaunchdService( }); return { ok: true, + restarted: true, serviceName: ADE_RUNTIME_SERVICE_NAME, action: "install", path: servicePath, diff --git a/apps/ade-cli/src/serviceManager/installWindows.test.ts b/apps/ade-cli/src/serviceManager/installWindows.test.ts index bd91631b04..6fe2f1932b 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.test.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.test.ts @@ -476,6 +476,143 @@ describe("Windows background service helpers", () => { }); }); + it("reports a supervised brain that has not answered yet as starting, not failed", async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "ERROR: value not found" }, + { status: 0, stdout: "", stderr: "" }, + { status: 0, stdout: "The operation completed successfully.", stderr: "" }, + { status: 0, stdout: "1234", stderr: "" }, + ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-starting-"), "brain-service.ps1"); + + const result = await installWindowsService({ + command: serviceCommand, + launcherPath, + readPidRecord: immediateReadiness.readPidRecord, + readinessProbe: () => ({ ready: false, diagnostic: "Runtime PID 5678 has not bound the pipe yet." }), + handoverTimeoutMs: 0, + pidAlive: () => true, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result).toMatchObject({ + ok: true, + starting: true, + serviceName, + action: "install", + path: taskName, + }); + expect(result.failureStep).toBeUndefined(); + expect(result.message).toContain("still starting"); + }); + + it("fails, not starting, when the record's supervisor is dead", async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "ERROR: value not found" }, + { status: 0, stdout: "", stderr: "" }, + { status: 0, stdout: "The operation completed successfully.", stderr: "" }, + { status: 0, stdout: "1234", stderr: "" }, + ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-dead-sup-"), "brain-service.ps1"); + + const result = await installWindowsService({ + command: serviceCommand, + launcherPath, + readPidRecord: immediateReadiness.readPidRecord, + readinessProbe: () => ({ ready: false, diagnostic: "not bound" }), + handoverTimeoutMs: 0, + pidAlive: () => false, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result).toMatchObject({ ok: false, failureStep: "replacement_responsive" }); + }); + + it("waits for a young unresponsive brain instead of replacing it", async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + // legacy task lookups (none) + { status: 3, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-young-"), "brain-service.ps1"); + const pidPath = `${launcherPath}.pid.json`; + // Pre-render the launcher exactly as the install would, so it reads as unchanged. + const machineLayout = resolveMachineAdeLayout( + { ...process.env, ...(serviceCommand.env ?? {}) }, + "win32", + ); + fs.mkdirSync(path.dirname(launcherPath), { recursive: true }); + fs.writeFileSync(launcherPath, `\uFEFF${renderWindowsServiceLauncher(serviceCommand, { + pidPath, + logPath: `${launcherPath}.log`, + heartbeatPath: path.win32.join(machineLayout.runtimeDir, "heartbeat.json"), + wedgeBreadcrumbPath: path.win32.join(machineLayout.runtimeDir, "event-loop-wedge.json"), + })}`, "utf8"); + const youngRecord = { ...readyPidRecord, runtimeStartedAtMs: Date.now() - 5_000 }; + const readinessProbe = vi.fn() + .mockReturnValueOnce({ ready: false, diagnostic: "not yet" }) + .mockReturnValue({ ready: true, diagnostic: "ready" }); + + const result = await installWindowsService({ + command: serviceCommand, + launcherPath, + pidPath, + readPidRecord: () => youngRecord, + readinessProbe, + handoverTimeoutMs: 5_000, + handoverPollMs: 10, + pidAlive: () => true, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result).toMatchObject({ ok: true, action: "install" }); + expect(result.restarted).toBeUndefined(); + // No run-key rewrite, no supervisor start: the brain was left alone. + expect(calls.some((call) => call.args.some((arg) => /Add|\/Run|Start-Process/i.test(arg)))).toBe(false); + }); + + it("still fails the install when the supervisor never publishes a brain at all", async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "ERROR: value not found" }, + { status: 0, stdout: "", stderr: "" }, + { status: 0, stdout: "The operation completed successfully.", stderr: "" }, + { status: 0, stdout: "1234", stderr: "" }, + ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-no-brain-"), "brain-service.ps1"); + + const result = await installWindowsService({ + command: serviceCommand, + launcherPath, + readPidRecord: () => null, + readinessProbe: () => ({ ready: false, diagnostic: "unused" }), + handoverTimeoutMs: 0, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result).toMatchObject({ + ok: false, + failureStep: "replacement_responsive", + }); + }); + it("ends and replaces a running channel task before starting the repaired runtime", async () => { const calls: Array<{ command: string; args: string[] }> = []; const spawnSync = spawnSequence(calls, [ diff --git a/apps/ade-cli/src/serviceManager/installWindows.ts b/apps/ade-cli/src/serviceManager/installWindows.ts index 6b01d1bdbc..bd4bdfba89 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.ts @@ -17,6 +17,7 @@ import { resolveAdeServeCliScriptPath, resolveAdeServeCommand, resolveRuntimeServiceName, + RUNTIME_SERVICE_YOUNG_BRAIN_MS, serviceManagerResultText, terminatePidGracefullyAsync, type ServiceManagerResult, @@ -116,6 +117,8 @@ type WindowsServiceManagerDeps = { handoverTimeoutMs?: number; handoverPollMs?: number; sleep?: (ms: number) => Promise; + /** Process liveness for the supervisor/brain pids in the record; tests inject it. */ + pidAlive?: (pid: number) => boolean; }; function resolvedServiceName( @@ -780,14 +783,22 @@ async function installWindowsServiceImpl( machineLayout.runtimeDir, BRAIN_LOOP_WATCHDOG_BREADCRUMB_FILE, ); + const renderedLauncher = `\uFEFF${renderWindowsServiceLauncher(serviceCommand, { + pidPath, + logPath, + heartbeatPath, + wedgeBreadcrumbPath, + })}`; + let existingLauncher: string | null = null; + try { + existingLauncher = fs.readFileSync(launcherPath, "utf8"); + } catch { + existingLauncher = null; + } + const launcherUnchanged = existingLauncher === renderedLauncher; try { fs.mkdirSync(path.dirname(launcherPath), { recursive: true }); - fs.writeFileSync(launcherPath, `\uFEFF${renderWindowsServiceLauncher(serviceCommand, { - pidPath, - logPath, - heartbeatPath, - wedgeBreadcrumbPath, - })}`, { + fs.writeFileSync(launcherPath, renderedLauncher, { encoding: "utf8", mode: 0o600, }); @@ -817,6 +828,56 @@ async function installWindowsServiceImpl( message: legacyRemoval.message, }; } + // launchd parity for the young-brain wait: an unchanged launcher whose + // supervisor and brain are alive, started less than RUNTIME_SERVICE_YOUNG_BRAIN_MS + // ago and never restarted, is a brain still coming up. Wait for it instead of + // ending and replacing it — every Repair used to kill exactly that brain. + const readPidRecord = deps.readPidRecord + ?? ((target: string) => readWindowsServicePidRecord({ pidPath: target })); + const youngRecord = launcherUnchanged ? readPidRecord(pidPath) : null; + if ( + youngRecord + && youngRecord.restartCount === 0 + && youngRecord.runtimePid != null + && youngRecord.runtimeStartedAtMs != null + && Date.now() - youngRecord.runtimeStartedAtMs < RUNTIME_SERVICE_YOUNG_BRAIN_MS + && (deps.pidAlive ?? isPidAlive)(youngRecord.supervisorPid) + && (deps.pidAlive ?? isPidAlive)(youngRecord.runtimePid) + ) { + const youngReadiness = await waitForWindowsRuntimeReadiness({ + command: serviceCommand, + launcherPath, + pidPath, + socketPath, + spawnSync: run, + readPidRecord, + readinessProbe: deps.readinessProbe ?? defaultWindowsRuntimeReadiness, + timeoutMs: deps.handoverTimeoutMs ?? 15_000, + pollMs: deps.handoverPollMs ?? 100, + sleep: deps.sleep, + pidAlive: deps.pidAlive, + }); + if (youngReadiness.ready) { + return { + ok: true, + serviceName, + action: "install", + path: taskName, + message: "ADE per-user startup entry is already installed; the channel brain finished starting.", + }; + } + if (youngReadiness.supervised) { + return { + ok: true, + starting: true, + serviceName, + action: "install", + path: taskName, + message: `ADE per-user startup entry is installed; the channel brain is still starting on ${socketPath}: ${youngReadiness.diagnostic}`, + }; + } + // The young brain died while we waited: fall through and replace it. + } const currentRemoval = removeWindowsTaskIfPresent( run, taskName, @@ -888,8 +949,6 @@ async function installWindowsServiceImpl( message: `ADE per-user startup entry was installed, but the background service failed to start: ${startFailure}`, }; } - const readPidRecord = deps.readPidRecord - ?? ((target: string) => readWindowsServicePidRecord({ pidPath: target })); const readiness = await waitForWindowsRuntimeReadiness({ command: serviceCommand, launcherPath, @@ -898,10 +957,31 @@ async function installWindowsServiceImpl( spawnSync: run, readPidRecord, readinessProbe: deps.readinessProbe ?? defaultWindowsRuntimeReadiness, + // Shorter than launchd's shared budget on purpose: the Windows install + // already spends several PowerShell round-trips before this wait, and the + // desktop bounds the whole child at 60s. A supervised brain that is not + // ready by then is reported as `starting`, not as a failure. timeoutMs: deps.handoverTimeoutMs ?? 15_000, pollMs: deps.handoverPollMs ?? 100, sleep: deps.sleep, + pidAlive: deps.pidAlive, }); + if (!readiness.ready && readiness.supervised) { + // Same contract as launchd: a supervised brain that has not answered yet + // is still starting, not broken. The supervisor keeps it; the caller keeps + // waiting for the endpoint. + return { + ok: true, + starting: true, + restarted: true, + serviceName, + action: "install", + path: taskName, + message: + `ADE per-user startup entry installed; the channel brain is still starting on ${socketPath}: ` + + readiness.diagnostic, + }; + } if (!readiness.ready) { return { ok: false, @@ -920,6 +1000,7 @@ async function installWindowsServiceImpl( const sessionBound = readPidRecord(pidPath)?.sessionBound === true; return { ok: true, + restarted: true, serviceName, action: "install", path: taskName, diff --git a/apps/ade-cli/src/serviceManager/windowsSupervisor.ts b/apps/ade-cli/src/serviceManager/windowsSupervisor.ts index 6fbac477c4..bbba6295f2 100644 --- a/apps/ade-cli/src/serviceManager/windowsSupervisor.ts +++ b/apps/ade-cli/src/serviceManager/windowsSupervisor.ts @@ -12,6 +12,7 @@ import { import { type AdeServiceCommand, cmdQuote, + isPidAlive, serviceManagerResultText, type ServiceManagerSpawnSync, } from "./common"; @@ -82,6 +83,12 @@ export type WindowsServicePidRecord = { export type WindowsRuntimeReadiness = { ready: boolean; diagnostic: string; + /** + * The supervisor published a PID record during the wait, i.e. it is running + * a brain that has not answered yet. Callers treat that as "still starting" + * rather than a failed install. + */ + supervised?: boolean; }; export type WindowsRuntimeReadinessProbe = (args: { @@ -629,15 +636,21 @@ export async function waitForWindowsRuntimeReadiness(args: { timeoutMs: number; pollMs: number; sleep?: (ms: number) => Promise; + /** Liveness of the supervisor pid named in the record; tests inject it. */ + pidAlive?: (pid: number) => boolean; }): Promise { const deadline = Date.now() + Math.max(0, args.timeoutMs); const readPidRecord = args.readPidRecord ?? readWindowsServicePidRecord; const readinessProbe = args.readinessProbe ?? defaultWindowsRuntimeReadiness; const sleep = args.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); let diagnostic = "The Windows brain supervisor did not publish a PID record."; + let supervised = false; do { const pidRecord = readPidRecord(args.pidPath); if (pidRecord) { + // A record alone is not a supervisor: a stale file from a supervisor + // that already died must not read as "still starting". + supervised = (args.pidAlive ?? isPidAlive)(pidRecord.supervisorPid); const result = readinessProbe({ command: args.command, launcherPath: args.launcherPath, @@ -652,5 +665,5 @@ export async function waitForWindowsRuntimeReadiness(args: { if (remaining <= 0) break; await sleep(Math.min(Math.max(10, args.pollMs), remaining)); } while (Date.now() <= deadline); - return { ready: false, diagnostic }; + return { ready: false, diagnostic, supervised }; } diff --git a/apps/ade-cli/src/services/projects/projectScope.ts b/apps/ade-cli/src/services/projects/projectScope.ts index 2bf72d31ba..56ee687347 100644 --- a/apps/ade-cli/src/services/projects/projectScope.ts +++ b/apps/ade-cli/src/services/projects/projectScope.ts @@ -181,6 +181,16 @@ export class ProjectScopeRegistry { return this.syncHostProjectId; } + /** + * The project a caller most recently asked to host sync, whether or not that + * switch has completed. `switchSyncHost` returns null both for "superseded by + * a newer switch" and for a genuinely absent host, and the brain's startup + * loop must not read the former as "no project, host projectless". + */ + getRequestedSyncHostProjectId(): ProjectId | null { + return this.latestSyncHostTransitionProjectId; + } + async resolveActiveSyncHost(): Promise { if (!this.options.syncRuntime?.enabled) return null; const existingHostId = this.syncHostProjectId; diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 14d335186d..7d1c031588 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1694,7 +1694,12 @@ app.whenReady().then(async () => { const status = localRuntimePool.getStatus().serviceInstall; if (status.state === "installed") { markMachineStateMigrationComplete({ layout: machineAdeLayout }); - if (machineTrustResetRestartRequired) { + // The reset is complete only once the brain has actually been + // replaced. A forced install may legitimately decline to restart a + // brain that is still starting up (it waits for it instead), and + // that brain loaded the pre-reset files; leaving the marker unset + // makes the next launch restart it for real. + if (machineTrustResetRestartRequired && status.restarted === true) { markMachineTrustResetComplete(machineAdeLayout); } } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index d2c0368a1a..f22f396cac 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -4569,13 +4569,20 @@ export function registerIpc({ return await projectRecoveryService.diagnose(projectRoot); }); - // Return the complete ordered step array with the final report. The current - // alert only needs one result, so it does not need a separate event lifecycle. - ipcMain.handle(IPC.recoveryRepair, async (_event, arg: { projectRoot: string }): Promise => { + // The complete ordered step array comes back with the final report; each + // step is ALSO pushed to the calling window as it finishes. A repair can + // legitimately wait a minute or more for the background service to answer, + // and a spinner with no steps for that long reads as a hang. + ipcMain.handle(IPC.recoveryRepair, async (event, arg: { projectRoot: string }): Promise => { const projectRoot = typeof arg?.projectRoot === "string" ? arg.projectRoot.trim() : ""; if (!projectRoot) throw new Error("Project root path is required."); if (!projectRecoveryService) throw new Error("Project recovery is unavailable in this runtime mode."); - return await projectRecoveryService.repair(projectRoot); + return await projectRecoveryService.repair(projectRoot, { + onStep: (step) => { + if (event.sender.isDestroyed()) return; + event.sender.send(IPC.recoveryRepairStep, { projectRoot, step }); + }, + }); }); ipcMain.handle(IPC.projectStateGetSnapshot, async (): Promise => { diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index f713f26081..d1e99a0b2f 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -979,7 +979,16 @@ describe("local runtime connection pool", () => { ok: false, path: "/Users/admin/Library/LaunchAgents/com.ade.runtime.plist", message: "launchctl failed", + starting: false, + restarted: false, }); + expect(parseRuntimeServiceManagerOutput(JSON.stringify({ + ok: true, + action: "install", + starting: true, + path: "/Users/admin/Library/LaunchAgents/com.ade.runtime.plist", + message: "still starting", + }))?.starting).toBe(true); expect(parseRuntimeServiceManagerOutput("not json")).toBeNull(); }); diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index b5ef9c51b2..1d58c6f87c 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -84,6 +84,10 @@ type RuntimeServiceManagerOutput = { ok: boolean | null; path: string | null; message: string | null; + /** The installer left a live brain that has not answered yet; see `ServiceManagerResult.starting`. */ + starting: boolean; + /** The installer actually (re)started the service child; see `ServiceManagerResult.restarted`. */ + restarted: boolean; }; /** @@ -127,7 +131,19 @@ const LOCAL_RUNTIME_SERVICE_UNINSTALL_TIMEOUT_MS = 20_000; // `serve --install-service` does an unload → reap → load handover, so it is // allowed longer than the uninstall — but never forever: a wedged installer // used to pin `serviceInstallPromise` and block every later install. -const LOCAL_RUNTIME_SERVICE_INSTALL_TIMEOUT_MS = 60_000; +// Must cover the installer's own waits: the shared handover budget +// (RUNTIME_SERVICE_HANDOVER_TIMEOUT_MS, 30 s) plus one blocking probe overrun, +// predecessor termination and the launchctl round-trips. A child killed at +// this deadline reads as a failed install even when launchd's replacement is +// coming up, so the budget errs long. +const LOCAL_RUNTIME_SERVICE_INSTALL_TIMEOUT_MS = 90_000; +/** + * How long a freshly (re)installed service gets to answer on the socket before + * the desktop gives up on it. Longer than the installer's own handover wait on + * purpose: the installer may return `starting` with a live brain that is still + * booting, and this is where that brain gets the rest of its time. + */ +const LOCAL_RUNTIME_SERVICE_REPAIR_CONNECT_TIMEOUT_MS = 90_000; const LOCAL_RUNTIME_STATUS_REFRESH_TIMEOUT_MS = 2_000; // The Windows service probe itself costs ~2.1s (two PowerShell spawns), so the // off-thread child needs generous headroom before it is treated as unanswerable. @@ -943,6 +959,8 @@ export function parseRuntimeServiceManagerOutput(output: string): RuntimeService ok: typeof record.ok === "boolean" ? record.ok : null, path: typeof record.path === "string" && record.path.trim() ? record.path.trim() : null, message: typeof record.message === "string" && record.message.trim() ? record.message.trim() : null, + starting: record.starting === true, + restarted: record.restarted === true, }; } @@ -976,6 +994,8 @@ export class LocalRuntimeConnectionPool { intent: ProjectRegistrationIntent; promise: Promise; }>(); + /** ISO time of the first install attempt since the last successful connect; see `runServiceInstallBestEffort`. */ + private serviceStartupStreakStartedAt: string | null = null; private serviceInstallStatus: LocalRuntimeStatus["serviceInstall"] = { state: "not_attempted", attempted: false, @@ -1430,13 +1450,22 @@ export class LocalRuntimeConnectionPool { }); return; } + // The streak start, not this attempt's start: installs recur (connect + // failures re-run them, isolated recovery re-runs them every 60s), and a + // brain that has not answered since the FIRST of those attempts is what + // recovery needs to age. The streak resets when a connection succeeds. + if (this.serviceStartupStreakStartedAt == null) { + this.serviceStartupStreakStartedAt = new Date().toISOString(); + } + const attemptStartedAt = this.serviceStartupStreakStartedAt; this.serviceInstallStatus = { state: "installing", attempted: true, path: cliPath, message: "Installing the ADE service login item.", exitCode: null, - updatedAt: new Date().toISOString(), + updatedAt: attemptStartedAt, + attemptStartedAt, }; let result: ServiceManagerCommandResult; try { @@ -1462,6 +1491,7 @@ export class LocalRuntimeConnectionPool { message, exitCode: null, updatedAt: new Date().toISOString(), + attemptStartedAt, }; this.logger.warn("local_runtime.service_install_failed", { error: message }); return; @@ -1475,6 +1505,7 @@ export class LocalRuntimeConnectionPool { message, exitCode: null, updatedAt: new Date().toISOString(), + attemptStartedAt, }; this.logger.warn("local_runtime.service_install_failed", { cliPath, reason: "timeout", message }); return; @@ -1497,8 +1528,14 @@ export class LocalRuntimeConnectionPool { message: parsed?.message || output || "ADE service login item is installed.", exitCode: code, updatedAt: new Date().toISOString(), + starting: parsed?.starting === true, + restarted: parsed?.restarted === true, + attemptStartedAt, }; - this.logger.info("local_runtime.service_install_succeeded", payload); + this.logger.info( + parsed?.starting ? "local_runtime.service_install_starting" : "local_runtime.service_install_succeeded", + payload, + ); } else { this.serviceInstallStatus = { state: "failed", @@ -1507,6 +1544,7 @@ export class LocalRuntimeConnectionPool { message: parsed?.message || errorOutput || output || "ADE service login item installation failed.", exitCode: code, updatedAt: new Date().toISOString(), + attemptStartedAt, }; this.logger.warn("local_runtime.service_install_failed", payload); } @@ -2355,8 +2393,10 @@ export class LocalRuntimeConnectionPool { // attempt lands in that churn window and strands this desktop on an // isolated no-sync runtime, so keep retrying — through connect failures // AND through compatibility errors from the not-yet-replaced old brain — - // until the repaired service is actually reachable. - const deadline = Date.now() + 20_000; + // until the repaired service is actually reachable. The budget covers a + // brain the installer reported as `starting`: launchd/the supervisor owns + // it and it will answer, so waiting is right and restarting it is not. + const deadline = Date.now() + LOCAL_RUNTIME_SERVICE_REPAIR_CONNECT_TIMEOUT_MS; let lastError: unknown = null; for (;;) { try { @@ -2595,6 +2635,7 @@ export class LocalRuntimeConnectionPool { this.clearVersionSkewStatus(); } this.activeClient = client; + this.serviceStartupStreakStartedAt = null; this.activeRuntimePid = runtimeInfo.pid; this.activeRuntimeSyncPort = runtimeInfo.syncPort; this.activeRuntimePublishHealth = runtimeInfo.publishHealth; diff --git a/apps/desktop/src/main/services/runtime/lastFailureStore.ts b/apps/desktop/src/main/services/runtime/lastFailureStore.ts index 0ee8bd2648..b19ff3e5b9 100644 --- a/apps/desktop/src/main/services/runtime/lastFailureStore.ts +++ b/apps/desktop/src/main/services/runtime/lastFailureStore.ts @@ -10,7 +10,8 @@ import { readValidJson, writeFileAtomic } from "../state/durableFile"; const MESSAGE_MAX_BYTES = 2 * 1024; const DETAIL_MAX_BYTES = 8 * 1024; -const CRASH_LOOP_WINDOW_MS = 5 * 60 * 1_000; +export const LAST_FAILURE_CRASH_LOOP_WINDOW_MS = 5 * 60 * 1_000; +const CRASH_LOOP_WINDOW_MS = LAST_FAILURE_CRASH_LOOP_WINDOW_MS; export type LastFailureTarget = | { kind: "machine"; env?: NodeJS.ProcessEnv } diff --git a/apps/desktop/src/main/services/runtime/projectRecoveryService.test.ts b/apps/desktop/src/main/services/runtime/projectRecoveryService.test.ts index cf81d77ed8..52a799ddc1 100644 --- a/apps/desktop/src/main/services/runtime/projectRecoveryService.test.ts +++ b/apps/desktop/src/main/services/runtime/projectRecoveryService.test.ts @@ -22,6 +22,8 @@ function tempRoot(): string { return root; } +const NOW = Date.parse("2026-07-12T12:01:00.000Z"); + function logger(): Logger { return { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; } @@ -98,7 +100,7 @@ function deps(overrides: Partial = {}): ProjectRecov readFailureReports: vi.fn(async () => ({ project: null, machine: null })), clearFailureReports: vi.fn(async () => {}), socketExists: vi.fn(() => false), - now: () => Date.parse("2026-07-12T12:01:00.000Z"), + now: () => NOW, ...overrides, }; } @@ -162,6 +164,36 @@ describe("ProjectRecoveryService.diagnose", () => { canAutoRepair: false, overrides: { probeSocket: vi.fn(async () => true), pingEndpoint: vi.fn(async () => false) }, }, + { + name: "starting brain (service running, install just began, socket not up yet)", + expected: "brain_starting", + canAutoRepair: false, + overrides: { + connectionPool: pool(status({ + serviceInstall: { + state: "installed", attempted: true, path: null, message: null, exitCode: null, + updatedAt: new Date(NOW).toISOString(), starting: true, + attemptStartedAt: new Date(NOW - 15_000).toISOString(), + }, + serviceHealth: { state: "running", installed: true, running: true, path: null, message: null, checkedAt: null }, + })), + }, + }, + { + name: "brain still quiet long after its install began (not starting any more)", + expected: "brain_crash_looping", + canAutoRepair: true, + overrides: { + connectionPool: pool(status({ + serviceInstall: { + state: "installed", attempted: true, path: null, message: null, exitCode: null, + updatedAt: new Date(NOW).toISOString(), starting: true, + attemptStartedAt: new Date(NOW - 10 * 60_000).toISOString(), + }, + serviceHealth: { state: "installed", installed: true, running: false, path: null, message: null, checkedAt: null }, + })), + }, + }, { name: "unknown", expected: "unknown_failure", @@ -330,7 +362,7 @@ describe("ProjectRecoveryService.restartBrain", () => { // The ping is explicitly bounded: the RPC client's default is 10 minutes, // which would park this call — and any repair waiting on it — on a brain // that binds the socket but never answers. - expect(connectionPool.callSync).toHaveBeenCalledWith("ping", {}, { timeoutMs: 20_000 }); + expect(connectionPool.callSync).toHaveBeenCalledWith("ping", {}, { timeoutMs: 90_000 }); }); const installStatusPool = ( diff --git a/apps/desktop/src/main/services/runtime/projectRecoveryService.ts b/apps/desktop/src/main/services/runtime/projectRecoveryService.ts index 60fd3f31f4..528e22fada 100644 --- a/apps/desktop/src/main/services/runtime/projectRecoveryService.ts +++ b/apps/desktop/src/main/services/runtime/projectRecoveryService.ts @@ -1,13 +1,14 @@ import fs from "node:fs"; import net from "node:net"; import path from "node:path"; -import type { - AdeLastFailureReport, - AdeRecoveryErrorCode, - ProjectRecoveryDiagnosis, - ProjectRepairReport, - RepairStepId, - RepairStepResult, +import { + REPAIR_STEPS, + type AdeLastFailureReport, + type AdeRecoveryErrorCode, + type ProjectRecoveryDiagnosis, + type ProjectRepairReport, + type RepairStepId, + type RepairStepResult, } from "../../../shared/types/recovery"; import { resolveMachineAdeLayout } from "../../../../../ade-cli/src/services/projects/machineLayout"; import type { Logger } from "../logging/logger"; @@ -28,30 +29,21 @@ const MIB = 1024 * 1024; const GIB = 1024 * MIB; const FRESH_FAILURE_MS = 5 * 60 * 1_000; // How long a restarted brain gets to rebind the machine endpoint, shared by -// `repair()`'s restart_service step and `restartBrain()`. -const BRAIN_RESTART_TIMEOUT_MS = 20_000; - -const STEP_LABELS: Record = { - check_space: "Checking storage space", - stop_service: "Stopping ADE's background service", - validate_database: "Checking project data", - resolve_migrations: "Finishing interrupted saves", - restart_service: "Restarting ADE's background service", - verify_endpoint: "Checking the background service", - verify_project_rpc: "Checking this project", - reconcile_chats: "Checking chats", -}; - -const STEP_ORDER: readonly RepairStepId[] = [ - "check_space", - "stop_service", - "validate_database", - "resolve_migrations", - "restart_service", - "verify_endpoint", - "verify_project_rpc", - "reconcile_chats", -]; +// `repair()`'s restart_service step and `restartBrain()`. Generous on purpose: +// the installer reports a live-but-slow brain as `starting`, and this wait is +// where such a brain gets the rest of its time. It used to be 20s, which on a +// cold or slow machine expired against a healthy brain and turned into +// "didn't restart — try again". +const BRAIN_RESTART_TIMEOUT_MS = 90_000; +// A brain whose install/restart began less than this long ago and that is not +// answering yet is presumed to still be starting, not stuck. +const BRAIN_STARTING_WINDOW_MS = 120_000; + +const STEP_LABELS: Record = Object.fromEntries( + REPAIR_STEPS.map((step) => [step.id, step.label]), +) as Record; + +const STEP_ORDER: readonly RepairStepId[] = REPAIR_STEPS.map((step) => step.id); const REPAIR_MIN_FREE_BYTES = (dbSize: number): number => Math.max(GIB, dbSize + 512 * MIB); // Advice = repair gate + margin, so following the advice always satisfies repair. @@ -189,6 +181,12 @@ function diagnosisCopy(state: ProjectRecoveryDiagnosis["state"]): Pick< body: "Close other copies of ADE, then try again.", canAutoRepair: false, }; + case "brain_starting": + return { + headline: "ADE's background service is starting.", + body: "This can take a minute the first time or right after an update. ADE will open the project as soon as it's ready — nothing to do.", + canAutoRepair: false, + }; default: return { headline: "ADE couldn't open this project.", @@ -504,6 +502,14 @@ export class ProjectRecoveryService { const socketReachable = await this.probeSocket(this.socketPath, 750); const endpointHealthy = socketReachable && await this.pingEndpoint(this.socketPath, 1_500); const serviceStatus = this.deps.connectionPool.getStatus(); + const installStartedAt = Date.parse(serviceStatus.serviceInstall.attemptStartedAt ?? ""); + // Time-bounded on purpose: the installer's `starting` flag alone would keep + // a brain that wedged during boot reading as "starting" forever. + const brainStarting = + !socketReachable + && serviceStatus.serviceHealth.running === true + && Number.isFinite(installStartedAt) + && this.now() - installStartedAt < BRAIN_STARTING_WINDOW_MS; const dbCheck = endpointHealthy ? { healthy: null, detail: "Project data check skipped because the background service is using it." } : await this.quickCheck(dbPath); @@ -513,7 +519,7 @@ export class ProjectRecoveryService { `socketPath=${this.socketPath}`, `socketReachable=${socketReachable}`, `endpointHealthy=${endpointHealthy}`, - `serviceInstall=${serviceStatus.serviceInstall.state}`, + `serviceInstall=${serviceStatus.serviceInstall.state}${serviceStatus.serviceInstall.starting ? " (starting)" : ""}`, `serviceHealth=${serviceStatus.serviceHealth.state}`, `database=${dbCheck.detail}`, ...(latestFailure ? [`lastFailure=${latestFailure.code}: ${latestFailure.message}${latestFailure.detail ? ` (${latestFailure.detail})` : ""}`] : []), @@ -536,6 +542,13 @@ export class ProjectRecoveryService { } else if (socketReachable) { state = "socket_owned_by_other"; code = "socket_owned_by_other"; + } else if (brainStarting) { + // Ahead of the crash-loop and stale-socket branches: a brain that the + // installer just started (or reported as still starting) and that + // launchd/the supervisor shows running is booting, not broken. Repair + // here would only kill it and start its clock over. + state = "brain_starting"; + code = "unknown"; } else if (serviceStatus.serviceHealth.installed === false) { state = "brain_not_installed"; code = "brain_not_installed"; diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 4a152812c8..54bd444fa8 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -719,7 +719,7 @@ import type { StorageCompressionResult, StorageSnapshot, } from "../shared/types/storage"; -import type { ProjectRecoveryDiagnosis, ProjectRepairReport } from "../shared/types/recovery"; +import type { ProjectRecoveryDiagnosis, ProjectRepairReport, RepairStepResult } from "../shared/types/recovery"; import type { AppPackageChannel } from "../shared/packageChannel"; import type { ProductAnalyticsCapture, @@ -895,6 +895,10 @@ declare global { recovery: { diagnose: (projectRoot: string) => Promise; repair: (projectRoot: string) => Promise; + /** Live repair steps for the window that started the repair. */ + onRepairStep: ( + cb: (payload: { projectRoot: string; step: RepairStepResult }) => void, + ) => () => void; }; remoteRuntime: { listTargets: () => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index da9d18b642..c4ece2b7b5 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -26,7 +26,7 @@ import { REMOTE_RUNTIME_EVENT_IDLE_POLL_MS, } from "./pinnedRuntimeEvents"; import type { OrchestrationEventPayload } from "../shared/types/orchestration"; -import type { ProjectRecoveryDiagnosis, ProjectRepairReport } from "../shared/types/recovery"; +import type { ProjectRecoveryDiagnosis, ProjectRepairReport, RepairStepResult } from "../shared/types/recovery"; import type { ProductAnalyticsCapture, ProductAnalyticsCaptureResult, @@ -3974,6 +3974,16 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.recoveryDiagnose, { projectRoot }), repair: (projectRoot: string): Promise => ipcRenderer.invoke(IPC.recoveryRepair, { projectRoot }), + onRepairStep: ( + cb: (payload: { projectRoot: string; step: RepairStepResult }) => void, + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { projectRoot: string; step: RepairStepResult }, + ) => cb(payload); + ipcRenderer.on(IPC.recoveryRepairStep, listener); + return () => ipcRenderer.removeListener(IPC.recoveryRepairStep, listener); + }, }, remoteRuntime: { listTargets: async (): Promise => diff --git a/apps/desktop/src/renderer/components/app/ProjectRecoveryScreen.test.tsx b/apps/desktop/src/renderer/components/app/ProjectRecoveryScreen.test.tsx index f62287aaa1..81c57c971a 100644 --- a/apps/desktop/src/renderer/components/app/ProjectRecoveryScreen.test.tsx +++ b/apps/desktop/src/renderer/components/app/ProjectRecoveryScreen.test.tsx @@ -107,6 +107,55 @@ describe("ProjectRecoveryScreen", () => { expect(screen.getByRole("button", { name: "Review storage" })).toBeTruthy(); }); + it("waits out a starting background service and reopens the project by itself", async () => { + vi.useFakeTimers(); + try { + const starting = makeDiagnosis({ + state: "brain_starting", + code: "unknown", + headline: "ADE's background service is starting.", + body: "This can take a minute the first time. ADE will open the project as soon as it's ready.", + canAutoRepair: false, + }); + const healthy = makeDiagnosis({ + state: "healthy", + code: "unknown", + headline: "ADE is ready to open this project.", + body: "No repair is needed.", + canAutoRepair: false, + }); + const diagnose = vi.fn() + .mockResolvedValueOnce(starting) + .mockResolvedValueOnce(starting) + .mockResolvedValue(healthy); + const repair = vi.fn(); + globalThis.window.ade = { recovery: { diagnose, repair, onRepairStep: () => () => {} } } as any; + const switchProjectToPath = vi.fn(async () => {}); + useAppStore.setState({ switchProjectToPath }); + setError({ code: "unknown" }); + + render(); + await vi.waitFor(() => { + expect(screen.getByText("ADE's background service is starting.")).toBeTruthy(); + }); + expect(screen.getByText("Waiting for the background service…")).toBeTruthy(); + // No Repair offer while it is merely starting: Repair would restart it. + expect(screen.queryByRole("button", { name: "Repair ADE" })).toBeNull(); + + await vi.advanceTimersByTimeAsync(2_100); + expect(diagnose).toHaveBeenCalledTimes(2); + expect(switchProjectToPath).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(2_100); + await vi.waitFor(() => { + expect(switchProjectToPath).toHaveBeenCalledWith(ROOT); + }); + expect(repair).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it.each([ "provider_thread_missing", "provider_resume_failed", diff --git a/apps/desktop/src/renderer/components/app/ProjectRecoveryScreen.tsx b/apps/desktop/src/renderer/components/app/ProjectRecoveryScreen.tsx index 7b79f254fc..f503d4ad85 100644 --- a/apps/desktop/src/renderer/components/app/ProjectRecoveryScreen.tsx +++ b/apps/desktop/src/renderer/components/app/ProjectRecoveryScreen.tsx @@ -9,6 +9,7 @@ import { import { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { + REPAIR_STEPS, toAdeRecoveryErrorCode, type AdeRecoveryErrorCode, type ProjectRecoveryDiagnosis, @@ -45,6 +46,14 @@ const STEP_REVEAL_MS = 150; const SETTLE_MS = 250; /** Beat the success card stays up before ADE re-attempts the project open. */ const REOPEN_DELAY_MS = 700; +/** + * While the background service is still starting there is nothing for the + * user to do, so this surface keeps re-diagnosing on its own and reopens the + * project the moment the service answers. The diagnosis itself stops saying + * "starting" once the brain has been quiet for too long, so this cannot spin + * forever on a wedged brain — it degrades into the normal repair offer. + */ +const STARTING_POLL_MS = 2_000; /** * Plain-language headline/body used only when a live diagnosis is unavailable @@ -105,12 +114,23 @@ const FALLBACK_COPY: Record + step.id === "restart_service" + ? `${step.label} (this can take a few minutes)` + : step.label, +); + function pluralize(count: number, noun: string): string { return `${count} ${noun}${count === 1 ? "" : "s"}`; } @@ -150,6 +170,9 @@ export function ProjectRecoveryScreen() { const [phase, setPhase] = useState("diagnosing"); const [report, setReport] = useState(null); const [revealed, setRevealed] = useState(0); + // Steps pushed by the main process while the repair is still running. The + // final report replaces them; until then they are what the user watches. + const [liveSteps, setLiveSteps] = useState([]); const [repairError, setRepairError] = useState(null); const { copy, copied } = useCopyToClipboard(); const reopenStartedRef = useRef(false); @@ -179,7 +202,10 @@ export function ProjectRecoveryScreen() { return () => { cancelled = true; }; - }, [rootPath]); + // Keyed on the error object, not just its root: a fresh failure for the + // same project (e.g. the automatic reopen above hitting a different problem) + // must be diagnosed again rather than shown under the previous verdict. + }, [rootPath, projectTransitionError]); // Reveal repair steps sequentially, then resolve to success/failure. The API // returns the whole array at once; the stagger makes it read as a checklist. @@ -201,6 +227,40 @@ export function ProjectRecoveryScreen() { return () => window.clearTimeout(timer); }, [phase, report, revealed]); + // Nothing to repair while the service is booting: keep asking, and reopen the + // project ourselves as soon as it is healthy. The user should never have to + // click Repair (which restarts the brain) to recover from a slow start. + useEffect(() => { + if (phase !== "idle" || diagnosis?.state !== "brain_starting" || !rootPath) return; + if (!window.ade?.recovery?.diagnose) return; + let cancelled = false; + const timer = window.setInterval(() => { + window.ade.recovery + .diagnose(rootPath) + .then((result) => { + if (cancelled) return; + if (result.state === "healthy") { + if (reopenStartedRef.current) return; + reopenStartedRef.current = true; + void switchProjectToPath(rootPath).catch(() => { + // The open failed for a new reason; the store has replaced the + // transition error and the diagnose effect below re-runs. + reopenStartedRef.current = false; + }); + return; + } + setDiagnosis(result); + }) + .catch(() => { + // Keep polling; a failed diagnosis is not a verdict. + }); + }, STARTING_POLL_MS); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [phase, diagnosis?.state, rootPath, switchProjectToPath]); + // Once repaired, keep the success card up for a beat, then re-attempt the open. // A successful open clears the transition error and unmounts this surface. useEffect(() => { @@ -212,15 +272,28 @@ export function ProjectRecoveryScreen() { return () => window.clearTimeout(timer); }, [phase, rootPath, switchProjectToPath]); + // Subscribe for the whole life of the surface: a repair started from this + // window streams its steps here as each one finishes. + useEffect(() => { + if (!rootPath || !window.ade?.recovery?.onRepairStep) return; + return window.ade.recovery.onRepairStep(({ projectRoot, step }) => { + if (projectRoot !== rootPath) return; + setLiveSteps((prev) => (prev.some((s) => s.id === step.id) ? prev : [...prev, step])); + }); + }, [rootPath]); + const runRepair = useCallback(async () => { if (!rootPath || phase === "repairing") return; reopenStartedRef.current = false; setReport(null); setRevealed(0); + setLiveSteps([]); setRepairError(null); setPhase("repairing"); try { const result = await window.ade.recovery.repair(rootPath); + // Everything streamed already showed; reveal the rest without the stagger. + setRevealed(result.steps.length); setReport(result); } catch (error) { setRepairError(error instanceof Error ? error.message : String(error)); @@ -251,7 +324,13 @@ export function ProjectRecoveryScreen() { .filter((line): line is string => Boolean(line && line.trim())) .join("\n"); - const visibleSteps = report ? report.steps.slice(0, phase === "repairing" ? revealed : undefined) : []; + const visibleSteps = report + ? report.steps.slice(0, phase === "repairing" ? revealed : undefined) + : liveSteps; + // What the repair is doing right now: the step after the last finished one. + const activeStepLabel = phase === "repairing" && !report + ? (liveSteps.length ? REPAIR_STEP_LABELS[liveSteps.length] ?? null : REPAIR_STEP_LABELS[0]) + : null; const isSuccess = phase === "success"; @@ -312,7 +391,7 @@ export function ProjectRecoveryScreen() { aria-hidden="true" className="h-3 w-3 shrink-0 animate-spin rounded-full border border-amber-200/30 border-t-amber-300" /> - Repairing… + {activeStepLabel ? `${activeStepLabel}…` : "Repairing…"} ) : null} {visibleSteps.length ? ( @@ -325,10 +404,23 @@ export function ProjectRecoveryScreen() { ) : null} - {/* Actions */} + {/* Booting service: no actions, just a live status line. */} + {phase === "idle" && diagnosis?.state === "brain_starting" ? ( +
+
+ ) : null} + + {/* Actions. While the service is merely starting, Repair is withheld + (it would restart the very brain we are waiting for) but the other + ways out stay: a person must never be pinned on a spinner. */} {phase !== "repairing" && !isSuccess ? (
- {canAutoRepair ? ( + {canAutoRepair && diagnosis?.state !== "brain_starting" ? ( ) : null} + {rootPath ? ( + + ) : null} + ) : null}
{title}
{subtitle}
+ {action ? ( + + ) : null} ); diff --git a/apps/desktop/src/renderer/components/settings/BrainRepairButton.tsx b/apps/desktop/src/renderer/components/settings/BrainRepairButton.tsx index 443a63e165..261ac05d87 100644 --- a/apps/desktop/src/renderer/components/settings/BrainRepairButton.tsx +++ b/apps/desktop/src/renderer/components/settings/BrainRepairButton.tsx @@ -35,7 +35,11 @@ export function BrainRepairButton({ style={{ color: COLORS.warning, fontFamily: SANS_FONT, fontSize: 11 }} title={repair.error} > - Repair failed — quit and reopen ADE. + {/* The main process already phrases restart failures for people + ("A newer ADE runtime is already running — quit and reopen ADE + instead."); hiding that behind a generic line and a tooltip + left users with an instruction and no reason. */} + {`Repair didn't finish — ${repair.error.replace(/\.?\s*$/, ".")}`} ) : repair.notice ? ( { await runRepair(result); expect(result.current.notice?.tone).toBe("warn"); - expect(result.current.notice?.text).toContain("didn't restart"); + expect(result.current.notice?.text).toContain("didn't come back"); expect(result.current.error).toBeNull(); }); diff --git a/apps/desktop/src/renderer/hooks/useBrainRepair.ts b/apps/desktop/src/renderer/hooks/useBrainRepair.ts index 13978e35a2..09a8764cd3 100644 --- a/apps/desktop/src/renderer/hooks/useBrainRepair.ts +++ b/apps/desktop/src/renderer/hooks/useBrainRepair.ts @@ -54,8 +54,8 @@ export function useBrainRepair(onSettled?: () => void): BrainRepair { return { tone: "warn", text: result.outcome === "repaired" - ? "Your sign-in is back, but the ADE background service didn't restart. Try again." - : "The ADE background service didn't restart. Try again.", + ? "Your sign-in is back, but the ADE background service didn't come back. Wait a moment and try again; if it keeps failing, quit and reopen ADE." + : "The ADE background service didn't come back. Wait a moment and try again; if it keeps failing, quit and reopen ADE.", }; } if (result.outcome === "repaired") { diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index 98c01e4c7c..f00482497e 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -1514,6 +1514,8 @@ describe("appStore", () => { expect(useAppStore.getState().projectTransition).toBeNull(); expect(useAppStore.getState().projectTransitionError).toEqual({ message: "Switching projects took longer than 30 seconds, so ADE kept the current project active.", + // The banner offers "Try again" for uncoded switch failures. + retryRootPath: "/tmp/slow-project", }); }); @@ -1530,6 +1532,7 @@ describe("appStore", () => { expect(useAppStore.getState().projectTransitionError).toEqual({ message: "ADE needs Git to open this project.", + retryRootPath: "/tmp/project", }); }); diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 12bc6bfb07..349e0274ca 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -1013,6 +1013,12 @@ export type ProjectTransitionError = { code?: string; detail?: string; rootPath?: string; + /** + * The project the failed transition was heading to, for the banner's + * "Try again". Separate from `rootPath`, which together with `code` selects + * the full-screen recovery flow. + */ + retryRootPath?: string; }; /** @@ -2672,7 +2678,7 @@ const createAppState: StateCreator = (set, get) => { lanesLoading: false, projectTransitionError: projectTransitionError.code ? { ...projectTransitionError, rootPath } - : projectTransitionError, + : { ...projectTransitionError, retryRootPath: rootPath }, }); throw error; } diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index e3b3ce6ff6..610c173468 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -77,6 +77,8 @@ export const IPC = { projectSwitchToPath: "ade.project.switchToPath", recoveryDiagnose: "ade.recovery.diagnose", recoveryRepair: "ade.recovery.repair", + /** Main → renderer: one repair step as it finishes, so a long repair reads live. */ + recoveryRepairStep: "ade.recovery.repairStep", projectForgetRecent: "ade.project.forgetRecent", projectReorderRecent: "ade.project.reorderRecent", projectSetRecentPinned: "ade.project.setRecentPinned", diff --git a/apps/desktop/src/shared/types/core.ts b/apps/desktop/src/shared/types/core.ts index 3f0e371650..e31d49859a 100644 --- a/apps/desktop/src/shared/types/core.ts +++ b/apps/desktop/src/shared/types/core.ts @@ -64,6 +64,20 @@ export type LocalRuntimeStatus = { message: string | null; exitCode: number | null; updatedAt: string | null; + /** + * The install finished with the service registered and its brain alive but + * not yet answering on the socket — a brain still coming up. Consumers + * keep waiting for the endpoint; nothing here needs repairing. + */ + starting?: boolean; + /** The install actually (re)started the service child (see `ServiceManagerResult.restarted`). */ + restarted?: boolean; + /** + * When the current streak of install attempts began — the first attempt + * since the desktop last connected successfully. Recovery ages a quiet + * brain from here, so recurring installs cannot keep it "starting" forever. + */ + attemptStartedAt?: string | null; }; serviceHealth: { state: LocalRuntimeServiceHealthState; diff --git a/apps/desktop/src/shared/types/recovery.ts b/apps/desktop/src/shared/types/recovery.ts index 03d6eaa25e..dcd536a75a 100644 --- a/apps/desktop/src/shared/types/recovery.ts +++ b/apps/desktop/src/shared/types/recovery.ts @@ -46,6 +46,12 @@ export type ProjectRecoveryDiagnosis = { | "brain_not_installed" | "socket_stale_no_owner" | "socket_owned_by_other" + /** + * The background service is registered and its brain is alive but has not + * bound the socket yet. Nothing to repair — the desktop keeps checking and + * opens the project as soon as it answers. + */ + | "brain_starting" | "unknown_failure"; code: AdeRecoveryErrorCode; headline: string; @@ -67,6 +73,22 @@ export type RepairStepId = | "verify_project_rpc" | "reconcile_chats"; +/** + * The repair steps in the order `ProjectRecoveryService.repair` runs them, + * with the wording each one shows. Shared so the recovery screen can name the + * step that is running before it has reported, from the same list. + */ +export const REPAIR_STEPS: ReadonlyArray<{ id: RepairStepId; label: string }> = [ + { id: "check_space", label: "Checking storage space" }, + { id: "stop_service", label: "Stopping ADE's background service" }, + { id: "validate_database", label: "Checking project data" }, + { id: "resolve_migrations", label: "Finishing interrupted saves" }, + { id: "restart_service", label: "Restarting ADE's background service" }, + { id: "verify_endpoint", label: "Checking the background service" }, + { id: "verify_project_rpc", label: "Checking this project" }, + { id: "reconcile_chats", label: "Checking chats" }, +]; + export type RepairStepResult = { id: RepairStepId; label: string; diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index d3c6f8c87d..bc01c747f1 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -187,7 +187,7 @@ relay payload E2E encryption is planned security work. See the trust boundary in database, migration, endpoint, and chat continuity failures. It also owns `restartBrain()`, the machine-scoped restart behind the Connections **Repair** button. Both it and `repair()`'s restart_service/verify_endpoint steps go - through one `restartServiceAndWait()` sequence — install, wait up to 20 s for + through one `restartServiceAndWait()` sequence — install, wait up to 90 s for the machine endpoint to rebind, then `ping` — which reports which stage lost rather than the copy, because its two callers phrase the same stage differently (`repair()` speaks in repair steps, `restartBrain()` throws). @@ -201,6 +201,29 @@ relay payload E2E encryption is planned security work. See the trust boundary in repair stops the service and then does exclusive database work, and reinstalling the brain underneath it would put a writer back on the database mid-check. Repair wins; the button can be pressed again afterwards. +- **A slow brain is not a broken brain.** The brain binds `ade.sock` *before* + it starts the mobile sync host (`runServe` in `apps/ade-cli/src/cli.ts` + starts `runSyncHostStartupLoop` in the background right after the socket is + published), so a desktop can reach it within a second or two of spawn even + while a project scope is still opening or the sync port band is being + reclaimed. The launchd/Windows installers wait + `RUNTIME_SERVICE_HANDOVER_TIMEOUT_MS` (30 s; Windows 15 s) for the + replacement to answer and, if it is alive but still quiet, return + `ok: true, starting: true` instead of a `replacement_responsive` failure — + the supervisor owns that child and it will answer. The desktop then keeps + dialling the socket for `LOCAL_RUNTIME_SERVICE_REPAIR_CONNECT_TIMEOUT_MS` + (90 s). A forced install (Repair) that finds an unchanged agent whose child + is younger than `RUNTIME_SERVICE_YOUNG_BRAIN_MS` (120 s) and not answering + yet waits for that child rather than killing it — restarting a booting brain + only resets its clock, and doing it on every Repair click was how a slow + machine could never finish starting one. `projectRecoveryService.diagnose` + reports the same window as `brain_starting` (no Repair offered; the recovery + screen re-diagnoses every 2 s and reopens the project itself once healthy), + and `repair()` streams each step to the window via `IPC.recoveryRepairStep` + so a long restart wait reads as progress, not a hang. Before this, a 10 s + handover budget expired against healthy-but-slow brains on cold or slower + machines, the update transaction reported "couldn't be set up", and the + recovery screen's Repair killed the brain that was seconds from ready. - `apps/desktop/src/main/services/runtime/machineTrustResetMigration.ts` — one-time packaged-release reset of the old machine-connection trust files. It preserves account auth, machine identity, pairing PINs, projects, and SSH From 359ae24fd3fdf638b1e4b28cbeea34c068285aad Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:27:32 -0400 Subject: [PATCH 2/9] Report-issue diagnostics, Linux/Windows handover parity, and real error screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Diagnostic report + "Report issue": redacted report (paths, users, emails, tokens, IPs, hostnames stripped; PostHog installId kept for correlation), copied to clipboard + saved, prefilled GitHub issue opened; button on every failure surface; `ade report-issue [--open]` CLI counterpart. - systemd installer gets the same handover as launchd (it had none); shared serviceHandover.ts; Windows `supervised` requires an identity-verified pid; brain re-checks its socket inode so the #949 zombie stays closed; --no-sync brains no longer wipe the crash-loop record; `starting` handled by ade brain restart, ade connect, ade setup, TUI repair (never spawns a rival brain). - install-runtime.sh stages, preflights the staged binary, promotes by rename and rolls back on failure; CI runs the new rollback test. - Error/recovery screens rebuilt on a shared kit (recovery, renderer/page crash, transition alert, CTO, storage cleanup, welcome notices, update banner) — verified with real screenshots at 900/1400. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 2 +- .../scripts/install-runtime-rollback.test.mjs | 213 ++++++++ apps/ade-cli/scripts/install-runtime.sh | 121 ++++- apps/ade-cli/src/cli.test.ts | 273 +++++++++- apps/ade-cli/src/cli.ts | 180 ++++++- apps/ade-cli/src/commands/connect.test.ts | 19 + apps/ade-cli/src/commands/connect.ts | 17 +- apps/ade-cli/src/commands/reportIssue.ts | 162 ++++++ apps/ade-cli/src/commands/setup.test.ts | 58 ++ apps/ade-cli/src/commands/setup.ts | 73 ++- .../ade-cli/src/serviceManager/common.test.ts | 246 +++++++-- .../src/serviceManager/installLaunchd.ts | 108 +--- .../src/serviceManager/installSystemd.ts | 275 +++++++++- .../src/serviceManager/installWindows.test.ts | 23 +- .../src/serviceManager/installWindows.ts | 10 +- .../src/serviceManager/serviceHandover.ts | 118 ++++ .../serviceManager/windowsSupervisor.test.ts | 34 ++ .../src/serviceManager/windowsSupervisor.ts | 36 +- .../diagnostics/diagnosticReport.test.ts | 244 +++++++++ .../services/diagnostics/diagnosticReport.ts | 504 ++++++++++++++++++ .../tuiClient/__tests__/connection.test.ts | 58 +- apps/ade-cli/src/tuiClient/connection.ts | 61 ++- .../analytics/productAnalyticsPolicy.ts | 3 + .../analytics/productAnalyticsService.ts | 7 + .../diagnostics/diagnosticReportService.ts | 244 +++++++++ .../src/main/services/ipc/registerIpc.ts | 92 ++++ .../runtime/projectRecoveryService.ts | 2 +- apps/desktop/src/preload/global.d.ts | 9 + apps/desktop/src/preload/preload.ts | 11 + .../src/renderer/components/app/App.tsx | 100 +++- .../components/app/AutoUpdateBanner.tsx | 29 +- .../app/ProjectRecoveryScreen.test.tsx | 33 +- .../components/app/ProjectRecoveryScreen.tsx | 376 ++++++++----- .../app/ProjectTransitionErrorAlert.tsx | 58 +- .../components/app/RendererErrorBoundary.tsx | 75 ++- .../components/app/ReportIssueButton.test.tsx | 95 ++++ .../components/app/ReportIssueButton.tsx | 158 ++++++ .../components/app/errorSurfaceKit.tsx | 102 ++++ .../src/renderer/components/cto/CtoPage.tsx | 22 +- .../projects/ProjectWelcomeWebNotices.tsx | 11 + .../remoteTargets/RemoteTargetList.tsx | 79 ++- .../components/settings/BrainRepairButton.tsx | 26 +- .../settings/SyncDevicesSection.test.tsx | 5 +- .../storage/StorageCleanupDialog.test.tsx | 45 +- .../settings/storage/StorageCleanupDialog.tsx | 67 ++- .../src/renderer/state/appStore.test.ts | 3 +- apps/desktop/src/renderer/state/appStore.ts | 18 +- apps/desktop/src/shared/ipc.ts | 4 + apps/desktop/src/shared/types/diagnostics.ts | 43 ++ docs/ARCHITECTURE.md | 2 +- docs/features/remote-runtime/README.md | 32 +- docs/features/storage-and-recovery/README.md | 65 ++- 52 files changed, 4225 insertions(+), 426 deletions(-) create mode 100644 apps/ade-cli/scripts/install-runtime-rollback.test.mjs create mode 100644 apps/ade-cli/src/commands/reportIssue.ts create mode 100644 apps/ade-cli/src/serviceManager/serviceHandover.ts create mode 100644 apps/ade-cli/src/services/diagnostics/diagnosticReport.test.ts create mode 100644 apps/ade-cli/src/services/diagnostics/diagnosticReport.ts create mode 100644 apps/desktop/src/main/services/diagnostics/diagnosticReportService.ts create mode 100644 apps/desktop/src/renderer/components/app/ReportIssueButton.test.tsx create mode 100644 apps/desktop/src/renderer/components/app/ReportIssueButton.tsx create mode 100644 apps/desktop/src/renderer/components/app/errorSurfaceKit.tsx create mode 100644 apps/desktop/src/shared/types/diagnostics.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 886daecd37..b2140b7f54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,7 +134,7 @@ jobs: # scripts/validate-docs.test.mjs covers the docs validator that the # validate-docs job runs. - name: Test release runtime archive, packaging, and docs-validator guards - run: node --test apps/ade-cli/scripts/native-archive-verification.test.mjs apps/ade-cli/scripts/native-deps-entry-filter.test.mjs apps/ade-cli/scripts/notarize-static-runtime.test.mjs apps/desktop/scripts/mac-runtime-archive-mode.test.mjs apps/desktop/scripts/packaged-ade-cli-resources.test.mjs apps/desktop/scripts/runtime-fetched-tool-packages.test.mjs apps/desktop/scripts/runtime-resource-targets.test.mjs scripts/validate-docs.test.mjs scripts/validate-platform-gates.test.mjs + run: node --test apps/ade-cli/scripts/install-runtime-rollback.test.mjs apps/ade-cli/scripts/native-archive-verification.test.mjs apps/ade-cli/scripts/native-deps-entry-filter.test.mjs apps/ade-cli/scripts/notarize-static-runtime.test.mjs apps/desktop/scripts/mac-runtime-archive-mode.test.mjs apps/desktop/scripts/packaged-ade-cli-resources.test.mjs apps/desktop/scripts/runtime-fetched-tool-packages.test.mjs apps/desktop/scripts/runtime-resource-targets.test.mjs scripts/validate-docs.test.mjs scripts/validate-platform-gates.test.mjs typecheck-web: needs: install diff --git a/apps/ade-cli/scripts/install-runtime-rollback.test.mjs b/apps/ade-cli/scripts/install-runtime-rollback.test.mjs new file mode 100644 index 0000000000..fa8f73a58c --- /dev/null +++ b/apps/ade-cli/scripts/install-runtime-rollback.test.mjs @@ -0,0 +1,213 @@ +/** + * Rollback contract for the POSIX runtime installer (scripts/install-runtime.sh). + * + * The script used to copy the freshly downloaded `ade` over the installed one + * and only then run `ade --version`, so a bad download left a binary that could + * not start where a working one had been. These tests pin the guarantee the + * PowerShell installer already had: nothing is promoted until the staged binary + * passes its own preflight, and a promoted binary that fails is rolled back to + * the previous one. + * + * The script is driven end to end against a fake release: a `curl` earlier on + * PATH serves files from a fixture directory, and the "runtime" is a shell + * script whose `--version` behaviour is steered by environment variables. + */ +import assert from "node:assert/strict"; +import test from "node:test"; +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptPath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "install-runtime.sh", +); + +function releaseTarget() { + const platform = process.platform === "darwin" ? "darwin" : "linux"; + const cpu = process.arch === "arm64" ? "arm64" : "x64"; + return `${platform}-${cpu}`; +} + +/** + * `--version` fails for the staged copy when ADE_TEST_FAIL_STAGED is set, and + * for the promoted copy (the one under the install dir) when + * ADE_TEST_FAIL_INSTALLED is set. Every other subcommand -- `brain start`, + * `setup` -- succeeds silently, because neither is what these tests are about. + */ +const FAKE_ADE = `#!/bin/sh +if [ "\$1" = "--version" ]; then + case "\$0" in + */bin/ade) + if [ -n "\${ADE_TEST_FAIL_INSTALLED:-}" ]; then + echo "fake ade: installed copy cannot start" >&2 + exit 3 + fi + ;; + *) + if [ -n "\${ADE_TEST_FAIL_STAGED:-}" ]; then + echo "fake ade: staged copy cannot start" >&2 + exit 3 + fi + ;; + esac + echo "9.9.9-fake" + exit 0 +fi +exit 0 +`; + +const FAKE_CURL = `#!/bin/sh +# Serves \$ADE_TEST_ASSET_DIR/ instead of hitting the network. +url="" +out="" +while [ "\$#" -gt 0 ]; do + case "\$1" in + -o) out="\$2"; shift 2 ;; + http*) url="\$1"; shift ;; + *) shift ;; + esac +done +[ -n "\$url" ] || exit 2 +[ -n "\$out" ] || exit 2 +cp "\$ADE_TEST_ASSET_DIR/\${url##*/}" "\$out" +`; + +function writeExecutable(filePath, contents) { + fs.writeFileSync(filePath, contents, { mode: 0o755 }); + fs.chmodSync(filePath, 0o755); +} + +function sha256(filePath) { + const runner = spawnSync("shasum", ["-a", "256", filePath], { encoding: "utf8" }); + if (runner.status === 0) return runner.stdout.trim().split(/\s+/)[0]; + return execFileSync("sha256sum", [filePath], { encoding: "utf8" }).trim().split(/\s+/)[0]; +} + +/** A fake release plus a machine that already has ADE installed on it. */ +function makeInstall({ previousBinary = "#!/bin/sh\necho previous\n" } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-install-rollback-")); + const assets = path.join(root, "assets"); + const fakeBin = path.join(root, "fakebin"); + const adeHome = path.join(root, "home", ".ade"); + const installDir = path.join(adeHome, "bin"); + const target = releaseTarget(); + const runtimeDir = path.join(adeHome, "runtime", target); + + fs.mkdirSync(assets, { recursive: true }); + fs.mkdirSync(fakeBin, { recursive: true }); + fs.mkdirSync(installDir, { recursive: true }); + fs.mkdirSync(runtimeDir, { recursive: true }); + + // The machine's existing install: a working binary and a runtime directory + // with a file only this (older) install has. + writeExecutable(path.join(installDir, "ade"), previousBinary); + fs.writeFileSync(path.join(runtimeDir, "previous-runtime.txt"), "previous\n"); + + // The release: binary + native archive + checksum manifest. + const binaryAsset = `ade-${target}`; + const archiveAsset = `${binaryAsset}.native.tar.gz`; + writeExecutable(path.join(assets, binaryAsset), FAKE_ADE); + const archiveStage = path.join(root, "archive-stage"); + fs.mkdirSync(path.join(archiveStage, "node_modules", "fake-dep"), { recursive: true }); + fs.writeFileSync( + path.join(archiveStage, "node_modules", "fake-dep", "index.js"), + "module.exports = 1;\n", + ); + execFileSync("tar", ["-czf", path.join(assets, archiveAsset), "-C", archiveStage, "."]); + fs.writeFileSync( + path.join(assets, "SHA256SUMS"), + [binaryAsset, archiveAsset] + .map((name) => `${sha256(path.join(assets, name))} ${name}\n`) + .join(""), + ); + + writeExecutable(path.join(fakeBin, "curl"), FAKE_CURL); + + return { root, assets, fakeBin, adeHome, installDir, runtimeDir, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }; +} + +function runInstaller(fixture, extraEnv = {}) { + return spawnSync("sh", [scriptPath], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${fixture.fakeBin}${path.delimiter}${process.env.PATH ?? ""}`, + HOME: path.join(fixture.root, "home"), + ADE_TEST_ASSET_DIR: fixture.assets, + ADE_HOME: fixture.adeHome, + ADE_INSTALL_DIR: fixture.installDir, + ADE_INSTALL_NO_PROMPT: "1", + ADE_INSTALL_NO_PATH: "1", + ...extraEnv, + }, + }); +} + +test("a downloaded runtime that cannot start never replaces the installed one", () => { + const fixture = makeInstall(); + try { + const result = runInstaller(fixture, { ADE_TEST_FAIL_STAGED: "1" }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /could not start/); + // The failure has to name where the evidence is and what to do next. + assert.match(result.stderr, /install-failure\.log/); + assert.match(result.stderr, /run the installer again/); + + assert.equal( + fs.readFileSync(path.join(fixture.installDir, "ade"), "utf8"), + "#!/bin/sh\necho previous\n", + ); + // Nothing was promoted, so the previous native runtime is untouched too. + assert.ok(fs.existsSync(path.join(fixture.runtimeDir, "previous-runtime.txt"))); + assert.ok(!fs.existsSync(path.join(fixture.runtimeDir, "node_modules"))); + assert.ok(!fs.existsSync(path.join(fixture.installDir, "ade.new"))); + } finally { + fixture.cleanup(); + } +}); + +test("a promoted runtime that fails its version check is rolled back", () => { + const fixture = makeInstall(); + try { + const result = runInstaller(fixture, { ADE_TEST_FAIL_INSTALLED: "1" }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /could not start/); + assert.match(result.stderr, /put back/); + + assert.equal( + fs.readFileSync(path.join(fixture.installDir, "ade"), "utf8"), + "#!/bin/sh\necho previous\n", + ); + assert.ok(fs.existsSync(path.join(fixture.runtimeDir, "previous-runtime.txt"))); + assert.ok(!fs.existsSync(path.join(fixture.runtimeDir, "node_modules"))); + // The rollback copies are scratch state, not something to leave behind. + assert.ok(!fs.existsSync(path.join(fixture.installDir, "ade.bak"))); + assert.ok(!fs.existsSync(path.join(fixture.installDir, "ade.new"))); + + const log = fs.readFileSync(path.join(fixture.adeHome, "install-failure.log"), "utf8"); + assert.match(log, /installed copy cannot start/); + } finally { + fixture.cleanup(); + } +}); + +test("a healthy runtime is promoted and leaves no rollback state behind", () => { + const fixture = makeInstall(); + try { + const result = runInstaller(fixture); + + assert.equal(result.status, 0, result.stderr); + assert.equal(fs.readFileSync(path.join(fixture.installDir, "ade"), "utf8"), FAKE_ADE); + assert.ok(fs.existsSync(path.join(fixture.runtimeDir, "node_modules", "fake-dep"))); + assert.ok(!fs.existsSync(path.join(fixture.runtimeDir, "previous-runtime.txt"))); + assert.ok(!fs.existsSync(path.join(fixture.installDir, "ade.bak"))); + assert.ok(!fs.existsSync(path.join(fixture.installDir, "ade.new"))); + } finally { + fixture.cleanup(); + } +}); diff --git a/apps/ade-cli/scripts/install-runtime.sh b/apps/ade-cli/scripts/install-runtime.sh index 8c4a9f0d46..d75ff048ae 100644 --- a/apps/ade-cli/scripts/install-runtime.sh +++ b/apps/ade-cli/scripts/install-runtime.sh @@ -448,25 +448,102 @@ downloaded_bytes="$(( $(file_size_bytes "$tmp_dir/ade") + $(file_size_bytes "$tmp_dir/native.tar.gz") ))" -chmod 755 "$tmp_dir/ade" -cp "$tmp_dir/ade" "$dest_dir/ade" -chmod 755 "$dest_dir/ade" - +staged_binary="$tmp_dir/ade" staged_runtime_dir="$tmp_dir/runtime" staged_node_modules="$staged_runtime_dir/node_modules" backup_runtime_dir="$tmp_dir/runtime.previous" +# Both live next to the real binary, not in $TMPDIR: promoting and restoring +# have to be same-directory renames, which are atomic. A `mv` out of $TMPDIR is +# a copy across filesystems, and a half-copied `ade` is the broken install this +# whole block exists to prevent. +pending_binary="$dest_dir/ade.new" +backup_binary="$dest_dir/ade.bak" +promoted_runtime=0 +have_backup_binary=0 +# Kept out of $tmp_dir so it survives the EXIT trap: the failure message points +# a stuck user at it, and a log deleted on the way out points at nothing. +install_log="$ade_home/install-failure.log" + +trap 'rm -rf "$tmp_dir"; rm -f "$dest_dir/ade.new"' EXIT HUP INT TERM + +# The runtime sidecar env has to be in place before *any* `--version` check: +# the binary loads its native modules through it, so a preflight run without it +# tests something the real ade never does. Called once for the staged runtime +# and again for the promoted one. +previous_node_path="${NODE_PATH:-}" +set_runtime_env() { + ADE_RUNTIME_ROOT="$1" + ADE_RUNTIME_NODE_MODULES="$1/node_modules" + NODE_PATH="$1/node_modules${previous_node_path:+:$previous_node_path}" + export ADE_RUNTIME_ROOT ADE_RUNTIME_NODE_MODULES NODE_PATH +} + +# Runs `ade --version` and keeps whatever it printed, so a failure has evidence +# instead of just an exit status. Returns the binary's status. +version_check() { + version_check_binary="$1" + version_check_stage="$2" + if : >"$install_log" 2>/dev/null; then + printf 'ade install: %s check of %s\n' "$version_check_stage" "$version_check_binary" \ + >>"$install_log" 2>/dev/null || true + "$version_check_binary" --version >>"$install_log" 2>&1 + else + "$version_check_binary" --version >/dev/null 2>&1 + fi +} + +# Puts the machine back on the install it had. Best effort by design: every +# branch here runs while something has already gone wrong, and a failed restore +# must still let the caller print the real reason rather than abort on `set -e`. +restore_previous_install() { + if [ "$have_backup_binary" -eq 1 ] && [ -e "$backup_binary" ]; then + mv "$backup_binary" "$dest_dir/ade" 2>/dev/null || true + else + # Nothing to restore means this was a first install; leaving the binary that + # just failed its own version check is worse than leaving none. + rm -f "$dest_dir/ade" 2>/dev/null || true + fi + if [ "$promoted_runtime" -eq 1 ] && [ -e "$backup_runtime_dir" ]; then + rm -rf "$runtime_dir" 2>/dev/null || true + mv "$backup_runtime_dir" "$runtime_dir" 2>/dev/null || true + fi +} + +die_runtime_unusable() { + if [ "$have_backup_binary" -eq 1 ]; then + printf 'ade install: the ADE you already had was put back, so nothing is broken.\n' >&2 + else + printf 'ade install: nothing was left installed at %s.\n' "$dest_dir/ade" >&2 + fi + if [ -s "$install_log" ]; then + printf 'ade install: what it printed is in %s\n' "$install_log" >&2 + fi + printf 'ade install: next: run the installer again; if it fails the same way, open an issue at https://github.com/%s/issues with that log.\n' \ + "$repo" >&2 + die "$1" +} + +chmod 755 "$staged_binary" rm -rf "$staged_runtime_dir" "$backup_runtime_dir" mkdir -p "$staged_runtime_dir" tar -xzf "$tmp_dir/native.tar.gz" -C "$staged_runtime_dir" [ -d "$staged_node_modules" ] || die "native dependency archive is missing node_modules" +# Preflight before anything is promoted: the staged binary against the staged +# runtime. A download that cannot even print its version never reaches +# $dest_dir, so the previous install is still there and still working. +set_runtime_env "$staged_runtime_dir" +if ! version_check "$staged_binary" "staged"; then + die_runtime_unusable "the ADE runtime that was just downloaded could not start" +fi + if [ -e "$runtime_dir" ]; then mv "$runtime_dir" "$backup_runtime_dir" fi if mv "$staged_runtime_dir" "$runtime_dir"; then - rm -rf "$backup_runtime_dir" + promoted_runtime=1 else if [ -e "$backup_runtime_dir" ]; then rm -rf "$runtime_dir" @@ -475,11 +552,37 @@ else die "failed to install ADE native runtime dependencies" fi -export ADE_RUNTIME_ROOT="$runtime_dir" -export ADE_RUNTIME_NODE_MODULES="$runtime_dir/node_modules" -export NODE_PATH="$runtime_dir/node_modules${NODE_PATH:+:$NODE_PATH}" +# Promote the binary last, and only by rename. The copy lands on a scratch name +# first so a truncated write can never be the thing sitting at $dest_dir/ade. +rm -f "$pending_binary" +cp "$staged_binary" "$pending_binary" +chmod 755 "$pending_binary" +rm -f "$backup_binary" +if [ -e "$dest_dir/ade" ]; then + if cp "$dest_dir/ade" "$backup_binary"; then + chmod 755 "$backup_binary" 2>/dev/null || true + have_backup_binary=1 + else + rm -f "$pending_binary" + die "could not back up the existing ADE runtime at $dest_dir/ade" + fi +fi +mv "$pending_binary" "$dest_dir/ade" + +set_runtime_env "$runtime_dir" + +if ! version_check "$dest_dir/ade" "installed"; then + restore_previous_install + die_runtime_unusable "the newly installed ADE runtime could not start" +fi -"$dest_dir/ade" --version >/dev/null || die "installed ade binary failed to run" +# Past the point of no return: the install is good, so the rollback copies are +# just disk. ~150 MB of it, which is why they are not kept around. The log goes +# with them: a file called install-failure.log left behind by a successful +# install is a false alarm waiting to be found. +rm -f "$backup_binary" +rm -rf "$backup_runtime_dir" +rm -f "$install_log" if command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1; then try_install_service diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index d5f1c860d8..84b1e5a4c1 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -1,9 +1,10 @@ -import { spawn } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import fs from "node:fs"; import { createServer } from "node:http"; import net from "node:net"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PAIRING_REAUTHENTICATION_REQUIRED_MESSAGE } from "./services/account/accountMachinePublisherService"; import { @@ -24,6 +25,7 @@ import { isEphemeralRuntimeSocketPath, isFailedServiceManagerResult, machineRuntimeMismatchReason, + monitorBrainSocketOwnership, parseCliArgs, parseSnoozeDurationMs, readRuntimeIdleExitMs, @@ -149,6 +151,64 @@ function writeSyncHostSingletonLock(args: { }, null, 2)}\n`, "utf8"); } +function withTsxNodeOptions(value: string | undefined): string { + const existing = value?.trim(); + return existing ? `${existing} --import tsx` : "--import tsx"; +} + +/** + * A sync-host singleton lock owned by a SAME-channel brain (same ADE_HOME), + * which the startup loop retries against forever rather than failing the brain + * outright the way a cross-channel owner does. + */ +function writeSameChannelSyncHostLock(args: { + lockPath: string; + pid: number; + port: number; + adeHome: string; +}): void { + const now = new Date().toISOString(); + fs.mkdirSync(path.dirname(args.lockPath), { recursive: true }); + fs.writeFileSync(args.lockPath, `${JSON.stringify({ + version: 1, + owner: { + id: "sync-host-squatter", + pid: args.pid, + port: args.port, + appName: "ADE", + packageChannel: null, + adeHome: args.adeHome, + serviceName: "com.ade.runtime", + socketPath: path.join(args.adeHome, "sock", "ade.sock"), + projectRoot: null, + commandLine: null, + quitCommand: `ADE_HOME='${args.adeHome}' ade brain stop --text`, + createdAt: now, + updatedAt: now, + }, + }, null, 2)}\n`, "utf8"); +} + +function readSyncHostLockOwnerPid(lockPath: string): number | null { + try { + const parsed = JSON.parse(fs.readFileSync(lockPath, "utf8")) as { + owner?: { pid?: unknown }; + }; + return typeof parsed.owner?.pid === "number" ? parsed.owner.pid : null; + } catch { + return null; + } +} + +function killChildQuietly(child: ChildProcess | null, signal: NodeJS.Signals): void { + if (!child || child.exitCode != null || child.signalCode != null) return; + try { + child.kill(signal); + } catch { + // Already gone. + } +} + describe("ADE CLI", () => { const ambientChatSessionId = process.env.ADE_CHAT_SESSION_ID; beforeEach(() => { @@ -876,6 +936,146 @@ describe("ADE CLI", () => { } }); + /** + * `ade serve` publishes its RPC socket BEFORE the mobile sync host is up. + * + * The brain used to bind `ade.sock` only after `runSyncHostStartupLoop` + * returned, which coupled desktop reachability to phone-sync hosting: a busy + * sync port band, a stale lease from a just-killed predecessor, a slow + * project scope — anything that loop retried kept the socket unpublished, + * and the desktop's service handover budget expired against a brain that was + * alive and healthy ("the background service couldn't be set up — click + * Repair", then "ADE couldn't open this project"). + * + * This drives a real `ade serve` child with the machine-wide sync-host lease + * held by a live same-channel pid, so the startup loop can never finish, and + * proves the socket is nonetheless bound, listening, and answering RPC. + * Verified to fail against the pre-reorder `cli.ts` (socket never appears). + * + * A child process rather than an in-process `runCli`: a fully started brain + * owns background services (ingress pollers, sync status refreshers) whose + * teardown races surface as unhandled rejections in the runner, and killing + * a child is the only way to end a brain the way the OS does. + */ + crdtHostIt( + "binds and serves the RPC socket while the mobile sync host is still retrying", + async () => { + const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const cliPath = path.join(packageRoot, "src", "cli.ts"); + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-serve-order-")); + const projectRoot = path.join(adeHome, "project"); + const lockPath = path.join(adeHome, "sync-host-lock.json"); + // Deliberately NOT `/sock/ade.sock`: that is the machine layout + // socket, which would make this brain the "primary" one and start the + // freshness-monitor / service-reinstall paths this test has no business + // in. + const socketPath = path.join(adeHome, "sock", "ade-order.sock"); + fs.mkdirSync(projectRoot, { recursive: true }); + + // A live pid the sync-host singleton must treat as a real owner. + const squatter = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000);"], { + stdio: "ignore", + }); + squatter.on("error", () => {}); + if (!squatter.pid) throw new Error("Failed to start the fake sync-host owner process."); + writeSameChannelSyncHostLock({ lockPath, pid: squatter.pid, port: 8802, adeHome }); + + let brain: ChildProcess | null = null; + let brainExit: { code: number | null; signal: NodeJS.Signals | null } | null = null; + let stderr = ""; + try { + brain = spawn(process.execPath, [cliPath, "serve", "--socket", socketPath], { + cwd: packageRoot, + env: { + ...process.env, + ADE_HOME: adeHome, + ADE_PROJECT_ROOT: projectRoot, + ADE_PACKAGE_CHANNEL: "", + ADE_SYNC_HOST_LOCK_PATH: lockPath, + ADE_SYNC_HOST_SINGLETON_TEST_MODE: "1", + ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", + ADE_DISABLE_TOOLS_FETCH: "1", + NODE_OPTIONS: withTsxNodeOptions(process.env.NODE_OPTIONS), + }, + stdio: ["ignore", "ignore", "pipe"], + }); + brain.stderr?.setEncoding("utf8"); + brain.stderr?.on("data", (chunk: string) => { stderr += chunk; }); + brain.on("exit", (code, signal) => { brainExit = { code, signal }; }); + + let client: JsonRpcClient | null = null; + const deadline = Date.now() + 90_000; + for (;;) { + if (brainExit) { + throw new Error( + `ADE brain exited (${JSON.stringify(brainExit)}) instead of serving:\n${stderr}`, + ); + } + if (fs.existsSync(socketPath)) { + try { + client = await JsonRpcClient.connect(socketPath); + break; + } catch { + // Not listening yet. + } + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for ${socketPath} to accept connections:\n${stderr}`); + } + await new Promise((resolve) => setTimeout(resolve, 150)); + } + + try { + // Bound is not enough: it has to be serving desktop RPC. + await client.request("ade/initialize", { + protocolVersion: "2025-06-18", + clientName: "serve-socket-ordering-test", + identity: { role: "external", callerId: "serve-socket-ordering-test" }, + }); + await expect(client.request("ping")).resolves.toMatchObject({ pong: true }); + } finally { + client.close(); + } + + // ...and the sync host is provably still down: the squatter keeps the + // machine-wide lease, so the startup loop is still retrying. This is + // the assertion the old ordering could not satisfy — the socket did + // not exist until that loop returned. + expect(readSyncHostLockOwnerPid(lockPath)).toBe(squatter.pid); + + // The loop is genuinely running and losing, not silently skipped. Not + // asserted at the moment of connect on purpose: the socket can be (and + // routinely is) reachable before the first attempt has even failed + // once, which is the whole point of the reorder. + const logDeadline = Date.now() + 60_000; + while (!stderr.includes("ADE brain sync host failed")) { + if (Date.now() >= logDeadline) { + throw new Error(`ADE brain never reported a sync host failure:\n${stderr}`); + } + await new Promise((resolve) => setTimeout(resolve, 150)); + } + // A sync host that never starts must not take the brain down with it, + // and must not hand the lease over either. + expect(brainExit).toBeNull(); + expect(readSyncHostLockOwnerPid(lockPath)).toBe(squatter.pid); + } finally { + // SIGKILL, not SIGTERM: a graceful brain shutdown spends seconds + // tearing down scopes, and the next test in this file spawns its own + // brain — leaving this one competing for CPU is how neighbouring + // startup waits start timing out. + const exited = brain ? new Promise((resolve) => { + if (brain?.exitCode != null || brain?.signalCode != null) resolve(); + else brain?.once("exit", () => resolve()); + }) : null; + killChildQuietly(brain, "SIGKILL"); + if (exited) await exited; + killChildQuietly(squatter, "SIGKILL"); + fs.rmSync(adeHome, { recursive: true, force: true }); + } + }, + 150_000, + ); + const posixIt = process.platform === "win32" ? it.skip : it; posixIt( "creates the headless RPC unix socket with 0600 perms and parent dir 0700", @@ -11241,3 +11441,74 @@ describe("describeLastFailureForStartupLog", () => { expect(described.endsWith("…")).toBe(true); }); }); + +describe("monitorBrainSocketOwnership", () => { + const flush = async (): Promise => { + // The monitor floors its poll interval at 250ms; outlast one tick. + await new Promise((resolve) => setTimeout(resolve, 400)); + }; + const socketPath = "/tmp/ade-ownership/ade.sock"; + + it("ends the brain when another brain replaces the socket it bound", async () => { + let inode: bigint | null = 100n; + const lost: string[] = []; + const stop = monitorBrainSocketOwnership(socketPath, (reason) => lost.push(reason), { + intervalMs: 250, + readInode: () => inode, + }); + await flush(); + expect(lost).toEqual([]); + // A rival that had already probed this path as stale unlinked our inode + // and bound its own. No EADDRINUSE ever happened; only the inode changed. + inode = 200n; + await flush(); + expect(lost).toEqual(["replaced"]); + stop(); + }); + + it("ends the brain when the socket path is removed underneath it", async () => { + let inode: bigint | null = 100n; + const lost: string[] = []; + const stop = monitorBrainSocketOwnership(socketPath, (reason) => lost.push(reason), { + intervalMs: 250, + readInode: () => inode, + }); + inode = null; + await flush(); + expect(lost).toEqual(["removed"]); + stop(); + }); + + it("stays quiet while the inode is unchanged, and reports nothing after stop", async () => { + const lost: string[] = []; + const stop = monitorBrainSocketOwnership(socketPath, (reason) => lost.push(reason), { + intervalMs: 250, + readInode: () => 100n, + }); + await flush(); + stop(); + await flush(); + expect(lost).toEqual([]); + }); + + it("is a no-op for a Windows named pipe, which has no directory entry to steal", () => { + const lost: string[] = []; + const stop = monitorBrainSocketOwnership( + String.raw`\\.\pipe\ade-runtime-abc`, + (reason) => lost.push(reason), + { intervalMs: 250, readInode: () => { throw new Error("must not stat a named pipe"); } }, + ); + stop(); + expect(lost).toEqual([]); + }); + + it("fails open when the socket reports no inode", () => { + const lost: string[] = []; + const stop = monitorBrainSocketOwnership(socketPath, (reason) => lost.push(reason), { + intervalMs: 250, + readInode: () => null, + }); + stop(); + expect(lost).toEqual([]); + }); +}); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 753105c169..15f34ef491 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -33,6 +33,8 @@ import { runDoctorCommand, type DoctorRow, } from "./commands/doctor"; +import { buildCliDiagnosticReport } from "./commands/reportIssue"; +import { openExternalUrl } from "../../desktop/src/main/services/shared/externalLinks"; export { readInstalledDesktopVersion }; import { MAX_STATUS_NOTE_CHARACTERS, @@ -386,6 +388,7 @@ type CliPlan = | { kind: "setup"; rest: string[] } | { kind: "connect"; rest: string[] } | { kind: "doctor"; online: boolean } + | { kind: "report-issue"; open: boolean } | { kind: "serve"; rest: string[] } | { kind: "rpc-stdio"; rest: string[] } | { kind: "pty-host-worker" } @@ -684,6 +687,7 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade sync web [--open] [--no-clipboard] Print (and copy) the web client pairing link + code $ ade sync status | pin generate Manage machine sync and phone pairing $ ade doctor [--online] Inspect installed app and machine-brain health + $ ade report-issue [--open] Print a redacted diagnostic report for a bug report $ ade lanes list | show | create | child Work with lanes and lane stacks $ ade git status | commit | push | stash Run ADE-aware git operations $ ade operations status | wait Poll operation/test/chat/run status @@ -12630,6 +12634,12 @@ function buildCliPlan( online: readFlag(args, ["--online"]), }; } + if (primary === "report-issue") { + return { + kind: "report-issue", + open: readFlag(args, ["--open"]), + }; + } if (primary === "auth") { const sub = firstPositional(args) ?? "status"; if (sub !== "status") @@ -15423,7 +15433,19 @@ async function repairMachineRuntimeServiceConnection(args: { ); break; } catch (error) { - if (Date.now() >= connectDeadline) throw error; + if (Date.now() >= connectDeadline) { + // A `starting` install left a live, supervised brain behind. Even if + // it outlasted our wait, the supervisor still owns that endpoint, so + // returning null here — which lets the caller spawn an unmanaged + // rival on the same socket — is exactly the wrong recovery. + if (result.starting) { + throw new RuntimeServiceRecoveryOwnedError( + `${result.message} It had still not answered on ${args.socketPath} when ADE stopped waiting, ` + + "so ADE did not start a competing manual brain.", + ); + } + throw error; + } await new Promise((resolve) => setTimeout(resolve, 500)); } } @@ -15998,6 +16020,51 @@ async function runSetupCli( return { ok: true, detail: names.join(", ") }; }, getAccountStatus: () => readSetupAccountStatus(options), + // The installer has just registered the brain service. A brain that is + // registered and alive but not answering yet is *starting*, not broken, + // so this waits for its endpoint instead of letting the account step run + // against a socket that was never given time to open. + awaitRuntimeService: async ({ budgetMs, onStarting }) => { + const socketPath = await resolveMachineRuntimeSocketPath(options.socketPath); + const probe = async (): Promise => { + try { + const client = await SocketJsonRpcClient.connect( + socketPath, + options.timeoutMs, + "ADE runtime endpoint", + ); + client.close(); + return true; + } catch { + return false; + } + }; + if (await probe()) { + return { ready: true, starting: false, detail: "background service is running" }; + } + const { installRuntimeService } = await import("./serviceManager"); + const install = await withAdeDefaultRole("cto", () => installRuntimeService()); + if (!install.ok && !install.starting) { + return { ready: false, starting: false, detail: install.message }; + } + // Registered and supervised from here on, whether or not the install + // itself waited long enough to see it answer. + onStarting(); + const deadline = Date.now() + Math.max(0, budgetMs); + for (;;) { + if (await probe()) { + return { ready: true, starting: false, detail: "background service is running" }; + } + if (Date.now() >= deadline) { + return { + ready: false, + starting: true, + detail: "background service is still starting", + }; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + }, // Delegates to the same `ade connect` implementation so the OAuth flow, // the service step and the machine-directory wait stay in one place. runConnect: async () => { @@ -16155,6 +16222,7 @@ async function runConnectCli( ok: install.ok, message: install.message, selfMutationBlocked: install.selfMutationBlocked, + starting: install.starting, }; }, getMachineKey: () => machineKey, @@ -16295,7 +16363,12 @@ async function runBrainCommand( message: !stopped.ok ? `ADE brain restart attempted after stop warning: ${stopped.message}` : started.ok - ? "ADE brain restarted." + // `starting` means the replacement is alive but has not answered on + // the socket yet. Claiming "restarted." there reads as "ready now" + // and sends callers into a connect that is not due to succeed yet. + ? started.starting + ? started.message + : "ADE brain restarted." : started.message, }; } @@ -17514,7 +17587,14 @@ async function runServe( let syncHostStartupFailure: unknown = null; const startSyncHostInBackground = async (): Promise => { if (!syncEnabled) { - clearLastFailure({ kind: "machine" }); + // Deliberately NOT clearing the machine failure record. The bind above + // already cleared every non-`sync_host` failure, and a `--no-sync` brain + // proves nothing about whether the sync host can start. The record is + // keyed by ADE_HOME, not by socket path, so the two brains that run with + // `--no-sync` — an ephemeral runtime socket and the desktop's isolated + // runtime, both spawned precisely BECAUSE the real brain is unhealthy — + // would otherwise erase the crash-loop streak that the installer's + // young-brain veto and the `brain_crash_looping` diagnosis depend on. return; } try { @@ -17807,6 +17887,10 @@ async function runServe( stopBrainFreshnessMonitor = () => freshnessMonitor.stop(); } + const stopSocketOwnershipMonitor = monitorBrainSocketOwnership(socketPath, (reason) => { + headlessProjectLogger.warn("brain.socket_ownership_lost", { socketPath, reason }); + finish(); + }); const stopParentMonitor = monitorRuntimeParentProcess(finish); const stopIdleMonitor = monitorRuntimeIdleExit(states, finish); try { @@ -17831,6 +17915,7 @@ async function runServe( } finally { stopParentMonitor(); stopIdleMonitor(); + stopSocketOwnershipMonitor(); } for (const state of states) { @@ -17913,6 +17998,66 @@ function isPidAlive(pid: number): boolean { } } +/** How often a bound brain re-checks that it still owns its socket path. */ +const BRAIN_SOCKET_OWNERSHIP_POLL_MS = 5_000; + +/** + * A unix-domain brain can lose its own endpoint AFTER a successful `listen()`. + * The bind is preceded by a check, not a lock: `existsSync` -> await + * `assertBrainSocketUnowned` -> `unlink` -> `listen`. Two brains that both + * probe the same *stale* socket file race across that await — the first + * unlinks and binds inode X, the second (whose probe predates that bind) then + * unlinks the path->X link and binds a fresh inode Y. Neither sees + * `EADDRINUSE`, so the guard around `listen` never fires, and the first brain + * lives on listening to an inode nothing can reach: the PR #949 zombie. + * + * The sync-host loop's `abortIf` used to catch this incidentally, because the + * loser was still inside that loop when the socket went live. Binding before + * the sync host removed that accident, so make the check explicit: remember + * the inode we bound and end the brain if the path stops pointing at it. The + * supervisor then restarts us, and the restarted brain finds the rival's live + * socket and reports `socket_owned_by_other` instead of squatting silently. + * + * Windows named pipes are exempt: a pipe name is a kernel object with no + * directory entry to steal, and a bound pipe can never be probed as stale. + */ +export function monitorBrainSocketOwnership( + socketPath: string, + onLost: (reason: "removed" | "replaced") => void, + options: { + intervalMs?: number; + readInode?: (target: string) => bigint | null; + } = {}, +): () => void { + if (isAdeRuntimeNamedPipePath(socketPath)) return () => {}; + const readInode = options.readInode ?? ((target: string): bigint | null => { + try { + return fs.statSync(target, { bigint: true }).ino; + } catch { + return null; + } + }); + const ownInode = readInode(socketPath); + // No inode to compare against (a platform or filesystem that does not report + // one) means this guard cannot run. Fail open: a brain with no watchdog is + // strictly better than one that ends itself on an unreadable stat. + if (ownInode == null) return () => {}; + let done = false; + const timer = setInterval(() => { + if (done) return; + const current = readInode(socketPath); + if (current === ownInode) return; + done = true; + clearInterval(timer); + onLost(current == null ? "removed" : "replaced"); + }, Math.max(250, options.intervalMs ?? BRAIN_SOCKET_OWNERSHIP_POLL_MS)); + timer.unref?.(); + return () => { + done = true; + clearInterval(timer); + }; +} + function monitorRuntimeParentProcess(onGone: () => void): () => void { const parentPid = readRuntimeParentPid(); if (parentPid == null || parentPid === process.pid) return () => {}; @@ -21977,6 +22122,35 @@ async function runCli( throw error; } } + if (plan.kind === "report-issue") { + const { projectRoot } = resolveRoots(parsed.options); + const built = buildCliDiagnosticReport({ + surface: "cli", + projectRoot: fs.existsSync(path.join(projectRoot, ".ade")) ? projectRoot : null, + cliVersion: VERSION, + }); + if (plan.open) { + try { + await openExternalUrl(built.issueUrl); + } catch { + // Headless boxes have no browser; the URL below is still printed. + } + } + if (parsed.options.text) { + return { + output: `${built.report}\nFile the issue at:\n${built.issueUrl}\n`, + exitCode: 0, + }; + } + return { + output: formatOutput( + { ok: true, installId: built.installId, issueUrl: built.issueUrl, report: built.report }, + parsed.options, + undefined, + ), + exitCode: 0, + }; + } if (plan.kind === "doctor") { const result = await runDoctorCommand(plan.online, parsed.options, { resolveMachineRuntimeSocketPath, diff --git a/apps/ade-cli/src/commands/connect.test.ts b/apps/ade-cli/src/commands/connect.test.ts index d13fda95b0..2155f03984 100644 --- a/apps/ade-cli/src/commands/connect.test.ts +++ b/apps/ade-cli/src/commands/connect.test.ts @@ -291,6 +291,25 @@ describe("runConnectCommand", () => { expect(result.steps[1]).toMatchObject({ id: "service", state: "ok" }); }); + it("says the service is still starting instead of claiming it is running", async () => { + // `ok: true, starting: true`: registered, brain alive, socket not answering + // yet. Neither a failure nor "running" — the step has to say which. + const { deps } = makeDeps({ + service: { installed: false, running: false }, + installService: async () => ({ + ok: true, + starting: true, + message: "the background service (pid 4242) is still starting.", + }), + }); + const result = await runConnectCommand([], deps); + + expect(result.ok).toBe(true); + expect(result.steps[1]).toMatchObject({ id: "service", state: "ok" }); + expect(result.steps[1].detail).toContain("still starting"); + expect(result.steps[1].detail).not.toContain("installed and running"); + }); + it("reports a real service install failure with a foreground fallback", async () => { const { deps } = makeDeps({ service: { installed: false, running: false }, diff --git a/apps/ade-cli/src/commands/connect.ts b/apps/ade-cli/src/commands/connect.ts index 0ab7401c68..68f5ead35e 100644 --- a/apps/ade-cli/src/commands/connect.ts +++ b/apps/ade-cli/src/commands/connect.ts @@ -68,6 +68,12 @@ export type ConnectServiceInstallResult = { message?: string | null; /** Set when the caller is running inside the very brain it tried to mutate. */ selfMutationBlocked?: boolean; + /** + * The service is registered and its brain is alive, but it had not answered + * on the socket yet. Installed, not running — reporting it as "running" is a + * claim the install did not make. + */ + starting?: boolean; }; export type ConnectMachine = { @@ -423,7 +429,16 @@ export async function runConnectCommand( const install = await deps.installService(); if (install.ok) { service = await deps.getServiceStatus(); - pushStep({ id: "service", state: "ok", detail: `${mechanism} installed and running` }); + pushStep({ + id: "service", + state: "ok", + // A `starting` install registered the service and left a live brain + // that has not answered yet. It is not running *now*, and saying so + // is what sends people looking for a fault that does not exist. + detail: install.starting + ? `${mechanism} installed — the background service is still starting` + : `${mechanism} installed and running`, + }); } else if (install.selfMutationBlocked) { // Running inside the brain it would replace. Not a failure: the service // is already doing its job. diff --git a/apps/ade-cli/src/commands/reportIssue.ts b/apps/ade-cli/src/commands/reportIssue.ts new file mode 100644 index 0000000000..825737a7b6 --- /dev/null +++ b/apps/ade-cli/src/commands/reportIssue.ts @@ -0,0 +1,162 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + buildDiagnosticIssueUrl, + buildDiagnosticReport, + tailLogText, + type DiagnosticLogTail, + type DiagnosticVolumeSpace, +} from "../services/diagnostics/diagnosticReport"; +import { resolveMachineAdeLayout } from "../services/projects/machineLayout"; +import { resolveWindowsSupervisorLogPath } from "../serviceManager/installWindows"; + +/** + * Headless counterpart to the desktop "Report issue" button. Reads only local + * files — it never starts or contacts the brain — so it still works on the + * machine where ADE itself will not come up, and on Windows where there is no + * desktop error screen to press. + */ + +export type ReportIssueOptions = { + surface?: string; + projectRoot?: string | null; + cliVersion?: string | null; + env?: NodeJS.ProcessEnv; + now?: () => Date; +}; + +export type ReportIssueResult = { + report: string; + issueUrl: string; + installId: string; +}; + +function readJsonFile(filePath: string): unknown { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; + } catch { + return null; + } +} + +function readLogTail(label: string, filePath: string): DiagnosticLogTail { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) return { label, path: filePath, error: "(not a file)" }; + const readBytes = Math.min(stat.size, 512 * 1024); + const handle = fs.openSync(filePath, "r"); + try { + const buffer = Buffer.alloc(readBytes); + fs.readSync(handle, buffer, 0, readBytes, Math.max(0, stat.size - readBytes)); + return { label, path: filePath, text: tailLogText(buffer.toString("utf8")) }; + } finally { + fs.closeSync(handle); + } + } catch (error) { + const code = error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : ""; + return { label, path: filePath, error: code === "ENOENT" ? "(not present)" : "(could not be read)" }; + } +} + +function readVolume(label: string, dirPath: string): DiagnosticVolumeSpace | null { + try { + const stats = fs.statfsSync(dirPath, { bigint: true }); + return { + label, + path: dirPath, + freeBytes: Number(stats.bavail * stats.bsize), + totalBytes: Number(stats.blocks * stats.bsize), + }; + } catch { + return null; + } +} + +/** The same PostHog `distinct_id` the desktop reports, read without writing. */ +function readInstallId(secretsDir: string): string | null { + const state = readJsonFile(path.join(secretsDir, "product-analytics.json")); + if (!state || typeof state !== "object") return null; + const record = state as Record; + for (const key of ["identifiedUserHash", "anonymousId", "installationId"]) { + const value = record[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return null; +} + +export function buildCliDiagnosticReport(options: ReportIssueOptions = {}): ReportIssueResult { + const env = options.env ?? process.env; + const at = options.now?.() ?? new Date(); + const layout = resolveMachineAdeLayout(env); + const projectRoot = options.projectRoot?.trim() || null; + const surface = options.surface?.trim() || "cli"; + + const logs: DiagnosticLogTail[] = []; + if (process.platform === "win32") { + logs.push(readLogTail("Background service supervisor", resolveWindowsSupervisorLogPath({ env }))); + } else { + logs.push(readLogTail("Background service (stderr)", path.join(layout.runtimeDir, "launchd.err.log"))); + } + logs.push(readLogTail("Brain", path.join(layout.runtimeDir, "brain.jsonl"))); + if (projectRoot) { + logs.push(readLogTail("ADE CLI", path.join(projectRoot, ".ade", "transcripts", "logs", "ade-cli.jsonl"))); + } + + const installId = readInstallId(layout.secretsDir) ?? "unknown"; + + const report = buildDiagnosticReport({ + generatedAt: at.toISOString(), + app: { + version: options.cliVersion ?? null, + packageChannel: env.ADE_PACKAGE_CHANNEL?.trim() || null, + isPackaged: null, + platform: process.platform, + arch: process.arch, + osRelease: os.release(), + nodeVersion: process.versions.node ?? null, + timezoneOffsetMinutes: -at.getTimezoneOffset(), + }, + identity: { installId }, + context: { + surface, + headline: null, + code: null, + technicalDetail: null, + projectRoot, + }, + state: { + machineLastFailure: readJsonFile(path.join(layout.runtimeDir, "last-failure.json")), + projectLastFailure: projectRoot + ? readJsonFile(path.join(projectRoot, ".ade", "runtime", "last-failure.json")) + : null, + lastWedge: readJsonFile(path.join(layout.runtimeDir, "last-wedge.json")), + }, + storage: [ + readVolume("ADE home", layout.adeDir), + projectRoot ? readVolume("Project", projectRoot) : null, + ].filter((entry): entry is DiagnosticVolumeSpace => entry != null), + logs, + notes: ["doctor: not run (the report is collected without starting the background service)"], + redaction: { + homeDir: os.homedir(), + username: os.userInfo().username, + hostname: os.hostname(), + projectRoots: projectRoot ? [projectRoot] : [], + }, + }); + + return { + report, + installId, + issueUrl: buildDiagnosticIssueUrl({ + surface, + appVersion: options.cliVersion ?? null, + platform: process.platform, + arch: process.arch, + installId, + }), + }; +} diff --git a/apps/ade-cli/src/commands/setup.test.ts b/apps/ade-cli/src/commands/setup.test.ts index c845be9b98..5b49437b80 100644 --- a/apps/ade-cli/src/commands/setup.test.ts +++ b/apps/ade-cli/src/commands/setup.test.ts @@ -521,6 +521,64 @@ describe("runSetupCommand", () => { expect(chunks.join("")).toContain("What's left"); }); + it("waits out a still-starting background service instead of calling it broken", async () => { + // The regression this pins: a brain that is registered, alive and simply + // not answering yet used to surface as a failed install ("installed but + // not running" / "sign-in didn't finish") with a Repair button attached. + const chunks: string[] = []; + let budget = 0; + const result = await runSetupCommand(["--continue", "--no-desktop"], deps({ + reporter: reporter(chunks), + awaitRuntimeService: async ({ budgetMs, onStarting }) => { + budget = budgetMs; + onStarting(); + return { ready: false, starting: true, detail: "still starting" }; + }, + getAccountStatus: async () => ({ signedIn: false, identity: null }), + runConnect: async () => ({ ok: false, detail: "sign-in didn't finish" }), + verify: async () => ({ + ok: false, + detail: "the ADE brain is not running", + nextAction: "ade brain start", + }), + })); + + expect(budget).toBeGreaterThanOrEqual(60_000); + const account = result.steps.find((s) => s.id === "account"); + expect(account?.state).toBe("skipped"); + expect(account?.detail).toContain("still starting"); + expect(account?.nextAction).toBe("ade connect"); + // No step failed, so the install is not reported as one. + expect(result.ok).toBe(true); + const output = chunks.join(""); + expect(output).toContain("Starting ADE's background service"); + expect(output).not.toContain("the ADE brain is not running"); + }); + + it("still reports a real failure once the background service is answering", async () => { + const chunks: string[] = []; + const result = await runSetupCommand(["--continue", "--no-desktop"], deps({ + reporter: reporter(chunks), + awaitRuntimeService: async () => ({ + ready: true, + starting: false, + detail: "background service is running", + }), + getAccountStatus: async () => ({ signedIn: false, identity: null }), + runConnect: async () => ({ ok: false, detail: "sign-in didn't finish" }), + verify: async () => ({ + ok: false, + detail: "sign-in didn't finish", + nextAction: "ade connect", + }), + })); + + expect(result.ok).toBe(false); + expect(result.steps.find((s) => s.id === "account")?.state).toBe("failed"); + // Nothing was waited on, so nothing announced a wait. + expect(chunks.join("")).not.toContain("Starting ADE's background service"); + }); + it("fails verification for a signed-in machine that never reached the account", async () => { // The exact state a clean install lands in: brain healthy, account signed // in, machine absent from the account directory because no project is diff --git a/apps/ade-cli/src/commands/setup.ts b/apps/ade-cli/src/commands/setup.ts index 0def961e40..5f0e884867 100644 --- a/apps/ade-cli/src/commands/setup.ts +++ b/apps/ade-cli/src/commands/setup.ts @@ -95,6 +95,27 @@ export type SetupStepResult = { nextAction?: string; }; +/** Outcome of waiting for the machine brain's endpoint to answer. */ +export type SetupServiceReadiness = { + /** The endpoint answered. */ + ready: boolean; + /** + * The service is registered with a live brain that has simply not answered + * yet. Nothing downstream may call that a failed install. + */ + starting: boolean; + detail: string; +}; + +/** + * How long a brain the service installer reported as `starting` gets to answer + * before setup stops waiting on it. Matches the desktop's own post-install wait + * -- a cold machine opening a large project database routinely needs more than + * the ten seconds that used to be on offer, and reporting that as "installed + * but not running" sent people hunting a fault that did not exist. + */ +export const SETUP_SERVICE_START_BUDGET_MS = 90_000; + export type SetupDeps = { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; @@ -106,6 +127,16 @@ export type SetupDeps = { ensureAgentTools: ( onProgress: (progress: SetupProgress) => void, ) => Promise; + /** + * Registers the machine brain service if needed and waits for its endpoint. + * Optional so callers that do not own a brain (tests, `--no-*` paths) can + * omit it; `onStarting` fires once, the first time the wait actually begins, + * so a fast install prints no line at all. + */ + awaitRuntimeService?: (args: { + budgetMs: number; + onStarting: () => void; + }) => Promise; getAccountStatus: () => Promise; runConnect: () => Promise; readInstalledDesktop: () => { version: string | null; path: string | null }; @@ -298,6 +329,30 @@ export async function runSetupCommand( } reporter.completeStep(toolsStep); + // --- background service ---------------------------------------------------- + // Everything below needs a brain that answers. A brain that is still coming + // up is not a broken one, so this waits it out rather than letting the + // account step fail against an endpoint that was never given time to open. + let serviceStarting = false; + if (deps.awaitRuntimeService) { + try { + let announced = false; + const readiness = await deps.awaitRuntimeService({ + budgetMs: SETUP_SERVICE_START_BUDGET_MS, + onStarting: () => { + if (announced) return; + announced = true; + reporter.line(" Starting ADE's background service..."); + }, + }); + serviceStarting = readiness.starting && !readiness.ready; + } catch { + // The wait is a courtesy, not a gate: its failure must not cost the user + // the account and desktop steps that follow. + serviceStarting = false; + } + } + // --- step: account --------------------------------------------------------- try { await runAccountStep({ step: accountStep, ask, interactive, deps }); @@ -306,6 +361,14 @@ export async function runSetupCommand( accountStep.detail = describeError(error); accountStep.nextAction = "ade connect"; } + // A brain that is still starting is the reason this step could not finish, + // and "sign-in didn't finish" is not a true account of that. Say what is + // actually happening and leave the same recovery command. + if (serviceStarting && accountStep.state === "failed") { + accountStep.state = "skipped"; + accountStep.detail = "ADE's background service is still starting"; + accountStep.nextAction = "ade connect"; + } reporter.completeStep(accountStep); // --- step: desktop app ----------------------------------------------------- @@ -348,7 +411,9 @@ export async function runSetupCommand( const nextAction = result.nextAction ?? "ade connect"; const alreadyOffered = accountStep.state === "skipped" && accountStep.nextAction === nextAction; - if (!result.ok && accountStep.state !== "failed" && !alreadyOffered) { + // `serviceStarting` is the one case where a failed check is not a failed + // install: the brain is alive and coming up, and the installer said so. + if (!result.ok && !serviceStarting && accountStep.state !== "failed" && !alreadyOffered) { accountStep.state = "failed"; accountStep.detail = result.detail; accountStep.nextAction = nextAction; @@ -357,6 +422,12 @@ export async function runSetupCommand( verified = false; } + if (serviceStarting) { + reporter.line( + " ADE's background service is still starting. Give it a moment, then run `ade connect`.", + ); + } + const totals: SetupTotals = { elapsedMs: options.elapsedMs + (now() - startedAt), downloadedBytes, diff --git a/apps/ade-cli/src/serviceManager/common.test.ts b/apps/ade-cli/src/serviceManager/common.test.ts index fb5d058c2f..56f478610a 100644 --- a/apps/ade-cli/src/serviceManager/common.test.ts +++ b/apps/ade-cli/src/serviceManager/common.test.ts @@ -57,7 +57,14 @@ import { uninstallLaunchdService, } from "./installLaunchd"; import { resolveWatchdogServiceName } from "./installLaunchdWatchdog"; -import { installSystemdService, renderSystemdEnvironment, renderSystemdUnit, servicePath as systemdServicePath } from "./installSystemd"; +import { + getSystemdUnitState, + installSystemdService, + parseSystemdShowOutput, + renderSystemdEnvironment, + renderSystemdUnit, + servicePath as systemdServicePath, +} from "./installSystemd"; import { isWindowsTaskStateRunning } from "./installWindows"; const originalArgv = [...process.argv]; @@ -1448,89 +1455,254 @@ describe("systemd service rendering", () => { }); }); +type SystemdShowState = { activeState: string; mainPid: number }; + +/** + * A fake `systemctl` that answers `show` from a script of unit states (the last + * entry repeats) and lets individual subcommands be failed. Everything the + * systemd installer runs goes through `systemctl`, so one stub covers it. + */ +function systemdSpawn(options: { + calls: Array<{ command: string; args: string[] }>; + states: SystemdShowState[]; + failures?: Record; +}): ServiceManagerSpawnSync { + const states = [...options.states]; + return (command, args) => { + options.calls.push({ command, args }); + if (command !== "systemctl") return { status: 0, stdout: "", stderr: "" }; + const subcommand = args[1]; + if (subcommand === "show") { + const state = states.length > 1 ? states.shift()! : states[0]; + return { + status: 0, + stdout: `ActiveState=${state.activeState}\nMainPID=${state.mainPid}\n`, + stderr: "", + }; + } + const failure = options.failures?.[subcommand ?? ""]; + if (failure) return failure; + return { status: 0, stdout: "", stderr: "" }; + }; +} + +const INACTIVE: SystemdShowState = { activeState: "inactive", mainPid: 0 }; + +function systemdCallArgs(calls: Array<{ command: string; args: string[] }>): string[][] { + return calls.filter((call) => call.args[1] !== "show").map((call) => call.args); +} + describe("systemd service install", () => { const serviceCommand: AdeServiceCommand = { command: "/opt/ade/bin/ade", args: ["serve"], env: { NODE_PATH: "/opt/ade/node_modules" }, }; + const unitName = `${ADE_RUNTIME_SERVICE_NAME}.service`; + + function installDeps(homeDir: string, spawnSync: ServiceManagerSpawnSync, overrides: Record = {}) { + return { + command: serviceCommand, + spawnSync, + homeDir, + env: {} as NodeJS.ProcessEnv, + handoverTimeoutMs: 200, + handoverPollMs: 10, + sleep: async () => {}, + recentCrashLoop: () => false, + ...overrides, + }; + } - it("writes the user unit and enables it immediately", () => { + it("writes the user unit, enables it, and waits for the replacement to answer", async () => { const homeDir = makeTempHome("ade-systemd-install-"); const targetPath = systemdServicePath(homeDir); const calls: Array<{ command: string; args: string[] }> = []; - const spawnSync = spawnSequence(calls, [ - { status: 0, stdout: "", stderr: "" }, - { status: 0, stdout: "", stderr: "" }, - ]); + const spawnSync = systemdSpawn({ + calls, + states: [INACTIVE, { activeState: "active", mainPid: 4242 }], + }); - const result = installSystemdService({ command: serviceCommand, spawnSync, homeDir }); + const result = await installSystemdService(installDeps(homeDir, spawnSync, { + responsivenessProbe: () => true, + handoverPidAlive: () => true, + })); expect(result).toMatchObject({ ok: true, + restarted: true, serviceName: ADE_RUNTIME_SERVICE_NAME, action: "install", path: targetPath, }); + expect(result.starting).toBeUndefined(); expect(fs.readFileSync(targetPath, "utf8")).toBe(renderSystemdUnit(serviceCommand)); - expect(calls).toEqual([ - { command: "systemctl", args: ["--user", "daemon-reload"] }, - { command: "systemctl", args: ["--user", "enable", "--now", `${ADE_RUNTIME_SERVICE_NAME}.service`] }, - { command: "systemctl", args: ["--user", "restart", `${ADE_RUNTIME_SERVICE_NAME}.service`] }, + expect(systemdCallArgs(calls)).toEqual([ + ["--user", "daemon-reload"], + ["--user", "enable", "--now", unitName], + ["--user", "restart", unitName], ]); }); - it("does not enable when daemon-reload fails", () => { + it("reports a live replacement that has not answered yet as starting, not failed", async () => { + const homeDir = makeTempHome("ade-systemd-starting-"); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = systemdSpawn({ + calls, + states: [INACTIVE, { activeState: "active", mainPid: 4242 }], + }); + + const result = await installSystemdService(installDeps(homeDir, spawnSync, { + responsivenessProbe: () => false, + handoverPidAlive: () => true, + })); + + expect(result).toMatchObject({ ok: true, starting: true, restarted: true }); + expect(result.failureStep).toBeUndefined(); + expect(result.message).toContain("pid 4242"); + }); + + it("still fails the install when the replacement died without answering", async () => { + const homeDir = makeTempHome("ade-systemd-dead-"); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = systemdSpawn({ + calls, + states: [INACTIVE, { activeState: "active", mainPid: 4242 }], + }); + + const result = await installSystemdService(installDeps(homeDir, spawnSync, { + responsivenessProbe: () => false, + handoverPidAlive: (pid: number) => pid !== 4242, + })); + + expect(result).toMatchObject({ ok: false, failureStep: "replacement_responsive" }); + expect(result.starting).toBeUndefined(); + }); + + it("waits for a young unresponsive brain instead of restarting it", async () => { + const homeDir = makeTempHome("ade-systemd-young-"); + const targetPath = systemdServicePath(homeDir); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, renderSystemdUnit(serviceCommand), "utf8"); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = systemdSpawn({ calls, states: [{ activeState: "active", mainPid: 909 }] }); + + const result = await installSystemdService(installDeps(homeDir, spawnSync, { + responsivenessProbe: () => false, + handoverPidAlive: () => true, + pidElapsedMs: () => 5_000, + })); + + expect(result).toMatchObject({ ok: true, starting: true }); + expect(result.restarted).toBeUndefined(); + // The whole point: nothing was restarted out from under the booting brain. + expect(systemdCallArgs(calls)).toEqual([]); + }); + + it("restarts a young brain when a fresh crash-loop record vetoes the wait", async () => { + const homeDir = makeTempHome("ade-systemd-crashloop-"); + const targetPath = systemdServicePath(homeDir); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, renderSystemdUnit(serviceCommand), "utf8"); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = systemdSpawn({ + calls, + states: [{ activeState: "active", mainPid: 909 }, { activeState: "active", mainPid: 910 }], + }); + + const result = await installSystemdService(installDeps(homeDir, spawnSync, { + responsivenessProbe: () => false, + handoverPidAlive: (pid: number) => pid !== 909, + pidElapsedMs: () => 5_000, + recentCrashLoop: () => true, + })); + + expect(result).toMatchObject({ ok: true, starting: true, restarted: true }); + expect(systemdCallArgs(calls)).toContainEqual(["--user", "restart", unitName]); + }); + + it("does not restart an unchanged unit whose brain already answers", async () => { + const homeDir = makeTempHome("ade-systemd-noop-"); + const targetPath = systemdServicePath(homeDir); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, renderSystemdUnit(serviceCommand), "utf8"); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = systemdSpawn({ calls, states: [{ activeState: "active", mainPid: 909 }] }); + + const result = await installSystemdService(installDeps(homeDir, spawnSync, { + responsivenessProbe: () => true, + })); + + expect(result).toMatchObject({ ok: true }); + expect(result.restarted).toBeUndefined(); + expect(systemdCallArgs(calls)).toEqual([]); + }); + + it("treats a unit systemd reports as activating as a live brain, not a dead one", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = systemdSpawn({ calls, states: [{ activeState: "activating", mainPid: 77 }] }); + expect(getSystemdUnitState(spawnSync)).toEqual({ active: true, mainPid: 77 }); + }); + + it("reads MainPID=0 as no pid", () => { + expect(parseSystemdShowOutput("ActiveState=inactive\nMainPID=0\n").get("MainPID")).toBe("0"); + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = systemdSpawn({ calls, states: [INACTIVE] }); + expect(getSystemdUnitState(spawnSync)).toEqual({ active: false, mainPid: null }); + }); + + it("does not enable when daemon-reload fails", async () => { const homeDir = makeTempHome("ade-systemd-reload-fail-"); const calls: Array<{ command: string; args: string[] }> = []; - const spawnSync = spawnSequence(calls, [ - { status: 1, stdout: "", stderr: "reload failed" }, - ]); + const spawnSync = systemdSpawn({ + calls, + states: [INACTIVE], + failures: { "daemon-reload": { status: 1, stdout: "", stderr: "reload failed" } }, + }); - const result = installSystemdService({ command: serviceCommand, spawnSync, homeDir }); + const result = await installSystemdService(installDeps(homeDir, spawnSync)); expect(result.ok).toBe(false); expect(result.message).toBe("reload failed"); - expect(calls).toEqual([ - { command: "systemctl", args: ["--user", "daemon-reload"] }, - ]); + expect(systemdCallArgs(calls)).toEqual([["--user", "daemon-reload"]]); }); - it("surfaces enable failures after a successful reload", () => { + it("surfaces enable failures after a successful reload", async () => { const homeDir = makeTempHome("ade-systemd-enable-fail-"); const calls: Array<{ command: string; args: string[] }> = []; - const spawnSync = spawnSequence(calls, [ - { status: 0, stdout: "", stderr: "" }, - { status: 1, stdout: "", stderr: "enable failed" }, - ]); + const spawnSync = systemdSpawn({ + calls, + states: [INACTIVE], + failures: { enable: { status: 1, stdout: "", stderr: "enable failed" } }, + }); - const result = installSystemdService({ command: serviceCommand, spawnSync, homeDir }); + const result = await installSystemdService(installDeps(homeDir, spawnSync)); expect(result.ok).toBe(false); expect(result.message).toBe("enable failed"); - expect(calls.map((call) => call.args)).toEqual([ + expect(systemdCallArgs(calls)).toEqual([ ["--user", "daemon-reload"], - ["--user", "enable", "--now", `${ADE_RUNTIME_SERVICE_NAME}.service`], + ["--user", "enable", "--now", unitName], ]); }); - it("surfaces restart failures after enabling the user unit", () => { + it("surfaces restart failures after enabling the user unit", async () => { const homeDir = makeTempHome("ade-systemd-restart-fail-"); const calls: Array<{ command: string; args: string[] }> = []; - const spawnSync = spawnSequence(calls, [ - { status: 0, stdout: "", stderr: "" }, - { status: 0, stdout: "", stderr: "" }, - { status: 1, stdout: "", stderr: "restart failed" }, - ]); + const spawnSync = systemdSpawn({ + calls, + states: [INACTIVE], + failures: { restart: { status: 1, stdout: "", stderr: "restart failed" } }, + }); - const result = installSystemdService({ command: serviceCommand, spawnSync, homeDir }); + const result = await installSystemdService(installDeps(homeDir, spawnSync)); expect(result.ok).toBe(false); expect(result.message).toBe("restart failed"); - expect(calls.map((call) => call.args)).toEqual([ + expect(systemdCallArgs(calls)).toEqual([ ["--user", "daemon-reload"], - ["--user", "enable", "--now", `${ADE_RUNTIME_SERVICE_NAME}.service`], - ["--user", "restart", `${ADE_RUNTIME_SERVICE_NAME}.service`], + ["--user", "enable", "--now", unitName], + ["--user", "restart", unitName], ]); }); }); diff --git a/apps/ade-cli/src/serviceManager/installLaunchd.ts b/apps/ade-cli/src/serviceManager/installLaunchd.ts index 77103c0f65..33458ed36c 100644 --- a/apps/ade-cli/src/serviceManager/installLaunchd.ts +++ b/apps/ade-cli/src/serviceManager/installLaunchd.ts @@ -11,7 +11,6 @@ import { resolveAdeServeCliScriptPath, resolveAdeServeCommand, RUNTIME_SERVICE_HANDOVER_TIMEOUT_MS, - RUNTIME_SERVICE_YOUNG_BRAIN_MS, serviceManagerResultText, type ServiceManagerResult, type ServiceManagerSpawnSync, @@ -24,16 +23,19 @@ import { installLaunchdWatchdogAgent, uninstallLaunchdWatchdogAgent, } from "./installLaunchdWatchdog"; +import { + defaultResponsivenessProbe, + isYoungBrain, + recentCrashLoopForAdeHome, + RESPONSIVENESS_PROBE_INTERVAL_MS, + serviceHandoverSleep, +} from "./serviceHandover"; import { detectSyncHostSingletonConflict, formatSyncHostSingletonConflictMessage, isSameChannelSyncHostOwner, type SyncHostSingletonDeps, } from "../services/sync/syncHostSingleton"; -import { - LAST_FAILURE_CRASH_LOOP_WINDOW_MS, - readLastFailure, -} from "../../../desktop/src/main/services/runtime/lastFailureStore"; type LaunchdServiceManagerDeps = { command?: AdeServiceCommand; @@ -248,88 +250,6 @@ function launchdTerminateDeps(deps: TerminatePidDeps | undefined): TerminatePidD return { ...deps, platform: deps?.platform ?? "darwin" }; } -async function sleepAsync(ms: number): Promise { - // Awaited lifecycle delays must stay referenced: in a standalone CLI the - // handover polling can be the only pending work, and an unref'd timer lets - // the process exit mid-repair (before SIGKILL escalation / launchctl load). - await new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -function runtimeStatusArgs(command: AdeServiceCommand, socketPath: string): string[] { - const args = [...command.args]; - const serveIndex = args.lastIndexOf("serve"); - if (serveIndex >= 0) { - args.splice(serveIndex, 1, "runtime", "status"); - } else { - args.push("runtime", "status"); - } - args.push("--socket", socketPath, "--timeout", "1500", "--text"); - return args; -} - -/** - * The probe is a whole `ade runtime status` child process: Node/Electron - * start-up plus loading the CLI bundle BEFORE it can even dial the socket. Its - * kill timeout therefore has to be the socket wait plus a start-up allowance; - * when the two were equal, a machine where the CLI took longer than the socket - * budget to start killed every probe before it could answer, and a perfectly - * healthy brain read as "never responsive". - */ -const RESPONSIVENESS_PROBE_STARTUP_ALLOWANCE_MS = 8_000; - -function defaultResponsivenessProbe(args: { - socketPath: string; - timeoutMs: number; - command: AdeServiceCommand; -}): boolean { - const env = { - ...process.env, - ...(args.command.env ?? {}), - ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", - }; - const result = spawnSync( - args.command.command, - runtimeStatusArgs(args.command, args.socketPath), - { - encoding: "utf8", - env, - timeout: args.timeoutMs + RESPONSIVENESS_PROBE_STARTUP_ALLOWANCE_MS, - stdio: "ignore", - }, - ); - return result.status === 0 && !result.error; -} - -/** - * A running service child that is younger than the young-brain window and not - * answering yet is presumed to still be starting. Unknown age (ps failed) - * counts as not young, so a wedged brain is never mistaken for a booting one. - */ -/** - * A fresh failure streak recorded by the brain itself (`last-failure.json`, - * the same record project recovery and the startup backoff read). Two or more - * failures inside the crash-loop window means launchd is respawning a brain - * that dies, and its youth is not a reason to wait for it. - */ -function defaultRecentCrashLoop(): boolean { - const report = readLastFailure({ kind: "machine" }); - if (!report || report.count < 2) return false; - const firstAt = Date.parse(report.firstAt); - return Number.isFinite(firstAt) && Date.now() - firstAt <= LAST_FAILURE_CRASH_LOOP_WINDOW_MS; -} - -function isYoungBrain( - pid: number | null | undefined, - run: ServiceManagerSpawnSync, - elapsedMs: (pid: number, run: ServiceManagerSpawnSync) => number | null, -): boolean { - if (!pid) return false; - const elapsed = elapsedMs(pid, run); - return elapsed != null && elapsed < RUNTIME_SERVICE_YOUNG_BRAIN_MS; -} - function handoverFailure( servicePath: string, failureStep: NonNullable, @@ -390,11 +310,10 @@ export async function installLaunchdService( } } const isAlive = deps.handoverPidAlive ?? deps.terminateDeps?.pidAlive ?? pidAlive; - const sleep = deps.sleep ?? sleepAsync; + const sleep = deps.sleep ?? serviceHandoverSleep; const timeoutMs = Math.max(0, deps.handoverTimeoutMs ?? RUNTIME_SERVICE_HANDOVER_TIMEOUT_MS); const pollMs = Math.max(10, deps.handoverPollMs ?? 100); const pidElapsedMs = deps.pidElapsedMs ?? readPidElapsedMs; - const responsivenessProbeIntervalMs = 750; /** * Waits for a distinct replacement child to answer on the socket. Shared by @@ -425,7 +344,7 @@ export async function installLaunchdService( if ( predecessorGone && replacementDiffers - && Date.now() - lastProbeAt >= responsivenessProbeIntervalMs + && Date.now() - lastProbeAt >= RESPONSIVENESS_PROBE_INTERVAL_MS ) { lastProbeAt = Date.now(); replacementResponsive = probeResponsiveness({ @@ -450,7 +369,9 @@ export async function installLaunchdService( // A brain that keeps dying is always "young" (launchd just respawned it), so // the recorded failure streak vetoes the wait: that brain needs the restart // and the crash-loop diagnosis, not more patience. - const crashLooping = (deps.recentCrashLoop ?? defaultRecentCrashLoop)(); + const crashLooping = deps.recentCrashLoop + ? deps.recentCrashLoop() + : recentCrashLoopForAdeHome(adeHome); if ( plistUnchanged && loaded?.running === true @@ -573,7 +494,10 @@ export async function installLaunchdService( ); } if (!replacementResponsive) { - if (isAlive(replacementPid)) { + // `replacementPid != null` first: `isAlive` is injectable, and a test (or + // a future caller) that answers `true` unconditionally must not turn "no + // replacement at all" into a `starting` result the caller waits on. + if (replacementPid != null && isAlive(replacementPid)) { // launchd owns a live replacement that has not answered yet. That is a // slow start, not a failed install; the supervisor keeps the child and // the caller keeps waiting for the endpoint. Reporting this as a failure diff --git a/apps/ade-cli/src/serviceManager/installSystemd.ts b/apps/ade-cli/src/serviceManager/installSystemd.ts index 28a0bbcb22..36803617b7 100644 --- a/apps/ade-cli/src/serviceManager/installSystemd.ts +++ b/apps/ade-cli/src/serviceManager/installSystemd.ts @@ -5,18 +5,42 @@ import path from "node:path"; import { ADE_RUNTIME_SERVICE_NAME, type AdeServiceCommand, + isPidAlive, + readPidElapsedMs, renderCommand, resolveAdeServeCommand, + RUNTIME_SERVICE_HANDOVER_TIMEOUT_MS, serviceManagerResultText, type ServiceManagerResult, type ServiceManagerSpawnSync, type ServiceManagerStatusResult, } from "./common"; +import { + defaultResponsivenessProbe, + isYoungBrain, + type PidElapsedMsLookup, + recentCrashLoopForAdeHome, + RESPONSIVENESS_PROBE_INTERVAL_MS, + type ResponsivenessProbe, + serviceHandoverSleep, +} from "./serviceHandover"; type SystemdServiceManagerDeps = { command?: AdeServiceCommand; spawnSync?: ServiceManagerSpawnSync; homeDir?: string; + env?: NodeJS.ProcessEnv; + /** Defaults on. Repair-only callers may skip the preflight RPC probe. */ + probeResponsiveness?: boolean; + responsivenessProbe?: ResponsivenessProbe; + handoverTimeoutMs?: number; + handoverPollMs?: number; + handoverPidAlive?: (pid: number) => boolean; + /** Age of a live service pid; tests inject it, production asks `ps`. */ + pidElapsedMs?: PidElapsedMsLookup; + /** Whether the brain has recorded a fresh streak of startup failures; tests inject it. */ + recentCrashLoop?: () => boolean; + sleep?: (ms: number) => Promise; }; export function servicePath(homeDir = os.homedir()): string { @@ -57,12 +81,194 @@ WantedBy=default.target `; } -export function installSystemdService(deps: SystemdServiceManagerDeps = {}): ServiceManagerResult { +export type SystemdUnitState = { active: boolean; mainPid: number | null }; + +/** + * Parses `systemctl show`'s `KEY=value` output. Values are taken verbatim after + * the first `=`; systemd never wraps these two properties. + */ +export function parseSystemdShowOutput(output: string): Map { + const properties = new Map(); + for (const line of output.split(/\r?\n/)) { + const separator = line.indexOf("="); + if (separator <= 0) continue; + properties.set(line.slice(0, separator).trim(), line.slice(separator + 1).trim()); + } + return properties; +} + +/** + * One `systemctl show` round trip for both properties the handover needs. + * `null` means the query itself failed (no systemd user bus, unit unknown to + * this systemd) — distinct from "known and inactive", which is `active:false`. + */ +export function getSystemdUnitState( + run: ServiceManagerSpawnSync, + unitName = serviceUnitName(), +): SystemdUnitState | null { + const show = run( + "systemctl", + ["--user", "show", unitName, "-p", "ActiveState", "-p", "MainPID"], + { encoding: "utf8" }, + ); + if (show.status !== 0) return null; + const output = typeof show.stdout === "string" + ? show.stdout + : Buffer.isBuffer(show.stdout) ? show.stdout.toString("utf8") : ""; + const properties = parseSystemdShowOutput(output); + const activeState = properties.get("ActiveState"); + if (activeState == null) return null; + const rawPid = Number(properties.get("MainPID") ?? "0"); + const mainPid = Number.isFinite(rawPid) && rawPid > 0 ? Math.floor(rawPid) : null; + // `activating` is systemd's own "still coming up" and must not be read as + // dead: restarting a unit in that state is exactly the booting-brain kill + // this whole change exists to stop. + return { active: activeState === "active" || activeState === "activating", mainPid }; +} + +function handoverFailure( + targetPath: string, + failureStep: NonNullable, + message: string, +): ServiceManagerResult { + return { + ok: false, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: targetPath, + failureStep, + message, + }; +} + +/** + * Linux parity with the launchd installer. Headless brains, `install.sh` + * installs and remote runtimes all land here, and before this they got the + * pre-#1102 behaviour: write the unit, `enable --now`, `restart`, report + * success the instant systemd accepted the restart — no proof the replacement + * ever answered, and no way to tell a caller "installed, still starting". + * A remote bootstrap then dialled a socket that was not up yet and read a + * healthy-but-slow brain as a broken one. + */ +export async function installSystemdService( + deps: SystemdServiceManagerDeps = {}, +): Promise { const run = deps.spawnSync ?? spawnSync; - const targetPath = servicePath(deps.homeDir); + const env = deps.env ?? process.env; + const homeDir = deps.homeDir ?? os.homedir(); + const targetPath = servicePath(homeDir); const command = deps.command ?? resolveAdeServeCommand(); - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + const adeHome = command.env?.ADE_HOME?.trim() || env.ADE_HOME?.trim() || path.join(homeDir, ".ade"); + const socketPath = path.join(adeHome, "sock", "ade.sock"); + const unitName = serviceUnitName(); + const probeResponsiveness = deps.responsivenessProbe ?? defaultResponsivenessProbe; + const isAlive = deps.handoverPidAlive ?? isPidAlive; + const sleep = deps.sleep ?? serviceHandoverSleep; + const timeoutMs = Math.max(0, deps.handoverTimeoutMs ?? RUNTIME_SERVICE_HANDOVER_TIMEOUT_MS); + const pollMs = Math.max(10, deps.handoverPollMs ?? 100); + const pidElapsedMs = deps.pidElapsedMs ?? readPidElapsedMs; + const unit = renderSystemdUnit(command); + const existingUnit = fs.existsSync(targetPath) ? fs.readFileSync(targetPath, "utf8") : null; + const unitUnchanged = existingUnit === unit; + const forceRestart = env.ADE_FORCE_RUNTIME_SERVICE_RESTART === "1"; + + let state = getSystemdUnitState(run, unitName); + + // The unit is current and systemd already has a live, answering child. + // Restarting it would only interrupt a working brain. + if (!forceRestart && unitUnchanged && state?.active === true) { + if ( + deps.probeResponsiveness === false + || probeResponsiveness({ socketPath, timeoutMs: 1_500, command }) + ) { + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: targetPath, + message: "ADE service systemd user service is already installed and running.", + }; + } + } + + // One budget for the whole install, so a young-brain wait that gives up and + // the real handover after it cannot together exceed the caller's timeout. + const installDeadline = Date.now() + timeoutMs; + const awaitHandover = async (oldPid: number | null): Promise<{ + predecessorGone: boolean; + replacementPid: number | null; + replacementResponsive: boolean; + }> => { + let predecessorGone = oldPid == null || !isAlive(oldPid); + let replacementPid: number | null = null; + let replacementResponsive = false; + let lastProbeAt = 0; + do { + predecessorGone = oldPid == null || !isAlive(oldPid); + const replacement = getSystemdUnitState(run, unitName); + replacementPid = replacement?.active === true ? replacement.mainPid : null; + const replacementDiffers = replacementPid != null && replacementPid !== oldPid; + // Each probe is a full CLI child process; poll systemd cheaply and spend + // a probe only at the slower cadence. + if ( + predecessorGone + && replacementDiffers + && Date.now() - lastProbeAt >= RESPONSIVENESS_PROBE_INTERVAL_MS + ) { + lastProbeAt = Date.now(); + replacementResponsive = probeResponsiveness({ + socketPath, + timeoutMs: Math.min(1_500, Math.max(1, installDeadline - Date.now())), + command, + }); + if (replacementResponsive) break; + } + if (Date.now() >= installDeadline) break; + await sleep(Math.min(pollMs, Math.max(1, installDeadline - Date.now()))); + } while (Date.now() <= installDeadline); + return { predecessorGone, replacementPid, replacementResponsive }; + }; + + // A live child behind an unchanged unit that is simply not answering yet and + // is young is still starting — first launch, cold disk, big project + // database. Restarting it only resets its clock. A brain that keeps dying is + // always "young" (systemd just respawned it, `Restart=always`), so a + // recorded failure streak vetoes the wait. + const crashLooping = deps.recentCrashLoop + ? deps.recentCrashLoop() + : recentCrashLoopForAdeHome(adeHome); + if ( + unitUnchanged + && state?.active === true + && !crashLooping + && isYoungBrain(state.mainPid, run, pidElapsedMs) + ) { + const young = await awaitHandover(null); + if (young.replacementResponsive) { + return { + ok: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: targetPath, + message: "ADE service systemd user service is already installed; its background service finished starting.", + }; + } + if (young.replacementPid != null && isAlive(young.replacementPid)) { + return { + ok: true, + starting: true, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: targetPath, + message: `ADE service systemd user service is installed; the background service (pid ${young.replacementPid}) is still starting.`, + }; + } + // The young child died while we waited: fall through and (re)start it. + state = getSystemdUnitState(run, unitName); + } + + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); fs.writeFileSync(targetPath, unit, "utf8"); const reload = run("systemctl", ["--user", "daemon-reload"], { encoding: "utf8" }); if (reload.status !== 0) { @@ -74,28 +280,71 @@ export function installSystemdService(deps: SystemdServiceManagerDeps = {}): Ser message: serviceManagerResultText(reload) || "systemctl daemon-reload failed.", }; } - const unitName = serviceUnitName(); const enable = run("systemctl", ["--user", "enable", "--now", unitName], { encoding: "utf8" }); - if (enable.status === 0) { - const restart = run("systemctl", ["--user", "restart", unitName], { encoding: "utf8" }); - if (restart.status !== 0) { + if (enable.status !== 0) { + return { + ok: false, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: targetPath, + message: serviceManagerResultText(enable) || "systemctl enable --now failed.", + }; + } + const oldPid = state?.mainPid ?? null; + const restart = run("systemctl", ["--user", "restart", unitName], { encoding: "utf8" }); + if (restart.status !== 0) { + return { + ok: false, + serviceName: ADE_RUNTIME_SERVICE_NAME, + action: "install", + path: targetPath, + message: serviceManagerResultText(restart) || "systemctl restart failed.", + }; + } + + const { predecessorGone, replacementPid, replacementResponsive } = await awaitHandover(oldPid); + if (!predecessorGone) { + return handoverFailure( + targetPath, + "predecessor_exit", + `ADE service handover failed because predecessor pid ${oldPid} is still alive.`, + ); + } + if (replacementPid == null || replacementPid === oldPid) { + return handoverFailure( + targetPath, + "replacement_pid", + `ADE service handover failed because systemd did not report a distinct replacement pid (old ${oldPid ?? "none"}, new ${replacementPid ?? "none"}).`, + ); + } + if (!replacementResponsive) { + if (isAlive(replacementPid)) { + // systemd owns a live replacement that has not answered yet. A slow + // start, not a failed install: the supervisor keeps the child and the + // caller keeps waiting for the endpoint. return { - ok: false, + ok: true, + starting: true, + restarted: true, serviceName: ADE_RUNTIME_SERVICE_NAME, action: "install", path: targetPath, - message: serviceManagerResultText(restart) || "systemctl restart failed.", + message: `ADE service systemd user service installed; the background service (pid ${replacementPid}) is still starting after ${timeoutMs}ms.`, }; } + return handoverFailure( + targetPath, + "replacement_responsive", + `ADE service handover failed because replacement pid ${replacementPid} did not initialize over ${socketPath} within ${timeoutMs}ms.`, + ); } return { - ok: enable.status === 0, + ok: true, + restarted: true, serviceName: ADE_RUNTIME_SERVICE_NAME, action: "install", path: targetPath, - message: enable.status === 0 - ? "ADE service systemd user service installed." - : serviceManagerResultText(enable) || "systemctl enable --now failed.", + message: "ADE service systemd user service installed.", }; } diff --git a/apps/ade-cli/src/serviceManager/installWindows.test.ts b/apps/ade-cli/src/serviceManager/installWindows.test.ts index 6fe2f1932b..5e492cabf4 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.test.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.test.ts @@ -492,9 +492,14 @@ describe("Windows background service helpers", () => { command: serviceCommand, launcherPath, readPidRecord: immediateReadiness.readPidRecord, - readinessProbe: () => ({ ready: false, diagnostic: "Runtime PID 5678 has not bound the pipe yet." }), + // The probe's Win32_Process identity check confirmed the recorded pid IS + // our supervisor; it is the brain behind it that has not answered. + readinessProbe: () => ({ + ready: false, + supervised: true, + diagnostic: "Runtime PID 5678 has not bound the pipe yet.", + }), handoverTimeoutMs: 0, - pidAlive: () => true, serviceName, spawnSync, userName: taskUser, @@ -511,7 +516,7 @@ describe("Windows background service helpers", () => { expect(result.message).toContain("still starting"); }); - it("fails, not starting, when the record's supervisor is dead", async () => { + it("fails, not starting, when the recorded supervisor pid is not ours", async () => { const calls: Array<{ command: string; args: string[] }> = []; const spawnSync = spawnSequence(calls, [ { status: 3, stdout: "", stderr: "" }, @@ -527,15 +532,23 @@ describe("Windows background service helpers", () => { command: serviceCommand, launcherPath, readPidRecord: immediateReadiness.readPidRecord, - readinessProbe: () => ({ ready: false, diagnostic: "not bound" }), + // Recycled pid: alive, but `Win32_Process` says it is not a powershell + // running our launcher. `pidAlive` would call this "our brain, starting"; + // only the identity check can tell, and it says no. + readinessProbe: () => ({ + ready: false, + supervised: false, + diagnostic: "Supervisor PID 1234 is stale or belongs to another process.", + }), handoverTimeoutMs: 0, - pidAlive: () => false, + pidAlive: () => true, serviceName, spawnSync, userName: taskUser, }); expect(result).toMatchObject({ ok: false, failureStep: "replacement_responsive" }); + expect(result.starting).toBeUndefined(); }); it("waits for a young unresponsive brain instead of replacing it", async () => { diff --git a/apps/ade-cli/src/serviceManager/installWindows.ts b/apps/ade-cli/src/serviceManager/installWindows.ts index bd4bdfba89..9db73cd444 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.ts @@ -117,7 +117,13 @@ type WindowsServiceManagerDeps = { handoverTimeoutMs?: number; handoverPollMs?: number; sleep?: (ms: number) => Promise; - /** Process liveness for the supervisor/brain pids in the record; tests inject it. */ + /** + * Process liveness for the supervisor/brain pids in the record; tests inject + * it. Only ever a cheap gate on ENTERING the young-brain wait -- the + * authority on whether a recorded pid is really ours is the readiness + * probe's `Win32_Process` identity check, which runs inside that wait and + * decides whether the install may report `starting`. + */ pidAlive?: (pid: number) => boolean; }; @@ -855,7 +861,6 @@ async function installWindowsServiceImpl( timeoutMs: deps.handoverTimeoutMs ?? 15_000, pollMs: deps.handoverPollMs ?? 100, sleep: deps.sleep, - pidAlive: deps.pidAlive, }); if (youngReadiness.ready) { return { @@ -964,7 +969,6 @@ async function installWindowsServiceImpl( timeoutMs: deps.handoverTimeoutMs ?? 15_000, pollMs: deps.handoverPollMs ?? 100, sleep: deps.sleep, - pidAlive: deps.pidAlive, }); if (!readiness.ready && readiness.supervised) { // Same contract as launchd: a supervised brain that has not answered yet diff --git a/apps/ade-cli/src/serviceManager/serviceHandover.ts b/apps/ade-cli/src/serviceManager/serviceHandover.ts new file mode 100644 index 0000000000..49dc57515c --- /dev/null +++ b/apps/ade-cli/src/serviceManager/serviceHandover.ts @@ -0,0 +1,118 @@ +import { spawnSync } from "node:child_process"; +import { + type AdeServiceCommand, + RUNTIME_SERVICE_YOUNG_BRAIN_MS, + type ServiceManagerSpawnSync, +} from "./common"; +import { + LAST_FAILURE_CRASH_LOOP_WINDOW_MS, + readLastFailure, +} from "../../../desktop/src/main/services/runtime/lastFailureStore"; + +/** + * Handover primitives shared by every platform installer (launchd, systemd, + * Windows). They were born inside `installLaunchd.ts`; systemd needs the exact + * same semantics, and two copies of "is this brain young enough to wait for" + * would drift the moment one of them is tuned. + */ + +export type ResponsivenessProbe = (args: { + socketPath: string; + timeoutMs: number; + command: AdeServiceCommand; +}) => boolean; + +export type PidElapsedMsLookup = ( + pid: number, + run: ServiceManagerSpawnSync, +) => number | null; + +export async function serviceHandoverSleep(ms: number): Promise { + // Awaited lifecycle delays must stay referenced: in a standalone CLI the + // handover polling can be the only pending work, and an unref'd timer lets + // the process exit mid-repair (before SIGKILL escalation / service load). + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export function runtimeStatusArgs(command: AdeServiceCommand, socketPath: string): string[] { + const args = [...command.args]; + const serveIndex = args.lastIndexOf("serve"); + if (serveIndex >= 0) { + args.splice(serveIndex, 1, "runtime", "status"); + } else { + args.push("runtime", "status"); + } + args.push("--socket", socketPath, "--timeout", "1500", "--text"); + return args; +} + +/** + * The probe is a whole `ade runtime status` child process: Node/Electron + * start-up plus loading the CLI bundle BEFORE it can even dial the socket. Its + * kill timeout therefore has to be the socket wait plus a start-up allowance; + * when the two were equal, a machine where the CLI took longer than the socket + * budget to start killed every probe before it could answer, and a perfectly + * healthy brain read as "never responsive". + */ +export const RESPONSIVENESS_PROBE_STARTUP_ALLOWANCE_MS = 8_000; + +/** + * How often the handover wait spends a probe child. Each probe is a full CLI + * process; on the slow machines this wait exists for, probing every poll tick + * would compete with the very brain it is waiting on. + */ +export const RESPONSIVENESS_PROBE_INTERVAL_MS = 750; + +export const defaultResponsivenessProbe: ResponsivenessProbe = (args) => { + const env = { + ...process.env, + ...(args.command.env ?? {}), + ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", + }; + const result = spawnSync( + args.command.command, + runtimeStatusArgs(args.command, args.socketPath), + { + encoding: "utf8", + env, + timeout: args.timeoutMs + RESPONSIVENESS_PROBE_STARTUP_ALLOWANCE_MS, + stdio: "ignore", + }, + ); + return result.status === 0 && !result.error; +}; + +/** + * A fresh failure streak recorded by the brain itself (`last-failure.json`, + * the same record project recovery and the startup backoff read). Two or more + * failures inside the crash-loop window means the supervisor is respawning a + * brain that dies, and its youth is not a reason to wait for it. + * + * Scoped to the ADE home this install is FOR, not the installer's own: a + * desktop on one channel installing another channel's service would otherwise + * read the wrong `last-failure.json`. + */ +export function recentCrashLoopForAdeHome(adeHome: string): boolean { + const report = readLastFailure({ kind: "machine", env: { ADE_HOME: adeHome } }); + if (!report || report.count < 2) return false; + const firstAt = Date.parse(report.firstAt); + return Number.isFinite(firstAt) && Date.now() - firstAt <= LAST_FAILURE_CRASH_LOOP_WINDOW_MS; +} + +/** + * A running service child that is younger than the young-brain window and not + * answering yet is presumed to still be starting. Unknown age (the age lookup + * failed) counts as NOT young, so a wedged brain is never mistaken for a + * booting one — this fails toward restarting, never toward waiting forever. + */ +export function isYoungBrain( + pid: number | null | undefined, + run: ServiceManagerSpawnSync, + elapsedMs: PidElapsedMsLookup, +): boolean { + if (!pid) return false; + const elapsed = elapsedMs(pid, run); + return elapsed != null && elapsed < RUNTIME_SERVICE_YOUNG_BRAIN_MS; +} diff --git a/apps/ade-cli/src/serviceManager/windowsSupervisor.test.ts b/apps/ade-cli/src/serviceManager/windowsSupervisor.test.ts index 0820b97342..0902e676c1 100644 --- a/apps/ade-cli/src/serviceManager/windowsSupervisor.test.ts +++ b/apps/ade-cli/src/serviceManager/windowsSupervisor.test.ts @@ -116,6 +116,40 @@ describe("Windows runtime supervisor", () => { }); }); + it("reports `supervised` from the probe's identity check, never from pid liveness", async () => { + const record = { + supervisorPid: 4321, + runtimePid: 5678, + runtimeStartedAtMs: Date.now(), + restartCount: 0, + lastExitCode: null, + lastExitAt: null, + nextRestartAt: null, + lastLaunchError: null, + sessionBound: null, + }; + const wait = (supervised: boolean | undefined) => waitForWindowsRuntimeReadiness({ + command: { command: "C:\\ADE\\ade.exe", args: ["serve"] }, + launcherPath: "C:\\ADE\\brain-service.ps1", + pidPath: "C:\\ADE\\brain.pid.json", + socketPath: "\\\\.\\pipe\\ade-test", + spawnSync, + readPidRecord: () => record, + readinessProbe: () => ({ ready: false, supervised, diagnostic: "not yet" }), + timeoutMs: 0, + pollMs: 10, + }); + + // The recorded pids are alive as far as `process.kill(pid, 0)` is + // concerned in every one of these cases -- what differs is whether + // `Win32_Process` says the pid is a powershell running OUR launcher. Only + // that answer may promote a failed install to "still starting". + await expect(wait(true)).resolves.toMatchObject({ ready: false, supervised: true }); + await expect(wait(false)).resolves.toMatchObject({ ready: false, supervised: false }); + // A probe that says nothing is unknown, and unknown is not healthy. + await expect(wait(undefined)).resolves.toMatchObject({ ready: false, supervised: false }); + }); + (process.platform === "win32" ? it : it.skip)( "keeps supervising a missing executable and publishes launch-error backoff diagnostics", async () => { diff --git a/apps/ade-cli/src/serviceManager/windowsSupervisor.ts b/apps/ade-cli/src/serviceManager/windowsSupervisor.ts index bbba6295f2..1503bcc9b3 100644 --- a/apps/ade-cli/src/serviceManager/windowsSupervisor.ts +++ b/apps/ade-cli/src/serviceManager/windowsSupervisor.ts @@ -12,7 +12,6 @@ import { import { type AdeServiceCommand, cmdQuote, - isPidAlive, serviceManagerResultText, type ServiceManagerSpawnSync, } from "./common"; @@ -84,9 +83,18 @@ export type WindowsRuntimeReadiness = { ready: boolean; diagnostic: string; /** - * The supervisor published a PID record during the wait, i.e. it is running - * a brain that has not answered yet. Callers treat that as "still starting" - * rather than a failed install. + * A process that is verifiably OUR supervisor -- the recorded pid is alive + * AND is a powershell running THIS launcher, per + * `buildWindowsSupervisorQueryArgs` -- owns a brain that has not answered + * yet. Callers treat that as "still starting" rather than a failed install. + * + * Deliberately not a bare `process.kill(pid, 0)` liveness probe: the pid + * comes from an on-disk record, a pid that outlived its record can have been + * recycled by an unrelated process, and `isPidAlive` reports EPERM (a pid + * owned by somebody else entirely) as alive. Answering "still starting" for + * a recycled pid turns a genuinely failed install into an `ok: true` the + * caller waits on and never repairs, so the identity check the readiness + * probe already performs is what decides this. */ supervised?: boolean; }; @@ -506,18 +514,25 @@ export const defaultWindowsRuntimeReadiness: WindowsRuntimeReadinessProbe = (arg { encoding: "utf8", windowsHide: true }, ); if (supervisor.status !== 0) { + // Not supervised: either the pid is gone/recycled (3, 4) or the query + // itself could not answer. An unanswerable query is never reported as + // "still starting" -- unknown must not read as healthy. return { ready: false, + supervised: false, diagnostic: supervisor.status === 3 || supervisor.status === 4 ? `Supervisor PID ${pidRecord.supervisorPid} is stale or belongs to another process.` : serviceManagerResultText(supervisor) || `Unable to inspect supervisor PID ${pidRecord.supervisorPid}.`, }; } + // Past this point the recorded supervisor is verifiably ours, so every + // not-ready answer below is a brain still coming up under a live supervisor. if (pidRecord.runtimePid == null) { const restart = pidRecord.nextRestartAt ? `; restart scheduled for ${pidRecord.nextRestartAt}` : ""; const launchError = pidRecord.lastLaunchError ? ` Last launch error: ${pidRecord.lastLaunchError}.` : ""; return { ready: false, + supervised: true, diagnostic: `Supervisor PID ${pidRecord.supervisorPid} is running, but the ADE brain is between restart attempts${restart}.${launchError}`, }; } @@ -529,6 +544,7 @@ export const defaultWindowsRuntimeReadiness: WindowsRuntimeReadinessProbe = (arg if (runtime.status !== 0) { return { ready: false, + supervised: true, diagnostic: runtime.status === 3 || runtime.status === 4 ? `Runtime PID ${pidRecord.runtimePid} is stale or does not match this channel executable.` : serviceManagerResultText(runtime) || `Unable to inspect runtime PID ${pidRecord.runtimePid}.`, @@ -547,6 +563,7 @@ export const defaultWindowsRuntimeReadiness: WindowsRuntimeReadinessProbe = (arg if (status.status !== 0) { return { ready: false, + supervised: true, diagnostic: serviceManagerResultText(status) || `Runtime PID ${pidRecord.runtimePid} has not initialized on ${args.socketPath}.`, }; @@ -562,11 +579,13 @@ export const defaultWindowsRuntimeReadiness: WindowsRuntimeReadinessProbe = (arg } return { ready: false, + supervised: true, diagnostic: `Runtime endpoint responded with PID ${String(payload.pid ?? "unknown")}; expected ${pidRecord.runtimePid}.`, }; } catch { return { ready: false, + supervised: true, diagnostic: `Runtime PID ${pidRecord.runtimePid} returned an invalid readiness payload.`, }; } @@ -636,8 +655,6 @@ export async function waitForWindowsRuntimeReadiness(args: { timeoutMs: number; pollMs: number; sleep?: (ms: number) => Promise; - /** Liveness of the supervisor pid named in the record; tests inject it. */ - pidAlive?: (pid: number) => boolean; }): Promise { const deadline = Date.now() + Math.max(0, args.timeoutMs); const readPidRecord = args.readPidRecord ?? readWindowsServicePidRecord; @@ -648,9 +665,6 @@ export async function waitForWindowsRuntimeReadiness(args: { do { const pidRecord = readPidRecord(args.pidPath); if (pidRecord) { - // A record alone is not a supervisor: a stale file from a supervisor - // that already died must not read as "still starting". - supervised = (args.pidAlive ?? isPidAlive)(pidRecord.supervisorPid); const result = readinessProbe({ command: args.command, launcherPath: args.launcherPath, @@ -659,6 +673,10 @@ export async function waitForWindowsRuntimeReadiness(args: { spawnSync: args.spawnSync, }); if (result.ready) return result; + // A record alone is not a supervisor. `supervised` is whatever the + // probe's identity check concluded about the recorded pid, so a stale + // record naming a recycled pid never reads as "still starting". + supervised = result.supervised === true; diagnostic = result.diagnostic; } const remaining = deadline - Date.now(); diff --git a/apps/ade-cli/src/services/diagnostics/diagnosticReport.test.ts b/apps/ade-cli/src/services/diagnostics/diagnosticReport.test.ts new file mode 100644 index 0000000000..c4a3f1a58a --- /dev/null +++ b/apps/ade-cli/src/services/diagnostics/diagnosticReport.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from "vitest"; +import { + buildDiagnosticIssueUrl, + buildDiagnosticReport, + projectPathLabel, + redactDiagnosticText, + tailLogText, +} from "./diagnosticReport"; + +const CONTEXT = { + homeDir: "/Users/ada", + username: "ada", + hostname: "adas-macbook-pro.local", + projectRoots: ["/Users/ada/Projects/Photon"], +}; + +describe("redactDiagnosticText", () => { + it("collapses every spelling of the home directory", () => { + const input = [ + "/Users/ada/.ade/runtime/brain.jsonl", + "C:\\Users\\ada\\AppData\\Roaming\\ADE", + "C:\\\\Users\\\\ada\\\\AppData", + "file:///Users/ada/Library/Logs", + "%2FUsers%2Fada%2FDownloads", + "/home/ada/.ade", + ].join("\n"); + + const out = redactDiagnosticText(input, CONTEXT); + + expect(out).not.toMatch(/ada/i); + expect(out).toContain("~/.ade/runtime/brain.jsonl"); + expect(out).toContain("~\\AppData\\Roaming\\ADE"); + expect(out).toContain("~/Library/Logs"); + }); + + it("labels the project by name and hash instead of its path", () => { + const out = redactDiagnosticText( + "opening /Users/ada/Projects/Photon/.ade/ade.db", + CONTEXT, + ); + const label = projectPathLabel("/Users/ada/Projects/Photon"); + + expect(out).toBe(`opening ${label}/.ade/ade.db`); + expect(label).toMatch(/^$/); + // Same project, same label: two reports about one project correlate. + expect(projectPathLabel("/Users/ada/Projects/Photon")).toBe(label); + expect(projectPathLabel("/Users/bob/Projects/Photon")).not.toBe(label); + }); + + it("removes emails and every credential shape we have seen in logs", () => { + const input = [ + "signed in as ada.lovelace+ade@example.com", + "authorization: Bearer sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFF", + "token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijklmnop", + "github token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", + "posthog phc_ABCDEFGHIJKLMNOPQRSTUVWXYZ01", + "GET /pair?token=8e1f2a3b4c5d6e7f&mode=relay", + 'cookie: "0123456789abcdef0123456789abcdef0123"', + "https://ada:hunter2@relay.example.com/v1", + ].join("\n"); + + const out = redactDiagnosticText(input, CONTEXT); + + expect(out).not.toContain("example.com/v1".replace("/v1", "")); // userinfo host is fine, creds are not + expect(out).not.toMatch(/hunter2|ghp_ABCDEF|phc_ABCDEF|eyJhbGciOiJ/); + expect(out).not.toMatch(/sk-ant-api03/); + expect(out).not.toContain("8e1f2a3b4c5d6e7f"); + expect(out).not.toContain("0123456789abcdef0123456789abcdef0123"); + expect(out).toContain(""); + expect(out).toContain(""); + expect(out).toContain(""); + expect(out).toContain(""); + // Structure survives: a maintainer can still tell which line was which. + expect(out).toContain("github token"); + expect(out).toContain("mode=relay"); + }); + + it("keeps loopback addresses and drops routable ones", () => { + const input = [ + "brain answering on 127.0.0.1:8787", + "peer 192.168.1.44 connected", + "relay 2606:4700:4700::1111 handshake", + "local ::1 ok", + "chrome 140.0.7339.207", + "at 2026-08-16T09:30:00.123Z", + ].join("\n"); + + const out = redactDiagnosticText(input, CONTEXT); + + expect(out).toContain("127.0.0.1:8787"); + expect(out).toContain("::1 ok"); + expect(out).not.toContain("192.168.1.44"); + expect(out).not.toContain("2606:4700"); + expect(out).toContain(""); + // A four-part version is not an address, and a timestamp is not IPv6. + expect(out).toContain("chrome 140.0.7339.207"); + expect(out).toContain("2026-08-16T09:30:00.123Z"); + }); + + it("removes this machine's name and any tailnet name", () => { + const out = redactDiagnosticText( + "adas-macbook-pro.local reached mini-desktop.tailnet-cafe.ts.net", + CONTEXT, + ); + + expect(out).not.toContain("adas-macbook-pro"); + expect(out).not.toContain("ts.net"); + expect(out).toContain(""); + expect(out).toContain(""); + }); + + it("is idempotent, so re-redacting a stored report changes nothing", () => { + const input = [ + "/Users/ada/Projects/Photon/.ade/ade.db", + "ada@example.com Bearer sk-live-AAAABBBBCCCCDDDD", + "peer 10.0.0.7 via adas-macbook-pro.local", + ].join("\n"); + + const once = redactDiagnosticText(input, CONTEXT); + expect(redactDiagnosticText(once, CONTEXT)).toBe(once); + }); +}); + +describe("tailLogText", () => { + it("keeps the newest lines within both caps", () => { + const lines = Array.from({ length: 500 }, (_, index) => `line-${index}`); + const out = tailLogText(lines.join("\n"), { maxLines: 5, maxBytes: 32 * 1024 }); + + expect(out.split("\n")).toEqual(["line-495", "line-496", "line-497", "line-498", "line-499"]); + }); + + it("honours the byte cap even when the line cap allows more", () => { + const lines = Array.from({ length: 40 }, () => "x".repeat(100)); + const out = tailLogText(lines.join("\n"), { maxLines: 40, maxBytes: 250 }); + + expect(Buffer.byteLength(out, "utf8")).toBeLessThanOrEqual(250); + }); +}); + +describe("buildDiagnosticReport", () => { + function build() { + return buildDiagnosticReport({ + generatedAt: "2026-08-16T09:30:00.000Z", + app: { + version: "1.2.61", + packageChannel: "stable", + isPackaged: true, + platform: "darwin", + arch: "arm64", + osRelease: "25.3.0", + osProductVersion: "26.1", + electronVersion: "38.0.0", + nodeVersion: "22.14.0", + chromeVersion: "140.0.7339.207", + timezoneOffsetMinutes: -420, + }, + identity: { + installId: "ade_0123456789abcdef0123456789abcdef", + accountHash: "a1b2c3d4e5f6", + }, + context: { + surface: "project_recovery", + headline: "ADE couldn't open this project", + code: "db_integrity", + technicalDetail: "sqlite disk image is malformed at /Users/ada/Projects/Photon/.ade/ade.db", + projectRoot: "/Users/ada/Projects/Photon", + }, + state: { + localRuntimeStatus: { connectionState: "disconnected", runtimeMode: "primary", pid: 4321 }, + machineLastFailure: { code: "db_integrity", message: "cannot open /Users/ada/.ade/ade.db" }, + }, + storage: [{ label: "ADE home", path: "/Users/ada/.ade", freeBytes: 5 * 1024 ** 3, totalBytes: 500 * 1024 ** 3 }], + logs: [ + { + label: "Brain", + path: "/Users/ada/.ade/runtime/brain.jsonl", + text: '{"msg":"peer 192.168.1.9 for ada@example.com"}', + }, + { label: "Desktop updates", path: "/Users/ada/Library/ade-update.jsonl", error: "(not present)" }, + ], + notes: ["doctor: not run"], + redaction: CONTEXT, + }); + } + + it("includes every section a maintainer needs, keyed by the install id", () => { + const report = build(); + + for (const heading of [ + "# ADE diagnostic report", + "## What happened", + "## Install", + "## Technical detail", + "## Runtime status", + "## Last failure (machine)", + "## Disk space", + "## Logs", + "## Notes", + ]) { + expect(report).toContain(heading); + } + // The correlation key must survive redaction — it is the whole point. + expect(report).toContain("ade_0123456789abcdef0123456789abcdef"); + expect(report).toContain("Install id (PostHog distinct_id)"); + expect(report).toContain("Account hash: a1b2c3d4e5f6"); + expect(report).toContain("1.2.61"); + expect(report).toContain("db_integrity"); + expect(report).toContain("5.0 GB free of 500.0 GB"); + }); + + it("redacts the assembled document, not just the log lines", () => { + const report = build(); + + expect(report).not.toMatch(/\/Users\/ada/); + expect(report).not.toContain("ada@example.com"); + expect(report).not.toContain("192.168.1.9"); + expect(report).not.toContain("adas-macbook-pro"); + expect(report).toContain(projectPathLabel("/Users/ada/Projects/Photon")); + // Redaction already ran, so running it again is a no-op. + expect(redactDiagnosticText(report, CONTEXT)).toBe(report); + }); +}); + +describe("buildDiagnosticIssueUrl", () => { + it("targets the ADE repo with a title and a compact stub body", () => { + const url = buildDiagnosticIssueUrl({ + surface: "project_recovery", + headline: "ADE couldn't open this project", + code: "db_integrity", + appVersion: "1.2.61", + platform: "darwin", + arch: "arm64", + installId: "ade_0123456789abcdef0123456789abcdef", + }); + const parsed = new URL(url); + + expect(parsed.origin + parsed.pathname).toBe("https://github.com/arul28/ADE/issues/new"); + expect(parsed.searchParams.get("title")).toBe("[report] ADE couldn't open this project (db_integrity)"); + expect(parsed.searchParams.get("body")).toContain("clipboard"); + expect(parsed.searchParams.get("body")).toContain("ade_0123456789abcdef0123456789abcdef"); + // Well under GitHub's URL ceiling: the full report rides the clipboard. + expect(url.length).toBeLessThan(2_000); + }); +}); diff --git a/apps/ade-cli/src/services/diagnostics/diagnosticReport.ts b/apps/ade-cli/src/services/diagnostics/diagnosticReport.ts new file mode 100644 index 0000000000..9ac86f1978 --- /dev/null +++ b/apps/ade-cli/src/services/diagnostics/diagnosticReport.ts @@ -0,0 +1,504 @@ +import { createHash } from "node:crypto"; +import path from "node:path"; + +/** + * Pure diagnostic-report assembly and redaction, shared by the desktop + * "Report issue" button and the headless `ade report-issue` command. + * + * Nothing here touches the filesystem, Electron, or the network: collection + * lives in the callers, so both a Markdown report and its redaction can be + * unit-tested with synthetic inputs on every platform. + */ + +/** Where a maintainer files the issue this report is attached to. */ +export const DIAGNOSTIC_ISSUE_REPO = "arul28/ADE"; + +/** GitHub rejects very long URLs (~8 KB); the body in the URL stays a stub. */ +export const ISSUE_URL_MAX_LENGTH = 6_000; + +/** Per-log-file tail caps. Newest lines are kept, oldest dropped. */ +export const LOG_TAIL_MAX_LINES = 120; +export const LOG_TAIL_MAX_BYTES = 32 * 1024; + +export type DiagnosticRedactionContext = { + homeDir?: string | null; + username?: string | null; + hostname?: string | null; + /** Absolute project roots collapsed to ``. */ + projectRoots?: readonly (string | null | undefined)[]; +}; + +export type DiagnosticLogTail = { + label: string; + /** Displayed after redaction, so an absolute path is fine here. */ + path: string; + text?: string | null; + error?: string | null; +}; + +export type DiagnosticVolumeSpace = { + label: string; + path: string; + freeBytes: number | null; + totalBytes: number | null; +}; + +export type DiagnosticReportContext = { + /** Which screen the user pressed the button on. */ + surface: string; + headline?: string | null; + code?: string | null; + technicalDetail?: string | null; + projectRoot?: string | null; +}; + +export type DiagnosticReportInput = { + generatedAt: string; + app: { + version: string | null; + packageChannel?: string | null; + isPackaged?: boolean | null; + platform: string; + arch: string; + osRelease?: string | null; + /** macOS `sw_vers -productVersion`, when it could be read. */ + osProductVersion?: string | null; + electronVersion?: string | null; + nodeVersion?: string | null; + chromeVersion?: string | null; + timezoneOffsetMinutes?: number | null; + }; + identity: { + /** PostHog `distinct_id` for this installation (`ade_…`). */ + installId?: string | null; + /** Truncated one-way hash of the signed-in account id; never the email. */ + accountHash?: string | null; + machineKeyFingerprint?: string | null; + }; + context: DiagnosticReportContext; + /** JSON blobs rendered verbatim (after redaction). */ + state?: { + localRuntimeStatus?: unknown; + machineLastFailure?: unknown; + projectLastFailure?: unknown; + lastWedge?: unknown; + recoveryDiagnosis?: unknown; + updateTransaction?: unknown; + }; + storage?: readonly DiagnosticVolumeSpace[]; + logs?: readonly DiagnosticLogTail[]; + /** Free-form operational notes, e.g. "doctor: not run". */ + notes?: readonly string[]; + redaction?: DiagnosticRedactionContext; +}; + +// --------------------------------------------------------------------------- +// Redaction +// --------------------------------------------------------------------------- + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Every textual spelling of an absolute path we might meet in a log line: + * native separators, JSON-escaped backslashes, percent-encoded, and `file://`. + */ +function pathSpellings(absolutePath: string): string[] { + const posix = absolutePath.replace(/\\/g, "/"); + const win = absolutePath.replace(/\//g, "\\"); + const spellings = new Set([ + absolutePath, + posix, + win, + win.replace(/\\/g, "\\\\"), + encodeURIComponent(posix), + posix.replace(/\//g, "%2F"), + posix.replace(/\//g, "%2f"), + ]); + return [...spellings].filter((value) => value.length > 2); +} + +function alternationRegExp(values: readonly string[], flags = "gi"): RegExp | null { + const parts = [...new Set(values)].filter(Boolean).sort((a, b) => b.length - a.length); + if (parts.length === 0) return null; + return new RegExp(parts.map(escapeRegExp).join("|"), flags); +} + +/** + * Stable, non-reversible label for a project directory: the maintainer can + * correlate two reports about the same project without learning where it is. + */ +export function projectPathLabel(projectRoot: string): string { + const normalized = projectRoot.replace(/[\\/]+$/, ""); + const name = normalized.split(/[\\/]/).filter(Boolean).pop() ?? "project"; + const digest = createHash("sha256").update(normalized).digest("hex").slice(0, 6); + return ``; +} + +function isLoopbackIpv4(value: string): boolean { + return value === "0.0.0.0" || value.startsWith("127."); +} + +function isPlausibleIpv4(value: string): boolean { + const octets = value.split("."); + if (octets.length !== 4) return false; + return octets.every((octet) => { + if (!/^\d{1,3}$/.test(octet)) return false; + return Number(octet) <= 255; + }); +} + +/** + * Strips everything that could identify the machine or its owner. Applied to + * the whole report as the final step, so a section added later cannot leak by + * forgetting to call it. + * + * Idempotent: the placeholders it writes (`~`, ``, ``, …) never match + * any of its own patterns, so re-running it is a no-op. + */ +export function redactDiagnosticText( + text: string, + context: DiagnosticRedactionContext = {}, +): string { + if (!text) return ""; + let out = text; + + // 1. Project roots first — otherwise the home-dir rule below rewrites their + // prefix and the recognizable `` label can no longer be formed. + for (const root of context.projectRoots ?? []) { + const trimmed = typeof root === "string" ? root.trim() : ""; + if (!trimmed) continue; + const label = projectPathLabel(trimmed); + const pattern = alternationRegExp(pathSpellings(trimmed)); + if (pattern) out = out.replace(pattern, label); + } + + // 2. This machine's home directory, in every spelling. + const homeDir = context.homeDir?.trim(); + if (homeDir) { + const pattern = alternationRegExp(pathSpellings(homeDir)); + if (pattern) out = out.replace(pattern, "~"); + } + + // 3. Any other user's home directory shape, so a path we did not anticipate + // (another account, a copied log) still cannot name a person. + out = out + .replace(/(?:%2F|\/)(?:Users|home)(?:%2F|\/)[^/\\\s"'`,;:)\]}]+/gi, "~") + .replace(/[A-Za-z]:\\{1,2}Users\\{1,2}[^\\/\s"'`,;:)\]}]+/gi, "~") + .replace(/[A-Za-z]%3A(?:%5C)+Users(?:%5C)+[^%\s"'`,;:)\]}]+/gi, "~"); + + // 4. The OS account name wherever it appears on its own. + const username = context.username?.trim(); + if (username && username.length >= 3) { + out = out.replace(new RegExp(`(?"); + } + + // 5. Emails before token blobs: an address must not be eaten as a secret. + out = out.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, ""); + + // 6. Credentials. Prefixed forms first, then key-adjacent blobs. + out = out + .replace(/\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}(?:\.[A-Za-z0-9_-]+)?/g, "") + .replace(/\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 ") + .replace(/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{10,}/g, "") + .replace(/\bgh[pousr]_[A-Za-z0-9]{16,}/g, "") + .replace(/\bph[cx]_[A-Za-z0-9_-]{16,}/g, "") + .replace(/\bxox[abposr]-[A-Za-z0-9-]{10,}/g, "") + .replace( + /([?&](?:token|key|api[_-]?key|access[_-]?token|secret|password|pin|sig|signature|code|auth)=)[^&\s"'`<>]+/gi, + "$1", + ) + .replace( + /((?:token|secret|password|passwd|authorization|cookie|api[_-]?key|apikey|access[_-]?key)"?\s*[:=]\s*"?)([A-Za-z0-9+/=_-]{32,})/gi, + "$1", + ); + + // 7. URL userinfo (`https://user:pass@host`). + out = out.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+(?::[^/\s@]*)?@/gi, "$1@"); + + // 8. Addresses. Loopback stays: "the brain answered on 127.0.0.1" is the + // fact a maintainer needs, and it identifies nobody. + out = out.replace(/\b\d{1,3}(?:\.\d{1,3}){3}\b/g, (match) => { + if (!isPlausibleIpv4(match)) return match; + return isLoopbackIpv4(match) ? match : ""; + }); + out = out.replace(/(?"); + out = out.replace( + /(? { + if (match === "::1" || match === "::") return match; + // Without a digit this is far more likely to be `Namespace::member` + // than an address, and mangling code identifiers costs real signal. + return /\d/.test(match) ? "" : match; + }, + ); + + // 9. Machine and tailnet names. + const hostname = context.hostname?.trim(); + if (hostname && hostname.length >= 3) { + const short = hostname.split(".")[0] ?? ""; + const names = [hostname, ...(short.length >= 3 ? [short] : [])]; + const pattern = alternationRegExp(names.map(escapeRegExp).map((v) => `(?"); + } + out = out + .replace(/(?") + .replace(/(?"); + + return out; +} + +// --------------------------------------------------------------------------- +// Report assembly +// --------------------------------------------------------------------------- + +/** Keeps the newest lines of a log, bounded by both line count and bytes. */ +export function tailLogText( + text: string, + limits: { maxLines?: number; maxBytes?: number } = {}, +): string { + const maxLines = limits.maxLines ?? LOG_TAIL_MAX_LINES; + const maxBytes = limits.maxBytes ?? LOG_TAIL_MAX_BYTES; + const lines = text.split(/\r?\n/); + while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + let kept = lines.slice(Math.max(0, lines.length - maxLines)); + while (kept.length > 1 && Buffer.byteLength(kept.join("\n"), "utf8") > maxBytes) { + kept = kept.slice(1); + } + let joined = kept.join("\n"); + if (Buffer.byteLength(joined, "utf8") > maxBytes) { + joined = Buffer.from(joined, "utf8").subarray(-maxBytes).toString("utf8").replace(/^�+/, ""); + } + return joined; +} + +function formatBytes(bytes: number | null | undefined): string { + if (bytes == null || !Number.isFinite(bytes)) return "unknown"; + const gb = bytes / 1024 ** 3; + if (gb >= 1) return `${gb.toFixed(1)} GB`; + return `${Math.round(bytes / 1024 ** 2)} MB`; +} + +function bullet(label: string, value: unknown): string { + const text = + value == null || value === "" + ? "unknown" + : typeof value === "string" + ? value + : String(value); + return `- ${label}: ${text}`; +} + +/** Omits the line entirely when there is nothing to say, instead of "unknown". */ +function optionalBullet(label: string, value: unknown): string | null { + if (value == null || value === "") return null; + return bullet(label, value); +} + +function lines(...values: (string | null)[]): string { + return values.filter((value): value is string => value != null).join("\n"); +} + +/** + * Wraps `body` in a fence longer than any backtick run inside it, so a log line + * that itself contains ``` cannot break out of the block. + */ +function fence(body: string, language = ""): string { + const longestRun = [...body.matchAll(/`+/g)].reduce((max, match) => Math.max(max, match[0].length), 0); + const marker = "`".repeat(Math.max(3, longestRun + 1)); + return [marker + language, body, marker].join("\n"); +} + +function jsonBlock(value: unknown): string { + try { + return fence(JSON.stringify(value, null, 2) ?? "null", "json"); + } catch { + return fence("(unserializable)", ""); + } +} + +function section(title: string, body: string | null): string | null { + if (!body || !body.trim()) return null; + return `## ${title}\n\n${body.trim()}`; +} + +/** + * Renders the Markdown report, then redacts the whole thing. Redaction runs on + * the assembled text rather than per-field on purpose: a future section cannot + * leak by forgetting to opt in. + */ +export function buildDiagnosticReport(input: DiagnosticReportInput): string { + const { app, identity, context } = input; + const projectRoots = [ + context.projectRoot, + ...(input.redaction?.projectRoots ?? []), + ].filter((value): value is string => typeof value === "string" && value.trim().length > 0); + + const parts: (string | null)[] = []; + + parts.push("# ADE diagnostic report"); + + parts.push( + section( + "What happened", + lines( + bullet("Surface", context.surface), + optionalBullet("Headline", context.headline), + optionalBullet("Recovery code", context.code), + bullet("Generated", input.generatedAt), + ), + ), + ); + + const osLine = [app.osProductVersion, app.osRelease].filter(Boolean).join(" / ") || null; + parts.push( + section( + "Install", + lines( + bullet("ADE version", app.version), + optionalBullet("Channel", app.packageChannel), + optionalBullet("Packaged", app.isPackaged == null ? null : String(app.isPackaged)), + bullet("Platform", `${app.platform} ${app.arch}`), + optionalBullet("OS", osLine), + optionalBullet("Electron", app.electronVersion), + optionalBullet("Node", app.nodeVersion), + optionalBullet("Chrome", app.chromeVersion), + optionalBullet( + "Timezone offset (min)", + app.timezoneOffsetMinutes == null ? null : String(app.timezoneOffsetMinutes), + ), + // The one line that makes the whole report correlatable: this is the + // PostHog `distinct_id` for anonymous events from this installation. + bullet("Install id (PostHog distinct_id)", identity.installId), + optionalBullet("Account hash", identity.accountHash), + optionalBullet("Machine key fingerprint", identity.machineKeyFingerprint), + bullet("Project", context.projectRoot ? projectPathLabel(context.projectRoot) : "none"), + ), + ), + ); + + parts.push(section("Technical detail", context.technicalDetail ? fence(context.technicalDetail) : null)); + + const state = input.state ?? {}; + parts.push(section("Runtime status", state.localRuntimeStatus == null ? null : jsonBlock(state.localRuntimeStatus))); + parts.push(section("Recovery diagnosis", state.recoveryDiagnosis == null ? null : jsonBlock(state.recoveryDiagnosis))); + parts.push(section("Last failure (machine)", state.machineLastFailure == null ? null : jsonBlock(state.machineLastFailure))); + parts.push(section("Last failure (project)", state.projectLastFailure == null ? null : jsonBlock(state.projectLastFailure))); + parts.push(section("Last wedge", state.lastWedge == null ? null : jsonBlock(state.lastWedge))); + parts.push(section("Update transaction", state.updateTransaction == null ? null : jsonBlock(state.updateTransaction))); + + const storage = input.storage ?? []; + parts.push( + section( + "Disk space", + storage.length + ? storage + .map((entry) => `- ${entry.label} (${entry.path}): ${formatBytes(entry.freeBytes)} free of ${formatBytes(entry.totalBytes)}`) + .join("\n") + : null, + ), + ); + + const logs = input.logs ?? []; + parts.push( + section( + "Logs", + logs.length + ? logs + .map((log) => { + const heading = `### ${log.label}\n\n\`${log.path}\``; + if (log.error) return `${heading}\n\n${log.error}`; + const text = (log.text ?? "").trim(); + if (!text) return `${heading}\n\n(empty)`; + return `${heading}\n\n${fence(text)}`; + }) + .join("\n\n") + : null, + ), + ); + + parts.push( + section("Notes", (input.notes ?? []).length ? (input.notes ?? []).map((note) => `- ${note}`).join("\n") : null), + ); + + const rendered = parts.filter((part): part is string => Boolean(part)).join("\n\n") + "\n"; + + return redactDiagnosticText(rendered, { + ...input.redaction, + projectRoots, + }); +} + +// --------------------------------------------------------------------------- +// GitHub issue URL +// --------------------------------------------------------------------------- + +export type DiagnosticIssueUrlInput = { + surface: string; + headline?: string | null; + code?: string | null; + appVersion?: string | null; + platform?: string | null; + arch?: string | null; + installId?: string | null; + repo?: string; +}; + +export function diagnosticIssueTitle(input: DiagnosticIssueUrlInput): string { + const headline = input.headline?.trim(); + const base = headline || `Problem on the ${input.surface.replace(/_/g, " ")} screen`; + const suffix = input.code?.trim() ? ` (${input.code.trim()})` : ""; + return `[report] ${base}${suffix}`.slice(0, 180); +} + +/** + * The URL carries only a stub: the full report is on the clipboard, because a + * GitHub issue URL stops working somewhere north of 8 KB. + */ +export function diagnosticIssueBody(input: DiagnosticIssueUrlInput): string { + return [ + "", + "", + "**What I was doing:**", + "", + "", + "---", + "", + bullet("Surface", input.surface), + ...(input.code ? [bullet("Recovery code", input.code)] : []), + bullet("ADE version", input.appVersion), + bullet("Platform", [input.platform, input.arch].filter(Boolean).join(" ") || null), + bullet("Install id", input.installId), + "", + "Paste the full diagnostic report from your clipboard here:", + "", + ].join("\n"); +} + +export function buildDiagnosticIssueUrl(input: DiagnosticIssueUrlInput): string { + const repo = input.repo?.trim() || DIAGNOSTIC_ISSUE_REPO; + const url = new URL(`https://github.com/${repo}/issues/new`); + url.searchParams.set("title", diagnosticIssueTitle(input)); + url.searchParams.set("labels", "bug"); + const body = diagnosticIssueBody(input); + url.searchParams.set("body", body); + if (url.toString().length <= ISSUE_URL_MAX_LENGTH) return url.toString(); + // Degrade to a title-only link rather than handing the browser a URL the + // GitHub frontend will reject outright. + const short = new URL(`https://github.com/${repo}/issues/new`); + short.searchParams.set("title", diagnosticIssueTitle(input)); + return short.toString(); +} + +/** `2026-08-16T09-30-00-000Z-project_recovery.md`, safe on every filesystem. */ +export function diagnosticReportFileName(surface: string, at: Date): string { + const stamp = at.toISOString().replace(/[:.]/g, "-"); + const slug = surface.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "report"; + return `${stamp}-${slug}.md`; +} + +/** Convenience for callers that already know the directory. */ +export function diagnosticReportFilePath(dir: string, surface: string, at: Date): string { + return path.join(dir, diagnosticReportFileName(surface, at)); +} diff --git a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts index 8833e02897..da1c416e20 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts @@ -29,6 +29,7 @@ import { } from "../eventDedup"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; import type { ProjectLaunchContext } from "../types"; +import type { ServiceManagerResult } from "../../serviceManager/common"; const childProcess = vi.hoisted(() => { // `pid` stays undefined by default so the spawn record is a no-op for the @@ -41,7 +42,9 @@ const childProcess = vi.hoisted(() => { }); const runtimeService = vi.hoisted(() => ({ - installRuntimeService: vi.fn(() => ({ + // Typed as the real result so a test can set `starting`/`failureStep` — the + // two fields that decide whether a rival brain may be spawned. + installRuntimeService: vi.fn((): ServiceManagerResult => ({ ok: false, serviceName: "com.ade.runtime", action: "install" as const, @@ -915,6 +918,59 @@ describe("connectToAde embedded mode", () => { expect(client.close).toHaveBeenCalledTimes(1); }); + it("refuses to spawn a rival brain while the installed service is still starting", async () => { + // The installer left a live, supervised brain that had not answered yet. + // Falling through to spawnDaemon here is what put a second, unmanaged brain + // on the socket the service already owns. + useMissingMachineSocket(); + runtimeService.installRuntimeService.mockReturnValue({ + ok: true, + starting: true, + serviceName: "com.ade.runtime", + action: "install", + path: "/tmp/com.ade.runtime.plist", + message: "ADE brain service is registered and starting", + }); + vi.spyOn(JsonRpcClient, "connect").mockRejectedValue( + Object.assign(new Error("connect ENOENT"), { code: "ENOENT" }), + ); + + vi.useFakeTimers(); + try { + const attempt = connectToAde({ project, preferServiceRepair: true }); + const rejection = expect(attempt).rejects.toThrow( + /background service is still starting/, + ); + // Long enough to outlast the whole `starting` retry budget. + await vi.advanceTimersByTimeAsync(180_000); + await rejection; + } finally { + vi.useRealTimers(); + } + + expect(childProcess.spawn).not.toHaveBeenCalled(); + }); + + it("refuses to spawn a rival brain when the failed install left a registered replacement", async () => { + useMissingMachineSocket(); + runtimeService.installRuntimeService.mockReturnValue({ + ok: false, + failureStep: "replacement_responsive", + serviceName: "com.ade.runtime", + action: "install", + path: "/tmp/com.ade.runtime.plist", + message: "replacement did not answer in time", + }); + vi.spyOn(JsonRpcClient, "connect").mockRejectedValue( + Object.assign(new Error("connect ENOENT"), { code: "ENOENT" }), + ); + + await expect(connectToAde({ project, preferServiceRepair: true })).rejects.toThrow( + /background service is still starting/, + ); + expect(childProcess.spawn).not.toHaveBeenCalled(); + }); + it("keeps the script entrypoint argv shape when a CLI script is resolved", async () => { const socketPath = useMissingMachineSocket(); const entrypointDir = fs.mkdtempSync( diff --git a/apps/ade-cli/src/tuiClient/connection.ts b/apps/ade-cli/src/tuiClient/connection.ts index b5c1fee8d0..c48dacd164 100644 --- a/apps/ade-cli/src/tuiClient/connection.ts +++ b/apps/ade-cli/src/tuiClient/connection.ts @@ -128,6 +128,12 @@ type CreateEmbeddedRpcRequestHandler = (args: { const DAEMON_CONNECT_RETRY_INITIAL_DELAY_MS = 50; const DAEMON_CONNECT_RETRY_MAX_DELAY_MS = 200; +/** + * Attempts to allow a brain the installer reported as `starting` — roughly 60s + * at the capped 200ms retry delay, matching the machine CLI's own wait for the + * same case. + */ +const STARTING_BRAIN_CONNECT_ATTEMPTS = 300; const MULTI_PROJECT_RUNTIME_METHODS = new Set([ "ade/initialize", @@ -464,6 +470,24 @@ function resolveCliEntrypoint(): string | null { return null; } +/** + * The registered service has a live brain that has not answered on its socket + * yet. Every path that could otherwise reach `spawnDaemon` has to see this and + * stop: an unmanaged brain on a supervised socket is a second brain, and the + * user's actual problem is only that the first one is still starting. + */ +export class RuntimeServiceStartingError extends Error { + constructor(socketPath: string, installMessage?: string) { + super( + "ADE's background service is still starting — try again in a moment." + + ` It had not answered on ${socketPath} yet` + + (installMessage?.trim() ? ` (${installMessage.trim()})` : "") + + ", so ADE did not start a second brain alongside it.", + ); + this.name = "RuntimeServiceStartingError"; + } +} + class StaleAdeSocketError extends Error { readonly pid: number | null; @@ -919,14 +943,40 @@ export async function connectToAde(args: { }); const repairService = async (): Promise => { if (!preferServiceRepair) return null; + const [{ installRuntimeService }, { serviceManagerOwnsRuntimeRecovery }] = + await Promise.all([ + import("../serviceManager"), + import("../serviceManager/common"), + ]); + let result: Awaited>; try { - const { installRuntimeService } = await import("../serviceManager"); - const result = await withAdeDefaultRole("cto", () => installRuntimeService()); - if (!result.ok) return null; - return await tryDaemon(25); + result = await withAdeDefaultRole("cto", () => installRuntimeService()); } catch { return null; } + if (!result.ok) { + // The replacement reached its readiness phase, so it is registered with + // the platform supervisor even though the install reported failure. + // That supervisor owns the retries; an unmanaged daemon on the same + // socket is a rival brain, not a recovery. + if (serviceManagerOwnsRuntimeRecovery(result)) { + throw new RuntimeServiceStartingError(machineSocketPath, result.message); + } + return null; + } + // `starting` means the supervisor has a live brain that had not + // answered inside the installer's budget. The default 25 attempts is + // ~5s — far too short for the case the flag exists to describe, and + // giving up early drops through to spawnDaemon, i.e. a second, + // unmanaged brain on the socket the service already owns. + try { + return await tryDaemon(result.starting ? STARTING_BRAIN_CONNECT_ATTEMPTS : 25); + } catch { + // A successful install means a service now owns this endpoint, + // whether or not it had answered yet. Returning null here is what let + // the caller start a competing manual brain on a supervised socket. + throw new RuntimeServiceStartingError(machineSocketPath, result.message); + } }; try { if (!fs.existsSync(machineSocketPath)) { @@ -940,6 +990,9 @@ export async function connectToAde(args: { } return await tryDaemon(1); } catch (firstError) { + // A supervised brain that is still coming up must not be answered with a + // second one: the fallback below this catch spawns exactly that. + if (firstError instanceof RuntimeServiceStartingError) throw firstError; if (firstError instanceof StaleAdeSocketError) { // tryDaemon ran with shutdownOnStale, so that brain was just asked to // exit. Its pid can outlive the request by a moment, and if it was one diff --git a/apps/desktop/src/main/services/analytics/productAnalyticsPolicy.ts b/apps/desktop/src/main/services/analytics/productAnalyticsPolicy.ts index a4db61c5d0..8d6a0f5d1f 100644 --- a/apps/desktop/src/main/services/analytics/productAnalyticsPolicy.ts +++ b/apps/desktop/src/main/services/analytics/productAnalyticsPolicy.ts @@ -124,6 +124,9 @@ const ANALYTICS_ONLY_ACTIONS = new Set([ // settle-tuple write that had to be reconciled through the chokepoint. "settle_teardown_residue", "settle_remote_write_reconciled", + // One coarse fact per "Report issue" press: whether the GitHub issue page + // opened. Never the surface it was pressed on, and never the report itself. + "issue_report", ]); const EVENT_PROPERTY_KEYS: Record> = { diff --git a/apps/desktop/src/main/services/analytics/productAnalyticsService.ts b/apps/desktop/src/main/services/analytics/productAnalyticsService.ts index 4399e8f2bb..6a9dd8f51f 100644 --- a/apps/desktop/src/main/services/analytics/productAnalyticsService.ts +++ b/apps/desktop/src/main/services/analytics/productAnalyticsService.ts @@ -1189,6 +1189,13 @@ export function createProductAnalyticsService(args: ProductAnalyticsServiceArgs) return new Date(state.enabledSinceMs).toISOString(); }, hashProjectId: (value: string) => opaqueId("project", value), + /** + * The id PostHog sees as `distinct_id` for anonymous events from this + * installation. Surfaced so a diagnostic report a user files by hand can be + * matched to the events this machine already sent; it is a random + * per-install token, not a device or account identifier. + */ + getDistinctId: (): string => state.identifiedUserHash ?? state.anonymousId, installationIdForTesting: () => state.installationId, identifiedUserHashForTesting: () => state.identifiedUserHash, }; diff --git a/apps/desktop/src/main/services/diagnostics/diagnosticReportService.ts b/apps/desktop/src/main/services/diagnostics/diagnosticReportService.ts new file mode 100644 index 0000000000..9cf68a9031 --- /dev/null +++ b/apps/desktop/src/main/services/diagnostics/diagnosticReportService.ts @@ -0,0 +1,244 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + buildDiagnosticIssueUrl, + buildDiagnosticReport, + diagnosticReportFilePath, + tailLogText, + type DiagnosticLogTail, + type DiagnosticReportContext, + type DiagnosticVolumeSpace, +} from "../../../../../ade-cli/src/services/diagnostics/diagnosticReport"; +import { resolveMachineAdeLayout } from "../../../../../ade-cli/src/services/projects/machineLayout"; +import { resolveWindowsSupervisorLogPath } from "../../../../../ade-cli/src/serviceManager/installWindows"; +import { readVolumeSpace } from "../storage/volume"; +import { readLastFailure } from "../runtime/lastFailureStore"; + +export { + buildDiagnosticIssueUrl, + buildDiagnosticReport, + redactDiagnosticText, +} from "../../../../../ade-cli/src/services/diagnostics/diagnosticReport"; + +export type DiagnosticReportRequest = DiagnosticReportContext & { + /** Verbatim `UpdateTransactionResult` (or anything JSON) from the caller. */ + updateTransaction?: unknown; +}; + +export type DiagnosticReportDeps = { + appVersion: string | null; + packageChannel: string | null; + isPackaged: boolean; + /** `app.getPath("userData")` — where the desktop's own jsonl logs live. */ + userDataPath: string; + /** Directory the written report file goes in. */ + reportsDir: string; + installId: string | null; + /** Raw account user id; hashed here and never stored or sent verbatim. */ + accountUserId?: string | null; + /** Project `.ade/logs` directory for the open project, when there is one. */ + projectLogsDir?: string | null; + getLocalRuntimeStatus?: () => Promise | unknown; + diagnoseProject?: (projectRoot: string) => Promise; + env?: NodeJS.ProcessEnv; + now?: () => Date; +}; + +export type DiagnosticReportResult = { + report: string; + filePath: string; + issueUrl: string; + installId: string; +}; + +/** Truncated one-way hash: correlatable across reports, never reversible. */ +export function hashAccountUserId(userId: string | null | undefined): string | null { + const trimmed = userId?.trim(); + if (!trimmed) return null; + return createHash("sha256").update(`ade-account:${trimmed}`).digest("hex").slice(0, 12); +} + +function readMacProductVersion(): Promise { + if (process.platform !== "darwin") return Promise.resolve(null); + return new Promise((resolve) => { + try { + execFile("/usr/bin/sw_vers", ["-productVersion"], { timeout: 2_000 }, (error, stdout) => { + resolve(error ? null : stdout.trim() || null); + }); + } catch { + resolve(null); + } + }); +} + +function readLogTail(label: string, filePath: string): DiagnosticLogTail { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) return { label, path: filePath, error: "(not a file)" }; + // Read at most the last 512 KB off disk; the tail helper trims from there. + const readBytes = Math.min(stat.size, 512 * 1024); + const handle = fs.openSync(filePath, "r"); + try { + const buffer = Buffer.alloc(readBytes); + fs.readSync(handle, buffer, 0, readBytes, Math.max(0, stat.size - readBytes)); + return { label, path: filePath, text: tailLogText(buffer.toString("utf8")) }; + } finally { + fs.closeSync(handle); + } + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : ""; + return { label, path: filePath, error: code === "ENOENT" ? "(not present)" : "(could not be read)" }; + } +} + +function readJsonFile(filePath: string): unknown { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; + } catch { + return null; + } +} + +function volumeEntry(label: string, dirPath: string): DiagnosticVolumeSpace | null { + const space = readVolumeSpace(dirPath); + if (!space) return null; + return { label, path: dirPath, freeBytes: space.freeBytes, totalBytes: space.totalBytes }; +} + +/** + * Gathers everything the report needs from this machine and renders it. Every + * step is best-effort: a missing log or a runtime that will not answer must + * never stop a user from filing an issue. + */ +export async function collectDiagnosticReport( + deps: DiagnosticReportDeps, + request: DiagnosticReportRequest, +): Promise { + const env = deps.env ?? process.env; + const at = deps.now?.() ?? new Date(); + const layout = resolveMachineAdeLayout(env); + const projectRoot = request.projectRoot?.trim() || null; + + const [osProductVersion, localRuntimeStatus, recoveryDiagnosis] = await Promise.all([ + readMacProductVersion().catch(() => null), + Promise.resolve() + .then(() => deps.getLocalRuntimeStatus?.()) + .catch(() => null), + projectRoot && deps.diagnoseProject + ? deps.diagnoseProject(projectRoot).catch(() => null) + : Promise.resolve(null), + ]); + + const logs: DiagnosticLogTail[] = []; + if (process.platform === "win32") { + logs.push(readLogTail("Background service supervisor", resolveWindowsSupervisorLogPath({ env }))); + } else { + logs.push(readLogTail("Background service (stderr)", path.join(layout.runtimeDir, "launchd.err.log"))); + } + logs.push(readLogTail("Brain", path.join(layout.runtimeDir, "brain.jsonl"))); + logs.push(readLogTail("Desktop local runtime", path.join(deps.userDataPath, "local-runtime.jsonl"))); + logs.push(readLogTail("Desktop updates", path.join(deps.userDataPath, "ade-update.jsonl"))); + if (deps.projectLogsDir) { + logs.push(readLogTail("Desktop main", path.join(deps.projectLogsDir, "main.jsonl"))); + } + + const storage = [ + volumeEntry("ADE home", layout.adeDir), + projectRoot ? volumeEntry("Project", projectRoot) : null, + ].filter((entry): entry is DiagnosticVolumeSpace => entry != null); + + const machineLastFailure = (() => { + try { + return readLastFailure({ kind: "machine", env }); + } catch { + return null; + } + })(); + const projectLastFailure = projectRoot + ? (() => { + try { + return readLastFailure({ kind: "project", projectRoot }); + } catch { + return null; + } + })() + : null; + const lastWedge = readJsonFile(path.join(layout.runtimeDir, "last-wedge.json")); + + const installId = deps.installId?.trim() || "unknown"; + + const report = buildDiagnosticReport({ + generatedAt: at.toISOString(), + app: { + version: deps.appVersion, + packageChannel: deps.packageChannel, + isPackaged: deps.isPackaged, + platform: process.platform, + arch: process.arch, + osRelease: os.release(), + osProductVersion, + electronVersion: process.versions.electron ?? null, + nodeVersion: process.versions.node ?? null, + chromeVersion: process.versions.chrome ?? null, + timezoneOffsetMinutes: -at.getTimezoneOffset(), + }, + identity: { + installId, + accountHash: hashAccountUserId(deps.accountUserId), + }, + context: { + surface: request.surface, + headline: request.headline ?? null, + code: request.code ?? null, + technicalDetail: request.technicalDetail ?? null, + projectRoot, + }, + state: { + localRuntimeStatus: localRuntimeStatus ?? null, + recoveryDiagnosis: recoveryDiagnosis ?? null, + machineLastFailure, + projectLastFailure, + lastWedge, + updateTransaction: request.updateTransaction ?? null, + }, + storage, + logs, + notes: ["doctor: not run (the report is collected without starting the background service)"], + redaction: { + homeDir: os.homedir(), + username: os.userInfo().username, + hostname: os.hostname(), + // Only the project root is collapsed to a `` label; the ADE + // home is already reduced to `~/.ade` by the home-directory rule, and + // labelling it would hide which channel's home this machine uses. + projectRoots: projectRoot ? [projectRoot] : [], + }, + }); + + const filePath = diagnosticReportFilePath(deps.reportsDir, request.surface, at); + const issueUrl = buildDiagnosticIssueUrl({ + surface: request.surface, + headline: request.headline ?? null, + code: request.code ?? null, + appVersion: deps.appVersion, + platform: process.platform, + arch: process.arch, + installId, + }); + + return { report, filePath, issueUrl, installId }; +} + +/** Writes the report next to the app's other user data. Best effort. */ +export function writeDiagnosticReportFile(filePath: string, report: string): boolean { + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(filePath, report, { encoding: "utf8", mode: 0o600 }); + return true; + } catch { + return false; + } +} diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index f22f396cac..5c207efc1d 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -795,6 +795,15 @@ import { quoteWindowsCmdArg } from "../shared/processExecution"; import { probeLocalhostPort } from "../probeLocalhostPort"; import type { ProcessRegistryService } from "../runtime/processRegistryService"; import { openExternalUrl } from "../shared/externalLinks"; +import { resolveAdeLayout } from "../../../shared/adeLayout"; +import { + collectDiagnosticReport, + writeDiagnosticReportFile, +} from "../diagnostics/diagnosticReportService"; +import type { + DiagnosticReportPayload, + DiagnosticReportRequestPayload, +} from "../../../shared/types/diagnostics"; const APP_RESOURCE_USAGE_CACHE_MS = 900; let appResourceUsageCache: { @@ -4585,6 +4594,89 @@ export function registerIpc({ }); }); + /** + * Assembles the redacted diagnostic report for whichever error screen asked. + * Read-only and best effort: a missing log, a wedged brain or a runtime mode + * without a recovery service must never stop someone from filing an issue, + * so every optional input degrades to "unknown" rather than throwing. + */ + const buildDiagnosticsReport = async ( + arg: DiagnosticReportRequestPayload | undefined, + ) => { + const surface = typeof arg?.surface === "string" && arg.surface.trim() ? arg.surface.trim() : "unknown"; + const requestedRoot = typeof arg?.projectRoot === "string" ? arg.projectRoot.trim() : ""; + const projectRoot = requestedRoot || getCtx().project.rootPath || null; + return await collectDiagnosticReport( + { + appVersion: app.getVersion(), + packageChannel: normalizeAppPackageChannel(process.env.ADE_PACKAGE_CHANNEL), + isPackaged: app.isPackaged, + userDataPath: app.getPath("userData"), + reportsDir: path.join(app.getPath("userData"), "diagnostic-reports"), + installId: productAnalyticsService?.getDistinctId() ?? null, + accountUserId: getCurrentAccountOwnerId?.() ?? null, + projectLogsDir: projectRoot ? resolveAdeLayout(projectRoot).logsDir : null, + getLocalRuntimeStatus: () => localRuntimeConnectionPool?.getStatus() ?? null, + diagnoseProject: projectRecoveryService + ? (root: string) => projectRecoveryService.diagnose(root) + : undefined, + }, + { + surface, + headline: typeof arg?.headline === "string" ? arg.headline.slice(0, 300) : null, + code: typeof arg?.code === "string" ? arg.code.slice(0, 120) : null, + technicalDetail: typeof arg?.technicalDetail === "string" ? arg.technicalDetail.slice(0, 16_000) : null, + projectRoot, + }, + ); + }; + + ipcMain.handle( + IPC.diagnosticsBuildReport, + async (_event, arg: DiagnosticReportRequestPayload): Promise => { + const result = await buildDiagnosticsReport(arg); + return { report: result.report, filePath: result.filePath, issueUrl: result.issueUrl, installId: result.installId }; + }, + ); + + ipcMain.handle( + IPC.diagnosticsOpenIssue, + async (_event, arg: DiagnosticReportRequestPayload): Promise => { + const result = await buildDiagnosticsReport(arg); + const written = writeDiagnosticReportFile(result.filePath, result.report); + let copied = false; + try { + clipboard.writeText(result.report); + copied = true; + } catch { + copied = false; + } + let opened = false; + try { + await openExternalUrl(result.issueUrl); + opened = true; + } catch { + opened = false; + } + productAnalyticsService?.capture({ + event: "ade_feature_used", + surface: "desktop", + properties: { feature: "connections", action: "issue_report", outcome: opened ? "opened" : "failed" }, + projectId: null, + dedupeKey: `issue_report:${opened ? "opened" : "failed"}`, + minimumIntervalMs: 60 * 60 * 1_000, + }); + return { + report: result.report, + filePath: written ? result.filePath : "", + issueUrl: result.issueUrl, + installId: result.installId, + copied, + opened, + }; + }, + ); + ipcMain.handle(IPC.projectStateGetSnapshot, async (): Promise => { const ctx = getCtx(); if (!ctx.adeProjectService) throw new Error("Project state service unavailable."); diff --git a/apps/desktop/src/main/services/runtime/projectRecoveryService.ts b/apps/desktop/src/main/services/runtime/projectRecoveryService.ts index 528e22fada..521c3a15cc 100644 --- a/apps/desktop/src/main/services/runtime/projectRecoveryService.ts +++ b/apps/desktop/src/main/services/runtime/projectRecoveryService.ts @@ -190,7 +190,7 @@ function diagnosisCopy(state: ProjectRecoveryDiagnosis["state"]): Pick< default: return { headline: "ADE couldn't open this project.", - body: "You can try a repair, or send the technical details to support.", + body: "Something stopped ADE's background service from answering. A repair restarts it and checks the project's data — your files and chats aren't touched.", canAutoRepair: true, }; } diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 54bd444fa8..1d9bcde077 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -720,6 +720,7 @@ import type { StorageSnapshot, } from "../shared/types/storage"; import type { ProjectRecoveryDiagnosis, ProjectRepairReport, RepairStepResult } from "../shared/types/recovery"; +import type { DiagnosticReportPayload, DiagnosticReportRequestPayload } from "../shared/types/diagnostics"; import type { AppPackageChannel } from "../shared/packageChannel"; import type { ProductAnalyticsCapture, @@ -892,6 +893,14 @@ declare global { onMissing: (cb: (data: { rootPath: string }) => void) => () => void; onStateEvent: (cb: (event: AdeProjectEvent) => void) => () => void; }; + /** + * Absent on older preloads: every call site must tolerate `undefined` + * and simply not offer the button. + */ + diagnostics: { + buildReport: (context: DiagnosticReportRequestPayload) => Promise; + openIssue: (context: DiagnosticReportRequestPayload) => Promise; + }; recovery: { diagnose: (projectRoot: string) => Promise; repair: (projectRoot: string) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index c4ece2b7b5..ac4aae0fde 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -27,6 +27,7 @@ import { } from "./pinnedRuntimeEvents"; import type { OrchestrationEventPayload } from "../shared/types/orchestration"; import type { ProjectRecoveryDiagnosis, ProjectRepairReport, RepairStepResult } from "../shared/types/recovery"; +import type { DiagnosticReportPayload, DiagnosticReportRequestPayload } from "../shared/types/diagnostics"; import type { ProductAnalyticsCapture, ProductAnalyticsCaptureResult, @@ -3969,6 +3970,16 @@ contextBridge.exposeInMainWorld("ade", { }; }, }, + diagnostics: { + buildReport: ( + context: DiagnosticReportRequestPayload, + ): Promise => + ipcRenderer.invoke(IPC.diagnosticsBuildReport, context), + openIssue: ( + context: DiagnosticReportRequestPayload, + ): Promise => + ipcRenderer.invoke(IPC.diagnosticsOpenIssue, context), + }, recovery: { diagnose: (projectRoot: string): Promise => ipcRenderer.invoke(IPC.recoveryDiagnose, { projectRoot }), diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 8fa312ba55..01a6296e84 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -9,6 +9,7 @@ import { useNavigate } from "react-router-dom"; import { useShallow } from "zustand/react/shallow"; +import { WarningCircle } from "@phosphor-icons/react"; import { AppShell } from "./AppShell"; import { resolveSettingsTab } from "../settings/settingsManifest"; @@ -21,6 +22,14 @@ import { WindowsBetaNoticeHost } from "./WindowsBetaNoticeModal"; import { ClipboardDeeplinkBanner } from "./ClipboardDeeplinkBanner"; import { CrossRepoPrBanner } from "./CrossRepoPrBanner"; import { ProjectRecoveryScreen } from "./ProjectRecoveryScreen"; +import { ReportIssueButton } from "./ReportIssueButton"; +import { + ERROR_CARD, + ERROR_PRIMARY_BUTTON, + ERROR_SECONDARY_BUTTON, + TechnicalDetailsFold, + WhatToDo, +} from "./errorSurfaceKit"; import { ProjectWelcomePage } from "../projects/ProjectWelcomePage"; import { OnboardingBootstrap } from "../onboarding/OnboardingBootstrap"; import { LaunchGate } from "../onboarding/LaunchGate"; @@ -175,6 +184,11 @@ const GuardLoadingFallback = StartupSplashScreen; /* ---------- Per-route error boundary ---------- */ +const PAGE_CRASH_STEPS: readonly string[] = [ + "Go to Work — the rest of ADE keeps running.", + "Come back to this screen. If it breaks again, choose Report issue so we can see what happened here.", +]; + type PageErrorBoundaryState = { hasError: boolean; message: string }; class PageErrorBoundaryInner extends React.Component< @@ -200,29 +214,71 @@ class PageErrorBoundaryInner extends React.Component< render() { if (this.state.hasError) { return ( -
-
-
This page crashed
-
{this.state.message || "Unknown error"}
+
+ {/* Min-height row rather than `items-center` on the scroller: a card + taller than the pane would otherwise be clipped at the top. */} +
+
+
+
+
+
+
+

+ Something went wrong on this screen +

+

+ The rest of ADE is still running, and your project, chats and files are safe. +

+
+
+ + + +
+ {/* Work first: this screen just failed to draw, so re-rendering + it usually fails the same way. Leaving is the move that + reliably works, and Try again stays for the transient case. */} + + +
+
+ +
+ +
+ +
-
- -
); diff --git a/apps/desktop/src/renderer/components/app/AutoUpdateBanner.tsx b/apps/desktop/src/renderer/components/app/AutoUpdateBanner.tsx index 1d6692d13e..215630add3 100644 --- a/apps/desktop/src/renderer/components/app/AutoUpdateBanner.tsx +++ b/apps/desktop/src/renderer/components/app/AutoUpdateBanner.tsx @@ -6,6 +6,7 @@ import { useBrainRepair } from "../../hooks/useBrainRepair"; import { BrainRepairButton } from "../settings/BrainRepairButton"; import { dismissToast, showToast } from "./toast/toastStore"; import { captureUpdatePromptDecision } from "./captureUpdatePromptDecision"; +import { ReportIssueButton } from "./ReportIssueButton"; const AUTO_APPLY_TOAST_ID = "ade-auto-update-auto-apply"; @@ -155,7 +156,7 @@ export function AutoUpdateBanner() { captureUpdatePromptDecision(snapshot, "dismissed"); setDismissedSignature(signature); }} - className="shrink-0 text-amber-900/70 hover:text-amber-900" + className="shrink-0 text-amber-100/50 transition-colors hover:text-amber-100" title="Dismiss until the next update" aria-label="Dismiss update banner" > @@ -184,14 +185,30 @@ function UpdateTransactionNotice({ result }: { result: UpdateTransactionResult | if (!failureMessage || dismissed) return null; return ( -
-
+ {/* Meta actions live outside the card: they are about this screen, not about the project. */} @@ -615,9 +581,7 @@ function SuccessCard({ return ( <> -

- ADE repaired the project and reopened it -

+

ADE repaired the project and reopened it

    {dbLine ?
  • {dbLine}
  • : null} {resumedNormally != null ? ( diff --git a/apps/desktop/src/renderer/components/app/ProjectTransitionErrorAlert.tsx b/apps/desktop/src/renderer/components/app/ProjectTransitionErrorAlert.tsx index 8c2d735a53..f0a36b7cad 100644 --- a/apps/desktop/src/renderer/components/app/ProjectTransitionErrorAlert.tsx +++ b/apps/desktop/src/renderer/components/app/ProjectTransitionErrorAlert.tsx @@ -1,5 +1,6 @@ import { WarningCircle, X } from "@phosphor-icons/react"; import { useAppStore } from "../../state/appStore"; +import { TechnicalDetailsFold } from "./errorSurfaceKit"; /** * Fallback banner for project open/switch failures that do not have enough @@ -32,14 +33,9 @@ export function ProjectTransitionErrorAlert() {
    {projectTransitionError.message}
    {projectTransitionError.detail ? ( -
    - - Show technical details - -
    - {projectTransitionError.detail} -
    -
    + // The same fold as the full recovery surface, so the detail reads the + // same here and comes with the Copy affordance people reach for next. + ) : null}
    diff --git a/apps/desktop/src/renderer/components/app/RendererErrorBoundary.tsx b/apps/desktop/src/renderer/components/app/RendererErrorBoundary.tsx index 1c50fd3b19..e2b93d9b93 100644 --- a/apps/desktop/src/renderer/components/app/RendererErrorBoundary.tsx +++ b/apps/desktop/src/renderer/components/app/RendererErrorBoundary.tsx @@ -2,8 +2,8 @@ import React from "react"; import { ArrowsClockwise, WarningCircle } from "@phosphor-icons/react"; import { logRendererDebugEvent } from "../../lib/debugLog"; import { - ERROR_CARD, ERROR_PRIMARY_BUTTON, + ErrorSurfaceCard, TechnicalDetailsFold, WhatToDo, } from "./errorSurfaceKit"; @@ -55,22 +55,16 @@ export class RendererErrorBoundary extends React.Component<{ children: React.Rea taller than the window would otherwise be clipped at the top. */}
    -
    -
    -
    -
    -
    -

    - ADE needs to reload this window -

    -

    - Something went wrong while drawing the app. Your project, chats and files are - safe — this is only the window. -

    -
    -
    - +
    +
    = {}): DiagnosticReportP function installBridge(openIssue: ReturnType) { (window as unknown as { ade?: unknown }).ade = { - diagnostics: { openIssue, buildReport: vi.fn() }, + diagnostics: { openIssue }, }; } diff --git a/apps/desktop/src/renderer/components/app/ReportIssueButton.tsx b/apps/desktop/src/renderer/components/app/ReportIssueButton.tsx index 101af8aa87..1a36b48edb 100644 --- a/apps/desktop/src/renderer/components/app/ReportIssueButton.tsx +++ b/apps/desktop/src/renderer/components/app/ReportIssueButton.tsx @@ -4,15 +4,19 @@ import type { DiagnosticReportRequestPayload, } from "../../../shared/types/diagnostics"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; -import { ERROR_DISCLOSURE_CARET } from "./errorSurfaceKit"; +import { + ERROR_DISCLOSURE_CARET, + ERROR_PRIMARY_BUTTON, + ERROR_SECONDARY_BUTTON, +} from "./errorSurfaceKit"; export type ReportIssueVariant = "primary" | "secondary" | "ghost"; const VARIANT_CLASS: Record = { - primary: - "inline-flex h-9 items-center justify-center rounded-lg bg-amber-400/90 px-4 text-[13px] font-semibold text-[#1a1206] transition-colors hover:bg-amber-300 disabled:opacity-60", - secondary: - "inline-flex h-9 items-center justify-center rounded-lg border border-border/80 bg-fg/[0.03] px-4 text-[13px] font-medium text-fg/75 transition-colors hover:bg-fg/[0.07] disabled:opacity-60", + primary: ERROR_PRIMARY_BUTTON, + secondary: ERROR_SECONDARY_BUTTON, + // Not the kit's ghost: this one rides inside one-line banners, where the + // full-height button shape would turn a strip into a bar. ghost: "inline-flex h-[22px] items-center justify-center rounded-md px-2 text-[11px] font-medium text-fg/60 transition-colors hover:bg-fg/[0.06] hover:text-fg/85 disabled:opacity-60", }; diff --git a/apps/desktop/src/renderer/components/app/errorSurfaceKit.tsx b/apps/desktop/src/renderer/components/app/errorSurfaceKit.tsx index 9c32cb3dd5..3ba89f7fd9 100644 --- a/apps/desktop/src/renderer/components/app/errorSurfaceKit.tsx +++ b/apps/desktop/src/renderer/components/app/errorSurfaceKit.tsx @@ -43,6 +43,76 @@ export const ERROR_DISCLOSURE_CARET = ( export const ERROR_CARD = "rounded-2xl border border-border/70 bg-fg/[0.02] px-6 py-5 shadow-[0_1px_0_0_rgba(255,255,255,0.03)_inset]"; +/** The headline every failure card leads with. */ +export const ERROR_HEADLINE = + "text-[16.5px] font-semibold leading-snug tracking-[-0.01em] text-fg/95"; + +/** The sentence under it. */ +export const ERROR_BODY = "mt-1.5 text-[13px] leading-relaxed text-fg/60"; + +/** + * The badge tones these surfaces use. `warning` is the default (something + * broke), `success` closes a repair out, and `neutral` is for the states where + * nothing is wrong and ADE is simply working — a warning badge there is the + * "broken ADE" report those states exist to avoid. + */ +export type ErrorSurfaceTone = "warning" | "success" | "neutral"; + +const TONE_BADGE: Record = { + warning: "border-amber-400/25 bg-amber-400/10 text-amber-300", + success: "border-emerald-400/25 bg-emerald-400/10 text-emerald-400", + neutral: "border-border/70 bg-fg/[0.04] text-fg/55", +}; + +/** + * The card + badge + hero every full-screen failure state opens with. Three + * screens used to hand-assemble this identical block and drifted apart a class + * at a time; anything below the hero (checklists, actions, notes) goes in as + * children. + * + * `hero` replaces the headline/body pair for the rare state that needs a richer + * lede (the repair success report). + */ +export function ErrorSurfaceCard({ + tone = "warning", + icon, + headline, + body, + hero, + children, +}: { + tone?: ErrorSurfaceTone; + icon: ReactNode; + headline?: ReactNode; + body?: ReactNode; + hero?: ReactNode; + children?: ReactNode; +}) { + return ( +
    +
    +
    + {icon} +
    +
    + {hero ?? ( + <> +

    {headline}

    + {body ?

    {body}

    : null} + + )} +
    +
    + {children} +
    + ); +} + /** * A short "what to do" list. Kept plain: no icons, no emphasis — * these read as instructions, and decoration makes them read as decoration. diff --git a/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.test.tsx b/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.test.tsx index 250656aac1..b849992356 100644 --- a/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.test.tsx +++ b/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.test.tsx @@ -94,4 +94,50 @@ describe("StorageCleanupDialog failures", () => { expect(screen.queryByText("ADE couldn't check what's safe to remove.")).toBeNull(), ); }); + + it("shows the reopened dialog's preview when the abandoned read lands last", async () => { + const deferred: Array<(value: unknown) => void> = []; + const cleanupPreview = vi.fn( + () => new Promise((resolve) => { deferred.push(resolve); }), + ); + (window as unknown as { ade?: unknown }).ade = { + storage: { cleanupPreview, cleanup: vi.fn() }, + }; + + const props = { + title: "Free up space", + targets: [] as never[], + onClose: vi.fn(), + onCleaned: vi.fn(), + }; + const { rerender } = render(); + await waitFor(() => expect(cleanupPreview).toHaveBeenCalledTimes(1)); + + // Close before the first read answers, then reopen: a second read starts. + rerender(); + rerender(); + await waitFor(() => expect(cleanupPreview).toHaveBeenCalledTimes(2)); + + // The reopened dialog's read answers first, the abandoned one answers last. + deferred[1]({ + items: [{ path: "/tmp/fresh.log", label: "fresh.log", bytes: 10 }], + blocked: [], + totalBytes: 10, + }); + await screen.findByText("fresh.log"); + + deferred[0]({ + items: [{ path: "/tmp/stale.log", label: "stale.log", bytes: 99 }], + blocked: [], + totalBytes: 99, + }); + + // The stale answer must not repaint the dialog — it stays on the fresh + // review rather than falling back to a spinner or the abandoned list. + await waitFor(() => + expect(screen.getByRole("button", { name: /Remove 1 item/ })).toBeTruthy(), + ); + expect(screen.queryByText("stale.log")).toBeNull(); + expect(screen.getByText("fresh.log")).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.tsx b/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.tsx index 7613433be2..236e98635b 100644 --- a/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.tsx +++ b/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.tsx @@ -265,8 +265,15 @@ export function StorageCleanupDialog({ const initRef = React.useRef({ targets }); initRef.current = { targets }; + // Which read the dialog is currently showing. A close/reopen (or a Try again + // pressed twice) leaves the earlier `cleanupPreview` in flight, and those can + // land out of order — the stale one must not overwrite the fresh preview, nor + // drag a settled dialog back to "error". + const requestRef = React.useRef(0); + const loadPreview = React.useCallback(() => { const { targets: openTargets } = initRef.current; + const requestId = ++requestRef.current; setResult(null); setReport(null); setError(null); @@ -275,11 +282,13 @@ export function StorageCleanupDialog({ return window.ade.storage .cleanupPreview(openTargets) .then((next) => { + if (requestRef.current !== requestId) return false; setPreview(next); setStage("review"); return true; }) .catch((err: unknown) => { + if (requestRef.current !== requestId) return false; setError(err instanceof Error ? err.message : String(err)); setErrorPhase("checking"); setStage("error"); @@ -289,13 +298,11 @@ export function StorageCleanupDialog({ React.useEffect(() => { if (!open) return; - let active = true; - void loadPreview().then(() => { - // A dialog closed mid-read has nothing to show; the next open re-reads. - if (!active) setStage("loading"); - }); + void loadPreview(); return () => { - active = false; + // A dialog closed mid-read has nothing to show; retire the request so a + // late answer cannot paint over whatever the next open reads. + requestRef.current += 1; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index a6724e122a..cd62e74e6e 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -80,7 +80,6 @@ export const IPC = { /** Main → renderer: one repair step as it finishes, so a long repair reads live. */ recoveryRepairStep: "ade.recovery.repairStep", /** Assemble the redacted diagnostic report without acting on it. */ - diagnosticsBuildReport: "ade.diagnostics.buildReport", /** Assemble, save, copy to the clipboard, and open a prefilled GitHub issue. */ diagnosticsOpenIssue: "ade.diagnostics.openIssue", projectForgetRecent: "ade.project.forgetRecent", diff --git a/apps/desktop/src/shared/types/recovery.ts b/apps/desktop/src/shared/types/recovery.ts index dcd536a75a..a5a9d7ba11 100644 --- a/apps/desktop/src/shared/types/recovery.ts +++ b/apps/desktop/src/shared/types/recovery.ts @@ -119,3 +119,25 @@ export function mapKvDbOpenErrorCode(code: string): AdeRecoveryErrorCode { return "unknown"; } } + +/** + * The single mapping from a stored failure code to the recovery state it + * describes. Both the main process (when it falls back to the last recorded + * failure) and the recovery screen (when a live diagnosis is unavailable) read + * it from here, so the screen can never offer a different verdict — or a + * different repair offer — than the service would have given. + */ +export function stateForCode(code: AdeRecoveryErrorCode): ProjectRecoveryDiagnosis["state"] { + switch (code) { + case "disk_full": return "disk_full"; + case "insufficient_headroom": return "insufficient_headroom"; + case "db_integrity": + case "migration_incomplete": + case "migration_unknown_state": return "db_repair_needed"; + case "brain_crash_looping": return "brain_crash_looping"; + case "brain_not_installed": return "brain_not_installed"; + case "socket_stale_no_owner": return "socket_stale_no_owner"; + case "socket_owned_by_other": return "socket_owned_by_other"; + default: return "unknown_failure"; + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b0ac087ba9..e444a9f3b7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -120,7 +120,7 @@ Product positioning and workflows live in [`docs/PRD.md`](../docs/PRD.md). This - **SSH stdio bridge (`ade rpc --stdio`)** — runs a single-session JSON-RPC runtime over stdin/stdout. This is what desktop's `RemoteConnectionPool` execs over SSH after `bootstrapRemoteRuntime` has uploaded a matching `ade-` binary. Exits when the SSH channel closes. - **Terminal client (`ade code`)** — launches the Ink + React Work chat (`apps/ade-cli/src/tuiClient/`). Defaults to attaching to the machine brain and will start it if the endpoint is missing. `ade --socket /path code` requires a specific endpoint; `ade code --embedded` keeps the in-process runtime fallback explicit. -**Brain startup ordering.** `ade serve` binds its RPC endpoint *before* starting the mobile sync host. `runServe` publishes the endpoint, then fires `runSyncHostStartupLoop` in the background (`void startSyncHostInBackground()`). The order matters because that loop retries forever by design (so mobile sync auto-recovers the moment a rival owner exits): while the bind sat behind it, anything the loop retried — a project scope slow to open, a busy sync port band, a stale lease from a just-killed predecessor — kept `ade.sock` unpublished, so desktop reachability was coupled to phone-sync hosting and a service-install handover budget could expire against a brain that was alive and healthy (0.4 s to socket versus 3.3 s measured). Ownership is still decided before the bind rather than after it: `assertBrainSocketUnowned` runs immediately ahead of `listen()`, and again on the Windows named-pipe path, under one contract (message, cause, and the `socket_owned_by_other` code project recovery keys on), so a brain that cannot own its socket fails fast instead of living on as a zombie — signed in, dialing the relay, and fighting the legitimate brain for the machine's relay slot; we found 18 of them stacked up on one dev socket. The bind is itself the claim now, so the startup loop no longer carries its own socket-liveness abort. A sync-host startup failure (a cross-channel conflict with another build's live brain) still ends the brain, but it now closes an already-published socket and records the failure, rather than refusing to publish one. Symmetrically, `apps/ade-cli/src/services/runtime/runtimeSpawnRecord.ts` stops the CLI from stacking up detached brains: every `ade` command that cannot reach the brain spawns one detached-and-unref'd and forgets it, so a burst of failures used to leave a burst of immortal brains. The record (under the ADE-owned `runtime/spawns` dir, keyed by a hash of the socket path, `0600`) suppresses a duplicate spawn while a previously spawned brain is still alive and within `RUNTIME_SPAWN_RECORD_GRACE_MS` (30 s), reports success so the caller proceeds to its connect-with-retry, expires so a genuinely wedged brain never blocks recovery, and is cleared explicitly on the deliberate-shutdown path whose whole purpose is to make room for a replacement. +**Brain startup ordering.** `ade serve` binds its RPC endpoint *before* starting the mobile sync host. `runServe` publishes the endpoint, then fires `runSyncHostStartupLoop` in the background (`void startSyncHostInBackground()`). The order matters because that loop retries forever by design (so mobile sync auto-recovers the moment a rival owner exits): while the bind sat behind it, anything the loop retried — a project scope slow to open, a busy sync port band, a stale lease from a just-killed predecessor — kept `ade.sock` unpublished, so desktop reachability was coupled to phone-sync hosting and a service-install handover budget could expire against a brain that was alive and healthy (0.4 s to socket versus 3.3 s measured). Ownership is still decided before the bind rather than after it: `assertBrainSocketUnowned` runs immediately ahead of `listen()`, and again on the Windows named-pipe path, under one contract (message, cause, and the `socket_owned_by_other` code project recovery keys on), so a brain that cannot own its socket fails fast instead of living on as a zombie — signed in, dialing the relay, and fighting the legitimate brain for the machine's relay slot; we found 18 of them stacked up on one dev socket. The bind is itself the claim now, so the startup loop no longer carries its own socket-liveness abort; `monitorBrainSocketOwnership` covers the one remaining way to lose the path (a rival unlinking a socket it proved stale), and the shutdown that follows removes the socket file only when it still owns the inode it bound (`unlinkOwnedRuntimeSocket`) — an unconditional unlink there deleted the winner's socket. A sync-host startup failure (a cross-channel conflict with another build's live brain) still ends the brain, but it now closes an already-published socket and records the failure, rather than refusing to publish one. Symmetrically, `apps/ade-cli/src/services/runtime/runtimeSpawnRecord.ts` stops the CLI from stacking up detached brains: every `ade` command that cannot reach the brain spawns one detached-and-unref'd and forgets it, so a burst of failures used to leave a burst of immortal brains. The record (under the ADE-owned `runtime/spawns` dir, keyed by a hash of the socket path, `0600`) suppresses a duplicate spawn while a previously spawned brain is still alive and within `RUNTIME_SPAWN_RECORD_GRACE_MS` (30 s), reports success so the caller proceeds to its connect-with-retry, expires so a genuinely wedged brain never blocks recovery, and is cleared explicitly on the deliberate-shutdown path whose whole purpose is to make room for a replacement. **Machine and multi-project RPC.** The runtime exposes runtime-scoped methods (`projects.list/add/remove/touch`, `sync.*`, `runtime/info`, `machineInfo.get`, `machine.updateAndRestart`, `runtimeEvents.subscribe/unsubscribe`) directly. `sync.*` is answered by the project-scoped sync service when a project owns sync and by machine-level `ProjectlessSyncControls` when none does, so a machine that has never opened a project can still set its pairing PIN — previously it could not be paired at all. Project-scoped operations dispatch through `ade/actions/call` with a `projectId`. Personal chats use the separate machine methods `personalChats.call` and `personalChats.streamEvents`; they never enter project dispatch and their capability/version is advertised by `runtime/info`. Per-project services are spun up lazily by `ProjectScopeRegistry` (`apps/ade-cli/src/services/projects/projectScope.ts`) which calls `createAdeRuntime({ projectRoot, ... })` the first time a project is touched. `PersonalChatScope` (`apps/ade-cli/src/services/personalChats/personalChatScope.ts`) lazily boots a chat-only runtime under `$ADE_HOME/personal-chats`, with distinct state and scratch roots and no project-registry entry. The project registry (`projectRegistry.ts`) is the durable list of known projects; `machineLayout.ts` resolves machine-wide paths under `$ADE_HOME`. Wire formats live in `apps/ade-cli/src/multiProjectRpcServer.ts`. Runtime-event replay is backed by `apps/ade-cli/src/eventBuffer.ts`, a bounded buffer (10k events, 16 MB total, 1 MB per retained event by default) that returns `eventEpoch`, `gap`, and `oldestCursor` so clients can detect daemon restarts or evicted history. `projects.list` resolves at most 24 host-side project icons within 750 ms, with 128 KiB per-icon and 512 KiB aggregate wire caps; records outside those budgets get a null icon instead of blocking connection setup. diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index d3ca5452f4..e29815683f 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -207,13 +207,22 @@ relay payload E2E encryption is planned security work. See the trust boundary in published), so a desktop can reach it within a second or two of spawn even while a project scope is still opening or the sync port band is being reclaimed. All three installers — launchd, systemd, and Windows — wait - `RUNTIME_SERVICE_HANDOVER_TIMEOUT_MS` (30 s; Windows 15 s) for the - replacement to answer and, if it is alive but still quiet, return - `ok: true, starting: true` instead of a `replacement_responsive` failure — - the supervisor owns that child and it will answer. The shared primitives - (responsiveness probe, young-brain age check, crash-loop veto) live in - `apps/ade-cli/src/serviceManager/serviceHandover.ts` so the platforms cannot - drift; `installSystemd.ts` reads unit state from a single + `RUNTIME_SERVICE_HANDOVER_TIMEOUT_MS` (30 s; `WINDOWS_HANDOVER_TIMEOUT_MS`, + 15 s, on Windows) for the replacement to answer and, if it is alive but still + quiet, return `ok: true, starting: true` instead of a + `replacement_responsive` failure — the supervisor owns that child and it will + answer. Every budget in this lifecycle is defined once, in + `apps/ade-cli/src/serviceManager/runtimeServiceBudgets.ts`, because the + numbers only mean anything relative to each other (the desktop's wait has to + outlast the installer's). The shared handover itself — responsiveness probe, + young-brain age check, crash-loop veto, the wait loop and the young-brain + decision — lives in `apps/ade-cli/src/serviceManager/serviceHandover.ts` + (`awaitServiceHandover`, `awaitYoungBrainStart`) so launchd and systemd + cannot drift; each installer keeps only its own message text. The young-brain + wait and the real handover get a full budget **each**: when they shared one + install-wide deadline, a young brain that died late in its wait left the + restart with no time and its replacement was reported as a `replacement_pid` + failure. `installSystemd.ts` reads unit state from a single `systemctl --user show -p ActiveState -p MainPID` and treats systemd's own `activating` as a live brain. The desktop then keeps dialling the socket for `LOCAL_RUNTIME_SERVICE_REPAIR_CONNECT_TIMEOUT_MS` @@ -244,12 +253,16 @@ relay payload E2E encryption is planned security work. See the trust boundary in - **A brain that loses its socket ends itself.** `monitorBrainSocketOwnership` (`apps/ade-cli/src/cli.ts`) remembers the inode it bound and polls the path. Binding before the sync host removed the incidental protection the startup - loop's `abortIf` gave: two brains that both probe the same *stale* socket can - race across the `unlink`/`listen` await, and the loser ends up listening to - an inode nothing can reach without ever seeing `EADDRINUSE`. Losing the inode - now ends the brain, the supervisor restarts it, and the restart reports - `socket_owned_by_other` instead of squatting silently. Windows named pipes - are exempt — a pipe name has no directory entry to steal. + loop's socket-liveness abort gave: two brains that both probe the same + *stale* socket can race across the `unlink`/`listen` await, and the loser ends + up listening to an inode nothing can reach without ever seeing `EADDRINUSE`. + Losing the inode now ends the brain, the supervisor restarts it, and the + restart reports `socket_owned_by_other` instead of squatting silently. The + shutdown that follows removes the socket file only if it still owns that + inode (`unlinkOwnedRuntimeSocket`) — an unconditional `unlink` here deleted + the *winner's* socket and left the machine with a live brain nothing could + reach. Windows named pipes are exempt — a pipe name has no directory entry to + steal. - `apps/desktop/src/main/services/runtime/machineTrustResetMigration.ts` — one-time packaged-release reset of the old machine-connection trust files. It preserves account auth, machine identity, pairing PINs, projects, and SSH diff --git a/docs/features/storage-and-recovery/README.md b/docs/features/storage-and-recovery/README.md index 2fa36db7a9..b1d77aacc9 100644 --- a/docs/features/storage-and-recovery/README.md +++ b/docs/features/storage-and-recovery/README.md @@ -481,11 +481,17 @@ the full report rides the clipboard. | Piece | Where | | --- | --- | | Pure builder + redactor | `apps/ade-cli/src/services/diagnostics/diagnosticReport.ts` | -| Desktop collection (logs, disk, runtime status, recovery diagnosis) | `apps/desktop/src/main/services/diagnostics/diagnosticReportService.ts` | -| IPC | `IPC.diagnosticsBuildReport`, `IPC.diagnosticsOpenIssue` | +| Shared collection (logs, disk, notes, redaction context) | `apps/ade-cli/src/services/diagnostics/diagnosticSources.ts` (`collectMachineDiagnosticSources`) | +| Desktop-only extras (its own jsonl logs, runtime status, recovery diagnosis, typed last-failure store) | `apps/desktop/src/main/services/diagnostics/diagnosticReportService.ts` | +| IPC | `IPC.diagnosticsOpenIssue` | | Saved report | `/diagnostic-reports/-.md`, mode `0600` | | Headless equivalent | `ade report-issue [--open]` | +`ade report-issue` and the desktop button read the same machine sources through +`collectMachineDiagnosticSources`, so a log added for one appears in both; the +CLI adds the project's `ade-cli.jsonl` and the desktop adds its own jsonl logs +and Electron-aware volume reader on top. + The report contains: app version/channel/packaging, platform, arch, OS release (plus `sw_vers -productVersion` on macOS), Electron/Node/Chrome versions, timezone offset, the surface and recovery code the user hit, the technical @@ -510,7 +516,12 @@ addresses, credentials (JWTs, `Bearer`/`Basic`/`Token` headers, `sk-`/`gh?_`/ of 32+ characters adjacent to a key/secret/authorization/cookie word), URL userinfo, non-loopback IPv4 and IPv6 addresses (`127.0.0.0/8` and `::1` are kept — "the brain answered on 127.0.0.1" is signal and identifies nobody), this -machine's hostname, `*.ts.net` tailnet names, and `*.local` names. Environment +machine's hostname (the fully-qualified name and its short form, case +insensitive, on word boundaries), `*.ts.net` tailnet names, and `*.local` +names. The GitHub issue **title and stub body** go through the same redaction +before the URL is built — the headline they are made from is caller-supplied +and routinely carries OS paths (an update failure message, for instance). What +is redacted is the plain text, never the encoded URL. Environment variables, the credential store, `~/.ade/secrets/*`, keychain output and pairing PINs are never collected at all. The function is idempotent, so re-redacting a stored report is a no-op. From cccd2e253ecf38084f0cea7d66d260604301d4d7 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:44:59 -0400 Subject: [PATCH 4/9] quality (round 2): attempted-roots registry so a failed first open can still be repaired, case-folded root compare, machine-only diagnostics on unknown root, installer stages runtime on the install volume, comment/dead-code cleanups Co-Authored-By: Claude Fable 5 --- .../scripts/install-runtime-rollback.test.mjs | 64 ++++++++++++++ apps/ade-cli/scripts/install-runtime.sh | 51 ++++++++---- apps/ade-cli/src/cli.test.ts | 12 +++ apps/ade-cli/src/cli.ts | 52 +++++++----- .../src/serviceManager/installWindows.ts | 5 +- apps/desktop/src/main/main.ts | 14 ++++ .../diagnosticReportService.test.ts | 49 +++++++++++ .../diagnostics/diagnosticReportService.ts | 8 +- .../services/ipc/knownProjectRoots.test.ts | 60 +++++++++++++- .../main/services/ipc/knownProjectRoots.ts | 83 +++++++++++++++++-- .../src/main/services/ipc/registerIpc.ts | 34 ++++++-- .../components/app/errorSurfaceKit.tsx | 2 +- .../settings/storage/StorageCleanupDialog.tsx | 8 +- apps/desktop/src/shared/ipc.ts | 1 - docs/features/storage-and-recovery/README.md | 10 +++ 15 files changed, 392 insertions(+), 61 deletions(-) create mode 100644 apps/desktop/src/main/services/diagnostics/diagnosticReportService.test.ts diff --git a/apps/ade-cli/scripts/install-runtime-rollback.test.mjs b/apps/ade-cli/scripts/install-runtime-rollback.test.mjs index 4b93426c50..d95349d2df 100644 --- a/apps/ade-cli/scripts/install-runtime-rollback.test.mjs +++ b/apps/ade-cli/scripts/install-runtime-rollback.test.mjs @@ -44,6 +44,11 @@ if [ "\$1" = "--version" ]; then if [ -n "\${ADE_TEST_VERSION_LOG:-}" ]; then echo "\$0" >>"\$ADE_TEST_VERSION_LOG" fi + # Records the runtime env the installer handed this check, so a test can + # prove the native modules are not \`dlopen\`ed out of \$TMPDIR either. + if [ -n "\${ADE_TEST_ENV_LOG:-}" ]; then + echo "\$0|\${ADE_RUNTIME_ROOT:-}|\${ADE_RUNTIME_NODE_MODULES:-}|\${NODE_PATH:-}" >>"\$ADE_TEST_ENV_LOG" + fi case "\$0" in */bin/ade) if [ -n "\${ADE_TEST_FAIL_INSTALLED:-}" ]; then @@ -170,6 +175,10 @@ test("a downloaded runtime that cannot start never replaces the installed one", assert.ok(fs.existsSync(path.join(fixture.runtimeDir, "previous-runtime.txt"))); assert.ok(!fs.existsSync(path.join(fixture.runtimeDir, "node_modules"))); assert.ok(!fs.existsSync(path.join(fixture.installDir, "ade.new"))); + // The runtime now stages next to the real one; a failed install must not + // leave either scratch directory under the ADE home. + assert.ok(!fs.existsSync(`${fixture.runtimeDir}.new`)); + assert.ok(!fs.existsSync(`${fixture.runtimeDir}.previous`)); } finally { fixture.cleanup(); } @@ -203,6 +212,59 @@ test("the staged preflight runs the install-directory copy, not the one in TMPDI } }); +// The binary is not the only thing the preflight has to run from an +// executable filesystem: it `dlopen`s the .node modules NODE_PATH points at. +// Staging the runtime archive in $TMPDIR made the binary copy's relocation +// pointless on a `noexec` /tmp, because the native modules were still loaded +// from there. +test("the staged preflight loads native modules from the ADE home, not TMPDIR", () => { + const fixture = makeInstall(); + const envLog = path.join(fixture.adeHome, "version-env.log"); + try { + const result = runInstaller(fixture, { ADE_TEST_ENV_LOG: envLog }); + + assert.equal(result.status, 0, result.stderr); + const rows = fs.readFileSync(envLog, "utf8").split("\n").filter(Boolean); + assert.ok(rows.length >= 2, `expected a staged and a promoted check, got ${rows.length}`); + + // Nothing the installer executed saw a TMPDIR staging path, as the binary + // it ran or anywhere in its runtime env. + for (const row of rows) { + assert.ok(!row.includes("ade-install."), `TMPDIR staging path in: ${row}`); + } + + // The staged preflight runs against the runtime staged under the ADE home, + // one same-filesystem rename away from where it will be promoted; the + // second check runs against the promoted one. NODE_PATH keeps whatever the + // ambient environment had after the runtime entry, so only the first entry + // is this script's. + const parse = (row) => { + const [binary, runtimeRoot, nodeModules, nodePath] = row.split("|"); + return { binary, runtimeRoot, nodeModules, firstNodePath: nodePath.split(":")[0] }; + }; + const staged = parse(rows[0]); + assert.deepEqual(staged, { + binary: path.join(fixture.installDir, "ade.new"), + runtimeRoot: `${fixture.runtimeDir}.new`, + nodeModules: path.join(`${fixture.runtimeDir}.new`, "node_modules"), + firstNodePath: path.join(`${fixture.runtimeDir}.new`, "node_modules"), + }); + const promoted = parse(rows[1]); + assert.deepEqual(promoted, { + binary: path.join(fixture.installDir, "ade"), + runtimeRoot: fixture.runtimeDir, + nodeModules: path.join(fixture.runtimeDir, "node_modules"), + firstNodePath: path.join(fixture.runtimeDir, "node_modules"), + }); + + // Neither staging directory survives a successful install. + assert.ok(!fs.existsSync(`${fixture.runtimeDir}.new`)); + assert.ok(!fs.existsSync(`${fixture.runtimeDir}.previous`)); + } finally { + fixture.cleanup(); + } +}); + test("a staged preflight failure says the existing install was not touched", () => { const fixture = makeInstall(); const versionLog = path.join(fixture.adeHome, "version-checks.log"); @@ -248,6 +310,8 @@ test("a promoted runtime that fails its version check is rolled back", () => { // The rollback copies are scratch state, not something to leave behind. assert.ok(!fs.existsSync(path.join(fixture.installDir, "ade.bak"))); assert.ok(!fs.existsSync(path.join(fixture.installDir, "ade.new"))); + assert.ok(!fs.existsSync(`${fixture.runtimeDir}.new`)); + assert.ok(!fs.existsSync(`${fixture.runtimeDir}.previous`)); const log = fs.readFileSync(path.join(fixture.adeHome, "install-failure.log"), "utf8"); assert.match(log, /installed copy cannot start/); diff --git a/apps/ade-cli/scripts/install-runtime.sh b/apps/ade-cli/scripts/install-runtime.sh index 790cd48aca..889bb7cac6 100644 --- a/apps/ade-cli/scripts/install-runtime.sh +++ b/apps/ade-cli/scripts/install-runtime.sh @@ -449,13 +449,22 @@ downloaded_bytes="$(( ))" staged_binary="$tmp_dir/ade" -staged_runtime_dir="$tmp_dir/runtime" +# The staged runtime, the runtime backup, the staged binary copy and the binary +# backup all live next to what they replace, under the ADE home -- never in +# $TMPDIR. Two reasons, and both have bitten this script: +# - promoting and restoring have to be same-directory renames, which are +# atomic. A `mv` out of $TMPDIR is a copy across filesystems, and a +# half-copied `ade` or runtime is the broken install this block exists to +# prevent; +# - a /tmp mounted `noexec` (common on hardened Linux hosts and in +# containers) makes the preflight fail on every install. The binary is not +# the only thing that has to be executable: it `dlopen`s the .node modules +# NODE_PATH points at, and those come out of the runtime archive. Staging +# the runtime under the ADE home is what makes the preflight test the same +# files the promoted install will load. +staged_runtime_dir="$runtime_dir.new" staged_node_modules="$staged_runtime_dir/node_modules" -backup_runtime_dir="$tmp_dir/runtime.previous" -# Both live next to the real binary, not in $TMPDIR: promoting and restoring -# have to be same-directory renames, which are atomic. A `mv` out of $TMPDIR is -# a copy across filesystems, and a half-copied `ade` is the broken install this -# whole block exists to prevent. +backup_runtime_dir="$runtime_dir.previous" pending_binary="$dest_dir/ade.new" backup_binary="$dest_dir/ade.bak" promoted_runtime=0 @@ -464,7 +473,22 @@ have_backup_binary=0 # a stuck user at it, and a log deleted on the way out points at nothing. install_log="$ade_home/install-failure.log" -trap 'rm -rf "$tmp_dir"; rm -f "$dest_dir/ade.new"' EXIT HUP INT TERM +# Scratch state only: the download, the staged runtime, and the staged binary +# copy. The runtime backup is deleted too, but only after the window in which +# it is the machine's only runtime -- an abort (Ctrl-C, SIGTERM) between moving +# the old runtime aside and moving the new one in would otherwise leave the +# machine with no runtime at all. +cleanup_install_scratch() { + rm -rf "$tmp_dir" + rm -f "$pending_binary" + rm -rf "$staged_runtime_dir" + if [ "$promoted_runtime" -eq 0 ] && [ -e "$backup_runtime_dir" ] && [ ! -e "$runtime_dir" ]; then + mv "$backup_runtime_dir" "$runtime_dir" 2>/dev/null || true + fi + rm -rf "$backup_runtime_dir" +} + +trap 'cleanup_install_scratch' EXIT HUP INT TERM # The runtime sidecar env has to be in place before *any* `--version` check: # the binary loads its native modules through it, so a preflight run without it @@ -541,18 +565,17 @@ mkdir -p "$staged_runtime_dir" tar -xzf "$tmp_dir/native.tar.gz" -C "$staged_runtime_dir" [ -d "$staged_node_modules" ] || die "native dependency archive is missing node_modules" -# The preflight copy lands in $dest_dir, not $TMPDIR. A /tmp mounted `noexec` -# is common on hardened Linux hosts and in containers, and running the staged -# binary from there fails with EACCES on every install -- reported as "the -# runtime could not start", which is a lie about a perfectly good download. -# $dest_dir/ade.new is the scratch name the promotion below renames from, and -# the EXIT trap already removes it, so this costs nothing but the copy. +# The preflight copy lands in $dest_dir, not $TMPDIR -- see the noexec note +# above. $dest_dir/ade.new is the scratch name the promotion below renames +# from, and the EXIT trap already removes it, so this costs nothing but the +# copy. rm -f "$pending_binary" cp "$staged_binary" "$pending_binary" chmod 755 "$pending_binary" # Preflight before anything is promoted: the new binary against the staged -# runtime. A download that cannot even print its version never replaces +# runtime, both under the ADE home so neither is executed or `dlopen`ed out of +# $TMPDIR. A download that cannot even print its version never replaces # $dest_dir/ade, so the previous install is still there and still working. set_runtime_env "$staged_runtime_dir" if ! version_check "$pending_binary" "staged"; then diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 05902a998d..15a4d3f1ce 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -11553,6 +11553,18 @@ describe("unlinkOwnedRuntimeSocket", () => { expect(unlinked).toEqual([]); }); + // A failed unlink used to be reported as "absent", which is the opposite of + // what happened: the socket file is still there, still ours, and the next + // brain will probe it as stale. The caller logs the difference. + it("reports a failed unlink as failed, not absent", () => { + const outcome = unlinkOwnedRuntimeSocket(socketPath, 100n, { + readInode: () => 100n, + unlink: () => { throw new Error("EPERM"); }, + }); + + expect(outcome).toBe("failed"); + }); + it("falls back to the unconditional unlink when no inode was recorded at bind time", () => { const unlinked: string[] = []; const outcome = unlinkOwnedRuntimeSocket(socketPath, null, { diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index c6b46110e5..4effc5db59 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -17892,6 +17892,11 @@ async function runServe( socketPath, reason: "Another process rebound this path; removing it would delete their socket.", }); + } else if (outcome === "failed") { + headlessProjectLogger.warn("brain.socket_unlink_failed", { + socketPath, + reason: "The socket file is still ours and still present; the next brain will probe it as stale.", + }); } } if (syncHostStartupFailure != null) { @@ -17968,26 +17973,6 @@ function isPidAlive(pid: number): boolean { /** How often a bound brain re-checks that it still owns its socket path. */ const BRAIN_SOCKET_OWNERSHIP_POLL_MS = 5_000; -/** - * A unix-domain brain can lose its own endpoint AFTER a successful `listen()`. - * The bind is preceded by a check, not a lock: `existsSync` -> await - * `assertBrainSocketUnowned` -> `unlink` -> `listen`. Two brains that both - * probe the same *stale* socket file race across that await — the first - * unlinks and binds inode X, the second (whose probe predates that bind) then - * unlinks the path->X link and binds a fresh inode Y. Neither sees - * `EADDRINUSE`, so the guard around `listen` never fires, and the first brain - * lives on listening to an inode nothing can reach: the PR #949 zombie. - * - * The sync-host loop's `abortIf` used to catch this incidentally, because the - * loser was still inside that loop when the socket went live. Binding before - * the sync host removed that accident, so make the check explicit: remember - * the inode we bound and end the brain if the path stops pointing at it. The - * supervisor then restarts us, and the restarted brain finds the rival's live - * socket and reports `socket_owned_by_other` instead of squatting silently. - * - * Windows named pipes are exempt: a pipe name is a kernel object with no - * directory entry to steal, and a bound pipe can never be probed as stale. - */ /** Inode of a socket path, or null when it is gone or unreadable. */ export function readRuntimeSocketInode(target: string): bigint | null { try { @@ -18016,7 +18001,7 @@ export function unlinkOwnedRuntimeSocket( readInode?: (target: string) => bigint | null; unlink?: (target: string) => void; } = {}, -): "unlinked" | "not_owned" | "absent" { +): "unlinked" | "not_owned" | "absent" | "failed" { if (isAdeRuntimeNamedPipePath(socketPath)) return "not_owned"; const readInode = deps.readInode ?? readRuntimeSocketInode; const unlink = deps.unlink ?? ((target: string) => fs.unlinkSync(target)); @@ -18026,11 +18011,34 @@ export function unlinkOwnedRuntimeSocket( try { unlink(socketPath); } catch { - return "absent"; + // Distinct from "absent": the socket file is still there and still ours, + // so the next brain to start will probe a stale path we failed to clean up. + return "failed"; } return "unlinked"; } +/** + * A unix-domain brain can lose its own endpoint AFTER a successful `listen()`. + * The bind is preceded by a check, not a lock: `existsSync` -> await + * `assertBrainSocketUnowned` -> `unlink` -> `listen`. Two brains that both + * probe the same *stale* socket file race across that await — the first + * unlinks and binds inode X, the second (whose probe predates that bind) then + * unlinks the path->X link and binds a fresh inode Y. Neither sees + * `EADDRINUSE`, so the guard around `listen` never fires, and the first brain + * lives on listening to an inode nothing can reach: the PR #949 zombie. + * + * The sync-host loop's socket-liveness abort used to catch this incidentally, + * because the loser was still inside that loop when the socket went live. + * Binding before the sync host removed that accident, so make the check + * explicit: remember the inode we bound and end the brain if the path stops + * pointing at it. The supervisor then restarts us, and the restarted brain + * finds the rival's live socket and reports `socket_owned_by_other` instead of + * squatting silently. + * + * Windows named pipes are exempt: a pipe name is a kernel object with no + * directory entry to steal, and a bound pipe can never be probed as stale. + */ export function monitorBrainSocketOwnership( socketPath: string, onLost: (reason: "removed" | "replaced") => void, diff --git a/apps/ade-cli/src/serviceManager/installWindows.ts b/apps/ade-cli/src/serviceManager/installWindows.ts index dee1005020..606e7d3406 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.ts @@ -966,8 +966,9 @@ async function installWindowsServiceImpl( readinessProbe: deps.readinessProbe ?? defaultWindowsRuntimeReadiness, // Shorter than the POSIX budget on purpose: the Windows install // already spends several PowerShell round-trips before this wait, and the - // desktop bounds the whole child at 60s. A supervised brain that is not - // ready by then is reported as `starting`, not as a failure. + // whole child is bounded by `RUNTIME_SERVICE_START_WAIT_MS` (90s). A + // supervised brain that is not ready by then is reported as `starting`, + // not as a failure. timeoutMs: deps.handoverTimeoutMs ?? WINDOWS_HANDOVER_TIMEOUT_MS, pollMs: deps.handoverPollMs ?? 100, sleep: deps.sleep, diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 7d1c031588..078497de67 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -47,6 +47,7 @@ import { selectWindowForProjectNavigation, } from "./services/deeplinks/projectNavigationWindowSelection"; import { registerIpc } from "./services/ipc/registerIpc"; +import { AttemptedProjectRoots } from "./services/ipc/knownProjectRoots"; import { createFileLogger } from "./services/logging/logger"; import { createProductAnalyticsService, @@ -507,6 +508,14 @@ const defaultEnabledBackgroundTaskFlags = new Set([ // a burst of near-simultaneous opens from each passing the cap before any has // begun. // --------------------------------------------------------------------------- +/** + * Every project open/switch attempt is recorded here, successful or not, so the + * recovery and diagnostics IPC handlers can accept the root the user just + * picked even when its first open failed before it could reach the + * recent-projects list. See `services/ipc/knownProjectRoots.ts`. + */ +const attemptedProjectRoots = new AttemptedProjectRoots(); + const RECONCILE_GLOBAL_MAX = 1; let reconcileActiveOrScheduled = 0; const pendingReconciles: Array<() => void> = []; @@ -5847,6 +5856,10 @@ app.whenReady().then(async () => { const switchProjectFromDialog = async ( selectedPath: string, ): Promise => { + // Recorded before anything can fail: a first open that fails never reaches + // the recent-projects list, and the recovery screen it puts on screen asks + // main to diagnose/repair this exact root. + attemptedProjectRoots.record(selectedPath); const startedAt = Date.now(); const windowId = currentIpcWindowId(); let repoRoot: string | null = null; @@ -7528,6 +7541,7 @@ app.whenReady().then(async () => { createWindow: openAdeWindow, closeWindow: closeAdeWindow, switchProjectFromDialog, + attemptedProjectRoots, closeCurrentProject, closeProjectByPath, globalStatePath, diff --git a/apps/desktop/src/main/services/diagnostics/diagnosticReportService.test.ts b/apps/desktop/src/main/services/diagnostics/diagnosticReportService.test.ts new file mode 100644 index 0000000000..295e3f6f01 --- /dev/null +++ b/apps/desktop/src/main/services/diagnostics/diagnosticReportService.test.ts @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { collectDiagnosticReport } from "./diagnosticReportService"; + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-diag-report-")); + +afterAll(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +function deps() { + return { + appVersion: "1.2.3", + packageChannel: null, + isPackaged: false, + userDataPath: path.join(tempRoot, "userData"), + reportsDir: path.join(tempRoot, "reports"), + installId: "install-abc", + }; +} + +describe("collectDiagnosticReport", () => { + // Regression: when the renderer named a project root main did not recognise, + // the handler silently substituted the currently open project, so a report + // about a failed open carried a different project's logs and diagnosis. It + // now degrades to machine-level state — and has to SAY so, or the reader + // draws conclusions from an absence they were never told about. + it("renders caller notes about a degraded report alongside the machine ones", async () => { + const { report } = await collectDiagnosticReport(deps(), { + surface: "project_recovery", + projectRoot: null, + extraNotes: ["requested project root was not recognised; machine-level state only"], + }); + + expect(report).toContain("## Notes"); + expect(report).toContain("- requested project root was not recognised; machine-level state only"); + }); + + it("omits the notes line when there is nothing to say", async () => { + const { report } = await collectDiagnosticReport(deps(), { + surface: "project_recovery", + projectRoot: null, + }); + + expect(report).not.toContain("requested project root was not recognised"); + }); +}); diff --git a/apps/desktop/src/main/services/diagnostics/diagnosticReportService.ts b/apps/desktop/src/main/services/diagnostics/diagnosticReportService.ts index 2eaacffd10..7c9af9e151 100644 --- a/apps/desktop/src/main/services/diagnostics/diagnosticReportService.ts +++ b/apps/desktop/src/main/services/diagnostics/diagnosticReportService.ts @@ -27,6 +27,12 @@ export { export type DiagnosticReportRequest = DiagnosticReportContext & { /** Verbatim `UpdateTransactionResult` (or anything JSON) from the caller. */ updateTransaction?: unknown; + /** + * Caller-supplied notes appended to the machine-collected ones — how a + * handler explains a degraded report, e.g. a project root it could not + * recognise and therefore did not collect project state for. + */ + extraNotes?: readonly string[]; }; export type DiagnosticReportDeps = { @@ -177,7 +183,7 @@ export async function collectDiagnosticReport( }, storage: sources.storage, logs, - notes: sources.notes, + notes: [...sources.notes, ...(request.extraNotes ?? [])], redaction, }); diff --git a/apps/desktop/src/main/services/ipc/knownProjectRoots.test.ts b/apps/desktop/src/main/services/ipc/knownProjectRoots.test.ts index 4bd78c8594..01f0dfe7ba 100644 --- a/apps/desktop/src/main/services/ipc/knownProjectRoots.test.ts +++ b/apps/desktop/src/main/services/ipc/knownProjectRoots.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterAll, describe, expect, it } from "vitest"; -import { resolveKnownProjectRoot } from "./knownProjectRoots"; +import { AttemptedProjectRoots, resolveKnownProjectRoot } from "./knownProjectRoots"; const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-known-roots-")); const openProject = path.join(tempRoot, "open-project"); @@ -58,6 +58,31 @@ describe("resolveKnownProjectRoot", () => { expect(resolveKnownProjectRoot(openProject, { openProjectRoot: null, recentProjectRoots: [] })).toBeNull(); }); + // Regression: the recovery screen is put on screen BY a failed open, and a + // folder whose first open failed is never written to the recent-projects + // list (that write only happens after a successful init). Refusing it made + // Repair a dead end on exactly the folder it exists for. + it("accepts a root that only ever failed to open", () => { + expect(resolveKnownProjectRoot(stranger, sources)).toBeNull(); + expect( + resolveKnownProjectRoot(stranger, { ...sources, attemptedProjectRoots: [stranger] }), + ).toBe(stranger); + // Still nothing else: the widening is one folder, not the filesystem. + expect( + resolveKnownProjectRoot(tempRoot, { ...sources, attemptedProjectRoots: [stranger] }), + ).toBeNull(); + }); + + it("folds case on win32 so a drive-letter mismatch is not a rejection", () => { + const known = String.raw`C:\Users\ada\project`; + const requested = String.raw`c:\users\ada\project`; + expect(resolveKnownProjectRoot(requested, { openProjectRoot: known }, "win32")).toBe(known); + expect(resolveKnownProjectRoot(requested, { openProjectRoot: known }, "linux")).toBeNull(); + expect( + resolveKnownProjectRoot(String.raw`C:\Users\ada\other`, { openProjectRoot: known }, "win32"), + ).toBeNull(); + }); + it("resolves a symlink to a known project", () => { const link = path.join(tempRoot, "link-to-open"); try { @@ -68,3 +93,36 @@ describe("resolveKnownProjectRoot", () => { expect(resolveKnownProjectRoot(link, sources)).toBe(openProject); }); }); + +describe("AttemptedProjectRoots", () => { + it("remembers a root until it expires", () => { + let now = 1_000; + const roots = new AttemptedProjectRoots(10, 500, () => now); + roots.record("/a"); + expect(roots.list()).toEqual(["/a"]); + now += 499; + expect(roots.list()).toEqual(["/a"]); + now += 2; + expect(roots.list()).toEqual([]); + }); + + it("keeps only the newest entries and re-ages a repeat attempt", () => { + let now = 1_000; + const roots = new AttemptedProjectRoots(2, 10_000, () => now); + roots.record("/a"); + roots.record("/b"); + // Re-recording /a moves it to the newest slot, so /b is the one evicted. + roots.record("/a"); + roots.record("/c"); + expect(roots.list()).toEqual(["/a", "/c"]); + }); + + it("ignores empty input", () => { + const roots = new AttemptedProjectRoots(); + roots.record(""); + roots.record(" "); + roots.record(null); + roots.record(undefined); + expect(roots.list()).toEqual([]); + }); +}); diff --git a/apps/desktop/src/main/services/ipc/knownProjectRoots.ts b/apps/desktop/src/main/services/ipc/knownProjectRoots.ts index d065c917bd..7a70a87080 100644 --- a/apps/desktop/src/main/services/ipc/knownProjectRoots.ts +++ b/apps/desktop/src/main/services/ipc/knownProjectRoots.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { pathsEqual } from "../shared/pathCompare"; /** * Validation for renderer-supplied project roots. @@ -10,7 +11,12 @@ import path from "node:path"; * buggy renderer could point either at any directory on the machine. * * The rule is that a renderer may only name a project main already knows — - * the one that is open, or one in the recent-projects list. + * the one that is open, one in the recent-projects list, or one main itself + * just tried to open. That last source is what keeps the recovery screen + * working: a folder whose FIRST open failed (disk full, db integrity, brain + * not installed) never reaches the recent-projects list, because that list is + * only written after a successful init. Without it, the one root the recovery + * screen exists to repair is the one root it would be refused. */ export type KnownProjectRootSources = { @@ -18,12 +24,19 @@ export type KnownProjectRootSources = { openProjectRoot?: string | null; /** Local entries from the recent-projects list; remote ones have no root. */ recentProjectRoots?: readonly (string | null | undefined)[]; + /** + * Roots main recently attempted to open, successfully or not. See + * {@link AttemptedProjectRoots}. + */ + attemptedProjectRoots?: readonly (string | null | undefined)[]; }; /** - * Best effort by design: a project on a volume that is not mounted right now - * still resolves through `path.resolve` alone, and refusing it would be a - * regression on a path that is otherwise legitimate. + * Resolves symlinks when it can, and falls back to `path.resolve` when it + * cannot — a project on a volume that is not mounted right now has no + * realpath, and refusing it would be a regression on a path that is otherwise + * legitimate. The result is therefore normalized, not guaranteed canonical, so + * comparisons still have to go through {@link pathsEqual} for case folding. */ export function canonicalProjectPath(value: string): string { const resolved = path.resolve(value); @@ -34,14 +47,62 @@ export function canonicalProjectPath(value: string): string { } } +/** + * Bounded, expiring record of the roots main has tried to open. + * + * Bounded and expiring because it widens what a renderer may name: an entry is + * a directory the user themselves picked moments ago, and it stops being one + * shortly after. Insertion order is the eviction order, with a re-attempt + * moving its root back to the newest slot. + */ +export class AttemptedProjectRoots { + private readonly entries = new Map(); + + constructor( + private readonly limit = 10, + private readonly ttlMs = 30 * 60 * 1_000, + private readonly now: () => number = Date.now, + ) {} + + /** Records an open/switch attempt for `root`. Ignores empty input. */ + record(root: string | null | undefined): void { + const trimmed = typeof root === "string" ? root.trim() : ""; + if (!trimmed) return; + // Delete first so a re-attempt moves to the end of the insertion order + // rather than keeping its original (about-to-be-evicted) slot. + this.entries.delete(trimmed); + this.entries.set(trimmed, this.now()); + this.prune(); + while (this.entries.size > this.limit) { + const oldest = this.entries.keys().next(); + if (oldest.done) break; + this.entries.delete(oldest.value); + } + } + + /** The still-live attempts, oldest first. */ + list(): string[] { + this.prune(); + return [...this.entries.keys()]; + } + + private prune(): void { + const cutoff = this.now() - this.ttlMs; + for (const [root, at] of this.entries) { + if (at <= cutoff) this.entries.delete(root); + } + } +} + /** * Returns the known root that `requested` refers to — in the registry's own - * spelling, so the rest of the flow works with a canonical path — or null when + * spelling, so the rest of the flow works with a normalized path — or null when * it is not a project this machine knows about. */ export function resolveKnownProjectRoot( requested: string | null | undefined, sources: KnownProjectRootSources, + platform: NodeJS.Platform = process.platform, ): string | null { const trimmed = typeof requested === "string" ? requested.trim() : ""; if (!trimmed) return null; @@ -49,8 +110,14 @@ export function resolveKnownProjectRoot( const known: string[] = []; const openRoot = sources.openProjectRoot?.trim(); if (openRoot) known.push(openRoot); - for (const root of sources.recentProjectRoots ?? []) { - if (typeof root === "string" && root.trim()) known.push(root); + for (const list of [sources.recentProjectRoots, sources.attemptedProjectRoots]) { + for (const root of list ?? []) { + if (typeof root === "string" && root.trim()) known.push(root); + } } - return known.find((candidate) => canonicalProjectPath(candidate) === target) ?? null; + // `pathsEqual` rather than `===`: realpath only case-normalizes a path that + // exists, so on Windows and macOS two spellings of the same live directory + // still differ whenever either side skipped the realpath (unmounted volume, + // permission error) — and a case-only mismatch would read as "unknown". + return known.find((candidate) => pathsEqual(canonicalProjectPath(candidate), target, platform)) ?? null; } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 4dd59ffee7..733f455a1d 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -20,6 +20,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { IPC } from "../../../shared/ipc"; import { resolveKnownProjectRoot } from "./knownProjectRoots"; +import type { AttemptedProjectRoots } from "./knownProjectRoots"; import { redactIpcArgsForChannel } from "./ipcChannelRedaction"; import type { AttentionItem, @@ -1657,6 +1658,7 @@ export function registerIpc({ openAttentionItem, getCurrentAccountOwnerId, accountAttentionClient, + attemptedProjectRoots, }: { getCtx: () => AppContext; getResourceUsageContexts?: () => AppContext[]; @@ -1679,6 +1681,12 @@ export function registerIpc({ createWindow?: (args?: { projectRoot?: string | null }) => Promise<{ windowId: number | null; project: ProjectInfo | null }>; closeWindow?: (windowId: number | null) => Promise<{ closed: boolean }>; switchProjectFromDialog: (selectedPath: string) => Promise; + /** + * Roots main has tried to open. Owned by main.ts so every open path records + * into the same registry; see `knownProjectRoots.ts` for why a merely + * *attempted* root has to count as known. + */ + attemptedProjectRoots?: AttemptedProjectRoots; closeCurrentProject: () => Promise; closeProjectByPath: (projectRoot: string) => Promise; globalStatePath: string; @@ -1882,6 +1890,10 @@ export function registerIpc({ // custom properties from thrown errors, so we re-throw with the code // prepended to the message. Renderer matches on the prefix. const surfaceCodedError = (error: unknown, meta?: { rootPath?: string }): never => { + // A coded failure carrying a root is exactly what puts the recovery screen + // on screen for that root, so remember it even if this path never reached + // `switchProjectFromDialog`. + if (meta?.rootPath) attemptedProjectRoots?.record(meta.rootPath); if (error instanceof Error) { const code = (error as Error & { code?: unknown }).code; if (typeof code === "string" && code.length > 0) { @@ -4574,7 +4586,8 @@ export function registerIpc({ /** * The renderer names a project root; main decides whether that is a project - * it knows. See `knownProjectRoots.ts` for why trimming is not enough. + * it knows. See `knownProjectRoots.ts` for why trimming is not enough, and + * why a root that only ever FAILED to open still counts as known. */ const resolveRequestedProjectRoot = (requested: string): string | null => { let recentProjectRoots: string[] = []; @@ -4590,6 +4603,7 @@ export function registerIpc({ return resolveKnownProjectRoot(requested, { openProjectRoot: getCtx().project.rootPath, recentProjectRoots, + attemptedProjectRoots: attemptedProjectRoots?.list(), }); }; @@ -4631,11 +4645,16 @@ export function registerIpc({ ) => { const surface = typeof arg?.surface === "string" && arg.surface.trim() ? arg.surface.trim() : "unknown"; const requestedRoot = typeof arg?.projectRoot === "string" ? arg.projectRoot.trim() : ""; - // An unknown root falls back to the open project rather than failing: the - // point of this handler is that someone can always file an issue. - const projectRoot = (requestedRoot ? resolveRequestedProjectRoot(requestedRoot) : null) - ?? getCtx().project.rootPath - ?? null; + const resolvedRoot = requestedRoot ? resolveRequestedProjectRoot(requestedRoot) : null; + // A root the renderer named but main does not recognise is dropped rather + // than quietly swapped for the currently open project: substituting one + // would put another project's logs, volumes and recovery diagnosis under a + // report about this failure. Reporting stays possible either way — the + // report degrades to machine-level state and says so. + const rootWasRejected = Boolean(requestedRoot) && !resolvedRoot; + const projectRoot = rootWasRejected + ? null + : resolvedRoot ?? getCtx().project.rootPath ?? null; return await collectDiagnosticReport( { appVersion: app.getVersion(), @@ -4657,6 +4676,9 @@ export function registerIpc({ code: typeof arg?.code === "string" ? arg.code.slice(0, 120) : null, technicalDetail: typeof arg?.technicalDetail === "string" ? arg.technicalDetail.slice(0, 16_000) : null, projectRoot, + extraNotes: rootWasRejected + ? ["requested project root was not recognised; machine-level state only"] + : undefined, }, ); }; diff --git a/apps/desktop/src/renderer/components/app/errorSurfaceKit.tsx b/apps/desktop/src/renderer/components/app/errorSurfaceKit.tsx index 3ba89f7fd9..a1ae51d130 100644 --- a/apps/desktop/src/renderer/components/app/errorSurfaceKit.tsx +++ b/apps/desktop/src/renderer/components/app/errorSurfaceKit.tsx @@ -40,7 +40,7 @@ export const ERROR_DISCLOSURE_CARET = ( ); /** The card every full-screen failure state sits in. */ -export const ERROR_CARD = +const ERROR_CARD = "rounded-2xl border border-border/70 bg-fg/[0.02] px-6 py-5 shadow-[0_1px_0_0_rgba(255,255,255,0.03)_inset]"; /** The headline every failure card leads with. */ diff --git a/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.tsx b/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.tsx index 236e98635b..df72adade4 100644 --- a/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.tsx +++ b/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.tsx @@ -271,7 +271,7 @@ export function StorageCleanupDialog({ // drag a settled dialog back to "error". const requestRef = React.useRef(0); - const loadPreview = React.useCallback(() => { + const loadPreview = React.useCallback((): Promise => { const { targets: openTargets } = initRef.current; const requestId = ++requestRef.current; setResult(null); @@ -282,17 +282,15 @@ export function StorageCleanupDialog({ return window.ade.storage .cleanupPreview(openTargets) .then((next) => { - if (requestRef.current !== requestId) return false; + if (requestRef.current !== requestId) return; setPreview(next); setStage("review"); - return true; }) .catch((err: unknown) => { - if (requestRef.current !== requestId) return false; + if (requestRef.current !== requestId) return; setError(err instanceof Error ? err.message : String(err)); setErrorPhase("checking"); setStage("error"); - return false; }); }, []); diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index cd62e74e6e..71132ada9c 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -79,7 +79,6 @@ export const IPC = { recoveryRepair: "ade.recovery.repair", /** Main → renderer: one repair step as it finishes, so a long repair reads live. */ recoveryRepairStep: "ade.recovery.repairStep", - /** Assemble the redacted diagnostic report without acting on it. */ /** Assemble, save, copy to the clipboard, and open a prefilled GitHub issue. */ diagnosticsOpenIssue: "ade.diagnostics.openIssue", projectForgetRecent: "ade.project.forgetRecent", diff --git a/docs/features/storage-and-recovery/README.md b/docs/features/storage-and-recovery/README.md index b1d77aacc9..7a29de45c3 100644 --- a/docs/features/storage-and-recovery/README.md +++ b/docs/features/storage-and-recovery/README.md @@ -105,6 +105,16 @@ stays behind disclosure. `projectRecoveryService` does not depend on a healthy brain, so it can validate and repair the database that prevented the brain from starting. +Both `recovery.diagnose` and `recovery.repair` validate the root the renderer +names before acting on it (`apps/desktop/src/main/services/ipc/knownProjectRoots.ts`): +a renderer may only name the open project, a local recent-projects entry, or a +root main itself recently attempted to open. That last source is what keeps the +recovery screen working — a folder whose *first* open failed never reaches the +recent-projects list, so main records every open attempt in a bounded, expiring +registry and treats those roots as known. Diagnostics applies the same rule; a +root it cannot place is dropped rather than swapped for the open project, and +the report says it carries machine-level state only. + The repair sequence stops at the first unsafe step. It checks free space, establishes exclusive ownership, runs `quick_check`, opens the database so the pre-migration recovery pass can resolve staging, restarts the service, verifies From caea5b183c0e154d05f68267c633a3618b5943da Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:02:15 -0400 Subject: [PATCH 5/9] quality (round 3): Windows installer stages/preflights/promotes under the ADE home, attempted-roots recorded only after repo resolution, ENOENT unlink is absent, single-writer attempted-roots registry Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 11 ++ apps/ade-cli/scripts/install-runtime.ps1 | 131 ++++++++++++++++-- apps/ade-cli/src/cli.test.ts | 13 ++ apps/ade-cli/src/cli.ts | 7 +- .../scripts/windows-release-contract.test.mjs | 76 ++++++++++ apps/desktop/src/main/main.ts | 16 ++- .../src/main/services/ipc/registerIpc.ts | 16 ++- 7 files changed, 245 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2140b7f54..01f3118806 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -518,6 +518,17 @@ jobs: src/lib/trustedWindowsTools.test.ts src/services/credentials/credentialStore.test.ts + # The standalone PowerShell installer (apps/ade-cli/scripts/install-runtime.ps1) + # has no runnable harness off a Windows host: its POSIX twin is covered by + # `node --test apps/ade-cli/scripts/install-runtime-rollback.test.mjs` on + # the ubuntu job, but nothing there can parse or execute PowerShell. This + # suite does both on a real runner -- it parses the installer through + # `[Parser]::ParseFile` and exercises the path normalizer it shares with + # the uninstall cleanup -- so a syntax error or a broken staging path in + # the installer fails CI instead of a user's machine. + - name: Test Windows standalone installer and uninstall cleanup scripts + run: cd apps/desktop && node --test ./scripts/windows-uninstall-cleanup.test.mjs + - name: Test Windows desktop, SQLite, and capability contracts run: cd apps/desktop && npx vitest run src/main/packagedRuntimeSmoke.test.ts src/main/services/computerUse/localComputerUse.test.ts src/renderer/lib/platform.test.ts diff --git a/apps/ade-cli/scripts/install-runtime.ps1 b/apps/ade-cli/scripts/install-runtime.ps1 index ac5d6f2b86..7908dac1e4 100644 --- a/apps/ade-cli/scripts/install-runtime.ps1 +++ b/apps/ade-cli/scripts/install-runtime.ps1 @@ -327,13 +327,29 @@ $binaryAsset = "ade-$target.exe" $nativeAsset = "ade-$target.native.tar.gz" $runtimeDir = Join-Path $AdeHome "runtime\$target" $destinationBinary = Join-Path $InstallDir "ade.exe" +# $TEMP holds downloads and nothing else. The staged runtime, the runtime +# backup, the staged binary copy and the binary backup all live next to what +# they replace, under the ADE home. Two reasons, and both have bitten the POSIX +# installer this now mirrors (scripts/install-runtime.sh): +# - promoting and restoring have to be same-directory renames. `Move-Item` +# across volumes is a copy plus a delete, and %TEMP% is routinely on a +# different volume from the user profile (redirected TEMP, a RAM disk, a +# roaming profile), so a half-copied ade.exe or runtime tree is the broken +# install this block exists to prevent; +# - AppLocker and most EDR agents block execution out of %TEMP% outright, so +# a preflight staged there fails on every managed corporate machine. The +# binary is not the only thing that has to be executable: it loads the +# .node modules NODE_PATH points at, and those come out of the runtime +# archive. Staging the runtime under the ADE home is what makes the +# preflight test the same files the promoted install will load. $tempRoot = Join-Path ([IO.Path]::GetTempPath()) ("ade-install-" + [Guid]::NewGuid().ToString("N")) -$stagedBinary = Join-Path $tempRoot "ade.exe" +$downloadedBinary = Join-Path $tempRoot "ade.exe" $stagedArchive = Join-Path $tempRoot $nativeAsset $checksumManifest = Join-Path $tempRoot "SHA256SUMS" -$stagedRuntime = Join-Path $tempRoot "runtime" -$backupBinary = Join-Path $tempRoot "ade.previous.exe" -$backupRuntime = Join-Path $tempRoot "runtime.previous" +$stagedRuntime = "$runtimeDir.new" +$backupRuntime = "$runtimeDir.previous" +$pendingBinary = Join-Path $InstallDir "ade.new.exe" +$backupBinary = Join-Path $InstallDir "ade.bak.exe" $script:PreviousNodePath = $env:NODE_PATH $previousEnvironment = @{ ADE_HOME = $env:ADE_HOME @@ -348,16 +364,61 @@ $promotedBinary = $false $promotedRuntime = $false $preserveTempForRecovery = $false $installSucceeded = $false +# "staged" until the new binary is promoted, "installed" afterwards. The two +# stages leave the machine in different states, and a message that describes +# the wrong one sends a user looking for damage that is not there. +$installStage = "staged" + +# Plain, stage-aware note about what is on disk after a failed install. Mirrors +# `die_runtime_unusable` in scripts/install-runtime.sh. +function Write-AdeInstallStateNote( + [string]$Stage, + [bool]$RestoredPreviousBinary, + [string]$BinaryPath +) { + Clear-AdeActiveLine + if ($Stage -eq "staged") { + if (Test-Path -LiteralPath $BinaryPath -PathType Leaf) { + [Console]::Error.WriteLine("ade install: your existing ADE at $BinaryPath was not touched.") + } else { + [Console]::Error.WriteLine("ade install: nothing was left installed at $BinaryPath.") + } + } elseif ($RestoredPreviousBinary) { + [Console]::Error.WriteLine("ade install: the ADE you already had was put back, so nothing is broken.") + } else { + [Console]::Error.WriteLine("ade install: nothing was left installed at $BinaryPath.") + } + [Console]::Error.WriteLine( + "ade install: next: run the installer again; if it fails the same way, open an issue at https://github.com/$Repo/issues with the error below.") +} try { - New-Item -ItemType Directory -Force -Path $tempRoot, $stagedRuntime | Out-Null + # The install dir and the runtime's parent are created up front now: the + # staged runtime and the staged binary copy live inside them, not in %TEMP%. + New-Item -ItemType Directory -Force -Path $InstallDir, (Split-Path $runtimeDir -Parent), $tempRoot | Out-Null + # Leftovers from an install that was killed before its cleanup ran. A backup + # is put back before it is deleted: if the earlier run died between the two + # renames, that backup is the machine's only copy, and deleting it outright + # is how a retry would turn a recoverable abort into no install at all. + Remove-Item -LiteralPath $stagedRuntime -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $pendingBinary -Force -ErrorAction SilentlyContinue + if ((Test-Path -LiteralPath $backupRuntime) -and -not (Test-Path -LiteralPath $runtimeDir)) { + Move-Item -LiteralPath $backupRuntime -Destination $runtimeDir -Force -ErrorAction SilentlyContinue + } + if ((Test-Path -LiteralPath $backupBinary -PathType Leaf) -and + -not (Test-Path -LiteralPath $destinationBinary -PathType Leaf)) { + Move-Item -LiteralPath $backupBinary -Destination $destinationBinary -Force -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath $backupRuntime -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $backupBinary -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $stagedRuntime | Out-Null Write-AdeBanner Write-Host " Installing ADE to $AdeHome" Write-Host "" - Download-Asset $binaryAsset $stagedBinary "ADE runtime" + Download-Asset $binaryAsset $downloadedBinary "ADE runtime" Download-Asset $nativeAsset $stagedArchive "Native dependencies" Download-Asset "SHA256SUMS" $checksumManifest - Verify-Checksum $checksumManifest $binaryAsset $stagedBinary + Verify-Checksum $checksumManifest $binaryAsset $downloadedBinary Verify-Checksum $checksumManifest $nativeAsset $stagedArchive & tar.exe -xzf $stagedArchive -C $stagedRuntime @@ -366,8 +427,17 @@ try { Fail "native dependency archive is missing node_modules" } + # The preflight copy lands in $InstallDir, not %TEMP% -- see the note above. + # ade.new.exe is the scratch name the promotion below renames from, and the + # finally already removes it, so this costs nothing but the copy. + Copy-Item -LiteralPath $downloadedBinary -Destination $pendingBinary -Force + + # Preflight before anything is promoted: the new binary against the staged + # runtime, both under the ADE home. A download that cannot even print its + # version never replaces ade.exe, so the previous install is still there and + # still working. Set-ProcessRuntimeEnvironment $AdeHome $stagedRuntime - & $stagedBinary --version | Out-Null + & $pendingBinary --version | Out-Null if ($LASTEXITCODE -ne 0) { Fail "downloaded ADE runtime failed its version check" } if ((Test-Path -LiteralPath $destinationBinary -PathType Leaf) -and -not $NoService) { @@ -393,17 +463,23 @@ try { } } - New-Item -ItemType Directory -Force -Path $InstallDir, (Split-Path $runtimeDir -Parent) | Out-Null - if (Test-Path -LiteralPath $destinationBinary -PathType Leaf) { - Move-Item -LiteralPath $destinationBinary -Destination $backupBinary - } + # Every move below is a same-directory rename: $runtimeDir.new/.previous sit + # beside $runtimeDir, and ade.new.exe/ade.bak.exe beside ade.exe. Nothing + # crosses a volume, so nothing can be half-copied. if (Test-Path -LiteralPath $runtimeDir) { Move-Item -LiteralPath $runtimeDir -Destination $backupRuntime } Move-Item -LiteralPath $stagedRuntime -Destination $runtimeDir $promotedRuntime = $true - Move-Item -LiteralPath $stagedBinary -Destination $destinationBinary + # Promote the binary last, and only by rename -- of the very copy the + # preflight above just ran, so a truncated write can never be what ends up at + # ade.exe. + if (Test-Path -LiteralPath $destinationBinary -PathType Leaf) { + Move-Item -LiteralPath $destinationBinary -Destination $backupBinary + } + Move-Item -LiteralPath $pendingBinary -Destination $destinationBinary $promotedBinary = $true + $installStage = "installed" Set-ProcessRuntimeEnvironment $AdeHome $runtimeDir & $destinationBinary --version | Out-Null @@ -434,10 +510,12 @@ try { if ($LASTEXITCODE -ne 0) { $rollbackErrors.Add("new brain service cleanup exited with code $LASTEXITCODE") } } catch { $rollbackErrors.Add("new brain service cleanup failed: $($_.Exception.Message)") } } + $restoredPreviousBinary = $false try { if ($promotedBinary) { Remove-Item -LiteralPath $destinationBinary -Force -ErrorAction Stop } if (Test-Path -LiteralPath $backupBinary -PathType Leaf) { Move-Item -LiteralPath $backupBinary -Destination $destinationBinary -Force -ErrorAction Stop + $restoredPreviousBinary = $true } } catch { $rollbackErrors.Add("binary restore failed: $($_.Exception.Message)") } try { @@ -463,15 +541,40 @@ try { } if ($rollbackErrors.Count -gt 0) { $preserveTempForRecovery = $true - throw "ADE runtime install failed ($($installError.Exception.Message)); rollback also failed: $($rollbackErrors -join '; '). Recovery files were retained at $tempRoot" + Write-AdeInstallStateNote $installStage $restoredPreviousBinary $destinationBinary + throw "ADE runtime install failed ($($installError.Exception.Message)); rollback also failed: $($rollbackErrors -join '; '). Recovery files were retained at $tempRoot, $backupBinary and $backupRuntime" } + Write-AdeInstallStateNote $installStage $restoredPreviousBinary $destinationBinary throw $installError } finally { foreach ($name in $previousEnvironment.Keys) { [Environment]::SetEnvironmentVariable($name, $previousEnvironment[$name], "Process") } if (-not $preserveTempForRecovery) { + # Scratch state only: the downloads, the staged runtime and the staged + # binary copy. The two backups go too, but only after the window in which + # either is the machine's only copy -- an abort (Ctrl-C, a throw between + # the two renames) between moving the old one aside and moving the new one + # in would otherwise leave the machine with nothing installed. Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $pendingBinary -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $stagedRuntime -Recurse -Force -ErrorAction SilentlyContinue + if (-not $promotedRuntime -and + (Test-Path -LiteralPath $backupRuntime) -and + -not (Test-Path -LiteralPath $runtimeDir)) { + try { + Move-Item -LiteralPath $backupRuntime -Destination $runtimeDir -Force -ErrorAction Stop + } catch {} + } + if (-not $promotedBinary -and + (Test-Path -LiteralPath $backupBinary -PathType Leaf) -and + -not (Test-Path -LiteralPath $destinationBinary -PathType Leaf)) { + try { + Move-Item -LiteralPath $backupBinary -Destination $destinationBinary -Force -ErrorAction Stop + } catch {} + } + Remove-Item -LiteralPath $backupRuntime -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $backupBinary -Force -ErrorAction SilentlyContinue } } diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 15a4d3f1ce..a2abfd2dcc 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -11565,6 +11565,19 @@ describe("unlinkOwnedRuntimeSocket", () => { expect(outcome).toBe("failed"); }); + // ENOENT between the stat and the unlink is the one failure that is not a + // failure: the path is gone, which is exactly what we were trying to do. + it("reports an unlink that lost the race to ENOENT as absent", () => { + const outcome = unlinkOwnedRuntimeSocket(socketPath, 100n, { + readInode: () => 100n, + unlink: () => { + throw Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }); + }, + }); + + expect(outcome).toBe("absent"); + }); + it("falls back to the unconditional unlink when no inode was recorded at bind time", () => { const unlinked: string[] = []; const outcome = unlinkOwnedRuntimeSocket(socketPath, null, { diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 4effc5db59..c824fc92eb 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -18010,7 +18010,12 @@ export function unlinkOwnedRuntimeSocket( if (ownInode != null && current !== ownInode) return "not_owned"; try { unlink(socketPath); - } catch { + } catch (error) { + // ENOENT means someone else removed it between our stat and our unlink: + // the path is gone, which is the outcome "absent" describes and the + // outcome we wanted. Reporting it as "failed" would send the caller + // looking for a stale socket that does not exist. + if ((error as NodeJS.ErrnoException | null)?.code === "ENOENT") return "absent"; // Distinct from "absent": the socket file is still there and still ours, // so the next brain to start will probe a stale path we failed to clean up. return "failed"; diff --git a/apps/desktop/scripts/windows-release-contract.test.mjs b/apps/desktop/scripts/windows-release-contract.test.mjs index 70fd148b94..a3f4e3753b 100644 --- a/apps/desktop/scripts/windows-release-contract.test.mjs +++ b/apps/desktop/scripts/windows-release-contract.test.mjs @@ -386,6 +386,82 @@ test("standalone Windows runtime signing uses only canonical credentials and val assert.doesNotMatch(windowsRuntimeSigner, /Write-Output.*(?:AZURE_CLIENT_SECRET|expectedSubject)/); }); +// The PowerShell installer used to stage the binary, the extracted runtime and +// both rollback backups under %TEMP%, run its preflight from there, and promote +// with a cross-volume `Move-Item` (a copy plus a delete, so an interrupted +// promotion leaves a half-written ade.exe). %TEMP% is also where AppLocker and +// most EDR agents block execution outright, so the preflight failed on managed +// machines before it could test anything. The POSIX installer was fixed first +// (apps/ade-cli/scripts/install-runtime.sh, and its +// install-runtime-rollback.test.mjs); these assertions pin the same model on +// the Windows side, which has no runnable test harness off a Windows host. +// Behaviour on a real machine is covered by the CI Windows job, which parses +// the script and runs the standalone-installer gate in +// windows-uninstall-cleanup.test.mjs. +test("the Windows installer stages, preflights and promotes under the ADE home, not %TEMP%", () => { + const installer = fs.readFileSync( + path.join(repoRoot, "apps", "ade-cli", "scripts", "install-runtime.ps1"), + "utf8", + ); + + // Scratch paths sit next to what they replace, so every promotion and every + // restore is a same-directory rename. + assert.match(installer, /\$stagedRuntime = "\$runtimeDir\.new"/); + assert.match(installer, /\$backupRuntime = "\$runtimeDir\.previous"/); + assert.match(installer, /\$pendingBinary = Join-Path \$InstallDir "ade\.new\.exe"/); + assert.match(installer, /\$backupBinary = Join-Path \$InstallDir "ade\.bak\.exe"/); + // %TEMP% holds downloads only: no staged runtime, no backup, no exec target. + assert.doesNotMatch(installer, /Join-Path \$tempRoot "runtime/); + assert.doesNotMatch(installer, /Join-Path \$tempRoot "ade\.(?:previous|bak)\.exe"/); + + // The preflight runs the install-directory copy against the staged runtime, + // so it exercises exactly the files the promoted install will load. + assert.match(installer, /Copy-Item -LiteralPath \$downloadedBinary -Destination \$pendingBinary -Force/); + assert.match( + installer, + /Set-ProcessRuntimeEnvironment \$AdeHome \$stagedRuntime\s*\n\s*& \$pendingBinary --version/, + ); + // The downloaded copy in %TEMP% is never executed. + assert.doesNotMatch(installer, /& \$downloadedBinary/); + + // Promotion order: runtime first, binary last, each by rename. + assert.match( + installer, + /Move-Item -LiteralPath \$stagedRuntime -Destination \$runtimeDir\s*\n\s*\$promotedRuntime = \$true/, + ); + assert.match( + installer, + /Move-Item -LiteralPath \$pendingBinary -Destination \$destinationBinary\s*\n\s*\$promotedBinary = \$true/, + ); + + // The finally clears all four scratch paths, and puts a backup back first + // when an abort landed between the two renames -- deleting it there is what + // would leave the machine with nothing installed. + const cleanup = installer.slice(installer.indexOf("if (-not $preserveTempForRecovery) {")); + for (const scratch of ["$tempRoot", "$pendingBinary", "$stagedRuntime", "$backupRuntime", "$backupBinary"]) { + assert.ok( + cleanup.includes(`Remove-Item -LiteralPath ${scratch}`), + `installer cleanup must remove ${scratch}`, + ); + } + assert.match( + cleanup, + /-not \$promotedRuntime[\s\S]{0,200}Move-Item -LiteralPath \$backupRuntime -Destination \$runtimeDir/, + ); + assert.match( + cleanup, + /-not \$promotedBinary[\s\S]{0,200}Move-Item -LiteralPath \$backupBinary -Destination \$destinationBinary/, + ); + + // Stage-aware failure messages, matching `die_runtime_unusable` in the sh + // script: a preflight failure must not claim a rollback that never happened. + assert.match(installer, /\$installStage = "staged"/); + assert.match(installer, /\$installStage = "installed"/); + assert.match(installer, /was not touched\./); + assert.match(installer, /the ADE you already had was put back, so nothing is broken\./); + assert.match(installer, /nothing was left installed at \$BinaryPath\./); +}); + test("standalone Windows release assets remain behind the publication gate", () => { const publish = jobBlock(releasePublishWorkflow, "publish-release", null); const runtimeBuild = jobBlock(releaseWorkflow, "build-runtime-binaries", "build-results"); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 078497de67..0cbbca1a9b 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -5856,10 +5856,6 @@ app.whenReady().then(async () => { const switchProjectFromDialog = async ( selectedPath: string, ): Promise => { - // Recorded before anything can fail: a first open that fails never reaches - // the recent-projects list, and the recovery screen it puts on screen asks - // main to diagnose/repair this exact root. - attemptedProjectRoots.record(selectedPath); const startedAt = Date.now(); const windowId = currentIpcWindowId(); let repoRoot: string | null = null; @@ -5916,7 +5912,19 @@ app.whenReady().then(async () => { try { const resolveStartedAt = Date.now(); repoRoot = normalizeProjectRoot(await resolveRepoRoot(selectedPath)); // require a real git repo for onboarding. + // INVARIANT: a root is recorded as "attempted" only once it has been + // proven to be a real git repository on disk — `resolveRepoRoot` throws + // otherwise. The registry widens what a renderer may later name in + // diagnostics/recovery calls, so recording an unvalidated string would + // let a renderer launder any path on the machine into a known root by + // calling `projectOpenRepo` with it first. Still recorded BEFORE the + // init steps below, which are exactly the ones that fail with coded + // errors (disk_full, db_integrity, brain_not_installed) on a first open + // that never reaches the recent-projects list — the recovery screen for + // that root is what this registry exists to keep working. + attemptedProjectRoots.record(repoRoot); if (repoRoot !== normalizeProjectRoot(selectedPath)) { + attemptedProjectRoots.record(selectedPath); pendingRepoRootCleanup = authorizePendingWindowProjectRoot(windowId, repoRoot); } // Kick off base-ref detection IN PARALLEL with the existing-context diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 733f455a1d..99d92fa90e 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -1682,8 +1682,10 @@ export function registerIpc({ closeWindow?: (windowId: number | null) => Promise<{ closed: boolean }>; switchProjectFromDialog: (selectedPath: string) => Promise; /** - * Roots main has tried to open. Owned by main.ts so every open path records - * into the same registry; see `knownProjectRoots.ts` for why a merely + * Roots main has tried to open. Read-only here: main.ts is the single + * writer (it records a root only after resolving it to a real repository), + * and this module only consults `.list()` when validating a + * renderer-supplied root. See `knownProjectRoots.ts` for why a merely * *attempted* root has to count as known. */ attemptedProjectRoots?: AttemptedProjectRoots; @@ -1890,10 +1892,12 @@ export function registerIpc({ // custom properties from thrown errors, so we re-throw with the code // prepended to the message. Renderer matches on the prefix. const surfaceCodedError = (error: unknown, meta?: { rootPath?: string }): never => { - // A coded failure carrying a root is exactly what puts the recovery screen - // on screen for that root, so remember it even if this path never reached - // `switchProjectFromDialog`. - if (meta?.rootPath) attemptedProjectRoots?.record(meta.rootPath); + // Deliberately does NOT record `meta.rootPath` into `attemptedProjectRoots`. + // Every caller that supplies a root got there through + // `switchProjectFromDialog`, which already recorded the resolved repo root + // after validating it, so a write here would be dead — and it would make + // the registry two-writer, with this one accepting a root that was never + // proven to exist. Reads still go through `attemptedProjectRoots.list()`. if (error instanceof Error) { const code = (error as Error & { code?: unknown }).code; if (typeof code === "string" && code.length > 0) { From 3e43f8d887f9457ae5fc047d56b13c84c0b0a47f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:21:41 -0400 Subject: [PATCH 6/9] test: consolidate serviceManager suites, prune render-only/silent-pass tests, pin issue_report analytics + machine-only report, TUI/CLI/docs parity (/report-issue, starting states, ade doctor starting row) Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 3 +- apps/ade-cli/README.md | 25 +- apps/ade-cli/src/cli.ts | 25 +- apps/ade-cli/src/commands/doctor.test.ts | 26 ++ apps/ade-cli/src/commands/doctor.ts | 44 ++- apps/ade-cli/src/commands/reportIssue.test.ts | 53 +++ .../ade-cli/src/serviceManager/common.test.ts | 254 +++++++++++++- .../installLaunchdWatchdog.test.ts | 127 ------- .../src/serviceManager/installWindows.test.ts | 285 +++++++++++++++- .../runtimeShutdownRequest.test.ts | 132 -------- .../serviceManager/windowsSupervisor.test.ts | 309 ------------------ .../runtime/brainStartupState.test.ts | 54 +++ .../src/services/runtime/brainStartupState.ts | 97 ++++++ .../tuiClient/__tests__/appPolling.test.tsx | 23 ++ .../tuiClient/__tests__/reportIssue.test.ts | 64 ++++ apps/ade-cli/src/tuiClient/app.tsx | 58 ++++ apps/ade-cli/src/tuiClient/commands.ts | 3 + apps/ade-cli/src/tuiClient/reportIssue.ts | 91 ++++++ .../analytics/productAnalyticsService.test.ts | 39 +++ .../diagnosticReportService.test.ts | 31 +- .../services/ipc/knownProjectRoots.test.ts | 8 +- .../components/app/ReportIssueButton.test.tsx | 8 - docs/ARCHITECTURE.md | 29 +- docs/development/windows-support.md | 42 ++- docs/features/ade-code/README.md | 9 +- docs/features/cto/README.md | 2 +- .../onboarding-and-settings/README.md | 13 + .../desktop-auto-update.md | 6 +- docs/features/remote-runtime/README.md | 68 +++- docs/features/storage-and-recovery/README.md | 31 +- docs/features/web-client/README.md | 6 +- docs/logging.md | 42 +++ 32 files changed, 1395 insertions(+), 612 deletions(-) create mode 100644 apps/ade-cli/src/commands/reportIssue.test.ts delete mode 100644 apps/ade-cli/src/serviceManager/installLaunchdWatchdog.test.ts delete mode 100644 apps/ade-cli/src/serviceManager/runtimeShutdownRequest.test.ts delete mode 100644 apps/ade-cli/src/serviceManager/windowsSupervisor.test.ts create mode 100644 apps/ade-cli/src/services/runtime/brainStartupState.test.ts create mode 100644 apps/ade-cli/src/services/runtime/brainStartupState.ts create mode 100644 apps/ade-cli/src/tuiClient/__tests__/reportIssue.test.ts create mode 100644 apps/ade-cli/src/tuiClient/reportIssue.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01f3118806..78eafed0a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,7 +134,7 @@ jobs: # scripts/validate-docs.test.mjs covers the docs validator that the # validate-docs job runs. - name: Test release runtime archive, packaging, and docs-validator guards - run: node --test apps/ade-cli/scripts/install-runtime-rollback.test.mjs apps/ade-cli/scripts/native-archive-verification.test.mjs apps/ade-cli/scripts/native-deps-entry-filter.test.mjs apps/ade-cli/scripts/notarize-static-runtime.test.mjs apps/desktop/scripts/mac-runtime-archive-mode.test.mjs apps/desktop/scripts/packaged-ade-cli-resources.test.mjs apps/desktop/scripts/runtime-fetched-tool-packages.test.mjs apps/desktop/scripts/runtime-resource-targets.test.mjs scripts/validate-docs.test.mjs scripts/validate-platform-gates.test.mjs + run: node --test apps/ade-cli/scripts/install-runtime-rollback.test.mjs apps/ade-cli/scripts/native-archive-verification.test.mjs apps/ade-cli/scripts/native-deps-entry-filter.test.mjs apps/ade-cli/scripts/notarize-static-runtime.test.mjs apps/desktop/scripts/mac-runtime-archive-mode.test.mjs apps/desktop/scripts/packaged-ade-cli-resources.test.mjs apps/desktop/scripts/runtime-fetched-tool-packages.test.mjs apps/desktop/scripts/runtime-resource-targets.test.mjs apps/desktop/scripts/windows-release-contract.test.mjs scripts/validate-docs.test.mjs scripts/validate-platform-gates.test.mjs typecheck-web: needs: install @@ -474,7 +474,6 @@ jobs: src/bootstrap.test.ts src/serviceManager/common.test.ts src/serviceManager/installWindows.test.ts - src/serviceManager/windowsSupervisor.test.ts src/services/builtInBrowser/desktopBridgeClient.test.ts src/services/modelPickerStore.test.ts src/services/projects/machineLayout.test.ts diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 2a3f6a761c..c9cd09e893 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -207,6 +207,13 @@ ade brain pin set 123456 ade brain pin clear ``` +`ade brain status` and `ade runtime status` report `starting: true` when the +brain is not answering yet but its registered service and brain process say it +is coming up. Treat that as "wait", not "restart": `ade brain restart` on a +booting brain only starts its startup clock over. `ade brain restart` likewise +reports the installer's own `starting` message instead of claiming "restarted." +when the replacement is alive but has not bound the socket yet. + `ade brain repair-credentials` runs entirely locally and never contacts the brain — the state it repairs is the one that keeps the brain from starting, so a repair that needed a running brain would be unavailable exactly when it matters. @@ -460,6 +467,8 @@ ade machines connect --project ADE ade machines hop --session chat-1 ade doctor --json ade doctor --online --text # also check the latest desktop release over the network +ade report-issue --text # print a redacted diagnostic report + a prefilled GitHub issue URL (local files only; no brain needed) +ade report-issue --open # also open that issue URL in the browser ade tools status --text # pinned agent CLIs: installed version + entry path per tool, plus the machine tools root ade tools ensure --text # fetch whatever this build pins and is missing (no names = all); streams progress to stderr ade tools ensure codex --text # one tool; an unknown name is a usage error listing the pinned set @@ -742,7 +751,7 @@ status row (`ok` / `warn` / `fail`) per check. It exits non-zero when any row is `fail`. The rows are: - **App** — the installed ADE desktop version (read from the `.app` bundle on disk) against the latest known version. Latest-known comes from the on-disk `update-status.json` by default; pass `--online` to also fetch the latest release from GitHub (short timeout, best-effort). `warn` when the install is behind or missing. -- **Brain** — whether the machine brain responds on its socket, plus its version, pid, and uptime. `fail` when it is not responding or when its build identity does not match the expected runtime for this CLI/role. +- **Brain** — whether the machine brain responds on its socket, plus its version, pid, and uptime. `fail` when it is not responding or when its build identity does not match the expected runtime for this CLI/role. A brain that is not answering *yet* is reported as `warn` (`starting`) rather than `fail`: when the login service is registered and the brain process behind it is alive and younger than the young-brain window (`RUNTIME_SERVICE_YOUNG_BRAIN_MS`, 2 min), it is still coming up — first launch, cold disk, large project database — and restarting it would only reset its clock. This is the CLI's read of the same `brain_starting` state the desktop recovery screen shows; `ade runtime status` and `ade brain status` report it as a `starting: true` field with the same wording. Nothing to repair: keep waiting for the endpoint. - **Wedge history** — the last wedge that was recovered, read from the runtime dir or the brain's reported `lastWedge`. `warn` when the most recent wedge is within the last 24h. Two things write that record: the in-process loop watchdog (reported as the blocking command and how long it blocked) and the external watchdog (`ade runtime watchdog-check`), which stops a brain whose heartbeat has gone stale and is reported as how long the brain went without a beat. A brain that is wedged right now shows up as a failing **Brain** row; the heartbeat itself has no separate row because a stale heartbeat plus a live brain is exactly what the watchdog converts into a restart within a minute. - **Sync port** — the sync host port the brain bound. `ok` on the default port, `warn` when bound elsewhere (with the base-port holders it found), `fail` when the brain is up but reported no port. - **Publish health** — account-directory publish state from the brain's sync route health. `ok` when a publish succeeded recently, `fail` when it has been failing for ≥2 min, otherwise `warn`, with the slowest publish leg annotated. @@ -750,6 +759,20 @@ status row (`ok` / `warn` / `fail`) per check. It exits non-zero when any row is - **Account** — whether this machine's brain is signed in to an ADE account (and the credential source), read via the brain's `account.call status`. `warn` when signed out or unavailable. - **Credentials** — whether the shared credential store (`$ADE_HOME/secrets/credentials.json.enc`) can be read, and whether an unreadable one was set aside earlier. `fail` when it cannot be read, naming the next step: a store sealed with a key this process cannot obtain is unlocked by opening the ADE app on this computer, while anything else needs a fresh sign-in. `warn` when a quarantined file is still waiting to be restored. Unlike every other row, this one is read **straight from disk** rather than through the brain — the failure it exists for is a brain that cannot start, so a check that needed a running brain would be silent exactly when it matters. It is non-creating: it never mints a machine key or OS key material, so running the diagnostic cannot change the state it reports. `ade brain repair-credentials` acts on the same reading. +When a row fails and the checks above do not explain it, `ade doctor --text` +points at `ade report-issue`. That command is the headless counterpart to the +desktop "Report issue" button: it reads only local files — it never starts or +contacts the brain — so it still works on the machine where ADE itself will not +come up, and on Windows where there is no desktop error screen to press. It +prints a redacted diagnostic report plus a prefilled GitHub issue URL (`--open` +also opens that URL, `--json` returns `{ installId, issueUrl, report }`). + +There is no `ade recovery diagnose` / `ade recovery repair`: those are +Electron-main IPC (`ade.recovery.diagnose` / `ade.recovery.repair`) backed by +the desktop's local-runtime connection pool, which does not exist in a headless +CLI. The CLI equivalents are `ade doctor` for the diagnosis, `ade brain restart` +for the repair, and `ade brain repair-credentials` for the credential half. + Default doctor does not call provider, GitHub, or Linear networks. Every row but **Credentials** comes from the local brain over its socket; **Credentials** is a read-only inspection of the machine's own secrets directory. It never prints diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index c824fc92eb..e2550e9ca2 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -137,6 +137,7 @@ import { type AdeServiceCommand, } from "./serviceManager/common"; import { awaitRuntimeServiceEndpoint } from "./services/runtime/awaitRuntimeServiceEndpoint"; +import { readBrainStartupState } from "./services/runtime/brainStartupState"; import { connectWhileServiceStarts } from "./services/runtime/connectWhileServiceStarts"; import { normalizeAdeRuntimeRole, resolveAdeDefaultRole } from "./runtimeRoles"; import { @@ -15747,11 +15748,21 @@ async function runRuntimeCommand( client.close(); } } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + // Not answering is not the same as broken. When the service is + // registered and the brain behind it is alive and young, it is still + // coming up — the same verdict the desktop calls `brain_starting`. + // Callers keep waiting for the endpoint instead of restarting it. + const startup = await readBrainStartupState(); return { ok: false, running: false, + starting: startup.starting, socketPath, - message: error instanceof Error ? error.message : String(error), + message: startup.starting + ? `ADE brain is still starting; it has not answered on ${socketPath} yet. Keep waiting — there is nothing to repair.` + : detail, + ...(startup.starting ? { detail } : {}), }; } } @@ -16283,8 +16294,13 @@ async function runBrainCommand( // serve path clears it on a successful listen. Surface it as a plain // one-liner so `ade brain status --text` matches the Desktop recovery screen. const lastFailure = readLastFailure({ kind: "machine" }); + // Mirrors `ade runtime status`: a registered service whose young brain has + // not bound the socket yet is starting, not broken, so `ade brain status` + // does not read as a failure that wants repairing. + const starting = isRecord(runtime) && runtime.starting === true; return { ok: service.ok && (!isRecord(runtime) || runtime.ok !== false), + starting, service, runtime, sync, @@ -20517,7 +20533,7 @@ function formatTextOutput( ? value.rows.filter(isRecord) : []; if (doctorRows.length > 0) { - return renderTable( + const table = renderTable( ["check", "status", "detail"], doctorRows.map((row) => [ asString(row.label) ?? asString(row.key) ?? "Unknown", @@ -20530,6 +20546,11 @@ function formatTextOutput( ]), "No health checks were returned.", ); + // A failing row that these checks cannot explain is the case + // `ade report-issue` exists for: it reads local files only, so it still + // works on the machine where the brain will not come up. + if (!doctorRows.some((row) => row.status === "fail")) return table; + return `${table}\n\nIf a failure above is unexplained, run \`ade report-issue\` and file the printed report.`; } const project = isRecord(value) && isRecord(value.project) ? value.project : {}; diff --git a/apps/ade-cli/src/commands/doctor.test.ts b/apps/ade-cli/src/commands/doctor.test.ts index ba7939d45d..537e72e658 100644 --- a/apps/ade-cli/src/commands/doctor.test.ts +++ b/apps/ade-cli/src/commands/doctor.test.ts @@ -192,6 +192,32 @@ describe("doctor row evaluation", () => { ]); }); + it("reports a young registered brain as starting, and a dead one as failing", () => { + const starting = healthyInput(); + starting.brain = { + ...starting.brain, + running: false, + error: "connect ENOENT /tmp/ade.sock", + starting: true, + startingAgeMs: 12_000, + }; + const startingRow = evaluateDoctorRows(starting).find((row) => row.key === "brain"); + expect(startingRow?.status).toBe("warn"); + expect(startingRow?.detail).toContain("starting"); + expect(startingRow?.detail).not.toContain("connect ENOENT"); + + const dead = healthyInput(); + dead.brain = { + ...dead.brain, + running: false, + error: "connect ENOENT /tmp/ade.sock", + starting: false, + }; + const deadRow = evaluateDoctorRows(dead).find((row) => row.key === "brain"); + expect(deadRow?.status).toBe("fail"); + expect(deadRow?.detail).toContain("not responding"); + }); + it("names the credential store's two bad states with the right next step", () => { const lockedOut = healthyInput(); lockedOut.credentials = { diff --git a/apps/ade-cli/src/commands/doctor.ts b/apps/ade-cli/src/commands/doctor.ts index ed2be21fe5..03dc9a97fc 100644 --- a/apps/ade-cli/src/commands/doctor.ts +++ b/apps/ade-cli/src/commands/doctor.ts @@ -21,6 +21,10 @@ import { type CredentialStoreHealth, } from "../services/credentials/credentialStore"; import { resolveMachineAdeLayout } from "../services/projects/machineLayout"; +import { + readBrainStartupState, + type BrainStartupState, +} from "../services/runtime/brainStartupState"; import { DEFAULT_SYNC_HOST_PORT } from "../services/sync/syncProtocol"; import type { SyncListenerPortDiagnosis, @@ -45,6 +49,16 @@ export type DoctorRow = { export type DoctorBrainInput = { running: boolean; + /** + * The brain is not answering, but its service is registered and the brain + * process behind it is alive and young — it is still coming up. Reported as + * a warning, not a failure: this is the CLI's read of the desktop's + * `brain_starting` recovery state, and repairing here would only restart a + * booting brain. See `services/runtime/brainStartupState`. + */ + starting?: boolean; + /** Age of the starting brain in ms when known, for the row's wording. */ + startingAgeMs?: number | null; version: string | null; buildHash: string | null; pid: number | null; @@ -131,6 +145,11 @@ export type DoctorCommandDependencies< expectedDefaultRole: Options["role"], ): string | null; unwrapActionEnvelope(value: unknown): unknown; + /** + * Only consulted when the brain did not answer. Defaults to the real service + * probe; injectable so tests can drive the starting/failed split. + */ + readBrainStartupState?(): Promise; }; export type DoctorCommandResult = { @@ -556,6 +575,18 @@ function appRow(input: DoctorInput["app"]): DoctorRow { function brainRow(input: DoctorBrainInput): DoctorRow { if (!input.running) { + // A registered, alive, young brain that has not bound its socket yet is + // starting, not broken. Calling that a failure is what sent people into a + // Repair that killed the booting brain and started the race over. + if (input.starting) { + const age = input.startingAgeMs != null ? ` (${compactDuration(input.startingAgeMs)} so far)` : ""; + return { + key: "brain", + label: "Brain", + status: "warn", + detail: `starting · the background service is up and its brain is coming up${age}; nothing to repair`, + }; + } return { key: "brain", label: "Brain", @@ -879,6 +910,11 @@ export async function runDoctorCommand( } const wedge = readBrainLoopWatchdogLastWedge(layout.runtimeDir) ?? brainProbe.runtimeLastWedge; + // Asked only of a brain that did not answer: a responding brain is running, + // never starting. + const startupState = brainProbe.brain.running + ? null + : await (dependencies.readBrainStartupState ?? readBrainStartupState)(); const input: DoctorInput = { nowMs, app: { @@ -887,7 +923,13 @@ export async function runDoctorCommand( path: installedApp.path, online, }, - brain: brainProbe.brain, + brain: startupState + ? { + ...brainProbe.brain, + starting: startupState.starting, + startingAgeMs: startupState.ageMs, + } + : brainProbe.brain, wedge, syncPort, portDiagnoses, diff --git a/apps/ade-cli/src/commands/reportIssue.test.ts b/apps/ade-cli/src/commands/reportIssue.test.ts new file mode 100644 index 0000000000..ba0a98491e --- /dev/null +++ b/apps/ade-cli/src/commands/reportIssue.test.ts @@ -0,0 +1,53 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildCliDiagnosticReport } from "./reportIssue"; + +const tempDirs: string[] = []; + +function adeHome(analytics: Record | null): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-report-issue-")); + tempDirs.push(dir); + fs.mkdirSync(path.join(dir, "secrets"), { recursive: true }); + if (analytics) { + fs.writeFileSync( + path.join(dir, "secrets", "product-analytics.json"), + JSON.stringify(analytics), + "utf8", + ); + } + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("buildCliDiagnosticReport", () => { + it("reports the PostHog distinct_id and a prefilled issue URL", () => { + const built = buildCliDiagnosticReport({ + env: { ADE_HOME: adeHome({ identifiedUserHash: "hash-1", anonymousId: "anon-1" }) }, + cliVersion: "1.2.60", + now: () => new Date("2026-08-16T09:30:00.000Z"), + }); + + expect(built.installId).toBe("hash-1"); + expect(built.issueUrl.startsWith("https://github.com/")).toBe(true); + expect(built.issueUrl).toContain("/issues/new"); + expect(built.report).toContain("1.2.60"); + }); + + it("falls back to the anonymous id, then to 'unknown', without throwing", () => { + const anonymous = buildCliDiagnosticReport({ + env: { ADE_HOME: adeHome({ anonymousId: "anon-2", installationId: "install-2" }) }, + }); + // `installationId` is a different identifier: no PostHog event is attributed + // to it, so it must never be reported as the install id. + expect(anonymous.installId).toBe("anon-2"); + + const missing = buildCliDiagnosticReport({ env: { ADE_HOME: adeHome(null) } }); + expect(missing.installId).toBe("unknown"); + expect(missing.report.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/ade-cli/src/serviceManager/common.test.ts b/apps/ade-cli/src/serviceManager/common.test.ts index a40ca345e8..996177c140 100644 --- a/apps/ade-cli/src/serviceManager/common.test.ts +++ b/apps/ade-cli/src/serviceManager/common.test.ts @@ -1,4 +1,6 @@ import fs from "node:fs"; +import net from "node:net"; +import { EventEmitter } from "node:events"; import { createHash } from "node:crypto"; import { spawnSync as spawnChildSync } from "node:child_process"; import os from "node:os"; @@ -56,7 +58,15 @@ import { renderLaunchdPlist, uninstallLaunchdService, } from "./installLaunchd"; -import { resolveWatchdogServiceName } from "./installLaunchdWatchdog"; +import { requestAdeRuntimeShutdown } from "./runtimeShutdownRequest"; +import { + installLaunchdWatchdogAgent, + renderWatchdogLaunchdPlist, + resolveWatchdogServiceName, + uninstallLaunchdWatchdogAgent, + watchdogCommand, + watchdogLaunchAgentPath, +} from "./installLaunchdWatchdog"; import { isWindowsTaskStateRunning } from "./installWindows"; const originalArgv = [...process.argv]; @@ -1492,3 +1502,245 @@ function spawnSequence( return next ?? { status: 0, stdout: "", stderr: "" }; }; } + +const watchdogServiceCommand: AdeServiceCommand = { + command: "/usr/local/bin/node", + args: ["/opt/ade/cli.cjs", "serve"], + env: { ADE_HOME: "/Users/example/.ade" }, +}; + +function watchdogTempHome(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "ade-watchdog-home-")); +} + +function watchdogRecordingSpawn(calls: Array<{ command: string; args: string[] }>) { + return (command: string, args: string[]) => { + calls.push({ command, args }); + return { status: 0, stdout: "", stderr: "" }; + }; +} + +describe("resolveWatchdogServiceName", () => { + it("keeps each channel on its own watchdog", () => { + expect(resolveWatchdogServiceName("com.ade.runtime")).toBe("com.ade.watchdog"); + expect(resolveWatchdogServiceName("com.ade.runtime.beta")).toBe("com.ade.watchdog.beta"); + expect(resolveWatchdogServiceName("com.example.custom")).toBe("com.example.custom.watchdog"); + }); +}); + +describe("watchdogCommand", () => { + it("runs the same binary the brain was installed from", () => { + expect(watchdogCommand(watchdogServiceCommand)).toEqual({ + command: "/usr/local/bin/node", + args: ["/opt/ade/cli.cjs", "runtime", "watchdog-check"], + env: { + ADE_HOME: "/Users/example/.ade", + ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", + }, + }); + }); + + it("appends the check when the command has no serve argument", () => { + expect(watchdogCommand({ command: "/opt/ade/ade", args: [] }).args) + .toEqual(["runtime", "watchdog-check"]); + }); +}); + +describe("renderWatchdogLaunchdPlist", () => { + it("runs on an interval and never keeps itself alive", () => { + const plist = renderWatchdogLaunchdPlist({ + command: watchdogServiceCommand, + homeDir: "/Users/example", + }); + expect(plist).toContain("com.ade.watchdog"); + expect(plist).toContain("StartInterval"); + expect(plist).toContain("60"); + expect(plist).toContain("watchdog-check"); + // KeepAlive would make launchd respawn a one-shot check in a tight loop. + expect(plist).not.toContain("KeepAlive"); + }); + + it("refuses an interval short enough to thrash", () => { + const plist = renderWatchdogLaunchdPlist({ + command: watchdogServiceCommand, + homeDir: "/Users/example", + startIntervalSeconds: 1, + }); + expect(plist).toContain("15"); + }); +}); + +describe("installLaunchdWatchdogAgent", () => { + it("writes and loads the agent", () => { + const homeDir = watchdogTempHome(); + const calls: Array<{ command: string; args: string[] }> = []; + const result = installLaunchdWatchdogAgent({ + command: watchdogServiceCommand, + homeDir, + spawnSync: watchdogRecordingSpawn(calls), + }); + + const servicePath = watchdogLaunchAgentPath(homeDir); + expect(result.installed).toBe(true); + expect(fs.existsSync(servicePath)).toBe(true); + expect(calls.map((call) => call.args[0])).toEqual(["unload", "load"]); + }); + + it("reports a load failure instead of claiming the agent is armed", () => { + const homeDir = watchdogTempHome(); + const result = installLaunchdWatchdogAgent({ + command: watchdogServiceCommand, + homeDir, + spawnSync: (command, args) => + args[0] === "load" + ? { status: 1, stdout: "", stderr: "Load failed" } + : { status: 0, stdout: "", stderr: "" }, + }); + expect(result.installed).toBe(false); + }); + + it("removes the agent with the brain it guards", () => { + const homeDir = watchdogTempHome(); + installLaunchdWatchdogAgent({ + command: watchdogServiceCommand, + homeDir, + spawnSync: watchdogRecordingSpawn([]), + }); + const servicePath = watchdogLaunchAgentPath(homeDir); + expect(fs.existsSync(servicePath)).toBe(true); + + const calls: Array<{ command: string; args: string[] }> = []; + uninstallLaunchdWatchdogAgent({ homeDir, spawnSync: watchdogRecordingSpawn(calls) }); + + expect(fs.existsSync(servicePath)).toBe(false); + expect(calls.map((call) => call.args[0])).toEqual(["bootout", "unload"]); + }); +}); + +/** + * A stand-in for the brain's JSON-RPC endpoint. `replies` maps a method to the + * result it answers with; anything absent is simply not answered, which is how + * a wedged brain behaves. `closeOnShutdown` models the brain whose orderly exit + * drops the socket before its own response gets out. + */ +function fakeEndpoint( + replies: Record, + options: { closeOnShutdown?: boolean } = {}, +): { + socket: net.Socket; + written: string[]; +} { + const written: string[] = []; + const socket = new EventEmitter() as unknown as net.Socket & { destroy: () => void }; + let buffer = ""; + (socket as unknown as { write: unknown }).write = (payload: string) => { + written.push(payload); + buffer += payload; + for (;;) { + const newline = buffer.indexOf("\n"); + if (newline < 0) break; + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + const message = JSON.parse(line) as { id: number; method: string }; + if (options.closeOnShutdown && message.method === "shutdown") { + queueMicrotask(() => socket.emit("close")); + continue; + } + if (!(message.method in replies)) continue; + queueMicrotask(() => { + socket.emit( + "data", + Buffer.from(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, result: replies[message.method] })}\n`), + ); + }); + } + return true; + }; + (socket as unknown as { destroy: () => void }).destroy = () => {}; + queueMicrotask(() => socket.emit("connect")); + return { socket, written }; +} + +describe("requestAdeRuntimeShutdown", () => { + const socketPath = String.raw`\\.\pipe\ade-runtime-stable-0123456789abcdef`; + + it("identifies the endpoint before asking it to leave", async () => { + const endpoint = fakeEndpoint({ "runtime/info": { pid: 4242 }, shutdown: {} }); + const result = await requestAdeRuntimeShutdown({ + pid: 4242, + socketPath, + connect: () => endpoint.socket, + }); + expect(result).toEqual({ requested: true }); + const methods = endpoint.written.map((line) => (JSON.parse(line) as { method: string }).method); + expect(methods).toEqual(["runtime/info", "shutdown"]); + }); + + it("refuses to shut down a pid the endpoint does not belong to", async () => { + // A pid scraped from a port diagnosis or a supervisor record can have been + // recycled; shutting down whoever happens to answer would be a stranger. + const endpoint = fakeEndpoint({ "runtime/info": { pid: 999 }, shutdown: {} }); + const result = await requestAdeRuntimeShutdown({ + pid: 4242, + socketPath, + connect: () => endpoint.socket, + }); + expect(result.requested).toBe(false); + const methods = endpoint.written.map((line) => (JSON.parse(line) as { method: string }).method); + expect(methods).toEqual(["runtime/info"]); + }); + + /** + * The orderly exit this path asks for tears down the brain's listening + * socket, which races the JSON-RPC response back to us. Reading the close as + * a refusal would send the caller to `taskkill /F` and cut the flush short. + */ + it("treats a close after the request as the shutdown taking effect", async () => { + const endpoint = fakeEndpoint({ "runtime/info": { pid: 4242 } }, { closeOnShutdown: true }); + const result = await requestAdeRuntimeShutdown({ + pid: 4242, + socketPath, + connect: () => endpoint.socket, + }); + expect(result).toEqual({ requested: true }); + }); + + it("reports a close before the request as the endpoint hanging up", async () => { + const endpoint = fakeEndpoint({}); + queueMicrotask(() => endpoint.socket.emit("close")); + const result = await requestAdeRuntimeShutdown({ + pid: 4242, + socketPath, + connect: () => endpoint.socket, + }); + expect(result).toEqual({ + requested: false, + reason: "the runtime endpoint closed before it could be asked to stop", + }); + }); + + it("gives up on a wedged endpoint instead of hanging the caller", async () => { + const endpoint = fakeEndpoint({}); + const result = await requestAdeRuntimeShutdown({ + pid: 4242, + socketPath, + timeoutMs: 250, + connect: () => endpoint.socket, + }); + expect(result).toEqual({ + requested: false, + reason: "the runtime endpoint did not answer within 250ms", + }); + }); + + it("never dials a tcp runtime endpoint", async () => { + const result = await requestAdeRuntimeShutdown({ + pid: 4242, + socketPath: "tcp://127.0.0.1:9999?token=secret", + connect: () => { + throw new Error("must not connect"); + }, + }); + expect(result.requested).toBe(false); + }); +}); diff --git a/apps/ade-cli/src/serviceManager/installLaunchdWatchdog.test.ts b/apps/ade-cli/src/serviceManager/installLaunchdWatchdog.test.ts deleted file mode 100644 index a9c20a5524..0000000000 --- a/apps/ade-cli/src/serviceManager/installLaunchdWatchdog.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, it } from "vitest"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { - installLaunchdWatchdogAgent, - renderWatchdogLaunchdPlist, - resolveWatchdogServiceName, - uninstallLaunchdWatchdogAgent, - watchdogCommand, - watchdogLaunchAgentPath, -} from "./installLaunchdWatchdog"; -import type { AdeServiceCommand } from "./common"; - -const serviceCommand: AdeServiceCommand = { - command: "/usr/local/bin/node", - args: ["/opt/ade/cli.cjs", "serve"], - env: { ADE_HOME: "/Users/example/.ade" }, -}; - -function tempHome(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), "ade-watchdog-home-")); -} - -function recordingSpawn(calls: Array<{ command: string; args: string[] }>) { - return (command: string, args: string[]) => { - calls.push({ command, args }); - return { status: 0, stdout: "", stderr: "" }; - }; -} - -describe("resolveWatchdogServiceName", () => { - it("keeps each channel on its own watchdog", () => { - expect(resolveWatchdogServiceName("com.ade.runtime")).toBe("com.ade.watchdog"); - expect(resolveWatchdogServiceName("com.ade.runtime.beta")).toBe("com.ade.watchdog.beta"); - expect(resolveWatchdogServiceName("com.example.custom")).toBe("com.example.custom.watchdog"); - }); -}); - -describe("watchdogCommand", () => { - it("runs the same binary the brain was installed from", () => { - expect(watchdogCommand(serviceCommand)).toEqual({ - command: "/usr/local/bin/node", - args: ["/opt/ade/cli.cjs", "runtime", "watchdog-check"], - env: { - ADE_HOME: "/Users/example/.ade", - ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", - }, - }); - }); - - it("appends the check when the command has no serve argument", () => { - expect(watchdogCommand({ command: "/opt/ade/ade", args: [] }).args) - .toEqual(["runtime", "watchdog-check"]); - }); -}); - -describe("renderWatchdogLaunchdPlist", () => { - it("runs on an interval and never keeps itself alive", () => { - const plist = renderWatchdogLaunchdPlist({ - command: serviceCommand, - homeDir: "/Users/example", - }); - expect(plist).toContain("com.ade.watchdog"); - expect(plist).toContain("StartInterval"); - expect(plist).toContain("60"); - expect(plist).toContain("watchdog-check"); - // KeepAlive would make launchd respawn a one-shot check in a tight loop. - expect(plist).not.toContain("KeepAlive"); - }); - - it("refuses an interval short enough to thrash", () => { - const plist = renderWatchdogLaunchdPlist({ - command: serviceCommand, - homeDir: "/Users/example", - startIntervalSeconds: 1, - }); - expect(plist).toContain("15"); - }); -}); - -describe("installLaunchdWatchdogAgent", () => { - it("writes and loads the agent", () => { - const homeDir = tempHome(); - const calls: Array<{ command: string; args: string[] }> = []; - const result = installLaunchdWatchdogAgent({ - command: serviceCommand, - homeDir, - spawnSync: recordingSpawn(calls), - }); - - const servicePath = watchdogLaunchAgentPath(homeDir); - expect(result.installed).toBe(true); - expect(fs.existsSync(servicePath)).toBe(true); - expect(calls.map((call) => call.args[0])).toEqual(["unload", "load"]); - }); - - it("reports a load failure instead of claiming the agent is armed", () => { - const homeDir = tempHome(); - const result = installLaunchdWatchdogAgent({ - command: serviceCommand, - homeDir, - spawnSync: (command, args) => - args[0] === "load" - ? { status: 1, stdout: "", stderr: "Load failed" } - : { status: 0, stdout: "", stderr: "" }, - }); - expect(result.installed).toBe(false); - }); - - it("removes the agent with the brain it guards", () => { - const homeDir = tempHome(); - installLaunchdWatchdogAgent({ - command: serviceCommand, - homeDir, - spawnSync: recordingSpawn([]), - }); - const servicePath = watchdogLaunchAgentPath(homeDir); - expect(fs.existsSync(servicePath)).toBe(true); - - const calls: Array<{ command: string; args: string[] }> = []; - uninstallLaunchdWatchdogAgent({ homeDir, spawnSync: recordingSpawn(calls) }); - - expect(fs.existsSync(servicePath)).toBe(false); - expect(calls.map((call) => call.args[0])).toEqual(["bootout", "unload"]); - }); -}); diff --git a/apps/ade-cli/src/serviceManager/installWindows.test.ts b/apps/ade-cli/src/serviceManager/installWindows.test.ts index 5e492cabf4..c73e3afb1b 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.test.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.test.ts @@ -1,5 +1,5 @@ import fs from "node:fs"; -import { spawnSync as spawnChildSync } from "node:child_process"; +import { spawn, spawnSync, spawnSync as spawnChildSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; import { resolveMachineAdeLayout } from "../services/projects/machineLayout"; @@ -40,8 +40,14 @@ import { windowsSchtasksCommand, windowsTaskkillCommand, WINDOWS_TASK_ACTION_FIELD_SEPARATOR, + buildWindowsRuntimeQueryArgs, renderWindowsServiceLauncher, } from "./installWindows"; +import { + BRAIN_HEARTBEAT_INTERVAL_MS, + BRAIN_HEARTBEAT_STALE_MS, +} from "../services/runtime/brainHeartbeat"; +import { waitForWindowsRuntimeReadiness } from "./windowsSupervisor"; const tempDirs: string[] = []; @@ -1273,3 +1279,280 @@ describe("Windows background service helpers", () => { })).toMatch(/^C:\\Users\\arul\\\.ade-beta\\runtime\\brain-service-[a-f0-9]{12}\.ps1$/i); }); }); + +describe("Windows runtime supervisor", () => { + it("renders bounded restart state for both child exits and launch failures", () => { + const script = renderWindowsServiceLauncher({ + command: "C:\\Program Files\\ADE\\ade.exe", + args: ["C:\\Program Files\\ADE\\resources\\ade-cli\\cli.cjs", "serve"], + }, { + pidPath: "C:\\Users\\arul\\.ade-beta\\runtime\\brain.pid.json", + initialRestartDelayMs: 250, + maxRestartDelayMs: 5_000, + healthyRuntimeMs: 30_000, + }); + + expect(script).toContain("while ($true)"); + expect(script).toContain("$initialRestartDelayMs = 250"); + expect(script).toContain("$maxRestartDelayMs = 5000"); + expect(script).toContain("lastLaunchError = $lastLaunchError"); + expect(script).toContain("} catch {"); + expect(script).toContain("Start-Sleep -Milliseconds ([int]$restartDelayMs)"); + }); + + it("reads legacy and current PID records with bounded diagnostics", () => { + const pidPath = path.join(makeTempHome("ade-windows-supervisor-"), "brain.pid.json"); + fs.writeFileSync(pidPath, JSON.stringify({ supervisorPid: 101, runtimePid: 202 }), "utf8"); + expect(readWindowsServicePidRecord({ pidPath })).toEqual({ + supervisorPid: 101, + runtimePid: 202, + runtimeStartedAtMs: null, + restartCount: 0, + lastExitCode: null, + lastExitAt: null, + nextRestartAt: null, + lastLaunchError: null, + sessionBound: null, + }); + + fs.writeFileSync(pidPath, JSON.stringify({ + supervisorPid: 101, + runtimePid: null, + restartCount: 3, + lastLaunchError: "x".repeat(800), + }), "utf8"); + expect(readWindowsServicePidRecord({ pidPath })).toMatchObject({ + runtimePid: null, + restartCount: 3, + lastLaunchError: "x".repeat(512), + }); + }); + + it("binds runtime PID inspection to the executable, entrypoint, and serve command", () => { + const args = buildWindowsRuntimeQueryArgs(202, { + command: "C:\\Program Files\\ADE\\ade.exe", + args: ["C:\\Program Files\\ADE\\resources\\ade-cli\\cli.cjs", "serve"], + }); + const query = args.at(-1) ?? ""; + expect(query).toContain("ProcessId = 202"); + expect(query).toContain("C:\\Program Files\\ADE\\ade.exe"); + expect(query).toContain("C:\\Program Files\\ADE\\resources\\ade-cli\\cli.cjs"); + expect(query).toContain("matchesServe"); + }); + + it("waits asynchronously for semantic readiness without blocking the caller", async () => { + const sleepStarted: number[] = []; + const wait = waitForWindowsRuntimeReadiness({ + command: { command: "C:\\ADE\\ade.exe", args: ["serve"] }, + launcherPath: "C:\\ADE\\brain-service.ps1", + pidPath: "C:\\ADE\\brain.pid.json", + socketPath: "\\\\.\\pipe\\ade-test", + spawnSync, + readPidRecord: () => null, + timeoutMs: 12, + pollMs: 10, + sleep: async (ms) => { + sleepStarted.push(ms); + await new Promise((resolve) => setTimeout(resolve, ms)); + }, + }); + + expect(wait).toBeInstanceOf(Promise); + expect(sleepStarted).toEqual([10]); + await expect(wait).resolves.toMatchObject({ + ready: false, + diagnostic: expect.stringContaining("did not publish a PID record"), + }); + }); + + it("reports `supervised` from the probe's identity check, never from pid liveness", async () => { + const record = { + supervisorPid: 4321, + runtimePid: 5678, + runtimeStartedAtMs: Date.now(), + restartCount: 0, + lastExitCode: null, + lastExitAt: null, + nextRestartAt: null, + lastLaunchError: null, + sessionBound: null, + }; + const wait = (supervised: boolean | undefined) => waitForWindowsRuntimeReadiness({ + command: { command: "C:\\ADE\\ade.exe", args: ["serve"] }, + launcherPath: "C:\\ADE\\brain-service.ps1", + pidPath: "C:\\ADE\\brain.pid.json", + socketPath: "\\\\.\\pipe\\ade-test", + spawnSync, + readPidRecord: () => record, + readinessProbe: () => ({ ready: false, supervised, diagnostic: "not yet" }), + timeoutMs: 0, + pollMs: 10, + }); + + // The recorded pids are alive as far as `process.kill(pid, 0)` is + // concerned in every one of these cases -- what differs is whether + // `Win32_Process` says the pid is a powershell running OUR launcher. Only + // that answer may promote a failed install to "still starting". + await expect(wait(true)).resolves.toMatchObject({ ready: false, supervised: true }); + await expect(wait(false)).resolves.toMatchObject({ ready: false, supervised: false }); + // A probe that says nothing is unknown, and unknown is not healthy. + await expect(wait(undefined)).resolves.toMatchObject({ ready: false, supervised: false }); + }); + + (process.platform === "win32" ? it : it.skip)( + "keeps supervising a missing executable and publishes launch-error backoff diagnostics", + async () => { + const dir = makeTempHome("ade-windows-supervisor-"); + const launcherPath = path.join(dir, "brain-service.ps1"); + const pidPath = `${launcherPath}.pid.json`; + fs.writeFileSync(launcherPath, `\uFEFF${renderWindowsServiceLauncher({ + command: path.join(dir, "missing-ade.exe"), + args: ["serve"], + }, { + pidPath, + initialRestartDelayMs: 100, + maxRestartDelayMs: 200, + })}`, "utf8"); + const supervisor = spawn(windowsPowerShellCommand(), [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + launcherPath, + ], { stdio: "ignore", windowsHide: true }); + try { + // The supervisor is a real detached PowerShell process, so this waits on + // powershell.exe cold start plus two full launch-failure backoff cycles. + // A 5s budget is a coin flip on a loaded Windows CI runner, where the + // record simply had not been written yet and the assertion below read + // null. Widen the patience; the assertion itself is unchanged. + const deadline = Date.now() + 45_000; + let record = readWindowsServicePidRecord({ pidPath }); + while ((!record?.lastLaunchError || record.restartCount < 2) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + record = readWindowsServicePidRecord({ pidPath }); + } + expect(record).toMatchObject({ + supervisorPid: supervisor.pid, + runtimePid: null, + restartCount: expect.any(Number), + lastLaunchError: expect.any(String), + nextRestartAt: expect.any(String), + }); + expect(record?.restartCount).toBeGreaterThanOrEqual(2); + } finally { + if (supervisor.pid) { + spawnSync("taskkill.exe", ["/PID", String(supervisor.pid), "/T", "/F"], { + encoding: "utf8", + windowsHide: true, + }); + } + } + }, + 60_000, + ); +}); + +describe("windows supervisor wedge guard", () => { + const command = { + command: "C:\\ade\\node.exe", + args: ["C:\\ade\\cli.cjs", "serve"], + env: { ADE_HOME: "C:\\Users\\example\\.ade" }, + }; + + it("waits in slices and stops a brain that stopped beating", () => { + const script = renderWindowsServiceLauncher(command, { + pidPath: "C:\\ade\\launcher.pid.json", + heartbeatPath: "C:\\ade\\runtime\\heartbeat.json", + wedgeBreadcrumbPath: "C:\\ade\\runtime\\event-loop-wedge.json", + }); + + // An unbounded WaitForExit is exactly what makes a wedge invisible. + expect(script).not.toContain("$process.WaitForExit()\r\n $lastExitCode"); + expect(script).toContain("while (-not $process.WaitForExit($heartbeatPollMs))"); + expect(script).toContain("Test-BrainWedged $process.Id"); + expect(script).toContain("Write-WedgeBreadcrumb $wedgeAgeMs"); + // Kill($true) is .NET Core only; the supervisor must run under PS 5.1. + expect(script).toContain("$process.Kill()"); + expect(script).not.toContain("$process.Kill($true)"); + }); + + it("stops the wedged brain's whole process tree, through an absolute taskkill", () => { + const script = renderWindowsServiceLauncher(command, { + pidPath: "C:\\ade\\launcher.pid.json", + heartbeatPath: "C:\\ade\\runtime\\heartbeat.json", + wedgeBreadcrumbPath: "C:\\ade\\runtime\\event-loop-wedge.json", + }); + + // Absolute System32 path, never a bare `taskkill` off PATH. On a real + // Windows host the resolver returns the verified filesystem form + // (C:\Windows\System32\taskkill.exe); elsewhere it falls back to the + // kernel GLOBALROOT form — both end in System32\taskkill.exe. + expect(script).toMatch(/\$taskkillPath = '[^']*System32\\taskkill\.exe'/i); + expect(script).toContain("& $taskkillPath '/PID' $process.Id '/T' '/F'"); + // Kill() alone orphans ConPTYs and agent CLIs, so the tree kill must come + // first and Kill() must only mop up what taskkill could not. + const taskkillAt = script.indexOf("& $taskkillPath"); + const killAt = script.indexOf("$process.Kill()"); + expect(taskkillAt).toBeGreaterThan(-1); + expect(taskkillAt).toBeLessThan(killAt); + expect(script).toContain("if (-not $process.HasExited) { $process.Kill() }"); + // Bounded, and never a bare WaitForExit(): if taskkill was unresolvable and + // Kill() threw, an unbounded wait parks the supervisor on the wedge forever. + expect(script).not.toContain("$process.WaitForExit()"); + const waitAt = script.indexOf("if ($process.WaitForExit(30000)) { break }", killAt); + expect(waitAt).toBeGreaterThan(killAt); + expect(script).toContain("did not exit after the kill"); + // Leaving the wait loop is conditional on the process being GONE. An + // unconditional break after a timed-out kill would start a second brain + // beside an unkillable one, both wanting the same ports and worktrees. + expect(script).not.toMatch(/WaitForExit\(30000\)[^\r\n]*[\r\n]+\s*break/); + const wedgeRetryAt = script.indexOf("retrying on the next heartbeat check", waitAt); + expect(wedgeRetryAt).toBeGreaterThan(waitAt); + // The bounded wait can fall through with the process still alive, and + // `.ExitCode` throws on a live process -- which would surface the wedge as + // a launch failure. Read it only once the process has actually exited. + expect(script).toContain( + "if ($process.HasExited) { $lastExitCode = $process.ExitCode } else { $lastExitCode = $null }", + ); + // ...and never as an unguarded statement of its own. + expect(script).not.toMatch(/(?:^|[\r\n])\s*\$lastExitCode = \$process\.ExitCode/); + }); + + it("keeps the beat interval and stale threshold bound to the brain's own", () => { + const script = renderWindowsServiceLauncher(command, { + pidPath: "C:\\ade\\launcher.pid.json", + heartbeatPath: "C:\\ade\\runtime\\heartbeat.json", + }); + expect(script).toContain(`$heartbeatStaleMs = ${BRAIN_HEARTBEAT_STALE_MS}`); + expect(script).toContain(`$heartbeatPollMs = ${BRAIN_HEARTBEAT_INTERVAL_MS}`); + }); + + it("only judges a beat that belongs to the child it started", () => { + const script = renderWindowsServiceLauncher(command, { + pidPath: "C:\\ade\\launcher.pid.json", + heartbeatPath: "C:\\ade\\runtime\\heartbeat.json", + wedgeBreadcrumbPath: "C:\\ade\\runtime\\event-loop-wedge.json", + }); + expect(script).toContain("if ([int]$beat.pid -ne $runtimePid) { return $null }"); + expect(script).toContain("if ($ageMs -le $heartbeatStaleMs) { return $null }"); + }); + + it("keeps its old exit-only behaviour when no heartbeat path is configured", () => { + const script = renderWindowsServiceLauncher(command, { + pidPath: "C:\\ade\\launcher.pid.json", + }); + expect(script).toContain("$heartbeatPath = $null"); + expect(script).toContain("if ([string]::IsNullOrEmpty($heartbeatPath)) { return $null }"); + }); + + it("refuses a stale threshold short enough to fire on an ordinary gap", () => { + const script = renderWindowsServiceLauncher(command, { + pidPath: "C:\\ade\\launcher.pid.json", + heartbeatPath: "C:\\ade\\runtime\\heartbeat.json", + heartbeatStaleMs: 500, + }); + expect(script).toContain("$heartbeatStaleMs = 30000"); + }); +}); diff --git a/apps/ade-cli/src/serviceManager/runtimeShutdownRequest.test.ts b/apps/ade-cli/src/serviceManager/runtimeShutdownRequest.test.ts deleted file mode 100644 index e83f8e3129..0000000000 --- a/apps/ade-cli/src/serviceManager/runtimeShutdownRequest.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import net from "node:net"; -import { EventEmitter } from "node:events"; -import { describe, expect, it } from "vitest"; -import { requestAdeRuntimeShutdown } from "./runtimeShutdownRequest"; - -/** - * A stand-in for the brain's JSON-RPC endpoint. `replies` maps a method to the - * result it answers with; anything absent is simply not answered, which is how - * a wedged brain behaves. `closeOnShutdown` models the brain whose orderly exit - * drops the socket before its own response gets out. - */ -function fakeEndpoint( - replies: Record, - options: { closeOnShutdown?: boolean } = {}, -): { - socket: net.Socket; - written: string[]; -} { - const written: string[] = []; - const socket = new EventEmitter() as unknown as net.Socket & { destroy: () => void }; - let buffer = ""; - (socket as unknown as { write: unknown }).write = (payload: string) => { - written.push(payload); - buffer += payload; - for (;;) { - const newline = buffer.indexOf("\n"); - if (newline < 0) break; - const line = buffer.slice(0, newline); - buffer = buffer.slice(newline + 1); - const message = JSON.parse(line) as { id: number; method: string }; - if (options.closeOnShutdown && message.method === "shutdown") { - queueMicrotask(() => socket.emit("close")); - continue; - } - if (!(message.method in replies)) continue; - queueMicrotask(() => { - socket.emit( - "data", - Buffer.from(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, result: replies[message.method] })}\n`), - ); - }); - } - return true; - }; - (socket as unknown as { destroy: () => void }).destroy = () => {}; - queueMicrotask(() => socket.emit("connect")); - return { socket, written }; -} - -describe("requestAdeRuntimeShutdown", () => { - const socketPath = String.raw`\\.\pipe\ade-runtime-stable-0123456789abcdef`; - - it("identifies the endpoint before asking it to leave", async () => { - const endpoint = fakeEndpoint({ "runtime/info": { pid: 4242 }, shutdown: {} }); - const result = await requestAdeRuntimeShutdown({ - pid: 4242, - socketPath, - connect: () => endpoint.socket, - }); - expect(result).toEqual({ requested: true }); - const methods = endpoint.written.map((line) => (JSON.parse(line) as { method: string }).method); - expect(methods).toEqual(["runtime/info", "shutdown"]); - }); - - it("refuses to shut down a pid the endpoint does not belong to", async () => { - // A pid scraped from a port diagnosis or a supervisor record can have been - // recycled; shutting down whoever happens to answer would be a stranger. - const endpoint = fakeEndpoint({ "runtime/info": { pid: 999 }, shutdown: {} }); - const result = await requestAdeRuntimeShutdown({ - pid: 4242, - socketPath, - connect: () => endpoint.socket, - }); - expect(result.requested).toBe(false); - const methods = endpoint.written.map((line) => (JSON.parse(line) as { method: string }).method); - expect(methods).toEqual(["runtime/info"]); - }); - - /** - * The orderly exit this path asks for tears down the brain's listening - * socket, which races the JSON-RPC response back to us. Reading the close as - * a refusal would send the caller to `taskkill /F` and cut the flush short. - */ - it("treats a close after the request as the shutdown taking effect", async () => { - const endpoint = fakeEndpoint({ "runtime/info": { pid: 4242 } }, { closeOnShutdown: true }); - const result = await requestAdeRuntimeShutdown({ - pid: 4242, - socketPath, - connect: () => endpoint.socket, - }); - expect(result).toEqual({ requested: true }); - }); - - it("reports a close before the request as the endpoint hanging up", async () => { - const endpoint = fakeEndpoint({}); - queueMicrotask(() => endpoint.socket.emit("close")); - const result = await requestAdeRuntimeShutdown({ - pid: 4242, - socketPath, - connect: () => endpoint.socket, - }); - expect(result).toEqual({ - requested: false, - reason: "the runtime endpoint closed before it could be asked to stop", - }); - }); - - it("gives up on a wedged endpoint instead of hanging the caller", async () => { - const endpoint = fakeEndpoint({}); - const result = await requestAdeRuntimeShutdown({ - pid: 4242, - socketPath, - timeoutMs: 250, - connect: () => endpoint.socket, - }); - expect(result).toEqual({ - requested: false, - reason: "the runtime endpoint did not answer within 250ms", - }); - }); - - it("never dials a tcp runtime endpoint", async () => { - const result = await requestAdeRuntimeShutdown({ - pid: 4242, - socketPath: "tcp://127.0.0.1:9999?token=secret", - connect: () => { - throw new Error("must not connect"); - }, - }); - expect(result.requested).toBe(false); - }); -}); diff --git a/apps/ade-cli/src/serviceManager/windowsSupervisor.test.ts b/apps/ade-cli/src/serviceManager/windowsSupervisor.test.ts deleted file mode 100644 index 0902e676c1..0000000000 --- a/apps/ade-cli/src/serviceManager/windowsSupervisor.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import fs from "node:fs"; -import { spawn, spawnSync } from "node:child_process"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { - buildWindowsRuntimeQueryArgs, - readWindowsServicePidRecord, - windowsPowerShellCommand, -} from "./installWindows"; -import { - BRAIN_HEARTBEAT_INTERVAL_MS, - BRAIN_HEARTBEAT_STALE_MS, -} from "../services/runtime/brainHeartbeat"; -import { - renderWindowsServiceLauncher, - waitForWindowsRuntimeReadiness, -} from "./windowsSupervisor"; - -const tempDirs: string[] = []; - -afterEach(() => { - for (const dir of tempDirs.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); - } -}); - -function tempDir(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-windows-supervisor-")); - tempDirs.push(dir); - return dir; -} - -describe("Windows runtime supervisor", () => { - it("renders bounded restart state for both child exits and launch failures", () => { - const script = renderWindowsServiceLauncher({ - command: "C:\\Program Files\\ADE\\ade.exe", - args: ["C:\\Program Files\\ADE\\resources\\ade-cli\\cli.cjs", "serve"], - }, { - pidPath: "C:\\Users\\arul\\.ade-beta\\runtime\\brain.pid.json", - initialRestartDelayMs: 250, - maxRestartDelayMs: 5_000, - healthyRuntimeMs: 30_000, - }); - - expect(script).toContain("while ($true)"); - expect(script).toContain("$initialRestartDelayMs = 250"); - expect(script).toContain("$maxRestartDelayMs = 5000"); - expect(script).toContain("lastLaunchError = $lastLaunchError"); - expect(script).toContain("} catch {"); - expect(script).toContain("Start-Sleep -Milliseconds ([int]$restartDelayMs)"); - }); - - it("reads legacy and current PID records with bounded diagnostics", () => { - const pidPath = path.join(tempDir(), "brain.pid.json"); - fs.writeFileSync(pidPath, JSON.stringify({ supervisorPid: 101, runtimePid: 202 }), "utf8"); - expect(readWindowsServicePidRecord({ pidPath })).toEqual({ - supervisorPid: 101, - runtimePid: 202, - runtimeStartedAtMs: null, - restartCount: 0, - lastExitCode: null, - lastExitAt: null, - nextRestartAt: null, - lastLaunchError: null, - sessionBound: null, - }); - - fs.writeFileSync(pidPath, JSON.stringify({ - supervisorPid: 101, - runtimePid: null, - restartCount: 3, - lastLaunchError: "x".repeat(800), - }), "utf8"); - expect(readWindowsServicePidRecord({ pidPath })).toMatchObject({ - runtimePid: null, - restartCount: 3, - lastLaunchError: "x".repeat(512), - }); - }); - - it("binds runtime PID inspection to the executable, entrypoint, and serve command", () => { - const args = buildWindowsRuntimeQueryArgs(202, { - command: "C:\\Program Files\\ADE\\ade.exe", - args: ["C:\\Program Files\\ADE\\resources\\ade-cli\\cli.cjs", "serve"], - }); - const query = args.at(-1) ?? ""; - expect(query).toContain("ProcessId = 202"); - expect(query).toContain("C:\\Program Files\\ADE\\ade.exe"); - expect(query).toContain("C:\\Program Files\\ADE\\resources\\ade-cli\\cli.cjs"); - expect(query).toContain("matchesServe"); - }); - - it("waits asynchronously for semantic readiness without blocking the caller", async () => { - const sleepStarted: number[] = []; - const wait = waitForWindowsRuntimeReadiness({ - command: { command: "C:\\ADE\\ade.exe", args: ["serve"] }, - launcherPath: "C:\\ADE\\brain-service.ps1", - pidPath: "C:\\ADE\\brain.pid.json", - socketPath: "\\\\.\\pipe\\ade-test", - spawnSync, - readPidRecord: () => null, - timeoutMs: 12, - pollMs: 10, - sleep: async (ms) => { - sleepStarted.push(ms); - await new Promise((resolve) => setTimeout(resolve, ms)); - }, - }); - - expect(wait).toBeInstanceOf(Promise); - expect(sleepStarted).toEqual([10]); - await expect(wait).resolves.toMatchObject({ - ready: false, - diagnostic: expect.stringContaining("did not publish a PID record"), - }); - }); - - it("reports `supervised` from the probe's identity check, never from pid liveness", async () => { - const record = { - supervisorPid: 4321, - runtimePid: 5678, - runtimeStartedAtMs: Date.now(), - restartCount: 0, - lastExitCode: null, - lastExitAt: null, - nextRestartAt: null, - lastLaunchError: null, - sessionBound: null, - }; - const wait = (supervised: boolean | undefined) => waitForWindowsRuntimeReadiness({ - command: { command: "C:\\ADE\\ade.exe", args: ["serve"] }, - launcherPath: "C:\\ADE\\brain-service.ps1", - pidPath: "C:\\ADE\\brain.pid.json", - socketPath: "\\\\.\\pipe\\ade-test", - spawnSync, - readPidRecord: () => record, - readinessProbe: () => ({ ready: false, supervised, diagnostic: "not yet" }), - timeoutMs: 0, - pollMs: 10, - }); - - // The recorded pids are alive as far as `process.kill(pid, 0)` is - // concerned in every one of these cases -- what differs is whether - // `Win32_Process` says the pid is a powershell running OUR launcher. Only - // that answer may promote a failed install to "still starting". - await expect(wait(true)).resolves.toMatchObject({ ready: false, supervised: true }); - await expect(wait(false)).resolves.toMatchObject({ ready: false, supervised: false }); - // A probe that says nothing is unknown, and unknown is not healthy. - await expect(wait(undefined)).resolves.toMatchObject({ ready: false, supervised: false }); - }); - - (process.platform === "win32" ? it : it.skip)( - "keeps supervising a missing executable and publishes launch-error backoff diagnostics", - async () => { - const dir = tempDir(); - const launcherPath = path.join(dir, "brain-service.ps1"); - const pidPath = `${launcherPath}.pid.json`; - fs.writeFileSync(launcherPath, `\uFEFF${renderWindowsServiceLauncher({ - command: path.join(dir, "missing-ade.exe"), - args: ["serve"], - }, { - pidPath, - initialRestartDelayMs: 100, - maxRestartDelayMs: 200, - })}`, "utf8"); - const supervisor = spawn(windowsPowerShellCommand(), [ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-File", - launcherPath, - ], { stdio: "ignore", windowsHide: true }); - try { - // The supervisor is a real detached PowerShell process, so this waits on - // powershell.exe cold start plus two full launch-failure backoff cycles. - // A 5s budget is a coin flip on a loaded Windows CI runner, where the - // record simply had not been written yet and the assertion below read - // null. Widen the patience; the assertion itself is unchanged. - const deadline = Date.now() + 45_000; - let record = readWindowsServicePidRecord({ pidPath }); - while ((!record?.lastLaunchError || record.restartCount < 2) && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 20)); - record = readWindowsServicePidRecord({ pidPath }); - } - expect(record).toMatchObject({ - supervisorPid: supervisor.pid, - runtimePid: null, - restartCount: expect.any(Number), - lastLaunchError: expect.any(String), - nextRestartAt: expect.any(String), - }); - expect(record?.restartCount).toBeGreaterThanOrEqual(2); - } finally { - if (supervisor.pid) { - spawnSync("taskkill.exe", ["/PID", String(supervisor.pid), "/T", "/F"], { - encoding: "utf8", - windowsHide: true, - }); - } - } - }, - 60_000, - ); -}); - -describe("windows supervisor wedge guard", () => { - const command = { - command: "C:\\ade\\node.exe", - args: ["C:\\ade\\cli.cjs", "serve"], - env: { ADE_HOME: "C:\\Users\\example\\.ade" }, - }; - - it("waits in slices and stops a brain that stopped beating", () => { - const script = renderWindowsServiceLauncher(command, { - pidPath: "C:\\ade\\launcher.pid.json", - heartbeatPath: "C:\\ade\\runtime\\heartbeat.json", - wedgeBreadcrumbPath: "C:\\ade\\runtime\\event-loop-wedge.json", - }); - - // An unbounded WaitForExit is exactly what makes a wedge invisible. - expect(script).not.toContain("$process.WaitForExit()\r\n $lastExitCode"); - expect(script).toContain("while (-not $process.WaitForExit($heartbeatPollMs))"); - expect(script).toContain("Test-BrainWedged $process.Id"); - expect(script).toContain("Write-WedgeBreadcrumb $wedgeAgeMs"); - // Kill($true) is .NET Core only; the supervisor must run under PS 5.1. - expect(script).toContain("$process.Kill()"); - expect(script).not.toContain("$process.Kill($true)"); - }); - - it("stops the wedged brain's whole process tree, through an absolute taskkill", () => { - const script = renderWindowsServiceLauncher(command, { - pidPath: "C:\\ade\\launcher.pid.json", - heartbeatPath: "C:\\ade\\runtime\\heartbeat.json", - wedgeBreadcrumbPath: "C:\\ade\\runtime\\event-loop-wedge.json", - }); - - // Absolute System32 path, never a bare `taskkill` off PATH. On a real - // Windows host the resolver returns the verified filesystem form - // (C:\Windows\System32\taskkill.exe); elsewhere it falls back to the - // kernel GLOBALROOT form — both end in System32\taskkill.exe. - expect(script).toMatch(/\$taskkillPath = '[^']*System32\\taskkill\.exe'/i); - expect(script).toContain("& $taskkillPath '/PID' $process.Id '/T' '/F'"); - // Kill() alone orphans ConPTYs and agent CLIs, so the tree kill must come - // first and Kill() must only mop up what taskkill could not. - const taskkillAt = script.indexOf("& $taskkillPath"); - const killAt = script.indexOf("$process.Kill()"); - expect(taskkillAt).toBeGreaterThan(-1); - expect(taskkillAt).toBeLessThan(killAt); - expect(script).toContain("if (-not $process.HasExited) { $process.Kill() }"); - // Bounded, and never a bare WaitForExit(): if taskkill was unresolvable and - // Kill() threw, an unbounded wait parks the supervisor on the wedge forever. - expect(script).not.toContain("$process.WaitForExit()"); - const waitAt = script.indexOf("if ($process.WaitForExit(30000)) { break }", killAt); - expect(waitAt).toBeGreaterThan(killAt); - expect(script).toContain("did not exit after the kill"); - // Leaving the wait loop is conditional on the process being GONE. An - // unconditional break after a timed-out kill would start a second brain - // beside an unkillable one, both wanting the same ports and worktrees. - expect(script).not.toMatch(/WaitForExit\(30000\)[^\r\n]*[\r\n]+\s*break/); - const wedgeRetryAt = script.indexOf("retrying on the next heartbeat check", waitAt); - expect(wedgeRetryAt).toBeGreaterThan(waitAt); - // The bounded wait can fall through with the process still alive, and - // `.ExitCode` throws on a live process -- which would surface the wedge as - // a launch failure. Read it only once the process has actually exited. - expect(script).toContain( - "if ($process.HasExited) { $lastExitCode = $process.ExitCode } else { $lastExitCode = $null }", - ); - // ...and never as an unguarded statement of its own. - expect(script).not.toMatch(/(?:^|[\r\n])\s*\$lastExitCode = \$process\.ExitCode/); - }); - - it("keeps the beat interval and stale threshold bound to the brain's own", () => { - const script = renderWindowsServiceLauncher(command, { - pidPath: "C:\\ade\\launcher.pid.json", - heartbeatPath: "C:\\ade\\runtime\\heartbeat.json", - }); - expect(script).toContain(`$heartbeatStaleMs = ${BRAIN_HEARTBEAT_STALE_MS}`); - expect(script).toContain(`$heartbeatPollMs = ${BRAIN_HEARTBEAT_INTERVAL_MS}`); - }); - - it("only judges a beat that belongs to the child it started", () => { - const script = renderWindowsServiceLauncher(command, { - pidPath: "C:\\ade\\launcher.pid.json", - heartbeatPath: "C:\\ade\\runtime\\heartbeat.json", - wedgeBreadcrumbPath: "C:\\ade\\runtime\\event-loop-wedge.json", - }); - expect(script).toContain("if ([int]$beat.pid -ne $runtimePid) { return $null }"); - expect(script).toContain("if ($ageMs -le $heartbeatStaleMs) { return $null }"); - }); - - it("keeps its old exit-only behaviour when no heartbeat path is configured", () => { - const script = renderWindowsServiceLauncher(command, { - pidPath: "C:\\ade\\launcher.pid.json", - }); - expect(script).toContain("$heartbeatPath = $null"); - expect(script).toContain("if ([string]::IsNullOrEmpty($heartbeatPath)) { return $null }"); - }); - - it("refuses a stale threshold short enough to fire on an ordinary gap", () => { - const script = renderWindowsServiceLauncher(command, { - pidPath: "C:\\ade\\launcher.pid.json", - heartbeatPath: "C:\\ade\\runtime\\heartbeat.json", - heartbeatStaleMs: 500, - }); - expect(script).toContain("$heartbeatStaleMs = 30000"); - }); -}); diff --git a/apps/ade-cli/src/services/runtime/brainStartupState.test.ts b/apps/ade-cli/src/services/runtime/brainStartupState.test.ts new file mode 100644 index 0000000000..ae321bc492 --- /dev/null +++ b/apps/ade-cli/src/services/runtime/brainStartupState.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { readBrainStartupState } from "./brainStartupState"; + +function deps(overrides: { + installed?: boolean | null; + running?: boolean | null; + pid?: number | null; + ageMs?: number | null; +} = {}) { + return { + getServiceStatus: async () => ({ + installed: overrides.installed === undefined ? true : overrides.installed, + running: overrides.running === undefined ? true : overrides.running, + }), + getServiceMainPid: async () => (overrides.pid === undefined ? 4242 : overrides.pid), + readBrainAgeMs: async () => (overrides.ageMs === undefined ? 5_000 : overrides.ageMs), + youngBrainMs: 120_000, + }; +} + +describe("readBrainStartupState", () => { + it("calls a registered service whose brain is young 'starting'", async () => { + await expect(readBrainStartupState(deps())).resolves.toMatchObject({ + starting: true, + ageMs: 5_000, + }); + }); + + it("stops calling it starting once the brain outlives the young window", async () => { + await expect(readBrainStartupState(deps({ ageMs: 130_000 }))).resolves.toMatchObject({ + starting: false, + }); + }); + + it("is not starting when the service is absent or stopped", async () => { + await expect(readBrainStartupState(deps({ installed: false }))).resolves.toMatchObject({ + starting: false, + }); + await expect(readBrainStartupState(deps({ running: false }))).resolves.toMatchObject({ + starting: false, + }); + }); + + it("fails closed when the age cannot be read or a probe throws", async () => { + await expect(readBrainStartupState(deps({ ageMs: null }))).resolves.toMatchObject({ + starting: false, + }); + await expect(readBrainStartupState({ + getServiceStatus: async () => { + throw new Error("systemctl missing"); + }, + })).resolves.toMatchObject({ starting: false }); + }); +}); diff --git a/apps/ade-cli/src/services/runtime/brainStartupState.ts b/apps/ade-cli/src/services/runtime/brainStartupState.ts new file mode 100644 index 0000000000..89de5f2cf8 --- /dev/null +++ b/apps/ade-cli/src/services/runtime/brainStartupState.ts @@ -0,0 +1,97 @@ +import { + readPidElapsedMs, + RUNTIME_SERVICE_YOUNG_BRAIN_MS, +} from "../../serviceManager/common"; + +/** + * The CLI's read of the desktop's `brain_starting` recovery state. + * + * A brain that does not answer on its socket is not automatically broken: when + * the service is registered and its brain process is alive but younger than the + * young-brain window, it is still coming up (first launch, cold disk, large + * project database). The desktop reaches this verdict through its connection + * pool (`ProjectRecoveryService.diagnose` -> `brain_starting`); the CLI has no + * pool, so it asks the platform service manager the same two questions — + * is the service registered and running, and how old is its brain — and applies + * the same `RUNTIME_SERVICE_YOUNG_BRAIN_MS` bound. + * + * Time-bounded for the same reason the desktop's is: without the age check a + * brain that wedged during boot would read as "starting" forever. + */ +export type BrainStartupState = { + /** Registered, alive, and young: waiting is the right move, not repairing. */ + starting: boolean; + /** Age of the service's brain process in ms, or null when unknown. */ + ageMs: number | null; + serviceInstalled: boolean | null; + serviceRunning: boolean | null; +}; + +export type BrainStartupStateDeps = { + getServiceStatus?: () => Promise<{ installed: boolean | null; running: boolean | null }>; + getServiceMainPid?: () => Promise; + readBrainAgeMs?: (pid: number | null) => Promise; + youngBrainMs?: number; +}; + +async function defaultGetServiceStatus(): Promise<{ + installed: boolean | null; + running: boolean | null; +}> { + const { getRuntimeServiceStatus } = await import("../../serviceManager"); + const status = getRuntimeServiceStatus(); + return { installed: status.installed, running: status.running }; +} + +async function defaultGetServiceMainPid(): Promise { + const { getRuntimeServiceMainPid } = await import("../../serviceManager"); + return getRuntimeServiceMainPid(); +} + +/** + * Windows has no `ps -o etime=`, but its supervisor already records when it + * launched the brain it is watching, which is the same measurement. + */ +async function defaultReadBrainAgeMs(pid: number | null): Promise { + if (process.platform === "win32") { + const { readWindowsServicePidRecord } = await import("../../serviceManager/installWindows"); + const startedAtMs = readWindowsServicePidRecord()?.runtimeStartedAtMs ?? null; + if (startedAtMs == null || !Number.isFinite(startedAtMs)) return null; + return Math.max(0, Date.now() - startedAtMs); + } + return pid == null ? null : readPidElapsedMs(pid); +} + +/** + * Call this only when the brain did NOT answer — a responding brain is running, + * never "starting". + */ +export async function readBrainStartupState( + deps: BrainStartupStateDeps = {}, +): Promise { + const youngBrainMs = deps.youngBrainMs ?? RUNTIME_SERVICE_YOUNG_BRAIN_MS; + let installed: boolean | null = null; + let running: boolean | null = null; + let ageMs: number | null = null; + try { + const status = await (deps.getServiceStatus ?? defaultGetServiceStatus)(); + installed = status.installed; + running = status.running; + // No registered service, or a registered one the supervisor is not running: + // nothing is coming up, so this is a real failure and stays one. + if (installed === true && running !== false) { + const pid = await (deps.getServiceMainPid ?? defaultGetServiceMainPid)(); + ageMs = await (deps.readBrainAgeMs ?? defaultReadBrainAgeMs)(pid); + } + } catch { + // Any probe failure fails closed to "not starting": reporting a brain as + // starting when we cannot tell would hide a genuinely dead one. + return { starting: false, ageMs: null, serviceInstalled: installed, serviceRunning: running }; + } + return { + starting: installed === true && running !== false && ageMs != null && ageMs < youngBrainMs, + ageMs, + serviceInstalled: installed, + serviceRunning: running, + }; +} diff --git a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx index dbf58be7fe..40c496adf2 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx @@ -7,6 +7,7 @@ import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; import type { BufferedEvent } from "../../eventBuffer"; import type { AdeCodeConnection, ProjectLaunchContext } from "../types"; import { captureTuiProductAnalytics, deriveTuiAnalyticsScreen } from "../productAnalytics"; +import { RuntimeServiceStillStartingError } from "../../serviceManager/common"; const mocks = vi.hoisted(() => ({ connectToAde: vi.fn(), @@ -512,6 +513,28 @@ describe("AdeCodeApp polling", () => { await unmountApp(instance); }); + it("shows a waiting state, not a failure, while the background service is starting", async () => { + // The desktop stopped calling a slow-starting brain a broken one; the TUI + // must not keep telling the same user that ADE Code failed to start. + mocks.connectToAde.mockImplementation(async () => { + throw new RuntimeServiceStillStartingError({ + kind: "not_answered", + socketPath: "/tmp/ade/sock/ade.sock", + installMessage: "ADE brain service is registered and starting", + }); + }); + + const instance = await renderApp(); + + await waitForFrame(instance, "ADE's background service is starting"); + const frame = stripAnsi(instance.frames.join("\n")); + expect(frame).toContain("there is nothing to do"); + expect(frame).toContain("r retry now"); + expect(frame).not.toContain("ADE Code failed to start"); + + await unmountApp(instance); + }); + it("renders remote startup failures as recoverable connection loss", async () => { mocks.connectToAde.mockImplementation(async () => { throw new Error( diff --git a/apps/ade-cli/src/tuiClient/__tests__/reportIssue.test.ts b/apps/ade-cli/src/tuiClient/__tests__/reportIssue.test.ts new file mode 100644 index 0000000000..fc49a90de0 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/reportIssue.test.ts @@ -0,0 +1,64 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildTuiDiagnosticReport } from "../reportIssue"; + +const tempDirs: string[] = []; + +function tempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + while (tempDirs.length) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("buildTuiDiagnosticReport", () => { + it("writes an owner-only report and points the pane at it", () => { + const adeHome = tempDir("ade-home-"); + const reportsDir = path.join(adeHome, "diagnostic-reports"); + const built = buildTuiDiagnosticReport({ + projectRoot: null, + env: { ADE_HOME: adeHome, ADE_CLI_VERSION: "9.9.9" }, + now: () => new Date("2026-08-16T09:30:00.000Z"), + reportsDir, + }); + + expect(built.filePath).toBe( + path.join(reportsDir, "2026-08-16T09-30-00-000Z-ade-code.md"), + ); + const stat = fs.statSync(built.filePath!); + // The report carries machine state; it must not be world-readable. + expect(stat.mode & 0o077).toBe(0); + expect(fs.readFileSync(built.filePath!, "utf8")).toContain("9.9.9"); + + // The pane body is the only thing the user sees, so it has to carry both + // ways of acting on the report. + expect(built.body).toContain(built.filePath!); + expect(built.body).toContain(built.issueUrl); + }); + + it("still yields an issue URL when the report cannot be written", () => { + const adeHome = tempDir("ade-home-"); + const blocked = path.join(adeHome, "blocked"); + // A file where the directory should be: mkdir fails, and reporting a bug + // must not itself fail. + fs.writeFileSync(blocked, "not a directory"); + + const built = buildTuiDiagnosticReport({ + projectRoot: null, + env: { ADE_HOME: adeHome }, + reportsDir: path.join(blocked, "reports"), + }); + + expect(built.filePath).toBeNull(); + expect(built.issueUrl).toMatch(/^https:\/\/github\.com\//); + expect(built.body).toContain(built.issueUrl); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 7565d608e1..a479c06a65 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -166,6 +166,10 @@ import type { SnoozeDurationKey } from "../../../desktop/src/renderer/lib/sessio import { buildHelpIndex, buildHelpRows, flattenHelpRows, pushRecent } from "./helpIndex"; import { hasFirstUserMessage, isPlanMode } from "./planMode"; import { connectToAde, INTERACTIVE_PROJECT_REGISTRATION } from "./connection"; +// Imported from the service manager rather than re-exported through +// ./connection: several suites mock ./connection with a partial factory, and a +// startup screen must not depend on an export those mocks have to remember. +import { RuntimeServiceStillStartingError } from "../serviceManager/common"; import { captureTuiProductAnalytics, deriveTuiAnalyticsScreen } from "./productAnalytics"; import { WorkSessionsPane } from "./components/WorkSessionsPane"; import { @@ -3500,6 +3504,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const [promptCursor, setPromptCursor] = useState(0); const [backgroundLaunchStatus, setBackgroundLaunchStatus] = useState(null); const [error, setError] = useState(null); + /** + * The last connect failed only because a supervised brain had not answered + * yet. Nothing is broken and nothing needs repairing, so the startup screen + * says so and keeps waiting instead of showing a red failure. + */ + const [startupServiceStarting, setStartupServiceStarting] = useState(false); const [contextPercent, setContextPercent] = useState(null); const [tokenSummary, setTokenSummary] = useState(null); const [statusLineStats, setStatusLineStats] = useState(null); @@ -8303,6 +8313,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, connectionRetryTimerRef.current = null; } setError(null); + setStartupServiceStarting(false); setMode("connecting"); setConnectionRetrySeq((seq) => seq + 1); }, []); @@ -8315,6 +8326,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } setMode("connecting"); setError(null); + setStartupServiceStarting(false); void (async () => { try { const conn = await connectToAde({ project, forceEmbedded, requireSocket, socketPath, preferServiceRepair, remote: remoteLaunch, projectRegistration: INTERACTIVE_PROJECT_REGISTRATION }); @@ -8382,6 +8394,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, heartbeatRef.current = null; const message = err instanceof Error ? err.message : String(err); setError(message); + setStartupServiceStarting(err instanceof RuntimeServiceStillStartingError); setMode("connecting"); connectionRetryTimerRef.current = setTimeout(() => { connectionRetryTimerRef.current = null; @@ -10429,6 +10442,30 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return; } + if (name === "/report-issue") { + // Deliberately above the `!conn` gate: the report reads local files only, + // so it still answers while the runtime is unreachable — the state a bug + // report is most worth filing from. + try { + const { buildTuiDiagnosticReport } = await import("./reportIssue"); + const built = buildTuiDiagnosticReport({ projectRoot: project.projectRoot }); + setRightPane({ kind: "details", title: "Report issue", body: built.body }); + } catch (error) { + setRightPane({ + kind: "details", + title: "Report issue", + body: [ + "The report could not be built.", + "", + error instanceof Error ? error.message : String(error), + "", + "Run `ade report-issue --open` in any terminal instead.", + ].join("\n"), + }); + } + return; + } + if (!conn) { if (name === "/help") { renderHelpPane("", 0, helpRecentsRef.current); @@ -17245,6 +17282,24 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, if (error && !connection) { const remoteLabel = project.remoteLabel?.trim() || "the remote computer"; + // A supervised brain that has not answered yet is not a failure. Mirror the + // desktop's `brain_starting` recovery copy: say what is happening, promise + // it opens on its own, and offer nothing to repair. + if (startupServiceStarting) { + return ( + + ADE's background service is starting + + This can take a minute the first time or right after an update. + ADE Code opens as soon as it is ready — there is nothing to do. + + {error} + + Waiting automatically · r retry now · Ctrl+C quit + + + ); + } return ( @@ -17261,6 +17316,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, Retrying automatically · r retry now · Ctrl+C quit + + Run `ade report-issue --open` in another terminal to file this with a redacted diagnostic report. + ); } diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index 1e92791c31..2436f418d4 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -144,6 +144,9 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/keybindings", description: "Show Claude-compatible keybinding config diagnostics", placement: "right", argumentHint: "[open]", category: "System" }, { name: "/statusline", description: "Show Claude-compatible status line config", placement: "right", category: "System" }, { name: "/doctor", description: "Show ADE Code and Claude-compat diagnostics", placement: "right", category: "System" }, + // The terminal counterpart of the desktop "Report issue" button. Local-only, + // like `ade report-issue`, so it still works when the brain is the problem. + { name: "/report-issue", description: "Build a redacted diagnostic report for a bug report", placement: "right", category: "System" }, { name: "/model", description: "Open the model, reasoning, and permission picker", placement: "right", category: "Model" }, { name: "/effort", description: "Open the reasoning-effort picker", placement: "right", category: "Model" }, { name: "/system", description: "Show system and runtime details", placement: "right", category: "System" }, diff --git a/apps/ade-cli/src/tuiClient/reportIssue.ts b/apps/ade-cli/src/tuiClient/reportIssue.ts new file mode 100644 index 0000000000..a9c96af440 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/reportIssue.ts @@ -0,0 +1,91 @@ +import fs from "node:fs"; +import path from "node:path"; +import { buildCliDiagnosticReport } from "../commands/reportIssue"; +import { diagnosticReportFilePath } from "../services/diagnostics/diagnosticReport"; +import { resolveMachineAdeLayout } from "../services/projects/machineLayout"; + +/** + * `/report-issue` for the TUI — the terminal counterpart of the desktop + * "Report issue" button and of `ade report-issue`. + * + * Like both of those it reads only local files: it never asks the brain for + * anything, so it still answers on a machine where the brain is the problem. + * The report is redacted in {@link buildCliDiagnosticReport} (private paths, + * account names, emails, addresses and tokens) before it is written anywhere. + */ + +declare const __ADE_VERSION__: string | undefined; + +/** + * The TUI bundle carries the same `__ADE_VERSION__` define as the `ade` + * entrypoint, so a report filed from `ade code` names the same build. Running + * from source (tests, `npm run dev:code`) has no define and falls back to the + * env var the packaged runtime sets. + */ +export function resolveTuiCliVersion(env: NodeJS.ProcessEnv = process.env): string | null { + const bundled = typeof __ADE_VERSION__ === "string" ? __ADE_VERSION__.trim() : ""; + if (bundled && bundled !== "0.0.0") return bundled; + return env.ADE_CLI_VERSION?.trim() || bundled || null; +} + +export type TuiDiagnosticReport = { + /** Narrow-pane summary rendered by the `details` right pane. */ + body: string; + /** Where the full report landed, or null when it could not be written. */ + filePath: string | null; + issueUrl: string; + installId: string; +}; + +/** Mirrors the desktop's report file: owner-only directory and file. */ +function writeReportFile(filePath: string, report: string): boolean { + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(filePath, report, { encoding: "utf8", mode: 0o600 }); + return true; + } catch { + // A read-only or full disk must not turn "report a bug" into a second bug; + // the issue URL below is still usable on its own. + return false; + } +} + +export function buildTuiDiagnosticReport(args: { + projectRoot: string | null; + env?: NodeJS.ProcessEnv; + now?: () => Date; + /** Overrides the directory the report is written to (tests). */ + reportsDir?: string; +}): TuiDiagnosticReport { + const env = args.env ?? process.env; + const at = args.now?.() ?? new Date(); + const surface = "ade_code"; + const built = buildCliDiagnosticReport({ + surface, + projectRoot: args.projectRoot, + cliVersion: resolveTuiCliVersion(env), + env, + now: () => at, + }); + const reportsDir = args.reportsDir + ?? path.join(resolveMachineAdeLayout(env).adeDir, "diagnostic-reports"); + const filePath = diagnosticReportFilePath(reportsDir, surface, at); + const written = writeReportFile(filePath, built.report); + const body = [ + "A diagnostic report has been prepared.", + "Private paths, account names, emails and tokens are removed before it is written.", + "", + written ? "Saved to:" : "It could not be saved to disk, so paste it from the issue page instead.", + written ? filePath : null, + "", + "File the issue at:", + built.issueUrl, + "", + `Install id: ${built.installId}`, + "", + "If ADE Code will not start at all, run `ade report-issue --open` in any terminal — it reads local files only.", + ] + .filter((line): line is string => line !== null) + .join("\n"); + return { body, filePath: written ? filePath : null, issueUrl: built.issueUrl, installId: built.installId }; +} diff --git a/apps/desktop/src/main/services/analytics/productAnalyticsService.test.ts b/apps/desktop/src/main/services/analytics/productAnalyticsService.test.ts index b450f458f8..39e6a5c668 100644 --- a/apps/desktop/src/main/services/analytics/productAnalyticsService.test.ts +++ b/apps/desktop/src/main/services/analytics/productAnalyticsService.test.ts @@ -1264,6 +1264,45 @@ describe("product analytics producers", () => { })).not.toHaveProperty("count_bucket"); }); + it("keeps the Report-issue outcome through the sanitizer and nothing else", () => { + // "Report issue" is the one control on ADE's error screens, so the only + // product question is whether pressing it reaches GitHub. `action` is + // allowlisted separately from the event's key list, so a new coarse action + // that is not registered ships anonymous rather than not at all. + expect(sanitizeProductAnalyticsProperties("ade_feature_used", { + feature: "connections", + action: "issue_report", + outcome: "opened", + })).toEqual({ feature: "connections", action: "issue_report", outcome: "opened" }); + + expect(sanitizeProductAnalyticsProperties("ade_feature_used", { + feature: "connections", + action: "issue_report", + outcome: "failed", + })).toMatchObject({ outcome: "failed" }); + + // The report itself, the screen it came from and the install id it carries + // are for the local file and the clipboard. None of them may ride along on + // the event, whether they arrive under a known key or an invented one. + const leaky = sanitizeProductAnalyticsProperties("ade_feature_used", { + feature: "connections", + action: "issue_report", + outcome: "opened", + surface: "project_recovery", + code: "db_integrity", + install_id: "ade_0123456789abcdef0123456789abcdef", + headline: "ADE couldn't open /Users/ada/photon", + } as Record); + expect(leaky).toEqual({ feature: "connections", action: "issue_report", outcome: "opened" }); + + // An outcome outside the closed set is dropped, not passed through. + expect(sanitizeProductAnalyticsProperties("ade_feature_used", { + feature: "connections", + action: "issue_report", + outcome: "ENOSPC: no space left on device", + })).not.toHaveProperty("outcome"); + }); + it("maps automation completion and failed chat turns into canonical bounded outcomes", () => { const captures: ProductAnalyticsCapture[] = []; const analytics = settledAnalytics(captures); diff --git a/apps/desktop/src/main/services/diagnostics/diagnosticReportService.test.ts b/apps/desktop/src/main/services/diagnostics/diagnosticReportService.test.ts index 295e3f6f01..75cdcd6528 100644 --- a/apps/desktop/src/main/services/diagnostics/diagnosticReportService.test.ts +++ b/apps/desktop/src/main/services/diagnostics/diagnosticReportService.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterAll, describe, expect, it } from "vitest"; +import { afterAll, describe, expect, it, vi } from "vitest"; import { collectDiagnosticReport } from "./diagnosticReportService"; const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-diag-report-")); @@ -38,6 +38,35 @@ describe("collectDiagnosticReport", () => { expect(report).toContain("- requested project root was not recognised; machine-level state only"); }); + // The other half of the same regression: refusing the renderer's root has to + // mean the report is genuinely machine-scoped. If the collector still ran a + // project diagnosis, the note would say "machine-level state only" over a + // body that quietly carried the open project's recovery verdict. + it("runs no project diagnosis at all for a machine-level report", async () => { + const diagnoseProject = vi.fn(async () => ({ state: "healthy" })); + + const machineLevel = await collectDiagnosticReport( + { ...deps(), diagnoseProject }, + { surface: "project_recovery", projectRoot: null }, + ); + + expect(diagnoseProject).not.toHaveBeenCalled(); + expect(machineLevel.report).not.toContain("healthy"); + + // ...and the project-scoped path is still wired, so the assertion above is + // about the null root rather than a diagnosis that never runs. + const projectRoot = path.join(tempRoot, "photon"); + fs.mkdirSync(projectRoot, { recursive: true }); + const scoped = await collectDiagnosticReport( + { ...deps(), diagnoseProject }, + { surface: "project_recovery", projectRoot }, + ); + + expect(diagnoseProject).toHaveBeenCalledTimes(1); + expect(diagnoseProject).toHaveBeenCalledWith(projectRoot); + expect(scoped.report).toContain("healthy"); + }); + it("omits the notes line when there is nothing to say", async () => { const { report } = await collectDiagnosticReport(deps(), { surface: "project_recovery", diff --git a/apps/desktop/src/main/services/ipc/knownProjectRoots.test.ts b/apps/desktop/src/main/services/ipc/knownProjectRoots.test.ts index 01f0dfe7ba..d044cbf03b 100644 --- a/apps/desktop/src/main/services/ipc/knownProjectRoots.test.ts +++ b/apps/desktop/src/main/services/ipc/knownProjectRoots.test.ts @@ -83,13 +83,17 @@ describe("resolveKnownProjectRoot", () => { ).toBeNull(); }); - it("resolves a symlink to a known project", () => { + it("resolves a symlink to a known project", (ctx) => { const link = path.join(tempRoot, "link-to-open"); try { fs.symlinkSync(openProject, link, "dir"); } catch { - return; // No symlink privilege (Windows without developer mode). + // Windows without developer mode has no symlink privilege. Skip loudly + // rather than `return`, which reads as a pass and hides the gap. + ctx.skip(); + return; } + expect(fs.lstatSync(link).isSymbolicLink(), "symlink setup").toBe(true); expect(resolveKnownProjectRoot(link, sources)).toBe(openProject); }); }); diff --git a/apps/desktop/src/renderer/components/app/ReportIssueButton.test.tsx b/apps/desktop/src/renderer/components/app/ReportIssueButton.test.tsx index 92f41aa658..c6b7416295 100644 --- a/apps/desktop/src/renderer/components/app/ReportIssueButton.test.tsx +++ b/apps/desktop/src/renderer/components/app/ReportIssueButton.test.tsx @@ -70,14 +70,6 @@ describe("ReportIssueButton", () => { expect(screen.queryByText(/ENOSPC/)).toBeNull(); }); - it("explains what the report contains and that private details are removed", () => { - installBridge(vi.fn()); - render(); - - expect(screen.getByText("What's in the report?")).toBeTruthy(); - expect(screen.getByText(/File paths, your name, email addresses/i)).toBeTruthy(); - }); - it("drops the disclosure inside one-line banners, and keeps it when asked", () => { installBridge(vi.fn()); const { rerender } = render(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e444a9f3b7..1e186c08d8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -136,6 +136,8 @@ Product positioning and workflows live in [`docs/PRD.md`](../docs/PRD.md). This | `agentRegistry.ts` | Per-machine agent registry. | **Service managers.** `apps/ade-cli/src/serviceManager/installLaunchd.ts` (macOS), `installSystemd.ts` (Linux), and `installWindows.ts` (Windows) register the brain as a login-time service. `index.ts` is the platform router; `common.ts` carries shared types (`ServiceManagerResult`, `ServiceManagerStatusResult`). Windows uses a per-user, per-channel entry under `HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run`, so installation does not require administrator access. The entry starts a UTF-8-with-BOM PowerShell supervisor under the channel's ADE home. It restores the complete resolved brain environment (including `ELECTRON_RUN_AS_NODE`, `NODE_PATH`, ADE paths/channel, and role), starts the executable with CRT-compatible argv quoting, and records supervisor/runtime PIDs plus restart count, last exit, and next-restart diagnostics. Unexpected exits restart with capped exponential backoff; a healthy runtime interval resets the counter. Install and status require runtime RPC readiness on the expected per-user/channel pipe and verify the recorded process's executable, entrypoint, and `serve` arguments, so a stale/reused PID or live-but-unready supervisor is never reported as a healthy brain. Service identity is resolved after packaged channel defaults are applied, keeping Stable and Beta Run entries and homes distinct. Install and uninstall also remove exact Scheduled Task registrations left by earlier Windows preview builds. + +**Installed is not the same as answering.** All three installers share one handover: `serviceHandover.ts` (`awaitServiceHandover`, `awaitYoungBrainStart` — responsiveness probe, young-brain age check, crash-loop veto, wait loop) and one set of numbers, `runtimeServiceBudgets.ts`, because the budgets only mean anything relative to each other (the desktop's post-install wait has to outlast the installer's). Each installer keeps only its own message text. A `ServiceManagerResult` therefore carries `starting` alongside `ok`: the service is registered and its child is alive, but it had not answered on the endpoint inside the installer's budget. That is a slow start, not a failed install, and every caller has to treat it as such — `ade connect` says "installed — the background service is still starting", `ade setup` waits for the endpoint (`services/runtime/awaitRuntimeServiceEndpoint.ts`) instead of failing its account step against a socket that was never given time to open, and `connectWhileServiceStarts.ts` keeps dialling and finally throws `RuntimeServiceStillStartingError` rather than returning a plain connect failure, because that failure is what let a caller spawn an unmanaged rival brain on a socket the supervisor already owns. A forced install (desktop **Repair**) that finds an unchanged agent whose child is younger than `RUNTIME_SERVICE_YOUNG_BRAIN_MS` and not answering yet waits for it instead of killing it; a recorded crash-loop streak vetoes that wait, since a brain that keeps dying is always young. See [features/remote-runtime/README.md](./features/remote-runtime/README.md) for the full lifecycle. On macOS, an unchanged loaded launch agent is retained only after a bounded runtime initialize probe succeeds. A failed probe takes the full unload, predecessor termination, stale-process reap, and load path; the install result @@ -230,7 +232,9 @@ ade brain update --text ade brain update status --text ``` -Use `ADE_VERSION=vX.Y.Z` for a pinned release or `ADE_INSTALL_DIR` to choose the destination directory. The installer defaults to `$ADE_HOME/bin/ade`; both install and `ade brain update` verify downloaded runtime assets against `SHA256SUMS`. `ade brain update` stages the next release under `$ADE_HOME/runtime/updates/`, verifies the staged binary against the staged native deps, promotes the binary/deps into place, and restarts the per-user brain service. +Use `ADE_VERSION=vX.Y.Z` for a pinned release or `ADE_INSTALL_DIR` to choose the destination directory. The installer defaults to `$ADE_HOME/bin/ade`; both install and `ade brain update` verify downloaded runtime assets against `SHA256SUMS`. + +`$TMPDIR` holds the downloads and nothing else. The staged runtime (`.new`), the runtime backup (`.previous`), the staged binary copy (`ade.new`) and the binary backup (`ade.bak`) all sit next to what they replace, under the ADE home, for two reasons: promotion and restore are then same-directory renames (a `mv` out of `$TMPDIR` crosses filesystems, and a half-copied `ade` or runtime tree is the broken install this staging exists to prevent), and a `noexec` `/tmp` — common on hardened Linux hosts and in containers — would otherwise fail the preflight on every install. The preflight runs the *new* binary against the *staged* runtime with the runtime sidecar env in place, so it exercises the same files the promoted install will `dlopen`; a download that cannot print its version never replaces `$dest_dir/ade`. What it printed is kept at `$ADE_HOME/install-failure.log` (deleted on success, so a leftover log is never a false alarm), and the failure message is stage-aware: before promotion it says the existing install was not touched, after a rollback it says the previous one was put back. Signal-trapped cleanup removes only scratch state, and restores a backup that is currently the machine's only copy — an abort between the two renames used to be able to leave a machine with nothing installed. `apps/ade-cli/scripts/install-runtime-rollback.test.mjs` drives these paths in CI. `ade brain update` stages the next release under `$ADE_HOME/runtime/updates/`, verifies the staged binary against the staged native deps, promotes the binary/deps into place, and restarts the per-user brain service. That same update path is reachable *remotely*, because a machine quietly running old code — or one no local desktop ever connects to — has to be fixable from @@ -253,10 +257,12 @@ Windows x64 uses the equivalent PowerShell installer: irm https://ade-app.dev/install.ps1 | iex ``` -It installs `ade-win32-x64.exe` as `%ADE_HOME%\bin\ade.exe`, transactionally stages the binary and native dependency tree, updates the current-user `PATH`, and registers the same per-user/channel brain service. Windows self-update stops the running executable before promotion and rolls back both the executable/native tree and service on failure. +It installs `ade-win32-x64.exe` as `%ADE_HOME%\bin\ade.exe`, transactionally stages the binary and native dependency tree, updates the current-user `PATH`, and registers the same per-user/channel brain service. Windows self-update stops the running executable before promotion and rolls back both the executable/native tree and service on failure. It stages exactly like its POSIX twin and for the same two reasons in Windows spellings: `%TEMP%` is routinely on a different volume from the user profile (redirected TEMP, a RAM disk, a roaming profile), where `Move-Item` degrades to copy-then-delete, and AppLocker and most EDR agents block execution out of `%TEMP%` outright. So `.new` / `.previous`, `ade.new.exe` and `ade.bak.exe` live under the ADE home and the install dir, every promotion is a same-directory rename, and the stage-aware failure note distinguishes "your existing ADE was not touched" from "the ADE you already had was put back". **Health check (`ade doctor [--online] [--text]`).** `apps/ade-cli/src/commands/doctor.ts` connects to the machine brain over the local socket (bounded ~2 s) and prints one status row (`ok` / `warn` / `fail`) per subsystem: **App** (installed desktop version from the `.app` `Info.plist` vs the latest known version — read from disk, or from GitHub with `--online`), **Brain** (running version/pid/uptime plus any build-hash or role mismatch), **Wedge history** (the most recent recovered event-loop wedge, if any), **Sync port** (whether the shared listener bound the default `8787`, and the holders of the base ports when it drifted — with no visible holder reported as exactly that, since a root-owned holder such as `tailscaled` is invisible to a user-level probe and must be checked with `tailscale serve status` / `netstat -an -p tcp`), **Publish health** (the account-directory publisher's last-leg durations and slowest leg), **Relay** (end-to-end verified vs a classified failure — with a deliberate suppression, i.e. another ADE process on this machine owning the relay slot, outranking every other reason, since nothing downstream can succeed while it holds and no other reason tells the user what to do), and **Account** (signed-in state and source). The command exits non-zero when any row is `fail`. The row-evaluation logic (`evaluateDoctorRows`) is pure and dependency-injected so the desktop connection-doctor card and the CLI share one verdict. +**Diagnostic report (`ade report-issue [--open]`).** `apps/ade-cli/src/commands/reportIssue.ts` prints the same redacted Markdown report the desktop's **Report issue** button produces, and `--open` opens the prefilled GitHub issue in the browser (`lib/externalLinks.ts`, which allows only `http(s)`/`mailto:` and falls back to Electron's `shell.openExternal` when the OS opener is unavailable). It reads local files only — it never starts or contacts the brain — so it still works on the machine where ADE will not come up, and on a headless or Windows host with no desktop error screen to press. The builder, the redactor, and the machine source collector (`services/diagnostics/diagnosticReport.ts`, `diagnosticSources.ts`) are shared with the desktop, so a log added for one appears in both. See [features/storage-and-recovery/README.md](./features/storage-and-recovery/README.md#diagnostic-reports-report-issue). + **Install + PATH wiring (when the desktop ships `ade`).** On macOS / Linux the desktop installer drops the launcher at `$HOME/.local/bin/ade`; on Windows it lands at `%LOCALAPPDATA%\ADE\bin\ade.cmd`. After a successful install on Windows, the packaged `.cmd` installer adds the target directory to HKCU `Environment\Path` when needed and broadcasts an environment-change notification. After a successful install on POSIX, `ensureUserBinOnShellPath` appends a marked `export PATH="$HOME/.local/bin:$PATH"` block to the user's shell rc (`.zshrc` for zsh, `.bashrc` for bash, `.profile` otherwise) iff (a) the install dir isn't already on the inherited `PATH` and (b) the file doesn't already contain the marker / line / target dir. The install IPC reply tells the renderer which profile was edited so the Settings/Onboarding UI can prompt the user to open a new terminal or `source` it. **Windows packaging.** The installer lays down `ade-cli-windows-wrapper.cmd` plus an `ade-cli-install-path.cmd` helper alongside the bundled Electron Node runtime. The helper installs `%LOCALAPPDATA%\ADE\bin\ade.cmd`, updates the user PATH when needed, and then `ade` works from a new normal Windows shell without a global Node install. See §14.4 for the packaging flow. @@ -743,8 +749,17 @@ Related feature docs: [Chat](./features/chat/README.md), [Agents](./features/age ade.app.* # app lifecycle, clipboard text and image (writeClipboardText, writeClipboardImage, saveClipboardImageAttachment), paths, image data-URL preview (getImageDataUrl), the deeplink navigation push channel ade.app.navigate (AppNavigationRequest payloads from the ade:// protocol handler, the ade code app/navigate JSON-RPC, and the iOS deeplinks.open sync command — see features/deeplinks/README.md), the one-way zoom push channel ade.app.zoomCommand (AppZoomCommand "in"/"out"/"reset" sent from the native View menu to the renderer's window.ade.zoom.onCommand so menu/keyboard zoom shares the in-app zoom path — display %, persistence, and the macOS traffic-light inset), and the resource-pressure snapshot ade.app.getResourceUsage (async, coalesced `AppResourceUsageSnapshot` backing the TopBar pressure indicator: one bounded/timeout-guarded `ps` sample shared across windows behind a 900 ms cache + in-flight coalescing, with disjoint per-role attribution built in `services/pty/resourceUsageSampling.ts` — see features/terminals-and-sessions/pty-and-sessions.md), the machine-level daemon-health snapshot ade.app.getRuntimeHealth (async `RuntimeHealthSnapshot` — a rolling 24 h count + p95 of slow/errored local-runtime action calls, read directly off `localRuntimeConnectionPool` with no action-domain routing, feeding the Storage > Diagnostics "slow responses" tile), and ade.app.restartBackgroundService (the Connections "Repair" control: restarts this machine's `com.ade.runtime` launch agent through `ProjectRecoveryService.restartBrain()` and resolves only after the replacement answers a ping, throwing otherwise. Direct IPC on purpose — the pool lives in Electron main and the daemon being restarted cannot route its own restart, so there is no action-domain routing and no null-service risk. It is optional in the preload surface: the hosted-web adapter and browser mock cannot touch a launch agent, so callers feature-detect. Each click records one `ade_feature_used` with `feature: "connections"`, `action: "brain_repair"`, and a coarse `outcome`) ade.project.* # project open/close/switch/state, unified local+remote recents (listRecent, key-based forget/reorder, setRecentPinned), in-app directory browser (browseDirectories, getDetail), git path inspection (inspectPath — ProjectPathInspection behind the renderer's worktree-open gate; promise-cached in services/projects/projectPathInspector.ts with a `fresh` bypass and invalidated on lane attach/adopt from both the in-process handler and the runtime-bridge action path), favicon resolver/override (resolveIcon, chooseIcon, removeIcon) with local-only filesystem allowlists. openRepo/switchToPath surface AdeRecoveryErrorCode-coded failures (via surfaceCodedError) so the renderer can route a failed open into the recovery screen ade.recovery.* # brain-independent project-open recovery: diagnose / repair - # (projectRecoveryService against projectRecoveryConnectionPool). + # (projectRecoveryService against projectRecoveryConnectionPool), + # plus the main → renderer push ade.recovery.repairStep, one + # RepairStepResult as each step finishes so a long restart wait + # reads as progress rather than a hang. diagnose/repair validate + # the renderer-supplied root against services/ipc/knownProjectRoots.ts. # See features/storage-and-recovery/README.md +ade.diagnostics.* # openIssue: assemble the redacted diagnostic report behind every + # error surface's "Report issue" button, save it under + # /diagnostic-reports/, copy it to the clipboard, and open + # a prefilled GitHub issue. Same builder as `ade report-issue`. + # See features/storage-and-recovery/README.md#diagnostic-reports-report-issue ade.storage.* # disk-pressure snapshot (getPressure) + storage dashboard: # getSnapshot / compressNow / runMaintenanceNow / cleanupPreview / # cleanup, backed by diskPressureMonitor + storageInsightsService @@ -932,13 +947,14 @@ Most services described here live under `apps/desktop/src/main/services/ | `deeplinks/` | `protocolHandler.ts`, `ownerAwareNavigation.ts`, `localProjectResolution.ts`, `projectNavigationWindowSelection.ts` | Registers the `ade://` OS protocol handler for the packaged Stable desktop build, owns the single-instance lock, buffers cold-start URLs until `app.whenReady()`, and dispatches parsed URLs through `IPC.appNavigate` to the focused window. Beta, Alpha, and source builds can receive explicitly delivered links but do not claim the OS-default handler. Re-used by the iOS Send-to-Mac sync command (`syncRemoteCommandService.deeplinks.open`). Account-owned targets route first through `ownerAwareNavigation.ts`: `localProjectResolution.ts` resolves the link's project against this machine (exact id, then the carried root path, then a recomputed canonical id), and `projectNavigationWindowSelection.ts` picks a window for a remote project without ever rebinding the one the user is working in. Shared parser + builder live in `apps/desktop/src/shared/deeplinks.ts`; the PR "Open in ADE" footer is in `apps/desktop/src/shared/adeDeeplinkFooter.ts`. See [features/deeplinks/README.md](./features/deeplinks/README.md). | | `devTools/` | `devToolsService.ts` | Probe for git + `gh` CLI availability. | | `diffs/` | `diffService.ts` | Diff computation for file panes. | +| `diagnostics/` | `diagnosticReportService.ts` | Assembles the redacted diagnostic report behind every error surface's **Report issue** button: the shared machine sources (`ade-cli/src/services/diagnostics/`) plus the desktop-only extras — its own jsonl logs, the local runtime status snapshot, the recovery diagnosis for the open project, the typed last-failure store, and an Electron-aware volume reader. Saves it `0600` under `/diagnostic-reports/`, copies it, and opens a prefilled GitHub issue carrying only a short (also redacted) stub. Never runs `ade doctor` and never starts the brain: the report has to be collectable on a machine whose brain will not start. | | `feedback/` | `feedbackReporterService.ts` | In-app feedback reporting. Two-stage: `prepareDraft` generates a structured issue title + labels (AI-assisted when a model is selected, deterministic fallback otherwise) so the user can review before posting; `submitPreparedDraft` files the GitHub issue. Each submission records `generationMode` and a `generationWarning` so the UI can flag deterministic drafts. | | `files/` | `fileService.ts`, `fileWatcherService.ts`, `fileSearchIndexService.ts` | Workspace file tree, read/write, watch, index. | | `git/` | `git.ts`, `gitOperationsService.ts`, `gitConflictState.ts` | Low-level git runner, high-level lane-scoped ops, conflict state queries. | | `github/` | `githubService.ts` | GitHub REST/GraphQL access; PR CRUD; checks; reviewers. | | `history/` | `operationService.ts` | Operation audit records (one row per mutation). | | `ios/` | `iosSimulatorService.ts` | macOS-only iOS Simulator backend: tool readiness probes, simctl device + app discovery, build/install/launch with progress events (hardened with `simctl bootstatus` and `simctl install` timeouts), screenshot + ADEInspector + accessibility hit-test, Simulator.app window live-view status, idb-backed input, and single-owner chat session locking. The macOS Simulator window placement / capture state probe (`getSimulatorWindowState`, `prepareSimulatorWindowForCapture`) lives next to the IPC handlers in `ipc/registerIpc.ts` because it depends on the active `BrowserWindow`. See [features/ios-simulator/README.md](./features/ios-simulator/README.md). | -| `ipc/` | `registerIpc.ts`, `runtimeBridge.ts`, `runtimeEventSubscriptionRegistry.ts`, `ipcTimeouts.ts` | Single registration point for all IPC handlers. `runtimeEventSubscriptionRegistry.ts` holds runtime-event subscriptions keyed by (sender, requestKey) with idle expiry and a single removal path (see §5.4). `runtimeBridge.ts` owns the runtime-facing channels (remote target registry, remote-runtime connect / project list / project-open / action dispatch / sync dispatch / event stream, per-target `listActionRegistry` lookup against the remote daemon, LAN + Tailscale discovery with diagnostics) and routes runtime calls through `LocalRuntimeConnectionPool` or `RemoteConnectionPool` based on the active window binding. The explicit `ade.sync.getLocalStatus` handler is the exception: it calls machine-level `sync.getStatus` on `LocalRuntimeConnectionPool` (with only the local in-process diagnostics service as fallback) so a remote-bound Connections panel can still identify the physical computer (its This computer card, pairing code, and local Phone/Web device lists). Device and pairing *mutations* still follow the window binding, so the panel presents them read-only while remote-bound rather than routing them to the remote machine. Event-stream subscription init/results preserve replay-gap metadata (`gap`, `oldestCursor`, `eventEpoch`) for both local and remote bindings, and subscription bookkeeping is delegated to `runtimeEventSubscriptionRegistry.ts`; `runtimeBridge.ts` derives the request key (one helper shared by the subscribe and release paths, so a release rebuilds exactly the key subscribe registered) and registers the `ade.runtime.events.release` handler, which resolves the binding from the same descriptor shape the subscribe call used and refuses to act on an unauthorized local root. Remote project opens are generation-guarded per window/webContents before main persists the binding. It also subscribes `powerMonitor` `resume` and `unlock-screen` to `remoteConnectionService.probeSavedConnections()` so a laptop waking up cycles dead SSH sessions before the renderer pokes them. Machine-level sync fallback recognizes only the canonical unavailable-service predicates in `shared/runtimeErrors.ts`, shared with preload and renderer recovery guidance. `ipcTimeouts.ts` carries the default 30-second handler timeout plus named channel-level overrides for long direct IPC operations; it does not inspect runtime action payloads. | +| `ipc/` | `registerIpc.ts`, `runtimeBridge.ts`, `runtimeEventSubscriptionRegistry.ts`, `knownProjectRoots.ts`, `ipcTimeouts.ts` | Single registration point for all IPC handlers. `runtimeEventSubscriptionRegistry.ts` holds runtime-event subscriptions keyed by (sender, requestKey) with idle expiry and a single removal path (see §5.4). `knownProjectRoots.ts` validates renderer-supplied project roots for the recovery and diagnostics channels — the open project, a local recent-projects entry, or a root main itself recently attempted to open (a bounded, expiring, single-writer registry, which is what keeps a folder whose *first* open failed repairable). `runtimeBridge.ts` owns the runtime-facing channels (remote target registry, remote-runtime connect / project list / project-open / action dispatch / sync dispatch / event stream, per-target `listActionRegistry` lookup against the remote daemon, LAN + Tailscale discovery with diagnostics) and routes runtime calls through `LocalRuntimeConnectionPool` or `RemoteConnectionPool` based on the active window binding. The explicit `ade.sync.getLocalStatus` handler is the exception: it calls machine-level `sync.getStatus` on `LocalRuntimeConnectionPool` (with only the local in-process diagnostics service as fallback) so a remote-bound Connections panel can still identify the physical computer (its This computer card, pairing code, and local Phone/Web device lists). Device and pairing *mutations* still follow the window binding, so the panel presents them read-only while remote-bound rather than routing them to the remote machine. Event-stream subscription init/results preserve replay-gap metadata (`gap`, `oldestCursor`, `eventEpoch`) for both local and remote bindings, and subscription bookkeeping is delegated to `runtimeEventSubscriptionRegistry.ts`; `runtimeBridge.ts` derives the request key (one helper shared by the subscribe and release paths, so a release rebuilds exactly the key subscribe registered) and registers the `ade.runtime.events.release` handler, which resolves the binding from the same descriptor shape the subscribe call used and refuses to act on an unauthorized local root. Remote project opens are generation-guarded per window/webContents before main persists the binding. It also subscribes `powerMonitor` `resume` and `unlock-screen` to `remoteConnectionService.probeSavedConnections()` so a laptop waking up cycles dead SSH sessions before the renderer pokes them. Machine-level sync fallback recognizes only the canonical unavailable-service predicates in `shared/runtimeErrors.ts`, shared with preload and renderer recovery guidance. `ipcTimeouts.ts` carries the default 30-second handler timeout plus named channel-level overrides for long direct IPC operations; it does not inspect runtime action payloads. | | `jobs/` | `jobEngine.ts` | Event-driven background scheduler for lane refresh + conflict prediction. Coalesced, debounced. | | `keybindings/` | `keybindingsService.ts` | User keybindings read/write. | | `lanes/` | `laneService.ts`, `laneEnvironmentService.ts`, `laneTemplateService.ts`, `laneProxyService.ts`, `portAllocationService.ts`, `autoRebaseService.ts`, `rebaseSuggestionService.ts`, `laneLaunchContext.ts`, `oauthRedirectService.ts`, `runtimeDiagnosticsService.ts`, `laneUsageTombstone.ts` | Worktree lifecycle, env bootstrap, templates, reverse proxy, port leases, auto-rebase, suggestions, OAuth redirect, diagnostics. `laneUsageTombstone.ts` writes the one aggregate row a deleted lane leaves behind (`lane_usage_tombstones`) so lifetime activity survives lane deletion. | @@ -1716,7 +1732,7 @@ Stages: 2. **Parallel checks**: - `secret-scan` — gitleaks on full history. - `typecheck-desktop` — `cd apps/desktop && npm run typecheck`. - - `typecheck-ade-cli` — `cd apps/ade-cli && npm run typecheck`. + - `typecheck-ade-cli` — `cd apps/ade-cli && npm run typecheck`, then a `node --test` release-guards step covering the runtime archive/packaging validators, the docs validator, and the POSIX standalone installer's staging and rollback paths (`install-runtime-rollback.test.mjs`). - `typecheck-web` — `cd apps/web && npm run typecheck`. - `typecheck-webhook-relay`, `typecheck-push-relay`, `typecheck-tunnel-relay`, `typecheck-account-directory` — the four Cloudflare Workers; account-directory also runs a Wrangler dry-run build. - `lint-desktop` — ESLint on `src/**/*.{ts,tsx}`. @@ -1725,7 +1741,7 @@ Stages: - `test-webhook-relay`, `test-push-relay`, `test-tunnel-relay`, `test-account-directory` — the four Cloudflare Workers. - `build` — desktop, ade-cli, and web built sequentially after install. - `validate-docs` — `node scripts/validate-docs.mjs`. -3. **Windows foundation proof** (`windows-foundation`) — a native `windows-latest` runner typechecks desktop and CLI code and exercises the per-user/channel service, filesystem layout, process/executable, named-pipe, PTY, packaged CR-SQLite, and platform-capability contracts. +3. **Windows foundation proof** (`windows-foundation`) — a native `windows-latest` runner typechecks desktop and CLI code and exercises the per-user/channel service, filesystem layout, process/executable, named-pipe, PTY, packaged CR-SQLite, and platform-capability contracts. It also runs the Windows standalone-installer and uninstall-cleanup script suite, which parses `install-runtime.ps1` through `[Parser]::ParseFile` and exercises the path normalizer it shares with the uninstall cleanup: nothing off a Windows host can parse or execute PowerShell, so a syntax error or a broken staging path in that installer would otherwise reach a user's machine before it reached CI. Its POSIX twin (`apps/ade-cli/scripts/install-runtime-rollback.test.mjs`) runs in the `typecheck-ade-cli` job's release-guards step instead. 4. **Gate** (`ci-pass`) — every job in its `needs:` list must succeed; a `skipped` result counts as failure so a conditional job can never pass green while dead (`if: always()`, results derived from `toJSON(needs)`). CI never builds installers — for any platform. Packaging (macOS DMG/ZIP and the signed Windows NSIS installer, including the installed-product lifecycle smoke: install, repair, reinstall, deep-link/file-association/startup/PATH registration, uninstall cleanup) happens once, on the release tag, in `release-core.yml`. CI's job is tests; the release's job is artifacts. @@ -1813,6 +1829,7 @@ The normative privacy, consent, taxonomy, quota, configuration, and instrumentat - CTO and AI UI components use try/catch around async loads with `isLoading`/`error` state and retry actions. - Graceful degradation: when no provider is configured, AI surfaces show explanatory disabled state rather than spinning. - Explicit fallbacks: Linear sync skips when no credentials/workflows; Linear ingress stays dormant without config; trivial session summaries skip AI entirely. +- The full-screen error surfaces share one kit (`renderer/components/app/errorSurfaceKit.tsx`: `ErrorSurfaceCard`, `WhatToDo`, `TechnicalDetailsFold`, `ERROR_PRIMARY_BUTTON`), so the recovery screen, the renderer/page error boundaries, and the CTO wake failure all say what happened, what is safe, and what to do — with the raw text behind a fold rather than on the headline. Each one carries a `ReportIssueButton`; a failure pane with no way out is a dead end, so each also carries a retry. ### 15.4 Observability / dev tools diff --git a/docs/development/windows-support.md b/docs/development/windows-support.md index fe0f0643b2..34a16dc769 100644 --- a/docs/development/windows-support.md +++ b/docs/development/windows-support.md @@ -113,13 +113,34 @@ ade doctor --json ade runtime service-status --text ``` -Before sharing output, remove user paths, repository names, machine/device ids, -account details, IPs, URLs with query strings, and any credential-shaped value. -Prefer reporting the status/error code and ADE version. Do not attach the whole -ADE home or a raw database. +`ade report-issue` produces the same redacted Markdown report the desktop's +**Report issue** button does, and it reads local files only — it never starts or +contacts the brain, so it works on a machine where ADE will not come up and on a +headless host with no error screen to press. `--open` opens a prefilled GitHub +issue. Prefer it over hand-assembled output: paths, the account name, hostnames, +tailnet and `.local` names, addresses and credential-shaped values are stripped +by `redactDiagnosticText` over the whole document, and secrets, environment +blocks and pairing PINs are never collected at all. See +[storage and recovery](../features/storage-and-recovery/README.md#diagnostic-reports-report-issue). + +Before sharing anything you assembled by hand, remove user paths, repository +names, machine/device ids, account details, IPs, URLs with query strings, and +any credential-shaped value. Prefer reporting the status/error code and ADE +version. Do not attach the whole ADE home or a raw database. ## Brain is installed but not running +First rule out a brain that is simply still starting. An install that reports +`starting` has registered the service and left a live child that had not +answered on the pipe inside `WINDOWS_HANDOVER_TIMEOUT_MS` (15 s — shorter than +the POSIX budget because the supervisor is a process the installer starts and +watches directly). That is not a failed install: the supervisor owns that child, +and restarting it only resets its clock. `ade connect` says "installed — the +background service is still starting", and the desktop shows `brain_starting` +with no Repair offered and reopens the project itself once the pipe answers. +Give it up to `RUNTIME_SERVICE_YOUNG_BRAIN_MS` (120 s) before treating it as +stuck. If it is genuinely not coming up: + 1. Confirm the channel-qualified value exists under the current user's Run key. Do not paste its command because it contains local installation paths. 2. Run `ade brain start`, then `ade brain status --text`. @@ -209,6 +230,19 @@ the named pipe is healthy. `install.ps1`, and `SHA256SUMS` from the immutable proof run. Missing or mismatched evidence is a public-release blocker; do not relabel it as a pass or substitute a manual binary copy. +- `install.ps1` stages everything under the ADE home, never `%TEMP%`: + `.new` / `.previous` beside the runtime, `ade.new.exe` / + `ade.bak.exe` beside `ade.exe`. `%TEMP%` holds the downloads and nothing else, + because it is routinely on a different volume from the user profile + (redirected TEMP, a RAM disk, a roaming profile) where `Move-Item` degrades to + copy-then-delete, and because AppLocker and most EDR agents block execution + out of `%TEMP%` outright — which would fail the preflight on every managed + corporate machine. Every promotion is a same-directory rename, and the + preflight runs the new binary against the staged runtime it will actually + load. A failed install prints a stage-aware note: before promotion, the + existing install was not touched; after a rollback, the previous one was put + back. If the rollback itself failed, the recovery copies are named in the + error and are the machine's only copy — do not delete them. - Repair a partial launcher/runtime installation with the product's bounded repair path before testing reinstall or uninstall. Repair must preserve projects and user state; reinstall and uninstall remain separate scenarios diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index a238c6685e..16e6eb4011 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -32,7 +32,7 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `apps/ade-cli/src/tuiClient/externalSessionBrowser.ts` | Pure state/actions for the provider-native session browser. Filters and clamps rows, consumes the shared Continue/Copy policy, puts `Open existing ADE session` first for imported rows, and exposes only Copy actions after it so Enter never re-imports the original session. | | `apps/ade-cli/src/tuiClient/deeplinkRow.ts` | Pure helper used by the `Ctrl+Y` keybinding. Maps the focused lane or PR row (including parsing a GitHub PR URL when the right pane only carries the URL) onto a `DeeplinkTarget` and returns the built `ade://` URL. Tested in `tuiClient/__tests__/deeplinkKeybind.test.ts`. | | `apps/ade-cli/src/commands/deeplinks.ts` | `ade open`, `ade link`, and `ade linear install` subcommands. Shares the parser + builder with the desktop main process so URLs round-trip across both surfaces. See [features/deeplinks/README.md](../deeplinks/README.md). | -| `apps/ade-cli/src/tuiClient/connection.ts` | Resolves attached vs embedded mode, runs the `ade/initialize` handshake, registers the project with `projects.add`, wraps subsequent requests with `projectId`, and exposes `subscribeRuntimeEvents`. Computes the expected SHA-256 build hash from the resolved CLI entrypoint and compares it against the runtime's reported `runtimeInfo.buildHash` / `defaultRole` / `projectRoot`; a mismatch throws `StaleAdeSocketError`, optionally shuts the stale runtime process down, and lets `spawnDaemon` start a compatible one (with `ADE_DEFAULT_ROLE=cto` in the spawned env). Remote sockets skip local build-hash/project-root compatibility checks. Runtime-event subscription responses surface replay gaps (`gap`, `oldestCursor`, `nextCursor`) to callers so the TUI can reset stale cursors instead of silently missing events. `initializeEmbeddedCto` injects a trusted `cto` role only when `ADE_DEFAULT_ROLE` is not already set to a valid value. | +| `apps/ade-cli/src/tuiClient/connection.ts` | Resolves attached vs embedded mode (including the service-repair path and its `starting` handling described under [Attached](#attached-default)), runs the `ade/initialize` handshake, registers the project with `projects.add`, wraps subsequent requests with `projectId`, and exposes `subscribeRuntimeEvents`. Computes the expected SHA-256 build hash from the resolved CLI entrypoint and compares it against the runtime's reported `runtimeInfo.buildHash` / `defaultRole` / `projectRoot`; a mismatch throws `StaleAdeSocketError`, optionally shuts the stale runtime process down, and lets `spawnDaemon` start a compatible one (with `ADE_DEFAULT_ROLE=cto` in the spawned env). Remote sockets skip local build-hash/project-root compatibility checks. Runtime-event subscription responses surface replay gaps (`gap`, `oldestCursor`, `nextCursor`) to callers so the TUI can reset stale cursors instead of silently missing events. `initializeEmbeddedCto` injects a trusted `cto` role only when `ADE_DEFAULT_ROLE` is not already set to a valid value. | | `apps/ade-cli/src/runtimeRoles.ts` | `ADE_RUNTIME_ROLES` (`cto`, `orchestrator`, `agent`, `external`, `evaluator`), role normalization, the runtime default-role ceiling, and `resolveSessionBoundRole`. A chat-session binding is an authority boundary: it preserves an explicit lower role but clamps an otherwise CTO-capable session to `agent`. Shared by `cli.ts`, `adeRpcServer.ts`, `multiProjectRpcServer.ts`, and `tuiClient/connection.ts` so role parsing stays consistent across surfaces. | | `apps/ade-cli/src/tuiClient/jsonRpcClient.ts` | Socket client for Unix/named-pipe and `tcp://` endpoints. Supports JSON-line and Content-Length frames, per-request timeouts, unexpected-close callbacks, notifications (`chat/event`, `runtime/event`), and bounded read buffers (16 MB frame cap, 64 KB header cap) so a wedged runtime cannot grow memory unbounded. | | `apps/ade-cli/src/tuiClient/connectionPool.ts` | Per-machine `AdeCodeConnection` pool for in-TUI hops. Reuses the paired bridge from `remoteLauncher.ts` / `remoteBridge.ts` so clicking a foreign Work row (or `/machines`) opens that machine without restarting Ink. Paired-only; Advanced SSH stays on `ade code remote` and desktop. | @@ -59,6 +59,7 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `apps/ade-cli/src/tuiClient/workListLayout.ts` | Single source of truth for sessions-pane row geometry: `workListRowHeight` (a card is always 3 lines, matching the desktop SessionCard), `computeWorkListLayout` (scroll window that always contains the selection), `workListMouseHitForLayout`, and `workListHitRects`. Singleton cards split the first line as a `lane-identity` hit so a click on the lane name opens lane details; title and preview still open the chat. The renderer and the mouse handler both consume `layout.placements`, so a click and what is on screen cannot drift. | | `apps/ade-cli/src/tuiClient/newLaneForm.ts` | Pure model for the `/new lane` form: start-from modes (primary / child / import), Linear issue + setup-template fields, per-mode field lists, and `buildNewLaneSubmission` mapping form values onto `lane.create` / `lane.createChild` / `lane.importBranch` payloads. | | `apps/ade-cli/src/tuiClient/eventDedup.ts` | Reserves and syncs chat-event dedupe keys so replayed runtime events do not render twice. | +| `apps/ade-cli/src/tuiClient/reportIssue.ts` | `/report-issue` for the TUI — the terminal counterpart of the desktop **Report issue** button and of `ade report-issue`. Builds the report through the same shared builder/redactor, writes it `0600` under an owner-only directory, and renders a narrow-pane summary with the saved path and the prefilled GitHub issue URL. Local files only: it never asks the brain for anything, so it still answers on a machine where the brain is the problem, and a read-only or full disk degrades to "no file, URL still usable" rather than a second failure. `resolveTuiCliVersion` reads the bundle's `__ADE_VERSION__` define (falling back to `ADE_CLI_VERSION`) so a report filed from `ade code` names the same build as `ade` itself. | | `apps/ade-cli/src/tuiClient/feedback.ts` | Builds the multi-field `/feedback` form. Validates required fields, packs the `FeedbackDraftInput` envelope, and adds project / lane / runtime context before submission. | | `apps/ade-cli/src/tuiClient/heartbeat.ts` | Maintains the `startTuiHeartbeat` loop that tells the runtime the terminal client is still attached. | | `apps/ade-cli/src/tuiClient/highlightCache.ts` | Pre-registers highlight.js languages (TypeScript, JavaScript, Python, Rust, Go, Swift, Bash, JSON, YAML, Markdown, XML, CSS, SQL) and caches token streams so chat code fences render once instead of being re-highlighted on every redraw. | @@ -98,10 +99,11 @@ Point Cursor’s browser inspector at the served page for layout debugging. The 1. `--socket /path/to/sock` on the parent `ade` process (also reads `ADE_RPC_SOCKET_PATH`). 2. The machine socket from `resolveMachineAdeLayout()` (`~/.ade/sock/ade.sock` or `\\.\pipe\ade-runtime`). -3. If the machine socket is not listening, `connection.ts` calls `spawnDaemon(socketPath)` — a detached `ade serve --socket ` — and retries up to 25 times with a 200 ms delay. +3. If the machine socket is not listening, `connection.ts` first tries the registered service (`installRuntimeService`) when service repair is preferred, and only spawns its own brain — `spawnDaemon(socketPath)`, a detached `ade serve --socket `, retried up to 25 times with a 200 ms delay — when no service owns the endpoint. + A service install that reports `starting` (registered, brain alive, not answering yet) is given `RUNTIME_SERVICE_STARTING_CONNECT_WAIT_MS` worth of attempts instead of the default 25 (~5 s), derived from the shared budget in `serviceManager/runtimeServiceBudgets.ts` rather than spelled as a count. If it still has not answered — or the install failed but `serviceManagerOwnsRuntimeRecovery` says the supervisor owns recovery for this endpoint — `connection.ts` throws `RuntimeServiceStillStartingError` and the spawn fallback is deliberately skipped: an unmanaged second brain on a supervised socket is a rival, not a recovery. 4. As a final fallback, the legacy project-scoped socket from `resolveAdeLayout(projectRoot)` if the user passed `--require-socket` and the machine socket is unavailable. -`ade code --print-state` exercises that whole path, prints the chosen mode and socket path, and exits. The interactive TUI does not strand users on a blank first connection failure: it renders the failure, offers `r` for immediate retry, and schedules an automatic reconnect. +`ade code --print-state` exercises that whole path, prints the chosen mode and socket path, and exits. The interactive TUI does not strand users on a blank first connection failure: it renders the failure, offers `r` for immediate retry, and schedules an automatic reconnect. A `RuntimeServiceStillStartingError` from that path is rendered as a *waiting* screen rather than the failure screen — "ADE's background service is starting", the same promise the desktop's `brain_starting` recovery copy makes, with the underlying message dimmed below it and nothing offered to repair. Either way the startup reconnect fires every `STARTUP_RECONNECT_DELAY_MS` (3 s) on its own, so a supervised brain that is simply slow opens the TUI without the user doing anything. ### Embedded @@ -308,6 +310,7 @@ Right pane (open contextual content): | `/keybindings [open]` | Show Claude-compatible keybinding config diagnostics. Pass `open` to launch the configured editor on `~/.claude/keybindings.json`. | | `/statusline` | Show Claude-compatible status line config. | | `/doctor` | Show ADE Code and Claude-compat diagnostics. | +| `/report-issue` | Build a redacted diagnostic report (saved path + prefilled GitHub issue URL) without contacting the brain. | | `/model` | Open the transient model wizard (provider → family → model → settings). | | `/effort` | Open the model wizard directly on its settings step for the active provider. | | `/import` | Import an external CLI session (provider-agnostic). Only actionable while starting a new chat; elsewhere it says so instead of no-opping. | diff --git a/docs/features/cto/README.md b/docs/features/cto/README.md index d26e0827b4..1ac3dbcfd9 100644 --- a/docs/features/cto/README.md +++ b/docs/features/cto/README.md @@ -23,7 +23,7 @@ The Linear services above are shared plumbing, not CTO-owned workflow machinery. ### Renderer (`apps/desktop/src/renderer/components/cto/`) -- `CtoPage.tsx` — the `/cto` shell. A single full-bleed chat thread (`AgentChatPane` with a locked session), not tabs. The slim header shows only the CTO name/avatar and Settings gear; personality and model controls stay in settings. The CTO composer also hides lane, permission, model, reasoning, and fast-mode controls because the session is project-level, always full-access, and settings-owned. When onboarding is incomplete the thread is replaced by a single `CtoOnboardingCard`. The primary session is cached module-side so it stays warm across tab switches, and is obtained via `window.ade.cto.ensureSession()`. +- `CtoPage.tsx` — the `/cto` shell. A single full-bleed chat thread (`AgentChatPane` with a locked session), not tabs. The slim header shows only the CTO name/avatar and Settings gear; personality and model controls stay in settings. The CTO composer also hides lane, permission, model, reasoning, and fast-mode controls because the session is project-level, always full-access, and settings-owned. When onboarding is incomplete the thread is replaced by a single `CtoOnboardingCard`. The primary session is cached module-side so it stays warm across tab switches, and is obtained via `window.ade.cto.ensureSession()`. When the wake retries are exhausted the thread is replaced by a failure pane rather than a raw error line: it says the CTO didn't answer and that the thread is still there, puts the underlying error in a `TechnicalDetailsFold`, and offers **Try again**, which resets the retry budget and re-runs the wake effect — a failure pane with no way out is a dead end. - `CtoSettingsPanel.tsx` — the right-side settings sheet. Sections, in order: Identity (`IdentityEditor`), Model (`ModelPicker` + reasoning-effort + supported Fast toggle), Memory (`CtoMemoryPanel`), Prompt (collapsible `CtoPromptPreview`), and Setup (re-run setup + collapsible session history). - `CtoMemoryPanel.tsx` — "what the CTO remembers": an editable `MEMORY.md` textarea (save via `window.ade.cto.updateMemory`), a read-only current thread-state, and a collapsible today's daily log. Loads via `window.ade.cto.getMemory`. - `CtoOnboardingCard.tsx` — the one-card first-run setup: personality preset (with a custom-overlay textarea for `custom`), work style (verbosity / proactivity / escalation via `Segmented`), and an optional name. Completing it saves identity and marks the `identity` onboarding step done. diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 1148425072..0e88085e40 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -1104,6 +1104,19 @@ install end to end (brain running, machine linked) and prints a summary recapping all five steps. A step that fails names the command that fixes it, inline and again under "What's left"; a clean run prints neither. +Between the tools step and the account step, setup waits for the brain's +endpoint. Everything after it needs a brain that answers, and a brain that is +still coming up is not a broken one: `awaitRuntimeService` probes the socket, +installs the service only if nothing answers, and then keeps dialling for +`SETUP_SERVICE_START_BUDGET_MS` (`RUNTIME_SERVICE_START_WAIT_MS`, 90 s), +printing "Starting ADE's background service..." only if it actually has to +wait. The wait is a courtesy, not a gate — its own failure never costs the user +the account and desktop steps. If the brain is still starting when the account +step fails, that step is reported as *skipped* with "ADE's background service +is still starting" rather than as a failed sign-in, and the summary ends with +"Give it a moment, then run `ade connect`." Reporting a slow start as a broken +install is what used to send people hunting a fault that did not exist. + The account step checks first: an already-linked machine is offered keep / switch / skip rather than being asked to sign in blind. The desktop step skips its ~1 GB download when that exact version is already diff --git a/docs/features/onboarding-and-settings/desktop-auto-update.md b/docs/features/onboarding-and-settings/desktop-auto-update.md index 0c47eaac14..3a74c85c45 100644 --- a/docs/features/onboarding-and-settings/desktop-auto-update.md +++ b/docs/features/onboarding-and-settings/desktop-auto-update.md @@ -243,7 +243,11 @@ mutually exclusive. The typed result rides the existing `AutoUpdateSnapshot` over `IPC.updateEvent` / `updateGetState` as `updateTransaction`; no new channel. A failure names the step in plain words and `AutoUpdateBanner` renders that one -line beside the shared **Repair** control: +line beside the shared **Repair** control and a **Report issue** button (the +notice wraps rather than truncating, since the message can run long and Repair +grows a failure line of its own). Its colours are the app shell's amber strip: +the notice used to be written for a light surface, which on ADE's near-black +shell rendered as brown text with an all-but-invisible dismiss. | Step | Line | | --- | --- | diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index e29815683f..b88c64151b 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -170,9 +170,22 @@ relay payload E2E encryption is planned security work. See the trust boundary in `serve --uninstall-service` run through one shared `runServiceManagerCommand` child-process boundary (spawn, output accumulation, single-settle latch, timeout kill, output parse) and differ only - in the policy applied to the result. Install is bounded at 60 s and uninstall - at 20 s: a wedged installer used to pin `serviceInstallPromise` forever, which - blocked every later install and left Repair spinning. `installServiceBestEffort` + in the policy applied to the result. Install is bounded at + `RUNTIME_SERVICE_START_WAIT_MS` (90 s) and uninstall at 20 s: a wedged + installer used to pin `serviceInstallPromise` forever, which blocked every + later install and left Repair spinning, but the budget errs long because it + has to cover the installer's own handover wait — a child killed at this + deadline reads as a failed install even when the supervisor's replacement is + coming up. The parsed result carries `starting` and `restarted` through to + `LocalRuntimeStatus.serviceInstall`, and a `starting` install is logged as + `local_runtime.service_install_starting` rather than as a success. The status + also carries `attemptStartedAt`: the start of the current *streak* of install + attempts, not of this attempt, because installs recur (every connect failure + re-runs one, isolated recovery re-runs one every 60 s) and what recovery has + to age is how long the brain has failed to answer since the first of them. It + resets on a successful connection, and it is what + `projectRecoveryService.diagnose` measures its `brain_starting` window + against. `installServiceBestEffort` coalesces concurrent callers onto one child, but a `forceRestart: true` call never coalesces onto a plain install — a background install may skip entirely or may have spawned before the user asked for a restart, so returning its @@ -250,6 +263,16 @@ relay payload E2E encryption is planned security work. See the trust boundary in macOS: no-op when an unchanged unit already answers, wait rather than restart a child younger than `RUNTIME_SERVICE_YOUNG_BRAIN_MS` (vetoed by a crash-loop streak), and `starting`/`restarted`/`failureStep` on the way out. +- **Windows: "still starting" needs proof of a supervisor.** The readiness + probe (`serviceManager/windowsSupervisor.ts`) returns `supervised` alongside + `ready`, and only sets it once the recorded pid is verifiably *our* + supervisor — alive, and a PowerShell running this launcher. A bare + `process.kill(pid, 0)` would not do: the pid comes from an on-disk record, a + recycled pid belongs to an unrelated process, and `isPidAlive` reports EPERM + (someone else's pid) as alive. Calling a recycled pid "still starting" turns a + genuinely failed install into an `ok: true` the caller waits on and never + repairs. An unanswerable query is likewise never `supervised`: unknown must + not read as healthy. - **A brain that loses its socket ends itself.** `monitorBrainSocketOwnership` (`apps/ade-cli/src/cli.ts`) remembers the inode it bound and polls the path. Binding before the sync host removed the incidental protection the startup @@ -267,7 +290,10 @@ relay payload E2E encryption is planned security work. See the trust boundary in one-time packaged-release reset of the old machine-connection trust files. It preserves account auth, machine identity, pairing PINs, projects, and SSH configuration, and completes only after the background service restart is - confirmed. + confirmed — specifically, only when the install reports `restarted`. A forced + install may legitimately decline to restart a brain that is still starting + (it waits for it instead), and that brain loaded the pre-reset files, so + leaving the marker unset is what makes the next launch restart it for real. - `apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts` (`codedRecoveryError`) — refuses to start an app-owned brain on a primary service socket and carries the recorded `AdeRecoveryErrorCode` to IPC and the @@ -301,7 +327,11 @@ relay payload E2E encryption is planned security work. See the trust boundary in share-this-machine and connection-doctor cards, saved/discovered machine rows, route and latency status, SSH host-key trust, structured connection errors, project picker, and the This-computer - route-publish health indicator. `remoteMachineModel.ts` + route-publish health indicator. A connect stopped because the host key still + has to be confirmed is not a failure, and `blockingHostKeyTrust` reports it as + its own outcome (`needs_trust` / `changed`) rather than through `onError`, so + callers reveal the trust prompt instead of showing a machine row an error it + did not have. Connection failures also carry a **Report issue** button. `remoteMachineModel.ts` (`describePublishHealth`) is the pure classifier for that indicator: the publishing `published` state reads healthy, the non-publishing states (`sync_disabled`, `not_host`, `account_signed_out`, `machine_key_unavailable`, @@ -539,6 +569,34 @@ relay payload E2E encryption is planned security work. See the trust boundary in of the `com.ade.watchdog` launch agent (`.beta` / `.alpha` per channel, `StartInterval` 60 s), done alongside the brain's own agent and best effort: a machine that cannot install the watchdog still gets a brain. +- `apps/ade-cli/src/serviceManager/runtimeServiceBudgets.ts` — every timeout in + the brain's start/handover lifecycle, in one file, because the numbers only + mean anything relative to each other: `RUNTIME_SERVICE_HANDOVER_TIMEOUT_MS` + (30 s) is how long an installer waits before reporting `starting`, + `WINDOWS_HANDOVER_TIMEOUT_MS` (15 s) the supervisor's shorter equivalent, + `RUNTIME_SERVICE_YOUNG_BRAIN_MS` (120 s) the "still starting, not broken" + window shared by the installers' young-brain wait and the desktop's + `brain_starting` diagnosis, `RUNTIME_SERVICE_START_WAIT_MS` (90 s) a caller's + wait for the endpoint, and `RUNTIME_SERVICE_STARTING_CONNECT_WAIT_MS` (60 s) + how long a caller keeps dialling a brain the installer called `starting`. + They were bare literals in seven files, where tuning one silently broke the + ordering the whole lifecycle depends on. +- `apps/ade-cli/src/serviceManager/serviceHandover.ts` — the handover itself, + shared by launchd, systemd and Windows: `awaitServiceHandover` (responsiveness + probe, wait loop, `starting` verdict) and `awaitYoungBrainStart` (age check + plus the crash-loop veto). Each installer keeps only its own message text, so + the three cannot drift. +- `apps/ade-cli/src/services/runtime/awaitRuntimeServiceEndpoint.ts` — the + install-then-wait policy `ade setup` uses, free of the CLI's globals so it can + be tested against a probe that answers on the Nth call. A failed install with + `starting` set is not a failure: the service is registered and supervised, so + it keeps dialling. +- `apps/ade-cli/src/services/runtime/connectWhileServiceStarts.ts` — dials the + endpoint of a just-installed service, allowing for a `starting` one, and + throws `RuntimeServiceStillStartingError` rather than a plain connect failure + when it runs out of time. That distinction matters: a plain failure is what + let a caller fall through to spawning an unmanaged rival brain on a socket the + supervisor already owns. - `apps/ade-cli/src/services/runtime/machineUpdateAndRestart.ts` — `createMachineUpdateControls` / `runMachineUpdateAndRestart`, the host side of `machine.updateAndRestart`. Absent for embedded and test runtimes, which have diff --git a/docs/features/storage-and-recovery/README.md b/docs/features/storage-and-recovery/README.md index 7a29de45c3..cc46d5fba8 100644 --- a/docs/features/storage-and-recovery/README.md +++ b/docs/features/storage-and-recovery/README.md @@ -31,12 +31,21 @@ | `apps/desktop/src/renderer/components/app/StoragePressureIndicator.tsx` | Quiet top-right warning/critical/exhausted status and entry point to Storage settings. Mounted in `TopBar.tsx` (enabled only when a workspace project is open). | | `apps/desktop/src/renderer/components/app/ProjectRecoveryScreen.tsx` | Full-project recovery surface for typed open failures, diagnosis, repair progress, next action, and technical details. `ProjectTabHost` in `App.tsx` renders it full-viewport whenever `projectTransitionError` carries a `code` and `rootPath`. | | `apps/desktop/src/renderer/components/app/ProjectTransitionErrorAlert.tsx` | Fallback dismissible banner for project open/switch failures that lack a code/rootPath (un-coded string errors); it renders nothing once a coded error hands the surface to `ProjectRecoveryScreen`. | +| `apps/desktop/src/main/services/ipc/knownProjectRoots.ts` | Validation for renderer-supplied project roots on the recovery and diagnostics channels. A renderer may only name the open project, a local recent-projects entry, or a root main itself recently attempted to open; `AttemptedProjectRoots` is that last, bounded and expiring, single-writer registry, recorded only after the repo path resolves. Comparison goes through `pathsEqual` (case folding) and falls back to `path.resolve` when a root has no realpath, so a project on an unmounted volume is not refused. | +| `apps/desktop/src/main/services/diagnostics/diagnosticReportService.ts` | Desktop half of **Report issue**: shared machine sources plus the desktop's own jsonl logs, local runtime status, the recovery diagnosis for the open project, the typed last-failure store, and an Electron-aware volume reader. Saves the report `0600`, copies it, and opens a prefilled GitHub issue. | +| `apps/ade-cli/src/services/diagnostics/diagnosticReport.ts` | The pure report builder, the redactor (`redactDiagnosticText`), and `buildDiagnosticIssueUrl`. No I/O, so both the desktop and the CLI produce byte-identical documents from the same sources. | +| `apps/ade-cli/src/services/diagnostics/diagnosticSources.ts` | `collectMachineDiagnosticSources` — the machine-level logs, layout, disk figures and redaction context both surfaces read, so a log added for one appears in both. | +| `apps/ade-cli/src/commands/reportIssue.ts` | `ade report-issue [--open]`, the headless equivalent. Local files only: it never starts or contacts the brain, so it still works where ADE will not come up and on hosts with no error screen to press. | +| `apps/ade-cli/src/lib/externalLinks.ts` | `normalizeExternalUrl` / `openExternalUrl` for the CLI: allows only `http(s)` and `mailto:`, opens through the platform helper (`open` / `rundll32` via the trusted-tool resolver / `xdg-open`), and falls back to Electron's `shell.openExternal` only when actually running inside Electron — a static `electron` import crashes headless startup. | +| `apps/desktop/src/shared/types/diagnostics.ts` | The `DiagnosticSurface` / request / payload contract shared by main, preload and renderer. | +| `apps/desktop/src/renderer/components/app/ReportIssueButton.tsx` | The button itself, on every error surface. One press assembles, saves, copies, and opens the issue; it reports what actually happened rather than claiming success. | +| `apps/desktop/src/renderer/components/app/errorSurfaceKit.tsx` | Shared parts for the full-screen error surfaces — `ErrorSurfaceCard`, `WhatToDo`, `TechnicalDetailsFold`, `ERROR_PRIMARY_BUTTON` — so the recovery screen, the renderer/page boundaries and the CTO wake failure keep the raw text behind a fold and the plain-language account on top. | | `apps/desktop/src/renderer/components/chat/ChatContinuityRecoveryCard.tsx` | In-transcript choices to retry the original thread, rebuild from ADE history, or start a separate chat. `AgentChatMessageList` renders it in place of a plain notice chip when a `system_notice` event's `detail.kind` is `"continuity_recovery"`. | | `apps/desktop/src/renderer/components/settings/StorageSection.tsx` | Storage dashboard: plain-language lane cleanup rules, last/next safety-scan status, and a review table for archived lanes, orphaned worktrees, DerivedData, and build output with ownership, age, blocked reasons, and reclaim estimates. Archive & Reclaim has a typed confirmation and explains exactly what stays and what restore recreates. The page also keeps the category totals, Health & diagnostics strip, project-database breakdown, cleanup preview, recent-cleanups journal, and manual history compression. | | `apps/desktop/src/renderer/components/settings/storage/StorageDiagnostics.tsx` | The "Health & diagnostics" strip: four tiles — database size (with a journal-fed sparkline + trend arrow), background-service resident memory, slow responses in 24 h (from `getRuntimeHealth`), and last cleanup — plus the overall health chip. Deep-linked as `#diagnostics` from the top-bar load pill. | | `apps/desktop/src/renderer/components/settings/storage/StorageMaintenanceJournal.tsx` | Collapsible "Recent cleanups" panel rendering the last runs from the maintenance journal, one humanized line per action. | | `apps/desktop/src/renderer/components/settings/storage/storageUiConstants.ts` | Shared presentational constants (`STORAGE_BRAND`, `PANEL_STYLE`) for the section shell and the split-out diagnostics/journal components, so they share styling without a circular import. | -| `apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.tsx` | Preview-confirmed cleanup dialog: lists selected removable items with sizes, surfaces blocked paths and reasons, and only enables Remove once a fresh preview is in hand. Also hosts the itemized "Clean up safely" plan and its `runMaintenance` path. | +| `apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.tsx` | Preview-confirmed cleanup dialog: lists selected removable items with sizes, surfaces blocked paths and reasons, and only enables Remove once a fresh preview is in hand. Its failure state is phase-aware — failing to *look* and failing to *remove* leave the disk in different states and call for different next steps — and it carries a Try again that re-runs the preview. Overlapping previews are retired by request id, so a stale answer cannot paint over a fresh one or drag a settled dialog back to "error". Also hosts the itemized "Clean up safely" plan and its `runMaintenance` path. | | `apps/desktop/src/renderer/components/settings/storage/storageView.ts` | Pure, DOM-free presentation + policy helpers. Category metadata/order/hues, safety labels, and `buildCleanupTarget` / `cleanableEntries` / `groupLaneItems` map a snapshot item to a typed `StorageCleanupTarget`. The overhaul adds the diagnostics/maintenance view-model: `dbBreakdownRows`, `buildSafeCleanupPlan`, journal/db-size-sparkline/trend helpers, `daemonMemoryBytes`, `healthChip`, `formatSlowActions`, and `categoryPolicyChip` — each degrading to a sensible "not available" value so the UI renders against an older daemon that never sends `extras`. | | `apps/desktop/src/shared/types/storage.ts` | Shared storage contracts: disk-pressure types, `StorageCategoryId`, `StorageSafety`, `StorageItem`/`StorageCategorySnapshot`/`StorageSnapshot`, and the `StorageCleanupTarget`/`StorageCleanupPreview`/`StorageCleanupResult` DTOs. `StorageItem` carries ownership, age, blocked reasons, reclaim estimate/state, and lane ownership for the review screen; `StorageLifecycleSnapshot` carries the effective four-rule policy plus last/next scan and review counts. The ledger/maintenance surface includes `StorageLedgerEntry`/`StoragePolicyClass`, `MaintenanceAction`/`MaintenanceRunReport`/`MaintenanceTrigger`, `DbBreakdownEntry`, `StorageSnapshotExtras`, and `RuntimeHealthSnapshot`. | | `apps/desktop/src/shared/types/recovery.ts` | Typed recovery contracts: the `AdeRecoveryErrorCode` union + `toAdeRecoveryErrorCode`, `AdeLastFailureReport`, `ProjectRecoveryDiagnosis`, the ordered `RepairStepId` list + `ProjectRepairReport`, and `mapKvDbOpenErrorCode`. | @@ -101,7 +110,20 @@ local runtime pool reads that report and throws a coded refusal instead of starting a second app-owned brain on the primary socket. IPC carries the code through project transition state. `ProjectRecoveryScreen` requests a fresh diagnosis and renders plain-language repair actions, while technical detail -stays behind disclosure. `projectRecoveryService` does not depend on a healthy +stays behind disclosure. `repair()` streams each step to the window as it +finishes (`IPC.recoveryRepairStep`), so a long service restart reads as +progress rather than a hang, and the screen names the step currently running +from `REPAIR_STEPS` in `shared/types/recovery.ts` — the same ordered list the +service runs. `stateForCode` lives there too, so a screen falling back to the +last recorded failure can never offer a different verdict, or a different +repair offer, than the service would have given. + +One diagnosis deliberately offers no repair: `brain_starting`, when the service +is registered and its brain is alive but has not bound the socket yet (see +[remote runtime](../remote-runtime/README.md)). There is nothing to fix and a +repair would only kill a booting brain and restart its clock, so the screen +re-diagnoses every 2 s and reopens the project itself the moment the endpoint +answers. `projectRecoveryService` does not depend on a healthy brain, so it can validate and repair the database that prevented the brain from starting. @@ -486,7 +508,9 @@ Connections pane's publish-failure line — carries a **Report issue** button redacted Markdown report, saves it, copies it to the clipboard, and opens a prefilled GitHub new-issue page for `arul28/ADE` in the default browser. The URL carries only a short stub: GitHub rejects issue URLs somewhere north of 8 KB, so -the full report rides the clipboard. +`buildDiagnosticIssueUrl` caps the whole URL at `ISSUE_URL_MAX_LENGTH` (6,000 +characters) and falls back to a title-and-stub URL past it. The full report +rides the clipboard. | Piece | Where | | --- | --- | @@ -550,7 +574,6 @@ One coarse analytics event is emitted per press: `ade_feature_used { feature: "connections", action: "issue_report", outcome: "opened" | "failed" }`, deduped to one per hour per outcome. - ## Gotchas - The interrupted-rebuild recovery pass **must run before `migrate()`**. An diff --git a/docs/features/web-client/README.md b/docs/features/web-client/README.md index 5eb9e173e8..084cfeae10 100644 --- a/docs/features/web-client/README.md +++ b/docs/features/web-client/README.md @@ -544,7 +544,11 @@ Reused desktop renderer (web-mode adaptation): the directory read failed or the session lapsed - and telling the second user their account is empty is a lie they cannot act on. Its `kind` (`loading` / `no_machines` / `signed_out` / `unconfigured` / `unavailable`) - is the stable hook for tests. + is the stable hook for tests. The two failure kinds also carry a + `reassurance` line — that the machines and their projects are untouched and + only the list failed to load — set only where it is both true and not + obvious: a failure that costs nothing should say so, and one that costs + something must not be dressed up as harmless. - `apps/desktop/src/renderer/components/settings/WebScopeBanner.tsx` - the per-section scope line in Settings. On the desktop "where does this setting go" has one answer; in the browser it has three - the connected machine, the diff --git a/docs/logging.md b/docs/logging.md index c716038458..87ba0cdb28 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -232,6 +232,48 @@ day, inside the existing `ade_feature_used` 140-per-day / 30-per-minute limits and the shared 200-event ceiling; no ceiling was raised. The dashboard spec is deliberately untouched: no card asks this question yet. +Pressing **Report issue** on any error surface records one `ade_feature_used` at +the IPC owner boundary with `feature: "connections"`, `action: "issue_report"`, +and a coarse `outcome` (`opened` / `failed`) — whether the GitHub issue page +opened, and nothing else. Never the surface it was pressed on, never the +recovery code, and never the report itself: the report is a redacted document +the user chooses to paste, not telemetry. A per-outcome one-hour deduplication +key bounds a click-loop the same way the controls above are bounded, inside the +existing `ade_feature_used` and shared ceilings. The report's own `Install id` +line is `productAnalyticsService.getDistinctId()` — the exact value PostHog sees +as `distinct_id` for this installation (the identified account hash when signed +in, otherwise the random anonymous install token), surfaced so a report a user +files by hand can be matched to the events this machine already sent. + +Pressing "Report issue" on any of ADE's error surfaces — the project recovery +screen, the renderer error boundary, the project transition alert, the update +banner — records the existing `ade_feature_used` event at the IPC owner boundary +(the `diagnostics.openIssue` handler, where the outcome is known) with +`feature: "connections"`, `action: "issue_report"`, and a coarse `outcome`: +`opened` when the prefilled GitHub issue page was launched, `failed` when it +could not be. That is the whole product question — whether the one control on a +broken screen reaches GitHub — so nothing else crosses the boundary: not the +surface it was pressed on, not the failure code or headline that put the screen +there, not the report, the clipboard result, the report file path, or the +install id the report carries. Those live in the local report file under +`/diagnostic-reports/` and on the clipboard, which the person reads +and pastes deliberately. A per-outcome one-hour deduplication key bounds a +click-loop to at most 24 accepted events per outcome — 48 across both — per +installation per UTC day, inside the existing `ade_feature_used` 140-per-day / +30-per-minute limits and the shared 200-event ceiling; no ceiling was raised. +The dashboard spec is deliberately untouched: no card asks this question yet. + +The diagnostic report itself is a **local** artifact and is not analytics. It +deliberately includes the PostHog `distinct_id` for this installation +(`productAnalyticsService.getDistinctId()`, a random per-install token — never a +device, machine, or account identifier) so a report someone files by hand can be +matched to the anonymous events the installation already sent. Nothing flows the +other way: the report is written to disk and copied to the clipboard, and only +the person filing it decides where it goes. Its body is redacted before it is +written (home directory, project paths, usernames, hostnames and tailnet names, +emails, credentials and routable IP addresses), and the GitHub issue title and +stub body are redacted with the same context. + Clicking "Reconnect this computer" on the Account pane's removed-machine banner records the existing `ade_feature_used` event at the IPC owner boundary (the `accountRepairMachinePairing` handler, where the repair outcome is known) with From 649c49ce3b3be1ff7c5df71524a595649d6a15c0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:54:52 -0400 Subject: [PATCH 7/9] =?UTF-8?q?ship:=20apply=20post-rebase=20quality=20rev?= =?UTF-8?q?alidation=20=E2=80=94=20crash-loop=20veto,=20real=20Windows=20s?= =?UTF-8?q?tartup=20probe,=20no=20recursive=20runtime=20status,=20secret-s?= =?UTF-8?q?can=20fixture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit-bound quality revalidation of the af6bee577 delta on the rebased head: - brainStartupState: veto "starting" when the brain is crash-looping (recentCrashLoopForAdeHome), so a supervisor respawning a dying brain every few seconds no longer reads as young-and-starting forever and `ade doctor` no longer exits 0 on a permanently broken brain. - brainStartupState: replace the dead win32 branch (its `running` meant "the brain answered", false by construction here) with a real supervisor-record probe using the installer's own predicate (restartCount 0, live runtime pid, young runtimeStartedAtMs). Absent record reports installed: null, not false. - cli: shouldProbeBrainStartupState skips the probe under ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 (the Windows readiness probe spawns `ade runtime status`, which re-entered the same path) and for a --socket override that is not the machine socket; always set detail; render the starting verdict in --text via formatBrainStatus so the field has a reader. - doctor: drop the zero-injector readBrainStartupState dependency. - TUI: the starting screen no longer repeats/contradicts itself with the raw error line; report-issue copy drops literal backticks and dev-facing wording. - diagnostics: share writeDiagnosticReportFile instead of a second copy of the 0o700/0o600 write; restore the redaction-copy assertion on the desktop Report issue disclosure. - tests: register the watchdog temp homes with the existing cleanup; assemble the synthetic JWT fixture at runtime so gitleaks stops failing secret-scan. - docs: de-duplicate the report-issue analytics section in logging.md and correct its install-id claim to match getDistinctId(); document brainStartupState; match the ade-code starting-screen description. Co-Authored-By: Claude Fable 5 --- .gitleaksignore | 7 + apps/ade-cli/src/cli.test.ts | 53 +++++++ apps/ade-cli/src/cli.ts | 93 ++++++++++- apps/ade-cli/src/commands/doctor.ts | 12 +- .../ade-cli/src/serviceManager/common.test.ts | 23 +-- .../diagnostics/diagnosticReport.test.ts | 5 +- .../services/diagnostics/diagnosticReport.ts | 29 +++- .../runtime/brainStartupState.test.ts | 138 ++++++++++++++++- .../src/services/runtime/brainStartupState.ts | 144 +++++++++++++++--- apps/ade-cli/src/tuiClient/app.tsx | 5 +- apps/ade-cli/src/tuiClient/reportIssue.ts | 23 +-- .../diagnostics/diagnosticReportService.ts | 14 +- .../components/app/ReportIssueButton.test.tsx | 5 + docs/features/ade-code/README.md | 2 +- docs/features/remote-runtime/README.md | 17 +++ docs/logging.md | 21 +-- 16 files changed, 479 insertions(+), 112 deletions(-) diff --git a/.gitleaksignore b/.gitleaksignore index 08b604fca7..6d17ea8277 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -30,3 +30,10 @@ # Public Clerk OAuth client id (PKCE public client) documented in the dev recipe. baa7a0bb4b5f4c3112680c37ab7572663bb87551:apps/desktop/vite.webclient.config.ts:generic-api-key:77 + +# Synthetic JWT used as a redaction fixture: the test proves `token=` is +# stripped from diagnostic reports, so it must contain a JWT-shaped string. The +# working tree now assembles it from three segments at runtime, but the commit +# that introduced the literal still carries it in its own patch, and gitleaks +# fingerprints are commit-scoped. Scoped to that one commit and finding. +359ae24fd3fdf638b1e4b28cbeea34c068285aad:apps/ade-cli/src/services/diagnostics/diagnosticReport.test.ts:generic-api-key:54 diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index a2abfd2dcc..90f47dcab9 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -39,7 +39,9 @@ import { startHeadlessRpcSocketServer, startHeadlessRpcTcpServer, shouldAutoRegisterProjectForPlan, + formatBrainStatus, shouldBlockManualMachineRuntimeSpawn, + shouldProbeBrainStartupState, shouldEnforceMachineRuntimeBuildCompatibility, shouldAttemptDesktopSocketConnection, summarizeExecution, @@ -1256,6 +1258,57 @@ describe("ADE CLI", () => { })).toBe(false); }); + it("renders the brain starting verdict in --text next to the last-failure line", () => { + const text = formatBrainStatus({ + ok: false, + starting: true, + runtime: { running: false, starting: true, socketPath: "/Users/example/.ade/sock/ade.sock" }, + service: { message: "ADE login service is loaded." }, + lastFailure: "brain serve failed 2x", + }); + expect(text).toContain("nothing to repair"); + expect(text).toContain("brain serve failed 2x"); + // The same output has to read as a plain failure when nothing is coming up. + expect(formatBrainStatus({ ok: false, starting: false, runtime: { running: false } })) + .not.toContain("nothing to repair"); + }); + + it("skips the brain-starting probe inside supervisor and handover probe children", () => { + // Those children run `ade runtime status` with the install lock set. On + // Windows the probe would ask the service manager, which spawns another + // `ade runtime status` — an unbounded recursive fan-out that leaks + // descendants past the spawn timeout. + expect(shouldProbeBrainStartupState({ + socketOverride: null, + socketPath: "/Users/example/.ade/sock/ade.sock", + machineSocketPath: "/Users/example/.ade/sock/ade.sock", + env: { ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1" }, + })).toBe(false); + expect(shouldProbeBrainStartupState({ + socketOverride: null, + socketPath: "/Users/example/.ade/sock/ade.sock", + machineSocketPath: "/Users/example/.ade/sock/ade.sock", + env: {}, + })).toBe(true); + }); + + it("skips the brain-starting probe when --socket points at another runtime", () => { + expect(shouldProbeBrainStartupState({ + socketOverride: "/tmp/other.sock", + socketPath: "/tmp/other.sock", + machineSocketPath: "/Users/example/.ade/sock/ade.sock", + env: {}, + })).toBe(false); + // An override that resolves back to this machine's own brain is still the + // machine brain, so it keeps the starting verdict. + expect(shouldProbeBrainStartupState({ + socketOverride: "/Users/example/.ade/sock/ade.sock", + socketPath: "/Users/example/.ade/sock/ade.sock", + machineSocketPath: "/Users/example/.ade/sock/ade.sock", + env: {}, + })).toBe(true); + }); + it("parses runtime idle expiry with a minimum clamp", () => { expect(readRuntimeIdleExitMs({ ADE_RUNTIME_IDLE_EXIT_MS: "30000" } as NodeJS.ProcessEnv)).toBe(30_000); expect(readRuntimeIdleExitMs({ ADE_RUNTIME_IDLE_EXIT_MS: "100" } as NodeJS.ProcessEnv)).toBe(5_000); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index e2550e9ca2..6569ff9b11 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -249,6 +249,7 @@ type InvocationStep = { type FormatterId = | "status" | "doctor" + | "brain-status" | "auth" | "account-auth" | "account-token" @@ -15381,6 +15382,32 @@ function isServiceManagedMachineRuntimeSocket(socketPath: string): boolean { && !isEphemeralRuntimeSocketPath(socketPath); } +/** + * Whether a silent socket earns the `brain_starting` probe. + * + * Two vetoes, both about not making a bad situation worse: + * + * - Supervisor and handover probes run `ade runtime status` as a CHILD process + * with `ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1`. Probing the service manager + * from inside such a child is recursive on Windows: the status probe asks the + * service manager, which spawns another `ade runtime status`, which fails on + * the same silent pipe and probes again. Windows has no process groups, so + * the spawn timeout kills only the leader and leaks every descendant. + * - A `--socket` override points at a different runtime entirely. The default + * machine service's youth says nothing about it, and "still starting, nothing + * to repair" would bury that runtime's real connect error. + */ +export function shouldProbeBrainStartupState(args: { + socketOverride: string | null; + socketPath: string; + machineSocketPath: string; + env?: NodeJS.ProcessEnv; +}): boolean { + const env = args.env ?? process.env; + if (env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL === "1") return false; + return !args.socketOverride || args.socketPath === args.machineSocketPath; +} + export function shouldBlockManualMachineRuntimeSpawn( socketPath: string, env: NodeJS.ProcessEnv = process.env, @@ -15753,16 +15780,25 @@ async function runRuntimeCommand( // registered and the brain behind it is alive and young, it is still // coming up — the same verdict the desktop calls `brain_starting`. // Callers keep waiting for the endpoint instead of restarting it. - const startup = await readBrainStartupState(); + const starting = shouldProbeBrainStartupState({ + socketOverride, + socketPath, + machineSocketPath: resolveMachineAdeLayout().socketPath, + }) + ? (await readBrainStartupState()).starting + : false; return { ok: false, running: false, - starting: startup.starting, + starting, socketPath, - message: startup.starting + // `detail` is the raw connect error on both branches, so it is always + // present: a caller reading it should never have to know which verdict + // produced the message above it. + detail, + message: starting ? `ADE brain is still starting; it has not answered on ${socketPath} yet. Keep waiting — there is nothing to repair.` : detail, - ...(startup.starting ? { detail } : {}), }; } } @@ -20508,6 +20544,49 @@ function formatAccountMachines(value: unknown): string { ].join("\n"); } +/** + * `ade brain status` and `ade runtime status` in --text mode. + * + * `starting` — the CLI's read of the desktop's `brain_starting` state — only + * means something if a human can see it, so it is printed here with the same + * wording as the `ade doctor` Brain row, next to the last-failure line it has + * to be read against. Accepts both shapes: `ade brain status` wraps the runtime + * result, `ade runtime status` is that result. + */ +function brainStatusFormatter(rest: string[]): FormatterId | undefined { + const sub = rest.find((arg) => arg !== "--" && !arg.startsWith("-")) ?? "status"; + return sub === "status" || sub === "show" ? "brain-status" : undefined; +} + +export function formatBrainStatus(value: unknown): string { + const result = isRecord(value) ? value : {}; + const runtime = isRecord(result.runtime) ? result.runtime : result; + const service = isRecord(result.service) ? result.service : null; + const starting = result.starting === true || runtime.starting === true; + return renderKeyValues("ADE brain", [ + ["ok", result.ok], + ["endpoint", runtime.running === true ? "running" : "not responding"], + ["socket", runtime.socketPath], + ["version", runtime.version], + ["pid", runtime.pid], + [ + "starting", + starting + ? "yes \u00b7 the background service is up and its brain is coming up; nothing to repair" + : null, + ], + ["channel", runtime.packageChannel], + ["build", runtime.buildHash], + ["role", runtime.defaultRole], + ["project", runtime.projectRoot], + ["service", service ? service.message : null], + ["port", result.port], + ["connected peers", result.connectedPeers], + ["last failure", result.lastFailure], + ["message", starting ? null : result.message], + ]); +} + function formatTextOutput( value: unknown, formatter: FormatterId | undefined, @@ -20528,6 +20607,8 @@ function formatTextOutput( ["workspace", isRecord(value) ? value.workspaceRoot : null], ["socket", isRecord(value) ? value.socketPath : null], ]); + case "brain-status": + return formatBrainStatus(value); case "doctor": { const doctorRows = isRecord(value) && Array.isArray(value.rows) ? value.rows.filter(isRecord) @@ -22214,14 +22295,14 @@ async function runCli( if (plan.kind === "runtime") { const result = await runRuntimeCommand(plan.rest, parsed.options); return { - output: formatOutput(result, parsed.options, undefined), + output: formatOutput(result, parsed.options, brainStatusFormatter(plan.rest)), exitCode: isRecord(result) && result.ok === false ? 1 : 0, }; } if (plan.kind === "brain") { const result = await runBrainCommand(plan.rest, parsed.options); return { - output: formatOutput(result, parsed.options, undefined), + output: formatOutput(result, parsed.options, brainStatusFormatter(plan.rest)), exitCode: isRecord(result) && result.ok === false ? 1 : 0, }; } diff --git a/apps/ade-cli/src/commands/doctor.ts b/apps/ade-cli/src/commands/doctor.ts index 03dc9a97fc..c0f110d75f 100644 --- a/apps/ade-cli/src/commands/doctor.ts +++ b/apps/ade-cli/src/commands/doctor.ts @@ -21,10 +21,7 @@ import { type CredentialStoreHealth, } from "../services/credentials/credentialStore"; import { resolveMachineAdeLayout } from "../services/projects/machineLayout"; -import { - readBrainStartupState, - type BrainStartupState, -} from "../services/runtime/brainStartupState"; +import { readBrainStartupState } from "../services/runtime/brainStartupState"; import { DEFAULT_SYNC_HOST_PORT } from "../services/sync/syncProtocol"; import type { SyncListenerPortDiagnosis, @@ -145,11 +142,6 @@ export type DoctorCommandDependencies< expectedDefaultRole: Options["role"], ): string | null; unwrapActionEnvelope(value: unknown): unknown; - /** - * Only consulted when the brain did not answer. Defaults to the real service - * probe; injectable so tests can drive the starting/failed split. - */ - readBrainStartupState?(): Promise; }; export type DoctorCommandResult = { @@ -914,7 +906,7 @@ export async function runDoctorCommand( // never starting. const startupState = brainProbe.brain.running ? null - : await (dependencies.readBrainStartupState ?? readBrainStartupState)(); + : await readBrainStartupState(); const input: DoctorInput = { nowMs, app: { diff --git a/apps/ade-cli/src/serviceManager/common.test.ts b/apps/ade-cli/src/serviceManager/common.test.ts index 996177c140..d66a47750b 100644 --- a/apps/ade-cli/src/serviceManager/common.test.ts +++ b/apps/ade-cli/src/serviceManager/common.test.ts @@ -1509,17 +1509,6 @@ const watchdogServiceCommand: AdeServiceCommand = { env: { ADE_HOME: "/Users/example/.ade" }, }; -function watchdogTempHome(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), "ade-watchdog-home-")); -} - -function watchdogRecordingSpawn(calls: Array<{ command: string; args: string[] }>) { - return (command: string, args: string[]) => { - calls.push({ command, args }); - return { status: 0, stdout: "", stderr: "" }; - }; -} - describe("resolveWatchdogServiceName", () => { it("keeps each channel on its own watchdog", () => { expect(resolveWatchdogServiceName("com.ade.runtime")).toBe("com.ade.watchdog"); @@ -1572,12 +1561,12 @@ describe("renderWatchdogLaunchdPlist", () => { describe("installLaunchdWatchdogAgent", () => { it("writes and loads the agent", () => { - const homeDir = watchdogTempHome(); + const homeDir = makeTempHome("ade-watchdog-home-"); const calls: Array<{ command: string; args: string[] }> = []; const result = installLaunchdWatchdogAgent({ command: watchdogServiceCommand, homeDir, - spawnSync: watchdogRecordingSpawn(calls), + spawnSync: spawnSequence(calls, []), }); const servicePath = watchdogLaunchAgentPath(homeDir); @@ -1587,7 +1576,7 @@ describe("installLaunchdWatchdogAgent", () => { }); it("reports a load failure instead of claiming the agent is armed", () => { - const homeDir = watchdogTempHome(); + const homeDir = makeTempHome("ade-watchdog-home-"); const result = installLaunchdWatchdogAgent({ command: watchdogServiceCommand, homeDir, @@ -1600,17 +1589,17 @@ describe("installLaunchdWatchdogAgent", () => { }); it("removes the agent with the brain it guards", () => { - const homeDir = watchdogTempHome(); + const homeDir = makeTempHome("ade-watchdog-home-"); installLaunchdWatchdogAgent({ command: watchdogServiceCommand, homeDir, - spawnSync: watchdogRecordingSpawn([]), + spawnSync: spawnSequence([], []), }); const servicePath = watchdogLaunchAgentPath(homeDir); expect(fs.existsSync(servicePath)).toBe(true); const calls: Array<{ command: string; args: string[] }> = []; - uninstallLaunchdWatchdogAgent({ homeDir, spawnSync: watchdogRecordingSpawn(calls) }); + uninstallLaunchdWatchdogAgent({ homeDir, spawnSync: spawnSequence(calls, []) }); expect(fs.existsSync(servicePath)).toBe(false); expect(calls.map((call) => call.args[0])).toEqual(["bootout", "unload"]); diff --git a/apps/ade-cli/src/services/diagnostics/diagnosticReport.test.ts b/apps/ade-cli/src/services/diagnostics/diagnosticReport.test.ts index 96ceb44c30..058952fea8 100644 --- a/apps/ade-cli/src/services/diagnostics/diagnosticReport.test.ts +++ b/apps/ade-cli/src/services/diagnostics/diagnosticReport.test.ts @@ -48,10 +48,13 @@ describe("redactDiagnosticText", () => { }); it("removes emails and every credential shape we have seen in logs", () => { + // Assembled from its segments at runtime so secret scanners do not flag + // this synthetic credential as a real leaked JWT. + const jwtFixture = ["eyJhbGciOiJIUzI1NiJ9", "eyJzdWIiOiIxIn0", "abcdefghijklmnop"].join("."); const input = [ "signed in as ada.lovelace+ade@example.com", "authorization: Bearer sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFF", - "token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijklmnop", + `token=${jwtFixture}`, "github token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", "posthog phc_ABCDEFGHIJKLMNOPQRSTUVWXYZ01", "GET /pair?token=8e1f2a3b4c5d6e7f&mode=relay", diff --git a/apps/ade-cli/src/services/diagnostics/diagnosticReport.ts b/apps/ade-cli/src/services/diagnostics/diagnosticReport.ts index a3d834bb94..271ca70630 100644 --- a/apps/ade-cli/src/services/diagnostics/diagnosticReport.ts +++ b/apps/ade-cli/src/services/diagnostics/diagnosticReport.ts @@ -1,13 +1,16 @@ import { createHash } from "node:crypto"; +import fs from "node:fs"; import path from "node:path"; /** - * Pure diagnostic-report assembly and redaction, shared by the desktop + * Diagnostic-report assembly and redaction, shared by the desktop * "Report issue" button and the headless `ade report-issue` command. * - * Nothing here touches the filesystem, Electron, or the network: collection - * lives in the callers, so both a Markdown report and its redaction can be - * unit-tested with synthetic inputs on every platform. + * Assembly and redaction are pure — they touch neither Electron nor the + * network, and collection lives in the callers — so both a Markdown report and + * its redaction can be unit-tested with synthetic inputs on every platform. + * The one exception is {@link writeDiagnosticReportFile}, which lives here so + * every surface writes the report with the same owner-only permissions. */ /** Where a maintainer files the issue this report is attached to. */ @@ -521,3 +524,21 @@ export function diagnosticReportFileName(surface: string, at: Date): string { export function diagnosticReportFilePath(dir: string, surface: string, at: Date): string { return path.join(dir, diagnosticReportFileName(surface, at)); } + +/** + * Writes the report with an owner-only directory and file. Best effort: a + * read-only or full disk must not turn "report a bug" into a second bug, and + * the issue URL is still usable on its own. + * + * Shared by every surface (desktop button, `ade report-issue`, the TUI) so the + * 0o700/0o600 modes are stated in exactly one place. + */ +export function writeDiagnosticReportFile(filePath: string, report: string): boolean { + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(filePath, report, { encoding: "utf8", mode: 0o600 }); + return true; + } catch { + return false; + } +} diff --git a/apps/ade-cli/src/services/runtime/brainStartupState.test.ts b/apps/ade-cli/src/services/runtime/brainStartupState.test.ts index ae321bc492..697ec030df 100644 --- a/apps/ade-cli/src/services/runtime/brainStartupState.test.ts +++ b/apps/ade-cli/src/services/runtime/brainStartupState.test.ts @@ -1,23 +1,75 @@ -import { describe, expect, it } from "vitest"; -import { readBrainStartupState } from "./brainStartupState"; +import { describe, expect, it, vi } from "vitest"; +import { + describeWindowsStartupProbe, + readBrainStartupState, + type BrainStartupProbe, +} from "./brainStartupState"; +import type { + WindowsServicePidRecord, + WindowsSupervisorState, +} from "../../serviceManager/windowsSupervisor"; function deps(overrides: { installed?: boolean | null; running?: boolean | null; pid?: number | null; ageMs?: number | null; + crashLooping?: boolean; } = {}) { return { + platform: "darwin" as NodeJS.Platform, getServiceStatus: async () => ({ installed: overrides.installed === undefined ? true : overrides.installed, running: overrides.running === undefined ? true : overrides.running, }), getServiceMainPid: async () => (overrides.pid === undefined ? 4242 : overrides.pid), readBrainAgeMs: async () => (overrides.ageMs === undefined ? 5_000 : overrides.ageMs), + hasRecentCrashLoop: async () => overrides.crashLooping === true, youngBrainMs: 120_000, }; } +function windowsProbe(overrides: Partial = {}) { + return { + platform: "win32" as NodeJS.Platform, + readWindowsStartupProbe: async (): Promise => ({ + installed: true, + running: false, + supervised: true, + ageMs: 5_000, + ...overrides, + }), + hasRecentCrashLoop: async () => false, + youngBrainMs: 120_000, + }; +} + +const NOW = 1_800_000_000_000; + +function runningSupervisor( + record: Partial = {}, +): WindowsSupervisorState { + return { + state: "running", + running: true, + pid: 4242, + record: { + supervisorPid: 4242, + runtimePid: 4343, + runtimeStartedAtMs: NOW, + restartCount: 0, + lastExitCode: null, + lastExitAt: null, + nextRestartAt: null, + lastLaunchError: null, + sessionBound: false, + ...record, + }, + error: null, + diagnostic: null, + }; +} + describe("readBrainStartupState", () => { it("calls a registered service whose brain is young 'starting'", async () => { await expect(readBrainStartupState(deps())).resolves.toMatchObject({ @@ -46,9 +98,91 @@ describe("readBrainStartupState", () => { starting: false, }); await expect(readBrainStartupState({ + platform: "darwin", getServiceStatus: async () => { throw new Error("systemctl missing"); }, })).resolves.toMatchObject({ starting: false }); }); + + // A crash-looping brain is respawned every few seconds, so it is ALWAYS young: + // without this veto `ade doctor` would report "starting, nothing to repair" + // forever on a permanently broken machine. + it("refuses to call a crash-looping brain starting even while it is young", async () => { + await expect( + readBrainStartupState(deps({ crashLooping: true })), + ).resolves.toMatchObject({ starting: false, ageMs: 5_000 }); + }); + + it("does not spend the crash-loop probe on a brain that is not young anyway", async () => { + const hasRecentCrashLoop = vi.fn(async () => false); + await readBrainStartupState({ ...deps({ ageMs: 130_000 }), hasRecentCrashLoop }); + expect(hasRecentCrashLoop).not.toHaveBeenCalled(); + }); + + describe("windows", () => { + it("calls a young single-start supervised brain starting", async () => { + await expect(readBrainStartupState(windowsProbe())).resolves.toMatchObject({ + starting: true, + ageMs: 5_000, + serviceInstalled: true, + }); + }); + + it("is not starting when no supervisor of ours is running", async () => { + await expect( + readBrainStartupState(windowsProbe({ supervised: false, ageMs: null, installed: true })), + ).resolves.toMatchObject({ starting: false }); + }); + + it("reads youth from the supervisor record, one start only", () => { + // Mirrors the installer's own young-brain predicate: a first start whose + // runtime pid is alive is young; anything the supervisor has already + // restarted is a crash loop wearing a fresh timestamp. + const young = describeWindowsStartupProbe({ + supervisor: runningSupervisor({ runtimeStartedAtMs: NOW - 5_000 }), + isAlive: () => true, + nowMs: NOW, + }); + expect(young).toMatchObject({ supervised: true, ageMs: 5_000 }); + + const restarted = describeWindowsStartupProbe({ + supervisor: runningSupervisor({ restartCount: 2, runtimeStartedAtMs: NOW - 5_000 }), + isAlive: () => true, + nowMs: NOW, + }); + expect(restarted).toMatchObject({ supervised: true, ageMs: null }); + + const deadRuntime = describeWindowsStartupProbe({ + supervisor: runningSupervisor({ runtimeStartedAtMs: NOW - 5_000 }), + isAlive: () => false, + nowMs: NOW, + }); + expect(deadRuntime).toMatchObject({ supervised: true, ageMs: null }); + }); + + it("is not supervised when the recorded supervisor is not verifiably ours", () => { + expect(describeWindowsStartupProbe({ + supervisor: { + state: "stopped", + running: false, + pid: null, + record: null, + error: null, + diagnostic: "no record", + }, + isAlive: () => true, + // No pid record leaves "installed" unknown, not false: this probe never + // reads the HKCU Run entry, which can be registered on its own. + })).toMatchObject({ supervised: false, installed: null, ageMs: null }); + }); + + it("never asks the status command whether the brain answered", async () => { + const getServiceStatus = vi.fn(async () => ({ installed: true, running: false })); + await expect( + readBrainStartupState({ ...windowsProbe(), getServiceStatus }), + ).resolves.toMatchObject({ starting: true }); + expect(getServiceStatus).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/ade-cli/src/services/runtime/brainStartupState.ts b/apps/ade-cli/src/services/runtime/brainStartupState.ts index 89de5f2cf8..16539940fa 100644 --- a/apps/ade-cli/src/services/runtime/brainStartupState.ts +++ b/apps/ade-cli/src/services/runtime/brainStartupState.ts @@ -2,6 +2,7 @@ import { readPidElapsedMs, RUNTIME_SERVICE_YOUNG_BRAIN_MS, } from "../../serviceManager/common"; +import type { WindowsSupervisorState } from "../../serviceManager/windowsSupervisor"; /** * The CLI's read of the desktop's `brain_starting` recovery state. @@ -12,14 +13,18 @@ import { * project database). The desktop reaches this verdict through its connection * pool (`ProjectRecoveryService.diagnose` -> `brain_starting`); the CLI has no * pool, so it asks the platform service manager the same two questions — - * is the service registered and running, and how old is its brain — and applies - * the same `RUNTIME_SERVICE_YOUNG_BRAIN_MS` bound. + * is the service registered and supervised, and how old is its brain — and + * applies the same `RUNTIME_SERVICE_YOUNG_BRAIN_MS` bound. * * Time-bounded for the same reason the desktop's is: without the age check a - * brain that wedged during boot would read as "starting" forever. + * brain that wedged during boot would read as "starting" forever. And + * crash-loop-vetoed for the reason the age check alone cannot cover: a + * supervisor that respawns a dying brain every few seconds keeps producing a + * young process forever, so youth stops being evidence of progress. This is + * exactly the veto `awaitYoungBrainStart` already applies before `isYoungBrain`. */ export type BrainStartupState = { - /** Registered, alive, and young: waiting is the right move, not repairing. */ + /** Registered, alive, young, and not crash-looping: waiting is the right move. */ starting: boolean; /** Age of the service's brain process in ms, or null when unknown. */ ageMs: number | null; @@ -27,11 +32,30 @@ export type BrainStartupState = { serviceRunning: boolean | null; }; +/** + * What a platform probe reports about the service behind a silent socket. + * `supervised` is the platform's own answer to "is a supervisor we recognize + * running a brain right now", which is NOT the same question as the status + * command's `running` on Windows (there it means "the brain already answered", + * which is false by construction everywhere this module is called). + */ +export type BrainStartupProbe = { + installed: boolean | null; + running: boolean | null; + supervised: boolean; + ageMs: number | null; +}; + export type BrainStartupStateDeps = { getServiceStatus?: () => Promise<{ installed: boolean | null; running: boolean | null }>; getServiceMainPid?: () => Promise; readBrainAgeMs?: (pid: number | null) => Promise; + /** Windows-only probe; ignored on the POSIX supervisors. */ + readWindowsStartupProbe?: () => Promise; + /** The `last-failure.json` crash-loop veto, scoped to this install's ADE home. */ + hasRecentCrashLoop?: () => Promise; youngBrainMs?: number; + platform?: NodeJS.Platform; }; async function defaultGetServiceStatus(): Promise<{ @@ -48,18 +72,75 @@ async function defaultGetServiceMainPid(): Promise { return getRuntimeServiceMainPid(); } +async function defaultReadBrainAgeMs(pid: number | null): Promise { + return pid == null ? null : readPidElapsedMs(pid); +} + /** - * Windows has no `ps -o etime=`, but its supervisor already records when it - * launched the brain it is watching, which is the same measurement. + * Windows has no launchd/systemd "the supervisor has a live child" answer, and + * its `ServiceManagerStatusResult.running` means "the brain answered on the + * pipe" — which is false by construction here. So this asks the supervisor + * record directly, and gates youth on the same predicate the Windows installer + * uses before it decides to wait for a booting brain instead of replacing it: + * a first start (never restarted) whose runtime pid is still alive. */ -async function defaultReadBrainAgeMs(pid: number | null): Promise { - if (process.platform === "win32") { - const { readWindowsServicePidRecord } = await import("../../serviceManager/installWindows"); - const startedAtMs = readWindowsServicePidRecord()?.runtimeStartedAtMs ?? null; - if (startedAtMs == null || !Number.isFinite(startedAtMs)) return null; - return Math.max(0, Date.now() - startedAtMs); +export function describeWindowsStartupProbe(args: { + supervisor: WindowsSupervisorState; + isAlive: (pid: number) => boolean; + nowMs?: number; +}): BrainStartupProbe { + const { supervisor } = args; + const record = supervisor.record; + // Absent record means "we cannot tell": the HKCU Run entry can be registered + // without a pid record, and this probe never reads that key. Report unknown + // rather than claiming the service is not installed. + const installed = supervisor.state === "error" || record == null ? null : true; + if (!supervisor.running || !record) { + return { installed, running: supervisor.running, supervised: false, ageMs: null }; } - return pid == null ? null : readPidElapsedMs(pid); + const startedAtMs = record.runtimeStartedAtMs; + const eligible = record.restartCount === 0 + && record.runtimePid != null + && startedAtMs != null + && args.isAlive(record.runtimePid); + return { + installed, + running: supervisor.running, + supervised: true, + ageMs: eligible && startedAtMs != null + ? Math.max(0, (args.nowMs ?? Date.now()) - startedAtMs) + : null, + }; +} + +async function defaultReadWindowsStartupProbe(): Promise { + const [ + { resolveWindowsServiceLauncherPath, resolveWindowsServicePidPath }, + { queryWindowsSupervisor }, + { isPidAlive }, + { spawnSync }, + ] = await Promise.all([ + import("../../serviceManager/installWindows"), + import("../../serviceManager/windowsSupervisor"), + import("../../serviceManager/common"), + import("node:child_process"), + ]); + return describeWindowsStartupProbe({ + supervisor: queryWindowsSupervisor({ + spawnSync, + launcherPath: resolveWindowsServiceLauncherPath(), + pidPath: resolveWindowsServicePidPath(), + }), + isAlive: isPidAlive, + }); +} + +async function defaultHasRecentCrashLoop(): Promise { + const [{ recentCrashLoopForAdeHome }, { resolveMachineAdeLayout }] = await Promise.all([ + import("../../serviceManager/serviceHandover"), + import("../projects/machineLayout"), + ]); + return recentCrashLoopForAdeHome(resolveMachineAdeLayout().adeDir); } /** @@ -70,18 +151,37 @@ export async function readBrainStartupState( deps: BrainStartupStateDeps = {}, ): Promise { const youngBrainMs = deps.youngBrainMs ?? RUNTIME_SERVICE_YOUNG_BRAIN_MS; + const platform = deps.platform ?? process.platform; let installed: boolean | null = null; let running: boolean | null = null; let ageMs: number | null = null; + let supervised = false; try { - const status = await (deps.getServiceStatus ?? defaultGetServiceStatus)(); - installed = status.installed; - running = status.running; - // No registered service, or a registered one the supervisor is not running: - // nothing is coming up, so this is a real failure and stays one. - if (installed === true && running !== false) { - const pid = await (deps.getServiceMainPid ?? defaultGetServiceMainPid)(); - ageMs = await (deps.readBrainAgeMs ?? defaultReadBrainAgeMs)(pid); + if (platform === "win32") { + const probe = await (deps.readWindowsStartupProbe ?? defaultReadWindowsStartupProbe)(); + installed = probe.installed; + running = probe.running; + supervised = probe.supervised; + ageMs = probe.ageMs; + } else { + const status = await (deps.getServiceStatus ?? defaultGetServiceStatus)(); + installed = status.installed; + running = status.running; + // No registered service, or a registered one the supervisor is not + // running: nothing is coming up, so this is a real failure and stays one. + supervised = installed === true && running !== false; + if (supervised) { + const pid = await (deps.getServiceMainPid ?? defaultGetServiceMainPid)(); + ageMs = await (deps.readBrainAgeMs ?? defaultReadBrainAgeMs)(pid); + } + } + if (supervised && ageMs != null && ageMs < youngBrainMs) { + // A supervisor respawning a brain that dies produces a young process on + // every loop, so the age bound alone never expires. The recorded failure + // streak is the only thing that tells the two apart. + if (await (deps.hasRecentCrashLoop ?? defaultHasRecentCrashLoop)()) { + return { starting: false, ageMs, serviceInstalled: installed, serviceRunning: running }; + } } } catch { // Any probe failure fails closed to "not starting": reporting a brain as @@ -89,7 +189,7 @@ export async function readBrainStartupState( return { starting: false, ageMs: null, serviceInstalled: installed, serviceRunning: running }; } return { - starting: installed === true && running !== false && ageMs != null && ageMs < youngBrainMs, + starting: supervised && ageMs != null && ageMs < youngBrainMs, ageMs, serviceInstalled: installed, serviceRunning: running, diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index a479c06a65..ae64d21137 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -10459,7 +10459,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, "", error instanceof Error ? error.message : String(error), "", - "Run `ade report-issue --open` in any terminal instead.", + "Run ade report-issue --open in any terminal instead.", ].join("\n"), }); } @@ -17293,7 +17293,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, This can take a minute the first time or right after an update. ADE Code opens as soon as it is ready — there is nothing to do. - {error} Waiting automatically · r retry now · Ctrl+C quit @@ -17317,7 +17316,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, Retrying automatically · r retry now · Ctrl+C quit - Run `ade report-issue --open` in another terminal to file this with a redacted diagnostic report. + Run ade report-issue --open in another terminal to prepare a report you can post. Personal information is removed. ); diff --git a/apps/ade-cli/src/tuiClient/reportIssue.ts b/apps/ade-cli/src/tuiClient/reportIssue.ts index a9c96af440..5f1bfc1c80 100644 --- a/apps/ade-cli/src/tuiClient/reportIssue.ts +++ b/apps/ade-cli/src/tuiClient/reportIssue.ts @@ -1,7 +1,9 @@ -import fs from "node:fs"; import path from "node:path"; import { buildCliDiagnosticReport } from "../commands/reportIssue"; -import { diagnosticReportFilePath } from "../services/diagnostics/diagnosticReport"; +import { + diagnosticReportFilePath, + writeDiagnosticReportFile, +} from "../services/diagnostics/diagnosticReport"; import { resolveMachineAdeLayout } from "../services/projects/machineLayout"; /** @@ -37,19 +39,6 @@ export type TuiDiagnosticReport = { installId: string; }; -/** Mirrors the desktop's report file: owner-only directory and file. */ -function writeReportFile(filePath: string, report: string): boolean { - try { - fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); - fs.writeFileSync(filePath, report, { encoding: "utf8", mode: 0o600 }); - return true; - } catch { - // A read-only or full disk must not turn "report a bug" into a second bug; - // the issue URL below is still usable on its own. - return false; - } -} - export function buildTuiDiagnosticReport(args: { projectRoot: string | null; env?: NodeJS.ProcessEnv; @@ -70,7 +59,7 @@ export function buildTuiDiagnosticReport(args: { const reportsDir = args.reportsDir ?? path.join(resolveMachineAdeLayout(env).adeDir, "diagnostic-reports"); const filePath = diagnosticReportFilePath(reportsDir, surface, at); - const written = writeReportFile(filePath, built.report); + const written = writeDiagnosticReportFile(filePath, built.report); const body = [ "A diagnostic report has been prepared.", "Private paths, account names, emails and tokens are removed before it is written.", @@ -83,7 +72,7 @@ export function buildTuiDiagnosticReport(args: { "", `Install id: ${built.installId}`, "", - "If ADE Code will not start at all, run `ade report-issue --open` in any terminal — it reads local files only.", + "If ADE Code will not start at all, run ade report-issue --open in any terminal — it reads local files only.", ] .filter((line): line is string => line !== null) .join("\n"); diff --git a/apps/desktop/src/main/services/diagnostics/diagnosticReportService.ts b/apps/desktop/src/main/services/diagnostics/diagnosticReportService.ts index 7c9af9e151..7cae87b5be 100644 --- a/apps/desktop/src/main/services/diagnostics/diagnosticReportService.ts +++ b/apps/desktop/src/main/services/diagnostics/diagnosticReportService.ts @@ -1,6 +1,5 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { @@ -22,6 +21,8 @@ export { buildDiagnosticIssueUrl, buildDiagnosticReport, redactDiagnosticText, + /** Writes the report next to the app's other user data. Best effort. */ + writeDiagnosticReportFile, } from "../../../../../ade-cli/src/services/diagnostics/diagnosticReport"; export type DiagnosticReportRequest = DiagnosticReportContext & { @@ -201,14 +202,3 @@ export async function collectDiagnosticReport( return { report, filePath, issueUrl, installId }; } - -/** Writes the report next to the app's other user data. Best effort. */ -export function writeDiagnosticReportFile(filePath: string, report: string): boolean { - try { - fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); - fs.writeFileSync(filePath, report, { encoding: "utf8", mode: 0o600 }); - return true; - } catch { - return false; - } -} diff --git a/apps/desktop/src/renderer/components/app/ReportIssueButton.test.tsx b/apps/desktop/src/renderer/components/app/ReportIssueButton.test.tsx index c6b7416295..599113a367 100644 --- a/apps/desktop/src/renderer/components/app/ReportIssueButton.test.tsx +++ b/apps/desktop/src/renderer/components/app/ReportIssueButton.test.tsx @@ -83,5 +83,10 @@ describe("ReportIssueButton", () => { rerender(); expect(screen.getByText("What's in the report?")).toBeTruthy(); + // The one sentence that tells the user what is stripped must survive any + // rewording of the fold above it. + expect( + screen.getByText(/File paths, your name, email addresses and any sign-in codes are removed/i), + ).toBeTruthy(); }); }); diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index 16e6eb4011..6b798ea936 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -103,7 +103,7 @@ Point Cursor’s browser inspector at the served page for layout debugging. The A service install that reports `starting` (registered, brain alive, not answering yet) is given `RUNTIME_SERVICE_STARTING_CONNECT_WAIT_MS` worth of attempts instead of the default 25 (~5 s), derived from the shared budget in `serviceManager/runtimeServiceBudgets.ts` rather than spelled as a count. If it still has not answered — or the install failed but `serviceManagerOwnsRuntimeRecovery` says the supervisor owns recovery for this endpoint — `connection.ts` throws `RuntimeServiceStillStartingError` and the spawn fallback is deliberately skipped: an unmanaged second brain on a supervised socket is a rival, not a recovery. 4. As a final fallback, the legacy project-scoped socket from `resolveAdeLayout(projectRoot)` if the user passed `--require-socket` and the machine socket is unavailable. -`ade code --print-state` exercises that whole path, prints the chosen mode and socket path, and exits. The interactive TUI does not strand users on a blank first connection failure: it renders the failure, offers `r` for immediate retry, and schedules an automatic reconnect. A `RuntimeServiceStillStartingError` from that path is rendered as a *waiting* screen rather than the failure screen — "ADE's background service is starting", the same promise the desktop's `brain_starting` recovery copy makes, with the underlying message dimmed below it and nothing offered to repair. Either way the startup reconnect fires every `STARTUP_RECONNECT_DELAY_MS` (3 s) on its own, so a supervised brain that is simply slow opens the TUI without the user doing anything. +`ade code --print-state` exercises that whole path, prints the chosen mode and socket path, and exits. The interactive TUI does not strand users on a blank first connection failure: it renders the failure, offers `r` for immediate retry, and schedules an automatic reconnect. A `RuntimeServiceStillStartingError` from that path is rendered as a *waiting* screen rather than the failure screen — "ADE's background service is starting", the same promise the desktop's `brain_starting` recovery copy makes, with nothing offered to repair and no raw error text on the screen. Either way the startup reconnect fires every `STARTUP_RECONNECT_DELAY_MS` (3 s) on its own, so a supervised brain that is simply slow opens the TUI without the user doing anything. ### Embedded diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index b88c64151b..390a28c806 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -591,6 +591,23 @@ relay payload E2E encryption is planned security work. See the trust boundary in be tested against a probe that answers on the Nth call. A failed install with `starting` set is not a failure: the service is registered and supervised, so it keeps dialling. +- `apps/ade-cli/src/services/runtime/brainStartupState.ts` — + `readBrainStartupState`, the CLI's read of the desktop's `brain_starting` + verdict, for callers whose brain did not answer (a brain that answers is + running, never starting). The desktop reaches that verdict through its + connection pool; the CLI has no pool, so it asks the platform service manager + the same questions — is the service registered and running, how old is the + brain it supervises, and has that brain been restarted — and calls it + `starting` only when the service is installed, not stopped, its brain is + younger than the shared `RUNTIME_SERVICE_YOUNG_BRAIN_MS` window, and that + brain is not crash-looping. The crash-loop veto matters because a brain that + dies and relaunches every few seconds is always young, so age alone would + report a crash loop as "starting" forever: on macOS and Linux the veto reads + the brain's own `last-failure.json` streak, and on Windows it is the + supervisor's `restartCount`. Windows has no `ps -o etime=` either, so the age + comes from the same supervisor pid record (`runtimeStartedAtMs`) rather than + from the process table. Every probe failure fails closed to not-starting: + claiming a brain is starting when we cannot tell would hide a dead one. - `apps/ade-cli/src/services/runtime/connectWhileServiceStarts.ts` — dials the endpoint of a just-installed service, allowing for a `starting` one, and throws `RuntimeServiceStillStartingError` rather than a plain connect failure diff --git a/docs/logging.md b/docs/logging.md index 87ba0cdb28..64f6fb6632 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -232,19 +232,6 @@ day, inside the existing `ade_feature_used` 140-per-day / 30-per-minute limits and the shared 200-event ceiling; no ceiling was raised. The dashboard spec is deliberately untouched: no card asks this question yet. -Pressing **Report issue** on any error surface records one `ade_feature_used` at -the IPC owner boundary with `feature: "connections"`, `action: "issue_report"`, -and a coarse `outcome` (`opened` / `failed`) — whether the GitHub issue page -opened, and nothing else. Never the surface it was pressed on, never the -recovery code, and never the report itself: the report is a redacted document -the user chooses to paste, not telemetry. A per-outcome one-hour deduplication -key bounds a click-loop the same way the controls above are bounded, inside the -existing `ade_feature_used` and shared ceilings. The report's own `Install id` -line is `productAnalyticsService.getDistinctId()` — the exact value PostHog sees -as `distinct_id` for this installation (the identified account hash when signed -in, otherwise the random anonymous install token), surfaced so a report a user -files by hand can be matched to the events this machine already sent. - Pressing "Report issue" on any of ADE's error surfaces — the project recovery screen, the renderer error boundary, the project transition alert, the update banner — records the existing `ade_feature_used` event at the IPC owner boundary @@ -265,10 +252,10 @@ The dashboard spec is deliberately untouched: no card asks this question yet. The diagnostic report itself is a **local** artifact and is not analytics. It deliberately includes the PostHog `distinct_id` for this installation -(`productAnalyticsService.getDistinctId()`, a random per-install token — never a -device, machine, or account identifier) so a report someone files by hand can be -matched to the anonymous events the installation already sent. Nothing flows the -other way: the report is written to disk and copied to the clipboard, and only +(`productAnalyticsService.getDistinctId()` — the identified account hash when +signed in, otherwise the random anonymous install token) so a report someone +files by hand can be matched to the events the installation already sent. +Nothing flows the other way: the report is written to disk and copied to the clipboard, and only the person filing it decides where it goes. Its body is redacted before it is written (home directory, project paths, usernames, hostnames and tailnet names, emails, credentials and routable IP addresses), and the GitHub issue title and From 3a470a3cdee1b9f1ef88dc8e838a86466f9bb224 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:37:47 -0400 Subject: [PATCH 8/9] =?UTF-8?q?ship:=20iteration=202=20=E2=80=94=20fix=20t?= =?UTF-8?q?ypecheck-desktop,=20validate-docs=20and=20windows-foundation,?= =?UTF-8?q?=20address=20CodeRabbit=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI: - productAnalyticsService.test.ts: drop the `as Record` cast that no longer typechecks against the sanitizer's parameter (all values are already valid property values). - cli.test.ts: annotate the new cr-sqlite serve test with DARWIN-GATE — booting a real brain needs the macOS-only extension — so the platform-gate ratchet stays at its recorded baseline instead of growing. Review (verified against the code, stale claims dismissed): - projectRecoveryService: a fresh sync_host failure no longer asks for repair while the endpoint answers, and brain_starting is judged before socket reachability — this branch binds the socket before the brain can answer, so the old order reported a booting brain as a stolen socket. - Analytics identity: getDistinctId() loads persisted state and returns null unless analytics are effective, and the CLI honours the disabled marker, so an opted-out install never carries an id into a report. - Diagnostics redaction: placeholders are no longer re-wrapped, and a bracketed real name (`user=`) is still redacted rather than mistaken for one. - registerIpc: a projectless window can file an issue and diagnose or repair a root it names; the known-roots check still refuses an unknown one. - Installer: signal traps clean up once and exit 129/130/143, a Ctrl-C during the promoted binary's version check restores the backup instead of deleting it, and the post-promotion message believes the disk, not the restore flag. - A crashed Work route leaves for Lanes instead of remounting itself; the storage cleanup dialog ignores a completion from a previous open; the copy button moved out of ``; repair step labels are exhaustive. - cli.test.ts serve timings fit inside the test timeout, so a stuck brain reports its diagnostic and still kills its child. Co-Authored-By: Claude Fable 5 --- .../scripts/install-runtime-rollback.test.mjs | 27 ++++ apps/ade-cli/scripts/install-runtime.ps1 | 6 + apps/ade-cli/scripts/install-runtime.sh | 34 ++++- apps/ade-cli/src/cli.test.ts | 9 +- apps/ade-cli/src/cli.ts | 19 ++- apps/ade-cli/src/commands/reportIssue.test.ts | 20 +++ apps/ade-cli/src/commands/reportIssue.ts | 15 +- .../diagnostics/diagnosticReport.test.ts | 39 +++++- .../services/diagnostics/diagnosticReport.ts | 36 ++++- .../analytics/productAnalyticsService.test.ts | 2 +- .../analytics/productAnalyticsService.ts | 15 +- .../main/services/ipc/knownProjectRoots.ts | 6 +- .../src/main/services/ipc/registerIpc.ts | 18 ++- .../localRuntimeConnectionPool.ts | 4 +- .../runtime/projectRecoveryService.test.ts | 54 ++++++++ .../runtime/projectRecoveryService.ts | 45 +++--- .../src/renderer/components/app/App.tsx | 115 +--------------- .../components/app/AutoUpdateBanner.tsx | 9 +- .../components/app/PageErrorBoundary.test.tsx | 61 ++++++++ .../components/app/PageErrorBoundary.tsx | 130 ++++++++++++++++++ .../components/app/errorSurfaceKit.tsx | 47 ++++--- .../storage/StorageCleanupDialog.test.tsx | 49 +++++++ .../settings/storage/StorageCleanupDialog.tsx | 9 +- apps/desktop/src/shared/types/recovery.ts | 45 ++++-- 24 files changed, 628 insertions(+), 186 deletions(-) create mode 100644 apps/desktop/src/renderer/components/app/PageErrorBoundary.test.tsx create mode 100644 apps/desktop/src/renderer/components/app/PageErrorBoundary.tsx diff --git a/apps/ade-cli/scripts/install-runtime-rollback.test.mjs b/apps/ade-cli/scripts/install-runtime-rollback.test.mjs index d95349d2df..c9c509b5e4 100644 --- a/apps/ade-cli/scripts/install-runtime-rollback.test.mjs +++ b/apps/ade-cli/scripts/install-runtime-rollback.test.mjs @@ -51,6 +51,13 @@ if [ "\$1" = "--version" ]; then fi case "\$0" in */bin/ade) + # Stands in for the user hitting Ctrl-C while the promoted binary hangs + # on its own \`--version\`: the installer shell gets the same SIGINT the + # terminal would have delivered, and runs its signal handler. + if [ -n "\${ADE_TEST_INTERRUPT_INSTALLED:-}" ]; then + kill -INT "\$PPID" 2>/dev/null || true + exit 130 + fi if [ -n "\${ADE_TEST_FAIL_INSTALLED:-}" ]; then echo "fake ade: installed copy cannot start" >&2 exit 3 @@ -320,6 +327,26 @@ test("a promoted runtime that fails its version check is rolled back", () => { } }); +test("Ctrl-C while the promoted binary is being checked puts the old one back", () => { + const fixture = makeInstall(); + try { + const result = runInstaller(fixture, { ADE_TEST_INTERRUPT_INSTALLED: "1" }); + + assert.notEqual(result.status, 0); + // The interrupt lands after the new binary is already in place but before + // it has proved it can start, so the machine must be left on the install + // it had -- not on an unverified binary with its backup deleted. + assert.equal( + fs.readFileSync(path.join(fixture.installDir, "ade"), "utf8"), + "#!/bin/sh\necho previous\n", + ); + assert.ok(!fs.existsSync(path.join(fixture.installDir, "ade.bak"))); + assert.ok(!fs.existsSync(path.join(fixture.installDir, "ade.new"))); + } finally { + fixture.cleanup(); + } +}); + test("a healthy runtime is promoted and leaves no rollback state behind", () => { const fixture = makeInstall(); try { diff --git a/apps/ade-cli/scripts/install-runtime.ps1 b/apps/ade-cli/scripts/install-runtime.ps1 index 7908dac1e4..017f409b62 100644 --- a/apps/ade-cli/scripts/install-runtime.ps1 +++ b/apps/ade-cli/scripts/install-runtime.ps1 @@ -385,6 +385,12 @@ function Write-AdeInstallStateNote( } } elseif ($RestoredPreviousBinary) { [Console]::Error.WriteLine("ade install: the ADE you already had was put back, so nothing is broken.") + } elseif (Test-Path -LiteralPath $BinaryPath -PathType Leaf) { + # The rollback is best effort, so the disk is the only thing worth + # believing here: if it could not remove the binary that just failed, the + # broken one is still what runs, and saying "nothing was left installed" + # sends the user looking in the wrong place. + [Console]::Error.WriteLine("ade install: the ADE at $BinaryPath is the one that just failed to start.") } else { [Console]::Error.WriteLine("ade install: nothing was left installed at $BinaryPath.") } diff --git a/apps/ade-cli/scripts/install-runtime.sh b/apps/ade-cli/scripts/install-runtime.sh index 889bb7cac6..d0875e2ea8 100644 --- a/apps/ade-cli/scripts/install-runtime.sh +++ b/apps/ade-cli/scripts/install-runtime.sh @@ -468,6 +468,7 @@ backup_runtime_dir="$runtime_dir.previous" pending_binary="$dest_dir/ade.new" backup_binary="$dest_dir/ade.bak" promoted_runtime=0 +promoted_binary=0 have_backup_binary=0 # Kept out of $tmp_dir so it survives the EXIT trap: the failure message points # a stuck user at it, and a log deleted on the way out points at nothing. @@ -486,9 +487,31 @@ cleanup_install_scratch() { mv "$backup_runtime_dir" "$runtime_dir" 2>/dev/null || true fi rm -rf "$backup_runtime_dir" + # Same protective order for the binary backup, for a different reason. This + # script *copies* the old `ade` aside, so $dest_dir/ade is never absent; the + # window that matters is the one between the new binary being renamed into + # place and its `--version` check passing. Abort in there (the obvious Ctrl-C + # when a bad build hangs) and what is installed is an unverified binary, so + # the backup goes back over it before it is deleted. Only after the check + # passes is the backup just disk. (install-runtime.ps1 uses Move-Item, so + # there the backup really is the only copy for a moment -- hence its own + # restore-if-missing branch.) Deleting it unconditionally at the end also + # keeps an abandoned `ade.bak` from sitting in the install dir forever. + if [ "$promoted_binary" -eq 0 ] && [ -e "$backup_binary" ]; then + mv "$backup_binary" "$dest_dir/ade" 2>/dev/null || true + fi + rm -f "$backup_binary" } -trap 'cleanup_install_scratch' EXIT HUP INT TERM +# The EXIT trap alone is not enough: a handler for HUP/INT/TERM that does not +# exit returns to the interrupted command, so a Ctrl-C mid-download deleted the +# scratch state and then carried on against paths that no longer existed. Each +# signal cleans up once (the EXIT trap is cleared first) and aborts with that +# signal's conventional status. +trap 'cleanup_install_scratch' EXIT +trap 'trap - EXIT; cleanup_install_scratch; exit 129' HUP +trap 'trap - EXIT; cleanup_install_scratch; exit 130' INT +trap 'trap - EXIT; cleanup_install_scratch; exit 143' TERM # The runtime sidecar env has to be in place before *any* `--version` check: # the binary loads its native modules through it, so a preflight run without it @@ -547,6 +570,12 @@ die_runtime_unusable() { fi elif [ "$have_backup_binary" -eq 1 ]; then printf 'ade install: the ADE you already had was put back, so nothing is broken.\n' >&2 + elif [ -e "$dest_dir/ade" ]; then + # The rollback is best effort, so the disk is the only thing worth + # believing here: if it could not remove the binary that just failed, the + # broken one is still what runs, and saying "nothing was left installed" + # sends the user looking in the wrong place. + printf 'ade install: the ADE at %s is the one that just failed to start.\n' "$dest_dir/ade" >&2 else printf 'ade install: nothing was left installed at %s.\n' "$dest_dir/ade" >&2 fi @@ -618,6 +647,9 @@ if ! version_check "$dest_dir/ade" "installed"; then restore_previous_install die_runtime_unusable "the newly installed ADE runtime could not start" fi +# The new binary has now proved it runs, so the cleanup handlers must stop +# treating $dest_dir/ade as unverified and rolling the backup back over it. +promoted_binary=1 # Past the point of no return: the install is good, so the rollback copies are # just disk. ~150 MB of it, which is why they are not kept around. The log goes diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 90f47dcab9..e58bea49d7 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -960,6 +960,8 @@ describe("ADE CLI", () => { * teardown races surface as unhandled rejections in the runner, and killing * a child is the only way to end a brain the way the OS does. */ + // DARWIN-GATE: booting a real brain needs the cr-sqlite extension, which ships + // only for macOS, so this can run nowhere else. crdtHostIt( "binds and serves the RPC socket while the mobile sync host is still retrying", async () => { @@ -1007,7 +1009,10 @@ describe("ADE CLI", () => { brain.on("exit", (code, signal) => { brainExit = { code, signal }; }); let client: JsonRpcClient | null = null; - const deadline = Date.now() + 90_000; + // 60s + 45s worst case leaves headroom inside the 150s test timeout, so + // a stuck brain fails with the diagnostic below and still runs `finally` + // (which kills the squatter) instead of being cut off by the runner. + const deadline = Date.now() + 60_000; for (;;) { if (brainExit) { throw new Error( @@ -1050,7 +1055,7 @@ describe("ADE CLI", () => { // asserted at the moment of connect on purpose: the socket can be (and // routinely is) reachable before the first attempt has even failed // once, which is the whole point of the reorder. - const logDeadline = Date.now() + 60_000; + const logDeadline = Date.now() + 45_000; while (!stderr.includes("ADE brain sync host failed")) { if (Date.now() >= logDeadline) { throw new Error(`ADE brain never reported a sync host failure:\n${stderr}`); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 6569ff9b11..39e0022114 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -17632,9 +17632,24 @@ async function runServe( // running. The RPC socket is already published by now; closing it is // what `finish()` does, and the recorded failure carries the same code // project recovery keyed on before. - const { SyncHostSingletonConflictError } = await import("./services/sync/syncHostSingleton"); const message = error instanceof Error ? error.message : String(error); - if (error instanceof SyncHostSingletonConflictError) { + // The conflict class is loaded to classify the failure, and that import + // can itself reject (a torn install, a disk error). Letting it escape + // would skip `finish()` — the brain would keep serving a socket it has + // already decided to give up, and the rejection would surface as an + // unhandled one. An unclassifiable failure is still a failure. + let conflict = false; + try { + const { SyncHostSingletonConflictError } = await import("./services/sync/syncHostSingleton"); + conflict = error instanceof SyncHostSingletonConflictError; + } catch (importError: unknown) { + process.stderr.write( + `ADE brain could not classify its sync host failure: ${ + importError instanceof Error ? importError.message : String(importError) + }\n`, + ); + } + if (conflict) { syncHostStartupFailure = new CliExecutionError("ADE brain refusing to run without mobile sync.", { cause: message, socketPath, diff --git a/apps/ade-cli/src/commands/reportIssue.test.ts b/apps/ade-cli/src/commands/reportIssue.test.ts index ba0a98491e..f6947b38c4 100644 --- a/apps/ade-cli/src/commands/reportIssue.test.ts +++ b/apps/ade-cli/src/commands/reportIssue.test.ts @@ -50,4 +50,24 @@ describe("buildCliDiagnosticReport", () => { expect(missing.installId).toBe("unknown"); expect(missing.report.length).toBeGreaterThan(0); }); + + it("omits the install id when analytics is switched off", () => { + const built = buildCliDiagnosticReport({ + env: { ADE_HOME: adeHome({ identifiedUserHash: "hash-3", anonymousId: "anon-3", enabled: false }) }, + }); + + expect(built.installId).toBe("unknown"); + expect(built.report).not.toContain("hash-3"); + expect(built.report).not.toContain("anon-3"); + }); + + it("omits the install id when the opt-out marker is on disk", () => { + const home = adeHome({ identifiedUserHash: "hash-4", anonymousId: "anon-4" }); + fs.writeFileSync(path.join(home, "secrets", "product-analytics.json.disabled"), "disabled\n", "utf8"); + + const built = buildCliDiagnosticReport({ env: { ADE_HOME: home } }); + + expect(built.installId).toBe("unknown"); + expect(built.report).not.toContain("hash-4"); + }); }); diff --git a/apps/ade-cli/src/commands/reportIssue.ts b/apps/ade-cli/src/commands/reportIssue.ts index 8e2a7c3593..3590d0a57e 100644 --- a/apps/ade-cli/src/commands/reportIssue.ts +++ b/apps/ade-cli/src/commands/reportIssue.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { @@ -30,11 +31,21 @@ export type ReportIssueResult = { installId: string; }; -/** The same PostHog `distinct_id` the desktop reports, read without writing. */ +/** + * The same PostHog `distinct_id` the desktop reports, read without writing. + * + * Null whenever analytics is off — the `.disabled` marker the desktop writes, + * or `enabled: false` in the state itself. No event carries the id then, so it + * correlates to nothing, and printing it into a report the user is about to + * paste into a public issue is the opposite of the choice they made. + */ function readInstallId(secretsDir: string): string | null { - const state = readDiagnosticJsonFile(path.join(secretsDir, "product-analytics.json")); + const statePath = path.join(secretsDir, "product-analytics.json"); + if (fs.existsSync(`${statePath}.disabled`)) return null; + const state = readDiagnosticJsonFile(statePath); if (!state || typeof state !== "object") return null; const record = state as Record; + if (record.enabled === false) return null; // Only the two keys PostHog actually uses as `distinct_id`; `installationId` // is a different identifier and would make the CLI report an id no event in // PostHog is ever attributed to. diff --git a/apps/ade-cli/src/services/diagnostics/diagnosticReport.test.ts b/apps/ade-cli/src/services/diagnostics/diagnosticReport.test.ts index 058952fea8..9e9f41b86d 100644 --- a/apps/ade-cli/src/services/diagnostics/diagnosticReport.test.ts +++ b/apps/ade-cli/src/services/diagnostics/diagnosticReport.test.ts @@ -64,7 +64,10 @@ describe("redactDiagnosticText", () => { const out = redactDiagnosticText(input, CONTEXT); - expect(out).not.toContain("example.com/v1".replace("/v1", "")); // userinfo host is fine, creds are not + // The email rule runs before the userinfo one, so `ada:hunter2@relay…` is + // eaten as an address whole — the host goes with it and the line is left + // as `https://:/v1`. + expect(out).not.toContain("example.com"); expect(out).not.toMatch(/hunter2|ghp_ABCDEF|phc_ABCDEF|eyJhbGciOiJ/); expect(out).not.toMatch(/sk-ant-api03/); expect(out).not.toContain("8e1f2a3b4c5d6e7f"); @@ -224,6 +227,40 @@ describe("buildDiagnosticReport", () => { }); }); +describe("redactDiagnosticText idempotency", () => { + // Regression: `user` and `host` are ordinary account and machine names on + // Windows and in containers, and the name rules matched them again inside + // the ``/`` they had just written — `<>`, then `<<>>`. + it("leaves its own placeholders alone when the name IS the placeholder word", () => { + const context = { username: "user", hostname: "host" }; + const once = redactDiagnosticText("user@host started on host as user", context); + + expect(once).toContain(""); + expect(once).toContain(""); + expect(once).not.toContain("<<"); + expect(redactDiagnosticText(once, context)).toBe(once); + }); + + // Regression: the first fix for the above excluded `<` and `>` from the name + // boundaries, which silently suppressed the match for every bracketed name a + // log actually writes — `user=` and `Host: ` shipped the real + // account and machine name in the report. + it("still redacts a bracketed name instead of mistaking it for a placeholder", () => { + const context = { username: "ada", hostname: "buildbox" }; + const once = redactDiagnosticText("user= host= peer ", context); + + expect(once).not.toMatch(/ada/i); + expect(once).not.toContain("buildbox"); + expect(once).toContain("user="); + expect(once).toContain("host="); + // Brackets are re-emitted, never doubled around a placeholder. + expect(once).not.toContain("<>"); + expect(once).not.toContain("<>"); + expect(once).toBe("user= host= peer <@x>"); + expect(redactDiagnosticText(once, context)).toBe(once); + }); +}); + describe("redactDiagnosticText hostnames", () => { // Regression: the machine-name rule used to hand an already-assembled // pattern to a helper that escapes what it is given, so the word-boundary diff --git a/apps/ade-cli/src/services/diagnostics/diagnosticReport.ts b/apps/ade-cli/src/services/diagnostics/diagnosticReport.ts index 271ca70630..4f64dc4299 100644 --- a/apps/ade-cli/src/services/diagnostics/diagnosticReport.ts +++ b/apps/ade-cli/src/services/diagnostics/diagnosticReport.ts @@ -152,6 +152,27 @@ function isPlausibleIpv4(value: string): boolean { }); } +/** + * Word boundaries for the name rules. The surrounding angle brackets are + * *captured* rather than excluded: an account or machine literally named + * `user` or `host` — both common on Windows and in containers — otherwise + * matched inside the ``/`` this function had just produced, and + * every further pass added another pair of brackets. Excluding `<`/`>` from + * the boundaries instead would be worse than the bug it fixed: it would also + * suppress the match for a genuine `user=` or `Host: ` log + * line, shipping the real name in the report. Re-emitting the brackets keeps + * the idempotency this module promises (and asserts in its tests) while still + * redacting bracketed names. + */ +const NAME_BOUNDARY_START = "(?` → ``, `ada` → ``, `` → ``. */ +function bracketAwarePlaceholder(placeholder: string) { + return (_match: string, open: string, close: string): string => + open === "<" && close === ">" ? placeholder : `${open}${placeholder}${close}`; +} + /** * Strips everything that could identify the machine or its owner. Applied to * the whole report as the final step, so a section added later cannot leak by @@ -194,7 +215,13 @@ export function redactDiagnosticText( // 4. The OS account name wherever it appears on its own. const username = context.username?.trim(); if (username && username.length >= 3) { - out = out.replace(new RegExp(`(?"); + out = out.replace( + new RegExp( + `${NAME_BOUNDARY_START}(?)${NAME_BOUNDARY_END}`, + "gi", + ), + bracketAwarePlaceholder(""), + ); } // 5. Emails before token blobs: an address must not be eaten as a secret. @@ -248,11 +275,14 @@ export function redactDiagnosticText( .filter(Boolean) .sort((a, b) => b.length - a.length); if (names.length > 0) { + // Boundaries and brackets hoisted outside the alternation: inside it, + // each branch would need its own copy and the capture-group numbering + // would shift with every extra name. const pattern = new RegExp( - names.map((name) => `(? escapeRegExp(name)).join("|")})(>?)${NAME_BOUNDARY_END}`, "gi", ); - out = out.replace(pattern, ""); + out = out.replace(pattern, bracketAwarePlaceholder("")); } } out = out diff --git a/apps/desktop/src/main/services/analytics/productAnalyticsService.test.ts b/apps/desktop/src/main/services/analytics/productAnalyticsService.test.ts index 39e6a5c668..1debac4190 100644 --- a/apps/desktop/src/main/services/analytics/productAnalyticsService.test.ts +++ b/apps/desktop/src/main/services/analytics/productAnalyticsService.test.ts @@ -1292,7 +1292,7 @@ describe("product analytics producers", () => { code: "db_integrity", install_id: "ade_0123456789abcdef0123456789abcdef", headline: "ADE couldn't open /Users/ada/photon", - } as Record); + }); expect(leaky).toEqual({ feature: "connections", action: "issue_report", outcome: "opened" }); // An outcome outside the closed set is dropped, not passed through. diff --git a/apps/desktop/src/main/services/analytics/productAnalyticsService.ts b/apps/desktop/src/main/services/analytics/productAnalyticsService.ts index 6a9dd8f51f..45cc2d74e3 100644 --- a/apps/desktop/src/main/services/analytics/productAnalyticsService.ts +++ b/apps/desktop/src/main/services/analytics/productAnalyticsService.ts @@ -1194,8 +1194,21 @@ export function createProductAnalyticsService(args: ProductAnalyticsServiceArgs) * installation. Surfaced so a diagnostic report a user files by hand can be * matched to the events this machine already sent; it is a random * per-install token, not a device or account identifier. + * + * Null unless events are actually being sent under it. Two reasons: until + * something loads the persisted file this service is holding a freshly + * minted in-memory id that no event has ever carried (a correlation id + * that correlates to nothing), and when the user has analytics off there + * is nothing to correlate against — putting the identifier in a report + * they are about to paste into a public issue is the opposite of the + * choice they made. `getStatus()` is the reader that loads durable state + * on both the configured and unconfigured paths. */ - getDistinctId: (): string => state.identifiedUserHash ?? state.anonymousId, + getDistinctId: (): string | null => { + const status = getStatus(); + if (!status.effective) return null; + return state.identifiedUserHash ?? state.anonymousId; + }, installationIdForTesting: () => state.installationId, identifiedUserHashForTesting: () => state.identifiedUserHash, }; diff --git a/apps/desktop/src/main/services/ipc/knownProjectRoots.ts b/apps/desktop/src/main/services/ipc/knownProjectRoots.ts index 7a70a87080..7b2bb77b25 100644 --- a/apps/desktop/src/main/services/ipc/knownProjectRoots.ts +++ b/apps/desktop/src/main/services/ipc/knownProjectRoots.ts @@ -96,8 +96,10 @@ export class AttemptedProjectRoots { /** * Returns the known root that `requested` refers to — in the registry's own - * spelling, so the rest of the flow works with a normalized path — or null when - * it is not a project this machine knows about. + * spelling, which is what the user picked and what the recent-projects list + * shows, NOT the canonical form the comparison runs on — or null when it is not + * a project this machine knows about. Callers that need an absolute path + * resolve it themselves. */ export function resolveKnownProjectRoot( requested: string | null | undefined, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 99d92fa90e..c2d09998bd 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -4588,6 +4588,20 @@ export function registerIpc({ } }); + /** + * The open project's root, or null when there is no project context at all. + * The surfaces that most need to file an issue are projectless — a renderer + * crash on startup, Connections on a fresh install — and `getCtx()` throws + * there, which turned "report this" into no report at all. + */ + const openProjectRootOrNull = (): string | null => { + try { + return getCtx().project.rootPath ?? null; + } catch { + return null; + } + }; + /** * The renderer names a project root; main decides whether that is a project * it knows. See `knownProjectRoots.ts` for why trimming is not enough, and @@ -4605,7 +4619,7 @@ export function registerIpc({ // is the safe subset — never a reason to widen what is accepted. } return resolveKnownProjectRoot(requested, { - openProjectRoot: getCtx().project.rootPath, + openProjectRoot: openProjectRootOrNull(), recentProjectRoots, attemptedProjectRoots: attemptedProjectRoots?.list(), }); @@ -4658,7 +4672,7 @@ export function registerIpc({ const rootWasRejected = Boolean(requestedRoot) && !resolvedRoot; const projectRoot = rootWasRejected ? null - : resolvedRoot ?? getCtx().project.rootPath ?? null; + : resolvedRoot ?? openProjectRootOrNull(); return await collectDiagnosticReport( { appVersion: app.getVersion(), diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index c1cc8bff5d..79a1c52da0 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -1465,7 +1465,9 @@ export class LocalRuntimeConnectionPool { path: cliPath, message: "Installing the ADE service login item.", exitCode: null, - updatedAt: attemptStartedAt, + // This transition is happening now; `attemptStartedAt` is the streak + // marker and the only field that reaches back to the first attempt. + updatedAt: new Date().toISOString(), attemptStartedAt, }; let result: ServiceManagerCommandResult; diff --git a/apps/desktop/src/main/services/runtime/projectRecoveryService.test.ts b/apps/desktop/src/main/services/runtime/projectRecoveryService.test.ts index 52a799ddc1..d680c94ef7 100644 --- a/apps/desktop/src/main/services/runtime/projectRecoveryService.test.ts +++ b/apps/desktop/src/main/services/runtime/projectRecoveryService.test.ts @@ -208,6 +208,60 @@ describe("ProjectRecoveryService.diagnose", () => { expect(diagnosis.canAutoRepair).toBe(canAutoRepair); expectNoJargon(`${diagnosis.headline} ${diagnosis.body}`); }); + + it("calls a bound-but-not-yet-answering brain starting, not another program's socket", async () => { + // The brain binds its RPC socket before it can answer `ade/initialize`, so + // reachable-but-silent is its own boot window, not a stranger's socket. + const service = createProjectRecoveryService(deps({ + probeSocket: vi.fn(async () => true), + pingEndpoint: vi.fn(async () => false), + connectionPool: pool(status({ + serviceInstall: { + state: "installed", attempted: true, path: null, message: null, exitCode: null, + updatedAt: new Date(NOW).toISOString(), starting: true, + attemptStartedAt: new Date(NOW - 15_000).toISOString(), + }, + serviceHealth: { state: "running", installed: true, running: true, path: null, message: null, checkedAt: null }, + })), + })); + + const diagnosis = await service.diagnose(tempRoot()); + + expect(diagnosis.state).toBe("brain_starting"); + expect(diagnosis.canAutoRepair).toBe(false); + }); + + it("does not report a healthy answering brain as broken over a fresh sync-host failure", async () => { + const syncHostFailure: AdeLastFailureReport = { + ...failure("socket_owned_by_other"), + component: "sync_host", + }; + const service = createProjectRecoveryService(deps({ + probeSocket: vi.fn(async () => true), + pingEndpoint: vi.fn(async () => true), + readFailureReports: vi.fn(async () => ({ project: null, machine: syncHostFailure })), + })); + + const diagnosis = await service.diagnose(tempRoot()); + + expect(diagnosis.state).toBe("healthy"); + // Still reported, just not treated as a reason to repair. + expect(diagnosis.lastFailure?.component).toBe("sync_host"); + }); + + it("still repairs on a fresh sync-host failure when the brain does not answer", async () => { + const syncHostFailure: AdeLastFailureReport = { + ...failure("socket_owned_by_other"), + component: "sync_host", + }; + const service = createProjectRecoveryService(deps({ + readFailureReports: vi.fn(async () => ({ project: null, machine: syncHostFailure })), + })); + + const diagnosis = await service.diagnose(tempRoot()); + + expect(diagnosis.state).toBe("socket_owned_by_other"); + }); }); describe("ProjectRecoveryService.repair", () => { diff --git a/apps/desktop/src/main/services/runtime/projectRecoveryService.ts b/apps/desktop/src/main/services/runtime/projectRecoveryService.ts index 16a4bca164..1364a44250 100644 --- a/apps/desktop/src/main/services/runtime/projectRecoveryService.ts +++ b/apps/desktop/src/main/services/runtime/projectRecoveryService.ts @@ -2,7 +2,8 @@ import fs from "node:fs"; import net from "node:net"; import path from "node:path"; import { - REPAIR_STEPS, + REPAIR_STEP_LABELS, + REPAIR_STEP_ORDER, stateForCode, type AdeLastFailureReport, type AdeRecoveryErrorCode, @@ -44,11 +45,9 @@ const BRAIN_RESTART_TIMEOUT_MS = RUNTIME_SERVICE_START_WAIT_MS; // answering yet is presumed to still be starting, not stuck. const BRAIN_STARTING_WINDOW_MS = RUNTIME_SERVICE_YOUNG_BRAIN_MS; -const STEP_LABELS: Record = Object.fromEntries( - REPAIR_STEPS.map((step) => [step.id, step.label]), -) as Record; +const STEP_LABELS = REPAIR_STEP_LABELS; -const STEP_ORDER: readonly RepairStepId[] = REPAIR_STEPS.map((step) => step.id); +const STEP_ORDER = REPAIR_STEP_ORDER; const REPAIR_MIN_FREE_BYTES = (dbSize: number): number => Math.max(GIB, dbSize + 512 * MIB); // Advice = repair gate + margin, so following the advice always satisfies repair. @@ -495,14 +494,27 @@ export class ProjectRecoveryService { const installStartedAt = Date.parse(serviceStatus.serviceInstall.attemptStartedAt ?? ""); // Time-bounded on purpose: the installer's `starting` flag alone would keep // a brain that wedged during boot reading as "starting" forever. + // + // Deliberately not gated on `!socketReachable`: the brain binds its RPC + // socket before it can answer `ade/initialize`, so there is a real window + // where the socket accepts connections and the ping still fails. Requiring + // an unreachable socket made that window impossible to classify as + // starting, and it fell through to "another program owns this" instead. const brainStarting = - !socketReachable - && serviceStatus.serviceHealth.running === true + serviceStatus.serviceHealth.running === true && Number.isFinite(installStartedAt) && this.now() - installStartedAt < BRAIN_STARTING_WINDOW_MS; const dbCheck = endpointHealthy ? { healthy: null, detail: "Project data check skipped because the background service is using it." } : await this.quickCheck(dbPath); + // A recorded sync-host failure says phone sync could not start. It says + // nothing about whether the desktop can reach this brain, and the brain + // deliberately keeps that record until sync really comes up — so a brain + // that is bound, answering, and healthy routinely carries a fresh + // `sync_host` failure. Repairing on it would kill a brain doing its job. + // It stays in `lastFailure` and the technical detail either way. + const actionableFailure = + freshFailure && endpointHealthy && freshFailure.component === "sync_host" ? null : freshFailure; const technicalParts = [ `freeBytes=${free}`, `dbSize=${dbSize}`, @@ -520,25 +532,26 @@ export class ProjectRecoveryService { if (free < GIB) { state = "disk_full"; code = "disk_full"; - } else if (freshFailure) { - state = stateForCode(freshFailure.code); - code = freshFailure.code; + } else if (actionableFailure) { + state = stateForCode(actionableFailure.code); + code = actionableFailure.code; } else if (dbCheck.healthy === false) { state = "db_repair_needed"; code = "db_integrity"; } else if (endpointHealthy) { state = "healthy"; code = "unknown"; - } else if (socketReachable) { - state = "socket_owned_by_other"; - code = "socket_owned_by_other"; } else if (brainStarting) { - // Ahead of the crash-loop and stale-socket branches: a brain that the - // installer just started (or reported as still starting) and that + // Ahead of the owner, crash-loop and stale-socket branches: a brain that + // the installer just started (or reported as still starting) and that // launchd/the supervisor shows running is booting, not broken. Repair - // here would only kill it and start its clock over. + // here would only kill it and start its clock over, and a socket it has + // bound but cannot answer on yet is this brain's, not a stranger's. state = "brain_starting"; code = "unknown"; + } else if (socketReachable) { + state = "socket_owned_by_other"; + code = "socket_owned_by_other"; } else if (serviceStatus.serviceHealth.installed === false) { state = "brain_not_installed"; code = "brain_not_installed"; diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 1f27093b6c..da4ecb2bf7 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -9,7 +9,6 @@ import { useNavigate } from "react-router-dom"; import { useShallow } from "zustand/react/shallow"; -import { WarningCircle } from "@phosphor-icons/react"; import { AppShell } from "./AppShell"; import { resolveSettingsTab } from "../settings/settingsManifest"; @@ -22,19 +21,11 @@ import { WindowsBetaNoticeHost } from "./WindowsBetaNoticeModal"; import { ClipboardDeeplinkBanner } from "./ClipboardDeeplinkBanner"; import { CrossRepoPrBanner } from "./CrossRepoPrBanner"; import { ProjectRecoveryScreen } from "./ProjectRecoveryScreen"; -import { ReportIssueButton } from "./ReportIssueButton"; -import { - ERROR_PRIMARY_BUTTON, - ERROR_SECONDARY_BUTTON, - ErrorSurfaceCard, - TechnicalDetailsFold, - WhatToDo, -} from "./errorSurfaceKit"; +import { PageErrorBoundary } from "./PageErrorBoundary"; import { ProjectWelcomePage } from "../projects/ProjectWelcomePage"; import { OnboardingBootstrap } from "../onboarding/OnboardingBootstrap"; import { LaunchGate } from "../onboarding/LaunchGate"; import { GlossaryPage } from "../onboarding/GlossaryPage"; -import { logRendererDebugEvent } from "../../lib/debugLog"; import { readStoredProjectRoute, writeStoredProjectRoute } from "./projectRouteStorage"; import { requestLinearIssueQuickView } from "../../lib/linearIssueQuickViewNavigation"; import { isWebClientMode } from "../../lib/webClientMode"; @@ -182,110 +173,6 @@ const StartupSplashScreen = ( /** Used by React.lazy Suspense boundaries while route chunks load. */ const GuardLoadingFallback = StartupSplashScreen; -/* ---------- Per-route error boundary ---------- */ - -const PAGE_CRASH_STEPS: readonly string[] = [ - "Go to Work — the rest of ADE keeps running.", - "Come back to this screen. If it breaks again, choose Report issue so we can see what happened here.", -]; - -type PageErrorBoundaryState = { hasError: boolean; message: string }; - -class PageErrorBoundaryInner extends React.Component< - { children: React.ReactNode; onGoHome: () => void }, - PageErrorBoundaryState -> { - state: PageErrorBoundaryState = { hasError: false, message: "" }; - - static getDerivedStateFromError(error: unknown): PageErrorBoundaryState { - return { hasError: true, message: error instanceof Error ? error.message : String(error) }; - } - - componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { - console.error("page.crash", error, errorInfo, error?.stack); - logRendererDebugEvent("renderer.page_boundary_crash", { - message: error?.message ?? String(error), - route: window.location.hash || window.location.pathname, - componentStack: errorInfo.componentStack ?? null, - causeStack: error?.stack ?? null, - }); - } - - render() { - if (this.state.hasError) { - return ( -
    - {/* Min-height row rather than `items-center` on the scroller: a card - taller than the pane would otherwise be clipped at the top. */} -
    -
    - - -
    - -
    - - -
    -
    -
    - ); - } - return this.props.children; - } -} - -function PageErrorBoundary({ children }: { children: React.ReactNode }) { - const navigate = useNavigate(); - return ( - navigate("/work")}> - {children} - - ); -} - const RouteLoadingFallback = (
    {showBanner && banner ? ( -
    -
    } /> + + , + ); +} + +beforeEach(() => { + // React logs the caught render error; the boundary is what is under test. + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("PageErrorBoundary", () => { + it("leaves a crashed Work route instead of remounting it", () => { + renderAt("/work/chat-1"); + + fireEvent.click(screen.getByRole("button", { name: "Go to Lanes" })); + + expect(screen.getByTestId("route").textContent).toBe("/lanes"); + }); + + it("sends every other crashed route home to Work", () => { + renderAt("/prs"); + + fireEvent.click(screen.getByRole("button", { name: "Go to Work" })); + + expect(screen.getByTestId("route").textContent).toBe("/work"); + }); +}); diff --git a/apps/desktop/src/renderer/components/app/PageErrorBoundary.tsx b/apps/desktop/src/renderer/components/app/PageErrorBoundary.tsx new file mode 100644 index 0000000000..db01b96ccd --- /dev/null +++ b/apps/desktop/src/renderer/components/app/PageErrorBoundary.tsx @@ -0,0 +1,130 @@ +import React from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { WarningCircle } from "@phosphor-icons/react"; +import { logRendererDebugEvent } from "../../lib/debugLog"; +import { + ERROR_PRIMARY_BUTTON, + ERROR_SECONDARY_BUTTON, + ErrorSurfaceCard, + TechnicalDetailsFold, + WhatToDo, +} from "./errorSurfaceKit"; +import { ReportIssueButton } from "./ReportIssueButton"; + +/** + * Per-route error boundary. One screen failing to draw must never take the + * window with it, and the way out of a broken screen must actually leave it. + */ + +const pageCrashSteps = (goHomeLabel: string): readonly string[] => [ + `${goHomeLabel} — the rest of ADE keeps running.`, + "Come back to this screen. If it breaks again, choose Report issue so we can see what happened here.", +]; + +type PageErrorBoundaryState = { hasError: boolean; message: string }; + +class PageErrorBoundaryInner extends React.Component< + { children: React.ReactNode; onGoHome: () => void; goHomeLabel: string }, + PageErrorBoundaryState +> { + state: PageErrorBoundaryState = { hasError: false, message: "" }; + + static getDerivedStateFromError(error: unknown): PageErrorBoundaryState { + return { hasError: true, message: error instanceof Error ? error.message : String(error) }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + console.error("page.crash", error, errorInfo, error?.stack); + logRendererDebugEvent("renderer.page_boundary_crash", { + message: error?.message ?? String(error), + route: window.location.hash || window.location.pathname, + componentStack: errorInfo.componentStack ?? null, + causeStack: error?.stack ?? null, + }); + } + + render() { + if (this.state.hasError) { + return ( +
    + {/* Min-height row rather than `items-center` on the scroller: a card + taller than the pane would otherwise be clipped at the top. */} +
    +
    + + +
    + +
    + + +
    +
    +
    + ); + } + return this.props.children; + } +} + +export function PageErrorBoundary({ children }: { children: React.ReactNode }) { + const navigate = useNavigate(); + const location = useLocation(); + // Work is home for every other screen, but it cannot be its own escape + // hatch: sending a crashed Work route back to /work remounts the route that + // just threw and draws this same card again. Lanes is the neighbouring + // top-level tab and does not depend on anything Work owns. + const crashedOnWork = location.pathname === "/work" || location.pathname.startsWith("/work/"); + return ( + navigate(crashedOnWork ? "/lanes" : "/work")} + > + {children} + + ); +} diff --git a/apps/desktop/src/renderer/components/app/errorSurfaceKit.tsx b/apps/desktop/src/renderer/components/app/errorSurfaceKit.tsx index a1ae51d130..1e83eef74a 100644 --- a/apps/desktop/src/renderer/components/app/errorSurfaceKit.tsx +++ b/apps/desktop/src/renderer/components/app/errorSurfaceKit.tsx @@ -146,27 +146,30 @@ export function TechnicalDetailsFold({ const { copy, copied } = useCopyToClipboard(); if (!text.trim()) return null; return ( -
    - - - {ERROR_DISCLOSURE_CARET} - Show technical details - - - -
    -        {text}
    -      
    -
    + // Copy sits ON the summary row but not INSIDE ``: a summary is + // itself the disclosure control, and a button nested in one is flattened + // away by some assistive technology and has to fight the toggle with + // `preventDefault`. Overlaying it keeps the row people already know. +
    +
    + + + {ERROR_DISCLOSURE_CARET} + Show technical details + + +
    +          {text}
    +        
    +
    + +
    ); } diff --git a/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.test.tsx b/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.test.tsx index b849992356..4480a96735 100644 --- a/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.test.tsx +++ b/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.test.tsx @@ -140,4 +140,53 @@ describe("StorageCleanupDialog failures", () => { expect(screen.queryByText("stale.log")).toBeNull(); expect(screen.getByText("fresh.log")).toBeTruthy(); }); + + it("ignores a cleanup completion after close and reopen", async () => { + const previews: Array<(value: unknown) => void> = []; + const cleanupPreview = vi.fn(() => new Promise((resolve) => { previews.push(resolve); })); + const cleanups: Array<(value: unknown) => void> = []; + const cleanupCall = vi.fn(() => new Promise((resolve) => { cleanups.push(resolve); })); + (window as unknown as { ade?: unknown }).ade = { + storage: { cleanupPreview, cleanup: cleanupCall }, + }; + + const onCleaned = vi.fn(); + const props = { + title: "Free up space", + targets: [] as never[], + onClose: vi.fn(), + onCleaned, + }; + const { rerender } = render(); + await waitFor(() => expect(cleanupPreview).toHaveBeenCalledTimes(1)); + previews[0]({ + items: [{ path: "/tmp/first.log", label: "first.log", bytes: 10 }], + blocked: [], + totalBytes: 10, + }); + await screen.findByText("first.log"); + + // Start the removal, then close and reopen before it answers. + fireEvent.click(screen.getByRole("button", { name: /Remove 1 item/ })); + await waitFor(() => expect(cleanupCall).toHaveBeenCalledTimes(1)); + rerender(); + rerender(); + await waitFor(() => expect(cleanupPreview).toHaveBeenCalledTimes(2)); + previews[1]({ + items: [{ path: "/tmp/second.log", label: "second.log", bytes: 20 }], + blocked: [], + totalBytes: 20, + }); + await screen.findByText("second.log"); + + // The abandoned removal answers last: it must not settle the reopened + // dialog to "done", and must not report a result for a job nobody is + // looking at any more. + cleanups[0]({ removed: ["/tmp/first.log"], failed: [], freedBytes: 10 }); + await waitFor(() => expect(cleanupCall).toHaveBeenCalledTimes(1)); + + expect(onCleaned).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: /Remove 1 item/ })).toBeTruthy(); + expect(screen.getByText("second.log")).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.tsx b/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.tsx index df72adade4..4564ce27d3 100644 --- a/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.tsx +++ b/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.tsx @@ -306,11 +306,16 @@ export function StorageCleanupDialog({ }, [open]); const confirm = React.useCallback(async () => { + // Same generation guard the preview uses, for the same reason: a removal + // (and the maintenance run after it) outlives a close, and a completion + // that lands after the dialog reopened would push the fresh dialog to + // "done" and hand the parent a result for a job it is no longer showing. + const requestId = requestRef.current; setStage("removing"); setError(null); try { if (!preview) { - setStage("review"); + if (requestRef.current === requestId) setStage("review"); return; } const filesystemResult = preview.items.length > 0 @@ -324,12 +329,14 @@ export function StorageCleanupDialog({ ...filesystemResult, freedBytes: filesystemResult.freedBytes + maintenanceBytes, }; + if (requestRef.current !== requestId) return; setReport(nextReport); setResult(next); setStage("done"); onCleaned(next); if (nextReport) plan?.onMaintenanceDone?.(nextReport); } catch (err) { + if (requestRef.current !== requestId) return; setError(err instanceof Error ? err.message : String(err)); setErrorPhase("removing"); setStage("error"); diff --git a/apps/desktop/src/shared/types/recovery.ts b/apps/desktop/src/shared/types/recovery.ts index a5a9d7ba11..d8e00846d8 100644 --- a/apps/desktop/src/shared/types/recovery.ts +++ b/apps/desktop/src/shared/types/recovery.ts @@ -74,21 +74,42 @@ export type RepairStepId = | "reconcile_chats"; /** - * The repair steps in the order `ProjectRecoveryService.repair` runs them, - * with the wording each one shows. Shared so the recovery screen can name the - * step that is running before it has reported, from the same list. + * The wording each repair step shows. A `Record` keyed by the id union rather + * than a list of pairs: a step added to {@link RepairStepId} without a label + * has to fail here, at the definition, instead of surfacing as an unlabeled + * row on the recovery screen. */ -export const REPAIR_STEPS: ReadonlyArray<{ id: RepairStepId; label: string }> = [ - { id: "check_space", label: "Checking storage space" }, - { id: "stop_service", label: "Stopping ADE's background service" }, - { id: "validate_database", label: "Checking project data" }, - { id: "resolve_migrations", label: "Finishing interrupted saves" }, - { id: "restart_service", label: "Restarting ADE's background service" }, - { id: "verify_endpoint", label: "Checking the background service" }, - { id: "verify_project_rpc", label: "Checking this project" }, - { id: "reconcile_chats", label: "Checking chats" }, +export const REPAIR_STEP_LABELS: Record = { + check_space: "Checking storage space", + stop_service: "Stopping ADE's background service", + validate_database: "Checking project data", + resolve_migrations: "Finishing interrupted saves", + restart_service: "Restarting ADE's background service", + verify_endpoint: "Checking the background service", + verify_project_rpc: "Checking this project", + reconcile_chats: "Checking chats", +}; + +/** The order `ProjectRecoveryService.repair` runs the steps in. */ +export const REPAIR_STEP_ORDER: readonly RepairStepId[] = [ + "check_space", + "stop_service", + "validate_database", + "resolve_migrations", + "restart_service", + "verify_endpoint", + "verify_project_rpc", + "reconcile_chats", ]; +/** + * The repair steps in the order they run, with their wording. Shared so the + * recovery screen can name the step that is running before it has reported, + * from the same list. + */ +export const REPAIR_STEPS: ReadonlyArray<{ id: RepairStepId; label: string }> = + REPAIR_STEP_ORDER.map((id) => ({ id, label: REPAIR_STEP_LABELS[id] })); + export type RepairStepResult = { id: RepairStepId; label: string; From d631237464b1cbc4f87a45301addac3243ea8900 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 17 Aug 2026 04:24:06 -0400 Subject: [PATCH 9/9] =?UTF-8?q?ship:=20iteration=203=20=E2=80=94=20fix=20t?= =?UTF-8?q?he=20Windows=20launchd=20test,=20redact=20fine-grained=20tokens?= =?UTF-8?q?,=20keep=20a=20rollback=20copy=20through=20an=20interrupted=20i?= =?UTF-8?q?nstall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI: - serviceManager/common.test.ts: the consolidated launchd handover test drove the host's real parent-pid probe, which on Windows reads "unknown" and fails safe into the self-mutation block. Inject the ancestry like every other install test in the file — this case is about handover windows, not parents. Review (Codex, CodeRabbit; verified against the code): - Diagnostics: redact fine-grained GitHub PATs (`github_pat_`) and the other prefixes ADE actually accepts, run the email rule before the account-name rule so a name that is also an email local part still hides the domain, and keep model ids and snake_case identifiers out of the token rule. - `ade report-issue --open` copies the report before opening the issue, which is what the template it opens tells the user to paste; `--json` reports it. - Installer: an interrupt between promoting the runtime and verifying the new binary now puts BOTH back, and neither backup is deleted unless its restore actually landed or the new binary passed its check. Windows tracks the verified binary separately from the promoted one and mirrors the rule. - Recovery: diagnostic steps are bounded and fail soft, a future install timestamp is not "starting", and the startup streak resets only when the machine service itself connects — not on an isolated runtime. Co-Authored-By: Claude Fable 5 --- apps/ade-cli/README.md | 2 +- .../scripts/install-runtime-rollback.test.mjs | 56 +++++++++++++ apps/ade-cli/scripts/install-runtime.ps1 | 69 ++++++++++++---- apps/ade-cli/scripts/install-runtime.sh | 74 ++++++++++++----- apps/ade-cli/src/cli.ts | 24 +++--- apps/ade-cli/src/commands/reportIssue.test.ts | 82 ++++++++++++++++++- apps/ade-cli/src/commands/reportIssue.ts | 62 ++++++++++++++ .../ade-cli/src/serviceManager/common.test.ts | 7 ++ .../src/serviceManager/installWindows.test.ts | 7 +- .../diagnostics/diagnosticReport.test.ts | 45 ++++++++++ .../services/diagnostics/diagnosticReport.ts | 34 +++++++- .../runtime/brainStartupState.test.ts | 10 +++ .../src/services/runtime/brainStartupState.ts | 6 +- .../scripts/windows-release-contract.test.mjs | 6 +- .../diagnosticReportService.test.ts | 60 ++++++++++++++ .../diagnostics/diagnosticReportService.ts | 44 +++++++++- .../localRuntimeConnectionPool.test.ts | 24 ++++++ .../localRuntimeConnectionPool.ts | 36 ++++++-- .../runtime/projectRecoveryService.test.ts | 24 ++++++ .../runtime/projectRecoveryService.ts | 8 +- .../storage/StorageCleanupDialog.test.tsx | 11 ++- docs/ARCHITECTURE.md | 2 +- 22 files changed, 625 insertions(+), 68 deletions(-) diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index c9cd09e893..3f3d9b5c7b 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -468,7 +468,7 @@ ade machines hop --session chat-1 ade doctor --json ade doctor --online --text # also check the latest desktop release over the network ade report-issue --text # print a redacted diagnostic report + a prefilled GitHub issue URL (local files only; no brain needed) -ade report-issue --open # also open that issue URL in the browser +ade report-issue --open # also copy the report to the clipboard and open that issue URL in the browser ade tools status --text # pinned agent CLIs: installed version + entry path per tool, plus the machine tools root ade tools ensure --text # fetch whatever this build pins and is missing (no names = all); streams progress to stderr ade tools ensure codex --text # one tool; an unknown name is a usage error listing the pinned set diff --git a/apps/ade-cli/scripts/install-runtime-rollback.test.mjs b/apps/ade-cli/scripts/install-runtime-rollback.test.mjs index c9c509b5e4..33abfe15e9 100644 --- a/apps/ade-cli/scripts/install-runtime-rollback.test.mjs +++ b/apps/ade-cli/scripts/install-runtime-rollback.test.mjs @@ -76,6 +76,24 @@ fi exit 0 `; +// A rename can fail for reasons the installer cannot control (a full disk, a +// permission change under it). What must not happen when the rollback's own +// `mv` fails is the cleanup then deleting the backup it just failed to put +// back. This shim fails exactly that one rename -- the previous runtime going +// back -- and is the real `mv` for everything else. +const FAKE_MV = `#!/bin/sh +case "\$1" in + *.previous) + if [ -n "\${ADE_TEST_FAIL_RUNTIME_RESTORE:-}" ]; then + echo "fake mv: cannot restore \$1" >&2 + exit 1 + fi + ;; +esac +if [ -x /bin/mv ]; then exec /bin/mv "\$@"; fi +exec /usr/bin/mv "\$@" +`; + const FAKE_CURL = `#!/bin/sh # Serves \$ADE_TEST_ASSET_DIR/ instead of hitting the network. url="" @@ -142,6 +160,7 @@ function makeInstall({ previousBinary = "#!/bin/sh\necho previous\n" } = {}) { ); writeExecutable(path.join(fakeBin, "curl"), FAKE_CURL); + writeExecutable(path.join(fakeBin, "mv"), FAKE_MV); return { root, assets, fakeBin, adeHome, installDir, runtimeDir, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }; } @@ -342,6 +361,43 @@ test("Ctrl-C while the promoted binary is being checked puts the old one back", ); assert.ok(!fs.existsSync(path.join(fixture.installDir, "ade.bak"))); assert.ok(!fs.existsSync(path.join(fixture.installDir, "ade.new"))); + // The runtime was already promoted when the interrupt landed, so putting + // the old binary back is only half a rollback: an old `ade` against the new + // native sidecar is an install that cannot start. The runtime has to go + // back with it. + assert.ok(fs.existsSync(path.join(fixture.runtimeDir, "previous-runtime.txt"))); + assert.ok(!fs.existsSync(path.join(fixture.runtimeDir, "node_modules"))); + assert.ok(!fs.existsSync(`${fixture.runtimeDir}.previous`)); + assert.ok(!fs.existsSync(`${fixture.runtimeDir}.new`)); + } finally { + fixture.cleanup(); + } +}); + +// `restore_previous_install` removes the promoted runtime before moving the +// backup back, so a failure in that move leaves the machine with no runtime at +// all. The scratch cleanup that runs next must not then delete the backup -- +// that backup is the only copy of the runtime left on the machine. +test("a failed runtime restore keeps the backup instead of deleting the last runtime", () => { + const fixture = makeInstall(); + try { + const result = runInstaller(fixture, { + ADE_TEST_FAIL_INSTALLED: "1", + ADE_TEST_FAIL_RUNTIME_RESTORE: "1", + }); + + assert.notEqual(result.status, 0); + // The binary rolled back normally; only the runtime restore failed. + assert.equal( + fs.readFileSync(path.join(fixture.installDir, "ade"), "utf8"), + "#!/bin/sh\necho previous\n", + ); + // The runtime the machine had is still on disk, under the backup name a + // re-run (or a human) can recover it from. + assert.ok( + fs.existsSync(path.join(`${fixture.runtimeDir}.previous`, "previous-runtime.txt")), + "the only remaining runtime was deleted by the scratch cleanup", + ); } finally { fixture.cleanup(); } diff --git a/apps/ade-cli/scripts/install-runtime.ps1 b/apps/ade-cli/scripts/install-runtime.ps1 index 017f409b62..8721e4d0c4 100644 --- a/apps/ade-cli/scripts/install-runtime.ps1 +++ b/apps/ade-cli/scripts/install-runtime.ps1 @@ -361,6 +361,12 @@ $previousEnvironment = @{ $previousServiceWasStopped = $false $previousServiceWasRunning = $false $promotedBinary = $false +# $promotedBinary only says the new ade.exe was renamed into place. Whether it +# can actually start is a separate fact, and the window between the two is +# exactly where an abort must put the previous binary back -- so the cleanup +# gates on this flag, not on the rename. Mirrors `promoted_binary` in +# scripts/install-runtime.sh, which is set only once the check has passed. +$binaryVerified = $false $promotedRuntime = $false $preserveTempForRecovery = $false $installSucceeded = $false @@ -415,8 +421,15 @@ try { -not (Test-Path -LiteralPath $destinationBinary -PathType Leaf)) { Move-Item -LiteralPath $backupBinary -Destination $destinationBinary -Force -ErrorAction SilentlyContinue } - Remove-Item -LiteralPath $backupRuntime -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $backupBinary -Force -ErrorAction SilentlyContinue + # Each backup is dropped only once the thing it backs up is actually on disk. + # The restores above are best effort, and deleting a backup whose restore + # silently failed is what would turn a recoverable abort into no install. + if (Test-Path -LiteralPath $runtimeDir) { + Remove-Item -LiteralPath $backupRuntime -Recurse -Force -ErrorAction SilentlyContinue + } + if (Test-Path -LiteralPath $destinationBinary -PathType Leaf) { + Remove-Item -LiteralPath $backupBinary -Force -ErrorAction SilentlyContinue + } New-Item -ItemType Directory -Force -Path $stagedRuntime | Out-Null Write-AdeBanner Write-Host " Installing ADE to $AdeHome" @@ -490,6 +503,9 @@ try { Set-ProcessRuntimeEnvironment $AdeHome $runtimeDir & $destinationBinary --version | Out-Null if ($LASTEXITCODE -ne 0) { Fail "installed ADE runtime failed its version check" } + # The new binary has now proved it runs, so the cleanup below must stop + # treating ade.exe as unverified and rolling the backup back over it. + $binaryVerified = $true if (-not $NoService) { # `brain start`, NOT `serve --install-service`. The latter registers the # service at whatever ADE_DEFAULT_ROLE happens to be, and in a fresh install @@ -565,22 +581,47 @@ try { Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $pendingBinary -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $stagedRuntime -Recurse -Force -ErrorAction SilentlyContinue - if (-not $promotedRuntime -and - (Test-Path -LiteralPath $backupRuntime) -and - -not (Test-Path -LiteralPath $runtimeDir)) { - try { - Move-Item -LiteralPath $backupRuntime -Destination $runtimeDir -Force -ErrorAction Stop - } catch {} - } - if (-not $promotedBinary -and - (Test-Path -LiteralPath $backupBinary -PathType Leaf) -and - -not (Test-Path -LiteralPath $destinationBinary -PathType Leaf)) { + # The binary first, because whether the old binary goes back decides + # whether the old runtime has to go back with it. What is at ade.exe + # between its rename and its version check is an unverified binary, so an + # abort in that window restores the backup over it -- Test-Path alone would + # see a file there and leave the broken one installed. + $restoredOldBinary = $false + if (-not $binaryVerified -and (Test-Path -LiteralPath $backupBinary -PathType Leaf)) { try { Move-Item -LiteralPath $backupBinary -Destination $destinationBinary -Force -ErrorAction Stop + $restoredOldBinary = $true } catch {} } - Remove-Item -LiteralPath $backupRuntime -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $backupBinary -Force -ErrorAction SilentlyContinue + # Before the runtime is promoted its backup is the machine's only runtime, + # so it goes back if nothing is at $runtimeDir. + if (-not $promotedRuntime) { + if ((Test-Path -LiteralPath $backupRuntime) -and -not (Test-Path -LiteralPath $runtimeDir)) { + try { + Move-Item -LiteralPath $backupRuntime -Destination $runtimeDir -Force -ErrorAction Stop + } catch {} + } + } + # After it is promoted, the old binary having just gone back is what makes + # the old runtime needed again: an old ade.exe against the new native + # sidecar is the broken install this whole block exists to prevent. + if ($promotedRuntime -and $restoredOldBinary) { + if (Test-Path -LiteralPath $backupRuntime) { + try { + Remove-Item -LiteralPath $runtimeDir -Recurse -Force -ErrorAction SilentlyContinue + Move-Item -LiteralPath $backupRuntime -Destination $runtimeDir -Force -ErrorAction Stop + } catch {} + } + } + # Same rule as the pre-install repair above: a backup is only scratch once + # what it backs up is on disk again. The restores are best effort, so a + # failed one must keep its backup rather than have it deleted underneath. + if (Test-Path -LiteralPath $runtimeDir) { + Remove-Item -LiteralPath $backupRuntime -Recurse -Force -ErrorAction SilentlyContinue + } + if ($binaryVerified -or $restoredOldBinary) { + Remove-Item -LiteralPath $backupBinary -Force -ErrorAction SilentlyContinue + } } } diff --git a/apps/ade-cli/scripts/install-runtime.sh b/apps/ade-cli/scripts/install-runtime.sh index d0875e2ea8..4947697c0a 100644 --- a/apps/ade-cli/scripts/install-runtime.sh +++ b/apps/ade-cli/scripts/install-runtime.sh @@ -475,32 +475,66 @@ have_backup_binary=0 install_log="$ade_home/install-failure.log" # Scratch state only: the download, the staged runtime, and the staged binary -# copy. The runtime backup is deleted too, but only after the window in which -# it is the machine's only runtime -- an abort (Ctrl-C, SIGTERM) between moving -# the old runtime aside and moving the new one in would otherwise leave the -# machine with no runtime at all. +# copy. The two backups go too, but only once neither is still the machine's +# only copy of what it replaced -- an abort (Ctrl-C, SIGTERM) part-way through +# the promotion would otherwise leave the machine with no runtime at all, or +# with a restore that silently failed and its backup deleted anyway. cleanup_install_scratch() { rm -rf "$tmp_dir" rm -f "$pending_binary" rm -rf "$staged_runtime_dir" - if [ "$promoted_runtime" -eq 0 ] && [ -e "$backup_runtime_dir" ] && [ ! -e "$runtime_dir" ]; then - mv "$backup_runtime_dir" "$runtime_dir" 2>/dev/null || true - fi - rm -rf "$backup_runtime_dir" - # Same protective order for the binary backup, for a different reason. This - # script *copies* the old `ade` aside, so $dest_dir/ade is never absent; the - # window that matters is the one between the new binary being renamed into - # place and its `--version` check passing. Abort in there (the obvious Ctrl-C - # when a bad build hangs) and what is installed is an unverified binary, so - # the backup goes back over it before it is deleted. Only after the check - # passes is the backup just disk. (install-runtime.ps1 uses Move-Item, so - # there the backup really is the only copy for a moment -- hence its own - # restore-if-missing branch.) Deleting it unconditionally at the end also - # keeps an abandoned `ade.bak` from sitting in the install dir forever. + # The binary first, because whether the old binary goes back decides whether + # the old runtime has to go back with it. This script *copies* the old `ade` + # aside, so $dest_dir/ade is never absent; the window that matters is the one + # between the new binary being renamed into place and its `--version` check + # passing. Abort in there (the obvious Ctrl-C when a bad build hangs) and what + # is installed is an unverified binary, so the backup goes back over it. Only + # after the check passes is the backup just disk. (install-runtime.ps1 uses + # Move-Item, so there the backup really is the only copy for a moment -- + # hence its own restore-if-missing branch.) + restored_old_binary=0 if [ "$promoted_binary" -eq 0 ] && [ -e "$backup_binary" ]; then - mv "$backup_binary" "$dest_dir/ade" 2>/dev/null || true + if mv "$backup_binary" "$dest_dir/ade" 2>/dev/null; then + restored_old_binary=1 + fi + fi + # Deleting the backup once it is no longer needed keeps an abandoned + # `ade.bak` from sitting in the install dir forever -- but a restore that + # failed leaves it as the last copy of the working binary, so it stays. + if [ "$promoted_binary" -eq 1 ] || [ "$restored_old_binary" -eq 1 ]; then + rm -f "$backup_binary" + fi + + # The runtime backup has two ways of still being needed. Before the runtime + # is promoted it is the machine's only runtime, so it goes back if the + # directory it came from is empty. After the runtime is promoted but before + # the new binary passes its check, the old binary is what the restore above + # just put back -- and an old binary paired with the new native sidecar is + # the broken install this whole block exists to prevent, so the old runtime + # goes back with it. + restore_old_runtime=0 + if [ "$promoted_runtime" -eq 0 ]; then + if [ -e "$backup_runtime_dir" ] && [ ! -e "$runtime_dir" ]; then + restore_old_runtime=1 + fi + elif [ "$restored_old_binary" -eq 1 ] && [ -e "$backup_runtime_dir" ]; then + restore_old_runtime=1 + rm -rf "$runtime_dir" 2>/dev/null || true + fi + runtime_restore_failed=0 + if [ "$restore_old_runtime" -eq 1 ]; then + mv "$backup_runtime_dir" "$runtime_dir" 2>/dev/null || runtime_restore_failed=1 + fi + # A restore that failed is not the only way the backup can still be the + # machine's only runtime. `restore_previous_install` removes $runtime_dir + # before its own best-effort `mv`, so if that `mv` failed there is nothing at + # $runtime_dir at all -- and none of the flags above record it, because the + # runtime was promoted and the old binary was put back by that function + # rather than by this one. Believe the disk: the backup only goes when a + # runtime is actually installed. + if [ "$runtime_restore_failed" -eq 0 ] && [ -e "$runtime_dir" ]; then + rm -rf "$backup_runtime_dir" fi - rm -f "$backup_binary" } # The EXIT trap alone is not enough: a handler for HUP/INT/TERM that does not diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 39e0022114..52bdb76f96 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -33,8 +33,11 @@ import { runDoctorCommand, type DoctorRow, } from "./commands/doctor"; -import { buildCliDiagnosticReport } from "./commands/reportIssue"; -import { openExternalUrl } from "./lib/externalLinks"; +import { + buildCliDiagnosticReport, + buildReportIssuePayload, + openDiagnosticIssue, +} from "./commands/reportIssue"; export { readInstalledDesktopVersion }; import { MAX_STATUS_NOTE_CHARACTERS, @@ -22263,22 +22266,21 @@ async function runCli( projectRoot: fs.existsSync(path.join(projectRoot, ".ade")) ? projectRoot : null, cliVersion: VERSION, }); - if (plan.open) { - try { - await openExternalUrl(built.issueUrl); - } catch { - // Headless boxes have no browser; the URL below is still printed. - } - } + // Copy-then-open, the same order the desktop button uses: the issue + // template asks the user to paste the report from their clipboard, so + // opening it without copying first sends them to a form with nothing to + // paste. Both steps are best effort and the report is printed regardless. + const openedIssue = plan.open ? await openDiagnosticIssue(built) : null; if (parsed.options.text) { + const clipboardNote = openedIssue?.copied ? "\n(the report is on your clipboard)" : ""; return { - output: `${built.report}\nFile the issue at:\n${built.issueUrl}\n`, + output: `${built.report}\nFile the issue at:\n${built.issueUrl}${clipboardNote}\n`, exitCode: 0, }; } return { output: formatOutput( - { ok: true, installId: built.installId, issueUrl: built.issueUrl, report: built.report }, + buildReportIssuePayload(built, openedIssue), parsed.options, undefined, ), diff --git a/apps/ade-cli/src/commands/reportIssue.test.ts b/apps/ade-cli/src/commands/reportIssue.test.ts index f6947b38c4..422c0ce96d 100644 --- a/apps/ade-cli/src/commands/reportIssue.test.ts +++ b/apps/ade-cli/src/commands/reportIssue.test.ts @@ -2,7 +2,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { buildCliDiagnosticReport } from "./reportIssue"; +import { + buildCliDiagnosticReport, + buildReportIssuePayload, + openDiagnosticIssue, +} from "./reportIssue"; const tempDirs: string[] = []; @@ -71,3 +75,79 @@ describe("buildCliDiagnosticReport", () => { expect(built.report).not.toContain("hash-4"); }); }); + +describe("openDiagnosticIssue", () => { + it("puts the report on the clipboard before it opens the issue template", async () => { + // The template says "paste the report from your clipboard", so the copy has + // to have happened by the time the browser is opened -- otherwise the user + // lands on a form asking for something that is not on their clipboard. + const order: string[] = []; + let copiedText: string | null = null; + + const result = await openDiagnosticIssue( + { report: "REPORT BODY", issueUrl: "https://github.com/acme/ade/issues/new?title=x" }, + { + copy: (text) => { + order.push("copy"); + copiedText = text; + return true; + }, + open: async (url) => { + order.push(`open:${url}`); + }, + }, + ); + + expect(order).toEqual(["copy", "open:https://github.com/acme/ade/issues/new?title=x"]); + expect(copiedText).toBe("REPORT BODY"); + expect(result).toEqual({ copied: true, opened: true }); + }); + + it("still opens the issue when the machine has no clipboard, and survives a browserless box", async () => { + const result = await openDiagnosticIssue( + { report: "REPORT BODY", issueUrl: "https://github.com/acme/ade/issues/new" }, + { + copy: () => { + throw new Error("no pbcopy here"); + }, + open: async () => { + throw new Error("no browser here"); + }, + }, + ); + + expect(result).toEqual({ copied: false, opened: false }); + }); +}); + +describe("buildReportIssuePayload", () => { + const built = { + report: "REPORT BODY", + issueUrl: "https://github.com/acme/ade/issues/new", + installId: "install-abc", + }; + + it("tells a scripted caller whether --open actually reached the clipboard", () => { + // `--open` copies the report and then opens a template that says "paste the + // report from your clipboard". A box with no clipboard binary silently + // skips the copy, so the JSON has to say so -- otherwise the only way to + // find out is a user pasting nothing into a GitHub issue. + expect(buildReportIssuePayload(built, { copied: true })).toEqual({ + ok: true, + installId: "install-abc", + issueUrl: "https://github.com/acme/ade/issues/new", + copied: true, + report: "REPORT BODY", + }); + expect(buildReportIssuePayload(built, { copied: false }).copied).toBe(false); + }); + + it("reports nothing copied when --open was not asked for", () => { + const payload = buildReportIssuePayload(built, null); + + expect(payload.copied).toBe(false); + // The rest of the contract is unchanged: same keys, same `ok`. + expect(payload.ok).toBe(true); + expect(payload.issueUrl).toBe(built.issueUrl); + }); +}); diff --git a/apps/ade-cli/src/commands/reportIssue.ts b/apps/ade-cli/src/commands/reportIssue.ts index 3590d0a57e..8ccde0bfca 100644 --- a/apps/ade-cli/src/commands/reportIssue.ts +++ b/apps/ade-cli/src/commands/reportIssue.ts @@ -9,6 +9,8 @@ import { collectMachineDiagnosticSources, readDiagnosticJsonFile, } from "../services/diagnostics/diagnosticSources"; +import { copyToClipboard } from "../lib/clipboard"; +import { openExternalUrl } from "../lib/externalLinks"; /** * Headless counterpart to the desktop "Report issue" button. Reads only local @@ -109,3 +111,63 @@ export function buildCliDiagnosticReport(options: ReportIssueOptions = {}): Repo }), }; } + +/** + * The side effects of `ade report-issue --open`, in the order the desktop + * "Report issue" button does them: the report goes on the clipboard, *then* + * the prefilled GitHub issue opens. + * + * The order is the whole point. The template the URL carries says "paste the + * report from your clipboard" — the URL itself only holds a stub, because a + * full report does not fit in a query string. A flow that opens that form + * without copying anything sends the user to a page asking for something that + * is not there (and whatever unrelated text they had copied is what they would + * paste). Both steps are best effort: a box with no clipboard binary and no + * browser still gets the whole report on stdout. + */ +export async function openDiagnosticIssue( + built: Pick, + deps: { + copy?: (text: string) => boolean; + open?: (url: string) => Promise; + } = {}, +): Promise<{ copied: boolean; opened: boolean }> { + const copy = deps.copy ?? copyToClipboard; + const open = deps.open ?? openExternalUrl; + let copied = false; + try { + copied = copy(built.report); + } catch { + copied = false; + } + let opened = false; + try { + await open(built.issueUrl); + opened = true; + } catch { + // Headless boxes have no browser; the caller still prints the URL. + opened = false; + } + return { copied, opened }; +} + +/** + * The `--json` shape of `ade report-issue`. `copied` is here because `--open` + * has two side effects, and a script that asked for machine-readable output + * could not tell whether the second one happened: on a box with no clipboard + * binary the report is only on stdout, and a caller that assumed otherwise + * would tell its user to paste something that is not there. Without `--open` + * nothing is copied, so it is simply false. + */ +export function buildReportIssuePayload( + built: Pick, + side: { copied: boolean } | null, +): { ok: true; installId: string; issueUrl: string; copied: boolean; report: string } { + return { + ok: true, + installId: built.installId, + issueUrl: built.issueUrl, + copied: side?.copied ?? false, + report: built.report, + }; +} diff --git a/apps/ade-cli/src/serviceManager/common.test.ts b/apps/ade-cli/src/serviceManager/common.test.ts index d66a47750b..dd2545c6ed 100644 --- a/apps/ade-cli/src/serviceManager/common.test.ts +++ b/apps/ade-cli/src/serviceManager/common.test.ts @@ -1084,6 +1084,13 @@ describe("launchd service install", () => { handoverTimeoutMs: 300, handoverPollMs: 10, terminateDeps: { kill: vi.fn(), pidAlive: () => false }, + // This test's spawn stub answers only `launchctl`, so the ancestry probe + // would read an empty parent list and, on a host whose backend reports + // "unknown" for that, fail safe into the self-mutation block. Inject the + // chain like every other install test here: this case is about handover + // windows, not about who our parent is. + currentPid: 9999, + parentPid: () => null, }); expect(result).toMatchObject({ ok: true, starting: true, restarted: true }); diff --git a/apps/ade-cli/src/serviceManager/installWindows.test.ts b/apps/ade-cli/src/serviceManager/installWindows.test.ts index c73e3afb1b..14addeb748 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.test.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.test.ts @@ -1349,7 +1349,12 @@ describe("Windows runtime supervisor", () => { socketPath: "\\\\.\\pipe\\ade-test", spawnSync, readPidRecord: () => null, - timeoutMs: 12, + // Comfortably more than one poll: the helper computes `remaining` from a + // deadline captured a few statements earlier, so a budget close to + // `pollMs` lets a stalled runner skip the first sleep and turn the + // synchronous `sleepStarted` assertion into a flake. Every iteration is + // free here (`readPidRecord` always answers null). + timeoutMs: 60, pollMs: 10, sleep: async (ms) => { sleepStarted.push(ms); diff --git a/apps/ade-cli/src/services/diagnostics/diagnosticReport.test.ts b/apps/ade-cli/src/services/diagnostics/diagnosticReport.test.ts index 9e9f41b86d..9fd8948bd2 100644 --- a/apps/ade-cli/src/services/diagnostics/diagnosticReport.test.ts +++ b/apps/ade-cli/src/services/diagnostics/diagnosticReport.test.ts @@ -81,6 +81,51 @@ describe("redactDiagnosticText", () => { expect(out).toContain("mode=relay"); }); + it("redacts every token prefix ADE itself accepts, not just the classic PAT", () => { + // Split so a secret scanner cannot read these synthetic keys as real ones. + const finePat = `github_pat_${"11ABCDEFG0abcdefghij"}_${"KLMNOPQRSTUVWXYZ0123456789abcdef"}`; + const input = [ + `fine-grained ${finePat}`, + `google ${"AIza"}SyABCDEFGHIJKLMNOPQRSTUVWXYZ0123456`, + `xai ${"xai-"}ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789`, + `groq ${"gsk_"}ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789`, + ].join("\n"); + + const out = redactDiagnosticText(input, CONTEXT); + + expect(out).not.toContain(finePat); + expect(out).not.toMatch(/AIzaSy|xai-ABCDEF|gsk_ABCDEF/); + expect(out).toContain("fine-grained "); + // Still idempotent with the added prefixes. + expect(redactDiagnosticText(out, CONTEXT)).toBe(out); + }); + + it("keeps model ids and snake_case identifiers that only look like keys", () => { + // Redaction that eats ordinary log text costs the maintainer the exact + // lines they were sent the report to read. None of these are key shapes: + // Together keys are `tgp_v1_...`, Mistral keys carry no prefix at all, and + // a Groq key is `gsk_` plus a run of base62 with no underscores in it. + const input = [ + "model mistral-small-2503-instruct-v1 selected", + "counter tg_message_delivery_attempt_count=4", + "error gsk_missing_runtime_module_error_here", + ].join("\n"); + + const out = redactDiagnosticText(input, CONTEXT); + + expect(out).toBe(input); + }); + + it("hides the domain when the account name is the email local part", () => { + // `ada` is both the OS account and the local part of the address. If the + // account rule ran first it would leave `@company.example`, which the + // email rule can no longer match — shipping the employer's domain. + const out = redactDiagnosticText("signed in as ada@company.example", CONTEXT); + + expect(out).not.toContain("company.example"); + expect(out).toBe("signed in as "); + }); + it("keeps loopback addresses and drops routable ones", () => { const input = [ "brain answering on 127.0.0.1:8787", diff --git a/apps/ade-cli/src/services/diagnostics/diagnosticReport.ts b/apps/ade-cli/src/services/diagnostics/diagnosticReport.ts index 4f64dc4299..1072876273 100644 --- a/apps/ade-cli/src/services/diagnostics/diagnosticReport.ts +++ b/apps/ade-cli/src/services/diagnostics/diagnosticReport.ts @@ -212,7 +212,16 @@ export function redactDiagnosticText( .replace(/[A-Za-z]:\\{1,2}Users\\{1,2}[^\\/\s"'`,;:)\]}]+/gi, "~") .replace(/[A-Za-z]%3A(?:%5C)+Users(?:%5C)+[^%\s"'`,;:)\]}]+/gi, "~"); - // 4. The OS account name wherever it appears on its own. + // 4. Emails, before both the account-name rule and the token blobs. Before + // the names because an account name is very often the local part of its + // owner's address (`ada` / `ada@company.com`): rewriting that first would + // leave `@company.com`, which the email pattern can no longer match + // (`>` is not a local-part character) — so the employer's domain would + // ship in the report. Before the tokens so an address is not eaten as a + // secret. + out = out.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, ""); + + // 5. The OS account name wherever it appears on its own. const username = context.username?.trim(); if (username && username.length >= 3) { out = out.replace( @@ -224,16 +233,33 @@ export function redactDiagnosticText( ); } - // 5. Emails before token blobs: an address must not be eaten as a secret. - out = out.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, ""); - // 6. Credentials. Prefixed forms first, then key-adjacent blobs. out = out .replace(/\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}(?:\.[A-Za-z0-9_-]+)?/g, "") .replace(/\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 ") .replace(/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{10,}/g, "") .replace(/\bgh[pousr]_[A-Za-z0-9]{16,}/g, "") + // Fine-grained GitHub PATs are their own format, not a fifth member of the + // `gh?_` family: the prefix is a whole word and the body carries + // underscores, so neither the rule above nor the key/value rule below sees + // one that appears on its own in a log line. ADE accepts these everywhere + // it accepts a classic PAT, so an unredacted one is a live credential in a + // report the user is being told to paste into a public issue. + .replace(/\bgithub_pat_[A-Za-z0-9_]{20,}/g, "") .replace(/\bph[cx]_[A-Za-z0-9_-]{16,}/g, "") + // The remaining real prefixes ADE stores keys for (Google AI, xAI, Groq). + // Anthropic/OpenAI/DeepSeek/OpenRouter/Moonshot are all `sk-`, already + // covered above. Together (`tgp_v1_...`) and Mistral (an unprefixed + // 32-char blob) have no prefix worth matching on: `tg_`/`mistral-` are not + // key shapes at all, and as patterns they ate ordinary log text + // (`tg_message_delivery_attempt_count`, the model id + // `mistral-small-2503-instruct-v1`) -- the real keys are long enough for + // the key-adjacent `{32,}` rule below. Groq keys are `gsk_` plus a run of + // base62, so the body deliberately excludes `_`: without that, any long + // enough `gsk_`-prefixed snake_case identifier disappeared too. + .replace(/\bAIza[A-Za-z0-9_-]{20,}/g, "") + .replace(/\bxai-[A-Za-z0-9]{20,}/g, "") + .replace(/\bgsk_[A-Za-z0-9]{32,}/g, "") .replace(/\bxox[abposr]-[A-Za-z0-9-]{10,}/g, "") .replace( /([?&](?:token|key|api[_-]?key|access[_-]?token|secret|password|pin|sig|signature|code|auth)=)[^&\s"'`<>]+/gi, diff --git a/apps/ade-cli/src/services/runtime/brainStartupState.test.ts b/apps/ade-cli/src/services/runtime/brainStartupState.test.ts index 697ec030df..0137598cc4 100644 --- a/apps/ade-cli/src/services/runtime/brainStartupState.test.ts +++ b/apps/ade-cli/src/services/runtime/brainStartupState.test.ts @@ -93,6 +93,16 @@ describe("readBrainStartupState", () => { }); }); + it("fails closed when the service running state is unknown", async () => { + // A status probe that could not tell answers null. Treating that as + // "supervised" pairs an unknown service with a young pid and reports a + // broken brain as a starting one, which suppresses the repair the user + // needs. + const state = await readBrainStartupState(deps({ running: null })); + expect(state.starting).toBe(false); + expect(state.serviceRunning).toBe(null); + }); + it("fails closed when the age cannot be read or a probe throws", async () => { await expect(readBrainStartupState(deps({ ageMs: null }))).resolves.toMatchObject({ starting: false, diff --git a/apps/ade-cli/src/services/runtime/brainStartupState.ts b/apps/ade-cli/src/services/runtime/brainStartupState.ts index 16539940fa..a72c543ff5 100644 --- a/apps/ade-cli/src/services/runtime/brainStartupState.ts +++ b/apps/ade-cli/src/services/runtime/brainStartupState.ts @@ -169,7 +169,11 @@ export async function readBrainStartupState( running = status.running; // No registered service, or a registered one the supervisor is not // running: nothing is coming up, so this is a real failure and stays one. - supervised = installed === true && running !== false; + // `running === true`, not `!== false`: an indeterminate probe answers + // null, and pairing "we could not tell" with a young pid is exactly how a + // broken brain gets reported as a starting one. Same fail-closed rule the + // catch below applies to a probe that threw. + supervised = installed === true && running === true; if (supervised) { const pid = await (deps.getServiceMainPid ?? defaultGetServiceMainPid)(); ageMs = await (deps.readBrainAgeMs ?? defaultReadBrainAgeMs)(pid); diff --git a/apps/desktop/scripts/windows-release-contract.test.mjs b/apps/desktop/scripts/windows-release-contract.test.mjs index a3f4e3753b..ebe0c40291 100644 --- a/apps/desktop/scripts/windows-release-contract.test.mjs +++ b/apps/desktop/scripts/windows-release-contract.test.mjs @@ -448,10 +448,14 @@ test("the Windows installer stages, preflights and promotes under the ADE home, cleanup, /-not \$promotedRuntime[\s\S]{0,200}Move-Item -LiteralPath \$backupRuntime -Destination \$runtimeDir/, ); + // `$binaryVerified`, not `$promotedBinary`: between the rename and the + // version check what sits at ade.exe is an unverified binary, so an abort in + // that window must still put the backup back over it. assert.match( cleanup, - /-not \$promotedBinary[\s\S]{0,200}Move-Item -LiteralPath \$backupBinary -Destination \$destinationBinary/, + /-not \$binaryVerified[\s\S]{0,200}Move-Item -LiteralPath \$backupBinary -Destination \$destinationBinary/, ); + assert.match(installer, /\$binaryVerified = \$true/); // Stage-aware failure messages, matching `die_runtime_unusable` in the sh // script: a preflight failure must not claim a rollback that never happened. diff --git a/apps/desktop/src/main/services/diagnostics/diagnosticReportService.test.ts b/apps/desktop/src/main/services/diagnostics/diagnosticReportService.test.ts index 75cdcd6528..773dc89c6d 100644 --- a/apps/desktop/src/main/services/diagnostics/diagnosticReportService.test.ts +++ b/apps/desktop/src/main/services/diagnostics/diagnosticReportService.test.ts @@ -67,6 +67,66 @@ describe("collectDiagnosticReport", () => { expect(scoped.report).toContain("healthy"); }); + it("still returns when the runtime never answers", async () => { + // Both optional steps talk to the subsystem the user is reporting as + // broken. A step that never settles used to hold the whole report, leaving + // the "Report issue" button spinning forever. + const projectRoot = fs.mkdtempSync(path.join(tempRoot, "project-")); + const { report } = await collectDiagnosticReport( + { + ...deps(), + stepTimeoutMs: 20, + getLocalRuntimeStatus: () => new Promise(() => {}), + diagnoseProject: () => new Promise(() => {}), + }, + { surface: "project_recovery", projectRoot }, + ); + + expect(report).toContain("## Notes"); + }); + + // The step deadline is a race, and losing a race does not cancel a timer. + // Every report used to leave one pending 8s timer per optional step behind + // it -- unref'd, so it held nothing open, but still a handle the process is + // carrying and enough to hang a fake-timer test that runs after it. + it("cancels the step deadline once the step has answered", async () => { + const projectRoot = fs.mkdtempSync(path.join(tempRoot, "project-")); + vi.useFakeTimers(); + try { + await collectDiagnosticReport( + { + ...deps(), + stepTimeoutMs: 60_000, + getLocalRuntimeStatus: async () => ({ state: "running" }), + diagnoseProject: async () => ({ state: "healthy" }), + }, + { surface: "project_recovery", projectRoot }, + ); + + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("still returns when a collection step throws synchronously", async () => { + const projectRoot = fs.mkdtempSync(path.join(tempRoot, "project-")); + const { report } = await collectDiagnosticReport( + { + ...deps(), + getLocalRuntimeStatus: () => { + throw new Error("runtime module is not loaded"); + }, + diagnoseProject: () => { + throw new Error("recovery service is gone"); + }, + }, + { surface: "project_recovery", projectRoot }, + ); + + expect(report).toContain("## Notes"); + }); + it("omits the notes line when there is nothing to say", async () => { const { report } = await collectDiagnosticReport(deps(), { surface: "project_recovery", diff --git a/apps/desktop/src/main/services/diagnostics/diagnosticReportService.ts b/apps/desktop/src/main/services/diagnostics/diagnosticReportService.ts index 7cae87b5be..176bb1ce52 100644 --- a/apps/desktop/src/main/services/diagnostics/diagnosticReportService.ts +++ b/apps/desktop/src/main/services/diagnostics/diagnosticReportService.ts @@ -51,6 +51,8 @@ export type DiagnosticReportDeps = { projectLogsDir?: string | null; getLocalRuntimeStatus?: () => Promise | unknown; diagnoseProject?: (projectRoot: string) => Promise; + /** Deadline for each optional step above. Test seam; defaults to 8s. */ + stepTimeoutMs?: number; env?: NodeJS.ProcessEnv; now?: () => Date; }; @@ -88,6 +90,36 @@ function volumeEntry(label: string, dirPath: string): DiagnosticVolumeSpace | nu return { label, path: dirPath, freeBytes: space.freeBytes, totalBytes: space.totalBytes }; } +/** Deadline for one optional collection step. */ +const DIAGNOSTIC_STEP_TIMEOUT_MS = 8_000; + +/** + * Runs one optional step and always settles: a rejection, a synchronous throw + * and a promise that never answers all collapse to null. Whatever the step + * would have contributed is simply absent from the report -- far better than a + * report the user can never get. + */ +function bestEffortStep( + run: () => Promise | T, + timeoutMs = DIAGNOSTIC_STEP_TIMEOUT_MS, +): Promise { + let timer: ReturnType | null = null; + return Promise.race([ + Promise.resolve() + .then(run) + .catch(() => null), + new Promise((resolve) => { + // Unref'd so an outstanding step can never hold the process open. + timer = setTimeout(() => resolve(null), timeoutMs); + if (typeof timer.unref === "function") timer.unref(); + }), + // Losing the race does not cancel the timer, so a step that answers first + // would otherwise leave a handle per report alive for the full deadline. + ]).finally(() => { + if (timer) clearTimeout(timer); + }); +} + /** * Gathers everything the report needs from this machine and renders it. Every * step is best-effort: a missing log or a runtime that will not answer must @@ -109,13 +141,17 @@ export async function collectDiagnosticReport( readVolume: volumeEntry, }); + // Both optional steps ask the very subsystem the user is reporting as broken + // -- the local runtime, and a recovery diagnosis that probes the brain's + // socket. A step that never settles would hold `Promise.all` forever and + // leave the "Report issue" button spinning, which is exactly the outcome + // this collector promises can never happen. A synchronous throw out of + // either one is caught here for the same reason. const [osProductVersion, localRuntimeStatus, recoveryDiagnosis] = await Promise.all([ readMacProductVersion().catch(() => null), - Promise.resolve() - .then(() => deps.getLocalRuntimeStatus?.()) - .catch(() => null), + bestEffortStep(() => deps.getLocalRuntimeStatus?.() ?? null, deps.stepTimeoutMs), projectRoot && deps.diagnoseProject - ? deps.diagnoseProject(projectRoot).catch(() => null) + ? bestEffortStep(() => deps.diagnoseProject?.(projectRoot) ?? null, deps.stepTimeoutMs) : Promise.resolve(null), ]); diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index d1e99a0b2f..22e1fb640d 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -966,6 +966,30 @@ describe("local runtime connection pool", () => { pool.dispose(); }); + it("keeps the service startup streak when only an app-owned runtime connected", () => { + // The streak measures how long the MACHINE service has failed to answer. + // An isolated or spawned runtime is what the desktop falls back to because + // the service did not answer, so landing on one must not reset the marker + // -- doing so made every 60s isolated-recovery install restart the streak, + // and a long-broken service kept reading as a brain that is just starting. + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), + } as never, {}); + const internals = pool as unknown as { + serviceStartupStreakStartedAt: string | null; + noteConnectedRuntime: (isMachineService: boolean) => void; + }; + + internals.serviceStartupStreakStartedAt = "2026-08-16T09:00:00.000Z"; + internals.noteConnectedRuntime(false); + expect(internals.serviceStartupStreakStartedAt).toBe("2026-08-16T09:00:00.000Z"); + + internals.noteConnectedRuntime(true); + expect(internals.serviceStartupStreakStartedAt).toBeNull(); + + pool.dispose(); + }); + it("parses structured service manager output for settings status", () => { expect(parseRuntimeServiceManagerOutput(JSON.stringify({ ok: false, diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index 79a1c52da0..2cf2193b85 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -140,9 +140,13 @@ const LOCAL_RUNTIME_SERVICE_UNINSTALL_TIMEOUT_MS = 20_000; const LOCAL_RUNTIME_SERVICE_INSTALL_TIMEOUT_MS = RUNTIME_SERVICE_START_WAIT_MS; /** * How long a freshly (re)installed service gets to answer on the socket before - * the desktop gives up on it. Longer than the installer's own handover wait on - * purpose: the installer may return `starting` with a live brain that is still - * booting, and this is where that brain gets the rest of its time. + * the desktop gives up on it. + * + * The same size as the install budget, and spent *after* it rather than + * instead of it: `tryRepairServiceConnection` runs the install first and then + * this connect loop, so a brain the installer returned as `starting` gets a + * second full window to come up (and one `createConnection` can therefore stay + * pending for up to about twice RUNTIME_SERVICE_START_WAIT_MS). */ const LOCAL_RUNTIME_SERVICE_REPAIR_CONNECT_TIMEOUT_MS = RUNTIME_SERVICE_START_WAIT_MS; const LOCAL_RUNTIME_STATUS_REFRESH_TIMEOUT_MS = 2_000; @@ -2326,7 +2330,7 @@ export class LocalRuntimeConnectionPool { private async tryConnect(socketPath: string): Promise { try { - const client = await this.connectClient(socketPath); + const client = await this.connectClient(socketPath, { isMachineService: true }); this.ownedRuntimeChild = null; return { client, child: null, socketPath }; } catch (error) { @@ -2404,7 +2408,7 @@ export class LocalRuntimeConnectionPool { for (;;) { try { await waitForSocket(socketPath, 2_000); - const client = await this.connectClient(socketPath); + const client = await this.connectClient(socketPath, { isMachineService: true }); this.ownedRuntimeChild = null; return { client, child: null, socketPath }; } catch (error) { @@ -2600,10 +2604,30 @@ export class LocalRuntimeConnectionPool { return localReleaseBuildOutputRuntimeBlock(resolveCliScriptPath()); } + /** + * Clears the service-install streak marker, but only when the connection + * that just succeeded is the machine background service. + * + * The streak answers "how long has the machine service been failing to + * answer", and recovery ages a `brain_starting` verdict off it. Connecting + * to an app-owned runtime — the isolated one, or a spawned primary — proves + * nothing about the service: it is what the desktop falls back to *because* + * the service did not answer. Clearing the marker there made the next + * isolated-recovery install (every 60s) restart the streak from now, so a + * service broken for an hour kept reporting a brand-new attempt and kept + * reading as a brain that is merely starting. + */ + private noteConnectedRuntime(isMachineService: boolean): void { + if (!isMachineService) return; + this.serviceStartupStreakStartedAt = null; + } + private async connectClient( socketPath: string, options: { preserveVersionSkew?: boolean; + /** True only for the machine service endpoint; gates the streak reset. */ + isMachineService?: boolean; expectedPid?: number | null; connectTimeoutMs?: number; initializeTimeoutMs?: number; @@ -2638,7 +2662,7 @@ export class LocalRuntimeConnectionPool { this.clearVersionSkewStatus(); } this.activeClient = client; - this.serviceStartupStreakStartedAt = null; + this.noteConnectedRuntime(options.isMachineService === true); this.activeRuntimePid = runtimeInfo.pid; this.activeRuntimeSyncPort = runtimeInfo.syncPort; this.activeRuntimePublishHealth = runtimeInfo.publishHealth; diff --git a/apps/desktop/src/main/services/runtime/projectRecoveryService.test.ts b/apps/desktop/src/main/services/runtime/projectRecoveryService.test.ts index d680c94ef7..fc414fdeac 100644 --- a/apps/desktop/src/main/services/runtime/projectRecoveryService.test.ts +++ b/apps/desktop/src/main/services/runtime/projectRecoveryService.test.ts @@ -231,6 +231,30 @@ describe("ProjectRecoveryService.diagnose", () => { expect(diagnosis.canAutoRepair).toBe(false); }); + it("does not classify a future install timestamp as brain_starting", async () => { + // A clock that moved backwards after the attempt was recorded leaves a + // stamp in the future. Only the upper bound was checked, so it read as + // "always starting" and suppressed repair until the clock caught up. + const service = createProjectRecoveryService(deps({ + connectionPool: pool(status({ + serviceInstall: { + state: "installed", attempted: true, path: null, message: null, exitCode: null, + updatedAt: new Date(NOW).toISOString(), starting: true, + attemptStartedAt: new Date(NOW + 60 * 60_000).toISOString(), + }, + serviceHealth: { state: "running", installed: true, running: true, path: null, message: null, checkedAt: null }, + })), + })); + + const diagnosis = await service.diagnose(tempRoot()); + + // The concrete state matters: "not brain_starting" would pass for any + // wrong answer, and the point of the fix is that the stuck project falls + // through to a diagnosis Repair is allowed to act on. + expect(diagnosis.state).toBe("unknown_failure"); + expect(diagnosis.canAutoRepair).toBe(true); + }); + it("does not report a healthy answering brain as broken over a fresh sync-host failure", async () => { const syncHostFailure: AdeLastFailureReport = { ...failure("socket_owned_by_other"), diff --git a/apps/desktop/src/main/services/runtime/projectRecoveryService.ts b/apps/desktop/src/main/services/runtime/projectRecoveryService.ts index 1364a44250..312852d312 100644 --- a/apps/desktop/src/main/services/runtime/projectRecoveryService.ts +++ b/apps/desktop/src/main/services/runtime/projectRecoveryService.ts @@ -500,10 +500,16 @@ export class ProjectRecoveryService { // where the socket accepts connections and the ping still fails. Requiring // an unreachable socket made that window impossible to classify as // starting, and it fell through to "another program owns this" instead. + // A negative age means the recorded attempt is in the future -- a clock + // that moved backwards after it was written. Only the upper bound was + // checked, so such a stamp read as "always inside the window" and + // suppressed repair until the clock caught up with it. + const startupAgeMs = this.now() - installStartedAt; const brainStarting = serviceStatus.serviceHealth.running === true && Number.isFinite(installStartedAt) - && this.now() - installStartedAt < BRAIN_STARTING_WINDOW_MS; + && startupAgeMs >= 0 + && startupAgeMs < BRAIN_STARTING_WINDOW_MS; const dbCheck = endpointHealthy ? { healthy: null, detail: "Project data check skipped because the background service is using it." } : await this.quickCheck(dbPath); diff --git a/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.test.tsx b/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.test.tsx index 4480a96735..a8047ce31f 100644 --- a/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.test.tsx +++ b/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.test.tsx @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { StorageCleanupDialog, StorageDialogFrame } from "./StorageCleanupDialog"; afterEach(() => { @@ -183,7 +183,14 @@ describe("StorageCleanupDialog failures", () => { // dialog to "done", and must not report a result for a job nobody is // looking at any more. cleanups[0]({ removed: ["/tmp/first.log"], failed: [], freedBytes: 10 }); - await waitFor(() => expect(cleanupCall).toHaveBeenCalledTimes(1)); + // Flush the abandoned resolution's own continuation. Polling the cleanup + // call count would pass on the first tick -- that call happened before the + // reopen -- so the assertions below could run before the stale completion + // was even delivered, and would hold with or without the guard. + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); expect(onCleaned).not.toHaveBeenCalled(); expect(screen.getByRole("button", { name: /Remove 1 item/ })).toBeTruthy(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1e186c08d8..c56c02c1ed 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -261,7 +261,7 @@ It installs `ade-win32-x64.exe` as `%ADE_HOME%\bin\ade.exe`, transactionally sta **Health check (`ade doctor [--online] [--text]`).** `apps/ade-cli/src/commands/doctor.ts` connects to the machine brain over the local socket (bounded ~2 s) and prints one status row (`ok` / `warn` / `fail`) per subsystem: **App** (installed desktop version from the `.app` `Info.plist` vs the latest known version — read from disk, or from GitHub with `--online`), **Brain** (running version/pid/uptime plus any build-hash or role mismatch), **Wedge history** (the most recent recovered event-loop wedge, if any), **Sync port** (whether the shared listener bound the default `8787`, and the holders of the base ports when it drifted — with no visible holder reported as exactly that, since a root-owned holder such as `tailscaled` is invisible to a user-level probe and must be checked with `tailscale serve status` / `netstat -an -p tcp`), **Publish health** (the account-directory publisher's last-leg durations and slowest leg), **Relay** (end-to-end verified vs a classified failure — with a deliberate suppression, i.e. another ADE process on this machine owning the relay slot, outranking every other reason, since nothing downstream can succeed while it holds and no other reason tells the user what to do), and **Account** (signed-in state and source). The command exits non-zero when any row is `fail`. The row-evaluation logic (`evaluateDoctorRows`) is pure and dependency-injected so the desktop connection-doctor card and the CLI share one verdict. -**Diagnostic report (`ade report-issue [--open]`).** `apps/ade-cli/src/commands/reportIssue.ts` prints the same redacted Markdown report the desktop's **Report issue** button produces, and `--open` opens the prefilled GitHub issue in the browser (`lib/externalLinks.ts`, which allows only `http(s)`/`mailto:` and falls back to Electron's `shell.openExternal` when the OS opener is unavailable). It reads local files only — it never starts or contacts the brain — so it still works on the machine where ADE will not come up, and on a headless or Windows host with no desktop error screen to press. The builder, the redactor, and the machine source collector (`services/diagnostics/diagnosticReport.ts`, `diagnosticSources.ts`) are shared with the desktop, so a log added for one appears in both. See [features/storage-and-recovery/README.md](./features/storage-and-recovery/README.md#diagnostic-reports-report-issue). +**Diagnostic report (`ade report-issue [--open]`).** `apps/ade-cli/src/commands/reportIssue.ts` prints the same redacted Markdown report the desktop's **Report issue** button produces, and `--open` copies the report to the system clipboard and then opens the prefilled GitHub issue in the browser (the template asks the user to paste the report, which is far too big for a query string; `--json` reports whether the copy succeeded as `copied`) (`lib/externalLinks.ts`, which allows only `http(s)`/`mailto:` and falls back to Electron's `shell.openExternal` when the OS opener is unavailable). It reads local files only — it never starts or contacts the brain — so it still works on the machine where ADE will not come up, and on a headless or Windows host with no desktop error screen to press. The builder, the redactor, and the machine source collector (`services/diagnostics/diagnosticReport.ts`, `diagnosticSources.ts`) are shared with the desktop, so a log added for one appears in both. See [features/storage-and-recovery/README.md](./features/storage-and-recovery/README.md#diagnostic-reports-report-issue). **Install + PATH wiring (when the desktop ships `ade`).** On macOS / Linux the desktop installer drops the launcher at `$HOME/.local/bin/ade`; on Windows it lands at `%LOCALAPPDATA%\ADE\bin\ade.cmd`. After a successful install on Windows, the packaged `.cmd` installer adds the target directory to HKCU `Environment\Path` when needed and broadcasts an environment-change notification. After a successful install on POSIX, `ensureUserBinOnShellPath` appends a marked `export PATH="$HOME/.local/bin:$PATH"` block to the user's shell rc (`.zshrc` for zsh, `.bashrc` for bash, `.profile` otherwise) iff (a) the install dir isn't already on the inherited `PATH` and (b) the file doesn't already contain the marker / line / target dir. The install IPC reply tells the renderer which profile was edited so the Settings/Onboarding UI can prompt the user to open a new terminal or `source` it.