WIP (mac-side drafts, untested, known lint/syntax issues): daemon health-check retry via socket probe, legacy-adoption retry + null-tolerant token cleanup, runtime staging, periodic sleeping-agent capture

Handover scaffolding for the Windows-local agent — fix or discard freely.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-04 03:25:46 -04:00
co-authored by Orca
parent a074ed6182
commit 504bdd173b
10 changed files with 1244 additions and 198 deletions
+9 -2
View File
@@ -210,7 +210,13 @@ vi.mock('fs', () => ({
mkdirSync: vi.fn(),
existsSync: (p: string) => probeSocketExistsMock(p) || p.includes('.pid'),
unlinkSync: vi.fn(),
writeFileSync: writeFileSyncMock
writeFileSync: writeFileSyncMock,
// Why: legacy adoption reads pid files and runtime pruning lists the
// daemon dir; both are no-ops in these tests (no evidence files exist).
readFileSync: vi.fn(() => {
throw new Error('ENOENT')
}),
readdirSync: vi.fn(() => [])
}))
vi.mock('child_process', () => ({ fork: forkMock }))
@@ -224,7 +230,8 @@ vi.mock('./daemon-health', () => ({
healthCheckDaemon: healthCheckDaemonMock,
isDaemonStaleForCurrentBundle: isDaemonStaleForCurrentBundleMock,
killStaleDaemon: killStaleDaemonMock,
getProcessStartedAtMs: getProcessStartedAtMsMock
getProcessStartedAtMs: getProcessStartedAtMsMock,
parseDaemonPidFile: vi.fn(() => null)
}))
vi.mock('./client', () => ({ DaemonClient: daemonClientMock }))
+222 -191
View File
@@ -8,9 +8,8 @@ module-level spawner/adapter singletons must stay co-located so a future
change cannot leave them drifting out of sync. */
import { join } from 'node:path'
import { app } from 'electron'
import { mkdirSync, existsSync, unlinkSync, writeFileSync } from 'node:fs'
import { mkdirSync, existsSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { fork } from 'node:child_process'
import { connect } from 'node:net'
import {
DaemonSpawner,
getDaemonPidPath,
@@ -23,18 +22,23 @@ import {
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonPtyRouter } from './daemon-pty-router'
import { DaemonClient } from './client'
import { PROTOCOL_VERSION, type ListSessionsResult } from './types'
import { probeSocket } from './daemon-socket-probe'
import { adoptLegacyDaemons } from './daemon-legacy-adoption'
import {
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
PROTOCOL_VERSION,
type ListSessionsResult
} from './types'
getDaemonRuntimeVersionDir,
pruneDaemonRuntimeStaging,
stageDaemonRuntime,
type StagedDaemonRuntime
} from './daemon-runtime-staging'
import {
getMacDaemonSystemResolverHealth,
getDaemonLaunchIdentity,
getProcessStartedAtMs,
checkDaemonHealth,
isDaemonStaleForCurrentBundle,
killStaleDaemon
killStaleDaemon,
parseDaemonPidFile
} from './daemon-health'
import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider'
import {
@@ -89,48 +93,57 @@ function getDaemonEntryPath(): string {
return join(basePath, 'out', 'main', 'daemon-entry.js')
}
// Why: before spawning a new daemon, check if an existing one is alive by
// attempting a TCP connection to the socket. If it connects, the daemon
// survived from a previous app session — reuse it instead of spawning.
function probeSocket(socketPath: string): Promise<boolean> {
return new Promise((resolve) => {
if (process.platform !== 'win32' && !existsSync(socketPath)) {
resolve(false)
return
}
const sock = connect({ path: socketPath })
let settled = false
let timer: ReturnType<typeof setTimeout>
function finish(alive: boolean, options?: { destroy?: boolean }): void {
if (settled) {
return
}
settled = true
clearTimeout(timer)
sock.removeListener('connect', onConnect)
sock.removeListener('error', onError)
if (options?.destroy) {
sock.destroy()
}
resolve(alive)
}
function getDaemonRuntimeStagingRoot(): string {
// Why: the Windows staging includes an Electron executable copy; keep it
// out of userData (%APPDATA% Roaming — synced on roaming profiles) and out
// of the NSIS $INSTDIR sweep (%LOCALAPPDATA%\Programs\orca).
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
return join(process.env.LOCALAPPDATA, 'orca', 'daemon-runtime')
}
return join(getRuntimeDir(), 'runtime')
}
function onConnect(): void {
finish(true, { destroy: true })
}
function onError(): void {
finish(false)
}
timer = setTimeout(() => {
finish(false, { destroy: true })
}, 1000)
sock.on('connect', onConnect)
sock.on('error', onError)
// Why: packaged installs are replaced in-place by updaters (NSIS overwrites
// %LOCALAPPDATA%\Programs\orca on Windows), so a detached daemon must not
// execute files from the install dir. Dev builds fork straight from out/ —
// entry-path identity checks already handle worktree staleness there.
async function resolveDaemonRuntime(installEntryPath: string): Promise<StagedDaemonRuntime> {
if (!app.isPackaged) {
return { entryPath: installEntryPath, execPath: null, staged: false }
}
return stageDaemonRuntime({
installEntryPath,
stagingRoot: getDaemonRuntimeStagingRoot(),
appVersion: app.getVersion(),
platform: process.platform,
execPath: process.execPath
})
}
function collectPidFileEntryPaths(runtimeDir: string): string[] {
const entryPaths: string[] = []
let names: string[]
try {
names = readdirSync(runtimeDir)
} catch {
return entryPaths
}
for (const name of names) {
if (!/^daemon-v\d+\.pid$/.test(name)) {
continue
}
try {
const parsed = parseDaemonPidFile(readFileSync(join(runtimeDir, name), 'utf8'))
if (parsed?.entryPath) {
entryPaths.push(parsed.entryPath)
}
} catch {
// Unreadable pid file — nothing to preserve for it.
}
}
return entryPaths
}
async function getAliveDaemonSessionCount(
socketPath: string,
tokenPath: string,
@@ -183,7 +196,9 @@ async function shouldPreserveDaemonWithLiveSessions(
function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
return async (socketPath, tokenPath) => {
const entryPath = getDaemonEntryPath()
const installEntryPath = getDaemonEntryPath()
const runtime = await resolveDaemonRuntime(installEntryPath)
const entryPath = runtime.entryPath
const health = await checkDaemonHealth(socketPath, tokenPath)
if (health === 'healthy') {
const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath)
@@ -259,114 +274,158 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
// before respawn so the new daemon does not race the stale process.
await killStaleDaemon(runtimeDir, socketPath, tokenPath)
const userDataPath = app.getPath('userData')
const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], {
// Why: detached daemons can outlive dev worktrees. Starting from
// userData keeps process.cwd() valid after a repo/worktree is deleted.
cwd: userDataPath,
// Why: detached + unref lets the daemon outlive the Electron process.
// stdio 'ignore' prevents the child from holding the parent's stdout
// open, which would prevent Electron from exiting cleanly.
detached: true,
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
// Why: ELECTRON_RUN_AS_NODE makes the forked process run as a plain
// Node.js process instead of an Electron renderer/main process. Without
// it, Electron's GPU/display initialization can interfere with native
// module operations like node-pty's posix_spawn of the spawn-helper.
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
// Why: the detached daemon is plain Node and cannot call Electron's
// app.getPath(), but shell-ready rcfiles must live outside swept tmp.
ORCA_USER_DATA_PATH: userDataPath
// Why a candidate ladder: the staged Windows executable copy is validated
// by the ready handshake itself. If a staged runtime cannot boot (missing
// Electron support file, AV quarantine), fall back to the default
// executable and finally to the install-dir entry — worst case is
// exactly today's behavior instead of no daemon at all.
const candidates: { entryPath: string; execPath?: string }[] = []
if (runtime.staged) {
if (runtime.execPath) {
candidates.push({ entryPath: runtime.entryPath, execPath: runtime.execPath })
}
})
candidates.push({ entryPath: runtime.entryPath })
}
candidates.push({ entryPath: installEntryPath })
// Wait for the daemon to signal readiness via IPC
await new Promise<void>((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | undefined
let settled = false
function cleanupStartupListeners(): void {
if (timer) {
clearTimeout(timer)
let lastError: Error | null = null
for (const candidate of candidates) {
try {
return await forkDaemonUntilReady(candidate, socketPath, tokenPath, runtimeDir)
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error))
if (candidate !== candidates[candidates.length - 1]) {
console.warn(
`[daemon] Daemon launch failed via ${candidate.execPath ?? 'default exec'} + ${candidate.entryPath} — trying next fallback: ${lastError.message}`
)
}
child.off('message', onReadyMessage)
child.off('error', onStartupError)
child.off('exit', onStartupExit)
}
function fail(error: Error): void {
}
throw lastError ?? new Error('Daemon launch failed with no candidates')
}
}
async function forkDaemonUntilReady(
candidate: { entryPath: string; execPath?: string },
socketPath: string,
tokenPath: string,
runtimeDir: string
): Promise<DaemonProcessHandle> {
const userDataPath = app.getPath('userData')
const child = fork(candidate.entryPath, ['--socket', socketPath, '--token', tokenPath], {
// Why: detached daemons can outlive dev worktrees. Starting from
// userData keeps process.cwd() valid after a repo/worktree is deleted.
cwd: userDataPath,
// Why: detached + unref lets the daemon outlive the Electron process.
// stdio 'ignore' prevents the child from holding the parent's stdout
// open, which would prevent Electron from exiting cleanly.
detached: true,
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
// Why execPath: on Windows the staged executable copy lives outside the
// install dir under a non-Orca.exe image name, which is what lets the
// daemon escape the NSIS updater's kill sweep (it stops processes by
// install-dir path, or by Orca.exe image name when PowerShell is absent).
...(candidate.execPath ? { execPath: candidate.execPath } : {}),
...(process.platform === 'win32' ? { windowsHide: true } : {}),
// Why: ELECTRON_RUN_AS_NODE makes the forked process run as a plain
// Node.js process instead of an Electron renderer/main process. Without
// it, Electron's GPU/display initialization can interfere with native
// module operations like node-pty's posix_spawn of the spawn-helper.
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
// Why: the detached daemon is plain Node and cannot call Electron's
// app.getPath(), but shell-ready rcfiles must live outside swept tmp.
ORCA_USER_DATA_PATH: userDataPath
}
})
// Wait for the daemon to signal readiness via IPC
await new Promise<void>((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | undefined
let settled = false
function cleanupStartupListeners(): void {
if (timer) {
clearTimeout(timer)
}
child.off('message', onReadyMessage)
child.off('error', onStartupError)
child.off('exit', onStartupExit)
}
function fail(error: Error): void {
if (settled) {
return
}
settled = true
cleanupStartupListeners()
if (child.pid) {
try {
process.kill(child.pid, 'SIGTERM')
} catch {
// Already dead
}
}
reject(error)
}
function onReadyMessage(msg: unknown): void {
if (msg && typeof msg === 'object' && (msg as { type?: string }).type === 'ready') {
if (settled) {
return
}
settled = true
// Why: the daemon process is detached after readiness; leaving
// startup listeners attached retains this launch promise closure.
cleanupStartupListeners()
if (child.pid) {
try {
process.kill(child.pid, 'SIGTERM')
} catch {
// Already dead
}
}
reject(error)
}
function onReadyMessage(msg: unknown): void {
if (msg && typeof msg === 'object' && (msg as { type?: string }).type === 'ready') {
if (settled) {
return
}
settled = true
// Why: the daemon process is detached after readiness; leaving
// startup listeners attached retains this launch promise closure.
cleanupStartupListeners()
if (child.pid) {
// Why: JSON pid file carries pid + process start time so later
// killStaleDaemon() can verify the pid still belongs to the daemon
// we forked before SIGTERMing it. Prevents pid-recycling hazard
// where the OS hands the daemon's old pid to an unrelated process.
writeFileSync(
getDaemonPidPath(runtimeDir),
serializeDaemonPidFile({
pid: child.pid,
startedAtMs: getProcessStartedAtMs(child.pid),
entryPath,
appVersion: app.getVersion()
}),
{ mode: 0o600 }
)
}
// Why: disconnect IPC channel and unref so Electron can exit
// without waiting for the daemon. The daemon keeps running.
child.disconnect()
child.unref()
resolve()
// Why: JSON pid file carries pid + process start time so later
// killStaleDaemon() can verify the pid still belongs to the daemon
// we forked before SIGTERMing it. Prevents pid-recycling hazard
// where the OS hands the daemon's old pid to an unrelated process.
// entryPath records the path actually forked (staged or install)
// so runtime-staging pruning never deletes a live daemon's files.
writeFileSync(
getDaemonPidPath(runtimeDir),
serializeDaemonPidFile({
pid: child.pid,
startedAtMs: getProcessStartedAtMs(child.pid),
entryPath: candidate.entryPath,
appVersion: app.getVersion()
}),
{ mode: 0o600 }
)
}
// Why: disconnect IPC channel and unref so Electron can exit
// without waiting for the daemon. The daemon keeps running.
child.disconnect()
child.unref()
resolve()
}
}
function onStartupError(err: Error): void {
fail(err)
}
function onStartupError(err: Error): void {
fail(err)
}
function onStartupExit(code: number | null): void {
fail(new Error(`Daemon exited during startup with code ${code}`))
}
function onStartupExit(code: number | null): void {
fail(new Error(`Daemon exited during startup with code ${code}`))
}
timer = setTimeout(() => {
fail(new Error('Daemon startup timed out'))
}, 10000)
timer = setTimeout(() => {
fail(new Error('Daemon startup timed out'))
}, 10000)
child.on('message', onReadyMessage)
child.on('error', onStartupError)
child.on('exit', onStartupExit)
})
child.on('message', onReadyMessage)
child.on('error', onStartupError)
child.on('exit', onStartupExit)
})
return {
shutdown: async () => {
if (child.pid) {
try {
process.kill(child.pid, 'SIGTERM')
} catch {
// Already dead
}
return {
shutdown: async () => {
if (child.pid) {
try {
process.kill(child.pid, 'SIGTERM')
} catch {
// Already dead
}
}
}
@@ -417,7 +476,16 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void>
}
})
const legacyAdapters = await createLegacyDaemonAdapters(runtimeDir)
const adoption = await adoptLegacyDaemons(runtimeDir, getHistoryDir())
const legacyAdapters = adoption.adapters
if (adoption.unreachableVersions.length > 0 && legacyAdapters.length === 0) {
// Why so loud: this is the exact signature of the update-crossing bug —
// sessions exist behind a previous-protocol daemon but none were adopted,
// so every terminal will silently cold-restore instead of reattaching.
console.error(
`[daemon] Previous-version daemon(s) v${adoption.unreachableVersions.join(', v')} appear live but zero legacy adapters were created — their sessions will NOT reattach this launch`
)
}
const routedAdapter =
launchMode === 'degraded-new-pty-fallback'
? new DegradedDaemonPtyProvider({
@@ -452,7 +520,22 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void>
// before daemon init finishes. Rebind here so daemon PTYs still fan out
// data/exit events through the renderer and runtime listeners.
rebindLocalProviderListeners()
logDaemonMilestone('daemon-init-done', { legacyAdapters: legacyAdapters.length })
logDaemonMilestone('daemon-init-done', {
legacyAdapters: legacyAdapters.length,
unreachableLegacyVersions: adoption.unreachableVersions
})
if (app.isPackaged) {
// Why fire-and-forget: pruning stale staged runtimes is pure disk
// hygiene; it must never delay startup or fail init. Pid-file entry
// paths guard runtimes that may still back a live (legacy) daemon.
const stagingRoot = getDaemonRuntimeStagingRoot()
void pruneDaemonRuntimeStaging({
stagingRoot,
keepVersionDir: getDaemonRuntimeVersionDir(stagingRoot, app.getVersion()),
referencedEntryPaths: collectPidFileEntryPaths(runtimeDir)
}).catch(() => {})
}
}
// Why: the Manage Sessions IPC handlers need read access to the current
@@ -708,55 +791,3 @@ export async function cleanupDaemonForProtocol(
return { cleaned: didRequestShutdown || didKillStaleDaemon, killedCount }
}
async function createLegacyDaemonAdapters(runtimeDir: string): Promise<DaemonPtyAdapter[]> {
const adapters: DaemonPtyAdapter[] = []
for (const protocolVersion of PREVIOUS_DAEMON_PROTOCOL_VERSIONS) {
const socketPath = getDaemonSocketPath(runtimeDir, protocolVersion)
const tokenPath = getDaemonTokenPath(runtimeDir, protocolVersion)
if (!(await probeSocket(socketPath))) {
// Why: dead legacy daemons leave pid/token files behind forever (one per
// protocol bump). A stale pid eventually gets recycled by an unrelated
// process, turning any future identity check into a PowerShell spawn.
// The socket is provably dead, so remove the leftovers — mirrors what
// cleanupDaemonForProtocol already does for the current version.
for (const stalePath of [
getDaemonPidPath(runtimeDir, protocolVersion),
getDaemonTokenPath(runtimeDir, protocolVersion)
]) {
try {
unlinkSync(stalePath)
} catch {
// Best-effort
}
}
if (process.platform !== 'win32' && existsSync(socketPath)) {
try {
unlinkSync(socketPath)
} catch {
// Best-effort
}
}
continue
}
// Why: old daemon PTYs can be running long-lived agents during an app
// upgrade. Keep those sessions routed to their original daemon while new
// terminals use the current protocol, instead of killing background work.
// Legacy adapters intentionally do not respawn: respawning an old protocol
// daemon from new code would recreate stale env semantics and can be less
// predictable than letting the session fail if that old daemon dies.
// Why historyPath is still passed: checkpoint writes will fail silently
// (pre-v4 daemons don't support getSnapshot), but the HistoryManager is
// still needed for cleanup — close/exit events must remove history dirs
// and mark meta.json as ended. Without it, a later v4 session reusing
// the same ID could false-restore stale scrollback.bin.
adapters.push(
new DaemonPtyAdapter({
socketPath,
tokenPath,
protocolVersion,
historyPath: getHistoryDir()
})
)
}
return adapters
}
@@ -0,0 +1,168 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createServer, type Server } from 'node:net'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { adoptLegacyDaemons } from './daemon-legacy-adoption'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import {
getDaemonPidPath,
getDaemonSocketPath,
getDaemonTokenPath,
serializeDaemonPidFile
} from './daemon-spawner'
const LEGACY_VERSION = 17
type CreatedAdapter = {
socketPath: string
tokenPath: string
protocolVersion: number
historyPath: string
}
function stubAdapterFactory(created: CreatedAdapter[]) {
return (opts: CreatedAdapter): DaemonPtyAdapter => {
created.push(opts)
return { protocolVersion: opts.protocolVersion } as unknown as DaemonPtyAdapter
}
}
describe('adoptLegacyDaemons', () => {
let dir: string
let historyDir: string
let warnSpy: ReturnType<typeof vi.spyOn>
let errorSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'daemon-legacy-adoption-test-'))
historyDir = join(dir, 'history')
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
errorSpy.mockRestore()
rmSync(dir, { recursive: true, force: true })
})
it('adopts a live previous-version daemon reachable on its socket', async () => {
// Simulate a still-running pre-upgrade daemon: a real server listening on
// the exact socket path the previous protocol version would use.
const socketPath = getDaemonSocketPath(dir, LEGACY_VERSION)
const server = await new Promise<Server>((resolve, reject) => {
const created = createServer()
created.once('error', reject)
created.listen(socketPath, () => resolve(created))
})
writeFileSync(getDaemonTokenPath(dir, LEGACY_VERSION), 'token', 'utf8')
try {
const created: CreatedAdapter[] = []
const result = await adoptLegacyDaemons(dir, historyDir, {
protocolVersions: [LEGACY_VERSION],
createAdapter: stubAdapterFactory(created)
})
expect(result.adapters).toHaveLength(1)
expect(result.unreachableVersions).toEqual([])
expect(created[0]).toMatchObject({
socketPath,
protocolVersion: LEGACY_VERSION,
historyPath: historyDir
})
expect(existsSync(getDaemonTokenPath(dir, LEGACY_VERSION))).toBe(true)
} finally {
await new Promise((resolve) => server.close(resolve))
}
})
it('retries transient probe failures before adopting', async () => {
writeFileSync(getDaemonTokenPath(dir, LEGACY_VERSION), 'token', 'utf8')
let calls = 0
const probe = vi.fn(async () => {
calls += 1
return calls >= 3
})
const created: CreatedAdapter[] = []
const result = await adoptLegacyDaemons(dir, historyDir, {
protocolVersions: [LEGACY_VERSION],
probe,
delay: async () => {},
createAdapter: stubAdapterFactory(created)
})
expect(probe).toHaveBeenCalledTimes(3)
expect(result.adapters).toHaveLength(1)
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('attempt 3/3'))
})
it('keeps token/pid files and logs loudly when the daemon pid is alive but unreachable', async () => {
const tokenPath = getDaemonTokenPath(dir, LEGACY_VERSION)
const pidPath = getDaemonPidPath(dir, LEGACY_VERSION)
writeFileSync(tokenPath, 'token', 'utf8')
writeFileSync(
pidPath,
serializeDaemonPidFile({ pid: process.pid, startedAtMs: null }),
'utf8'
)
const probe = vi.fn(async () => false)
const result = await adoptLegacyDaemons(dir, historyDir, {
protocolVersions: [LEGACY_VERSION],
probe,
delay: async () => {},
isPidAlive: () => true,
createAdapter: stubAdapterFactory([])
})
expect(probe).toHaveBeenCalledTimes(3)
expect(result.adapters).toHaveLength(0)
expect(result.unreachableVersions).toEqual([LEGACY_VERSION])
// The token file is the only credential that can ever adopt these
// sessions — a transient failure must not delete it.
expect(existsSync(tokenPath)).toBe(true)
expect(existsSync(pidPath)).toBe(true)
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining(`Previous-version daemon v${LEGACY_VERSION} looks alive`)
)
})
it('cleans up token/pid files when the daemon pid is provably dead', async () => {
const tokenPath = getDaemonTokenPath(dir, LEGACY_VERSION)
const pidPath = getDaemonPidPath(dir, LEGACY_VERSION)
writeFileSync(tokenPath, 'token', 'utf8')
writeFileSync(pidPath, serializeDaemonPidFile({ pid: 999999, startedAtMs: null }), 'utf8')
const result = await adoptLegacyDaemons(dir, historyDir, {
protocolVersions: [LEGACY_VERSION],
probe: async () => false,
delay: async () => {},
isPidAlive: () => false,
createAdapter: stubAdapterFactory([])
})
expect(result.adapters).toHaveLength(0)
expect(result.unreachableVersions).toEqual([])
expect(existsSync(tokenPath)).toBe(false)
expect(existsSync(pidPath)).toBe(false)
})
it('probes once and stays quiet when no evidence files exist', async () => {
const probe = vi.fn(async () => false)
const result = await adoptLegacyDaemons(dir, historyDir, {
protocolVersions: [LEGACY_VERSION],
probe,
delay: async () => {},
createAdapter: stubAdapterFactory([])
})
expect(probe).toHaveBeenCalledTimes(1)
expect(result.adapters).toHaveLength(0)
expect(result.unreachableVersions).toEqual([])
expect(errorSpy).not.toHaveBeenCalled()
})
})
+171
View File
@@ -0,0 +1,171 @@
import { existsSync, readFileSync, unlinkSync } from 'node:fs'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } from './daemon-spawner'
import { parseDaemonPidFile } from './daemon-health'
import { probeSocket } from './daemon-socket-probe'
import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS } from './types'
// Why: right after an app update the machine is busy (installer teardown, AV
// scans) and Windows named-pipe connects can transiently fail or stall. A
// single 1s probe misclassifying a live previous-version daemon as dead used
// to permanently strand its sessions (the old code deleted the token file the
// adoption path needs). Retry before concluding anything, and only clean up
// files when the pid provably no longer runs.
export const LEGACY_ADOPTION_PROBE_TIMEOUT_MS = 2000
export const LEGACY_ADOPTION_PROBE_ATTEMPTS = 3
export const LEGACY_ADOPTION_RETRY_DELAY_MS = [250, 750]
export type LegacyDaemonAdoptionResult = {
adapters: DaemonPtyAdapter[]
/** Protocol versions whose on-disk files said a daemon may still be running
* but whose socket/pipe never accepted a connection. Sessions owned by
* those daemons cannot be adopted this launch. */
unreachableVersions: number[]
}
type LegacyAdapterFactory = (opts: {
socketPath: string
tokenPath: string
protocolVersion: number
historyPath: string
}) => DaemonPtyAdapter
export type LegacyDaemonAdoptionOverrides = {
probe?: (socketPath: string, timeoutMs?: number) => Promise<boolean>
delay?: (ms: number) => Promise<void>
createAdapter?: LegacyAdapterFactory
protocolVersions?: readonly number[]
isPidAlive?: (pid: number) => boolean
}
function defaultDelay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function defaultIsPidAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
function readLegacyDaemonPid(pidPath: string): number | null {
try {
return parseDaemonPidFile(readFileSync(pidPath, 'utf8'))?.pid ?? null
} catch {
return null
}
}
function removeDeadLegacyDaemonFiles(
socketPath: string,
tokenPath: string,
pidPath: string
): void {
// Why: dead legacy daemons leave pid/token files behind forever (one per
// protocol bump). A stale pid eventually gets recycled by an unrelated
// process, turning any future identity check into a PowerShell spawn.
for (const stalePath of [pidPath, tokenPath]) {
try {
unlinkSync(stalePath)
} catch {
// Best-effort
}
}
if (process.platform !== 'win32' && existsSync(socketPath)) {
try {
unlinkSync(socketPath)
} catch {
// Best-effort
}
}
}
// Why: old daemon PTYs can be running long-lived agents during an app
// upgrade. Keep those sessions routed to their original daemon while new
// terminals use the current protocol, instead of killing background work.
// Legacy adapters intentionally do not respawn: respawning an old protocol
// daemon from new code would recreate stale env semantics and can be less
// predictable than letting the session fail if that old daemon dies.
export async function adoptLegacyDaemons(
runtimeDir: string,
historyPath: string,
overrides: LegacyDaemonAdoptionOverrides = {}
): Promise<LegacyDaemonAdoptionResult> {
const probe = overrides.probe ?? probeSocket
const delay = overrides.delay ?? defaultDelay
const isPidAlive = overrides.isPidAlive ?? defaultIsPidAlive
const createAdapter: LegacyAdapterFactory =
overrides.createAdapter ?? ((opts) => new DaemonPtyAdapter(opts))
const protocolVersions = overrides.protocolVersions ?? PREVIOUS_DAEMON_PROTOCOL_VERSIONS
const adapters: DaemonPtyAdapter[] = []
const unreachableVersions: number[] = []
for (const protocolVersion of protocolVersions) {
const socketPath = getDaemonSocketPath(runtimeDir, protocolVersion)
const tokenPath = getDaemonTokenPath(runtimeDir, protocolVersion)
const pidPath = getDaemonPidPath(runtimeDir, protocolVersion)
// Why token presence is the retry signal: the token file is written when
// a daemon for that protocol actually ran, and adoption cannot
// authenticate without it — so it is both the "a daemon may still be
// running" evidence and the precondition for adoption being useful.
// Without it a single probe keeps startup fast across the legacy list.
const tokenExists = existsSync(tokenPath)
const attempts = tokenExists ? LEGACY_ADOPTION_PROBE_ATTEMPTS : 1
let alive = false
for (let attempt = 1; attempt <= attempts; attempt++) {
alive = await probe(socketPath, tokenExists ? LEGACY_ADOPTION_PROBE_TIMEOUT_MS : undefined)
if (alive) {
if (attempt > 1) {
console.warn(
`[daemon] Previous-version daemon v${protocolVersion} accepted a connection on attempt ${attempt}/${attempts}`
)
}
break
}
if (attempt < attempts) {
await delay(
LEGACY_ADOPTION_RETRY_DELAY_MS[
Math.min(attempt - 1, LEGACY_ADOPTION_RETRY_DELAY_MS.length - 1)
]
)
}
}
if (!alive) {
const pid = readLegacyDaemonPid(pidPath)
if (pid !== null && isPidAlive(pid)) {
// Why: a live pid with an unreachable pipe is exactly the transient
// failure mode this launch cannot distinguish from a wedged daemon.
// Deleting the token here would make adoption impossible forever, so
// keep the files and let the next launch retry.
if (tokenExists) {
unreachableVersions.push(protocolVersion)
console.error(
`[daemon] Previous-version daemon v${protocolVersion} looks alive (pid ${pid}) but its socket/pipe refused ${attempts} connection attempts — its terminal sessions cannot be adopted this launch. Keeping its runtime files for the next launch.`
)
}
continue
}
if (tokenExists || pid !== null) {
console.warn(
`[daemon] Previous-version daemon v${protocolVersion} is gone (stale pid/token files) — cleaning up`
)
removeDeadLegacyDaemonFiles(socketPath, tokenPath, pidPath)
}
continue
}
// Why historyPath is still passed: checkpoint writes will fail silently
// (pre-v4 daemons don't support getSnapshot), but the HistoryManager is
// still needed for cleanup — close/exit events must remove history dirs
// and mark meta.json as ended. Without it, a later v4 session reusing
// the same ID could false-restore stale scrollback.bin.
adapters.push(createAdapter({ socketPath, tokenPath, protocolVersion, historyPath }))
}
return { adapters, unreachableVersions }
}
@@ -0,0 +1,224 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
STAGED_DAEMON_EXECUTABLE_FILENAME,
getDaemonRuntimeVersionDir,
pruneDaemonRuntimeStaging,
stageDaemonRuntime
} from './daemon-runtime-staging'
describe('daemon runtime staging', () => {
let dir: string
let installDir: string
let stagingRoot: string
let installEntryPath: string
function writeInstallBundle(): void {
// Mirrors the real packaged layout: entry + hashed chunk + externalized
// native package under a sibling node_modules.
mkdirSync(join(installDir, 'out', 'main', 'chunks'), { recursive: true })
installEntryPath = join(installDir, 'out', 'main', 'daemon-entry.js')
writeFileSync(
installEntryPath,
'require("node:net");\nconst s = require("./chunks/session-Abc123.js");\n',
'utf8'
)
writeFileSync(
join(installDir, 'out', 'main', 'chunks', 'session-Abc123.js'),
'require("node:fs");\nrequire("fake-pty");\n',
'utf8'
)
const fakePtyDir = join(installDir, 'node_modules', 'fake-pty')
mkdirSync(fakePtyDir, { recursive: true })
writeFileSync(
join(fakePtyDir, 'package.json'),
JSON.stringify({ name: 'fake-pty', dependencies: { 'fake-addon': '1.0.0' } }),
'utf8'
)
writeFileSync(join(fakePtyDir, 'index.js'), 'module.exports = 1\n', 'utf8')
const fakeAddonDir = join(installDir, 'node_modules', 'fake-addon')
mkdirSync(fakeAddonDir, { recursive: true })
writeFileSync(join(fakeAddonDir, 'package.json'), JSON.stringify({ name: 'fake-addon' }))
writeFileSync(join(fakeAddonDir, 'index.js'), 'module.exports = 2\n', 'utf8')
}
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'daemon-runtime-staging-test-'))
installDir = join(dir, 'install')
stagingRoot = join(dir, 'staging')
writeInstallBundle()
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
it('stages the entry, its chunk closure, and external packages transitively', async () => {
const staged = await stageDaemonRuntime({
installEntryPath,
stagingRoot,
appVersion: '1.0.0',
platform: 'darwin',
log: () => {}
})
expect(staged.staged).toBe(true)
expect(staged.execPath).toBeNull()
const versionDir = getDaemonRuntimeVersionDir(stagingRoot, '1.0.0')
expect(staged.entryPath).toBe(join(versionDir, 'daemon-entry.js'))
expect(existsSync(join(versionDir, 'chunks', 'session-Abc123.js'))).toBe(true)
expect(existsSync(join(versionDir, 'node_modules', 'fake-pty', 'index.js'))).toBe(true)
// Transitive dependency of the external package is staged too.
expect(existsSync(join(versionDir, 'node_modules', 'fake-addon', 'index.js'))).toBe(true)
})
it('reuses a completed staging for the same app version without re-copying', async () => {
const first = await stageDaemonRuntime({
installEntryPath,
stagingRoot,
appVersion: '1.0.0',
platform: 'darwin',
log: () => {}
})
// Mutate the source after staging; a re-run must not propagate it.
writeFileSync(installEntryPath, '// mutated\n', 'utf8')
const second = await stageDaemonRuntime({
installEntryPath,
stagingRoot,
appVersion: '1.0.0',
platform: 'darwin',
log: () => {}
})
expect(second.entryPath).toBe(first.entryPath)
expect(readFileSync(second.entryPath, 'utf8')).toContain('session-Abc123')
})
it('stages a fresh runtime when the app version changes', async () => {
const first = await stageDaemonRuntime({
installEntryPath,
stagingRoot,
appVersion: '1.0.0',
platform: 'darwin',
log: () => {}
})
const second = await stageDaemonRuntime({
installEntryPath,
stagingRoot,
appVersion: '1.0.1',
platform: 'darwin',
log: () => {}
})
expect(second.entryPath).not.toBe(first.entryPath)
expect(existsSync(first.entryPath)).toBe(true)
expect(existsSync(second.entryPath)).toBe(true)
})
it('falls back to the install entry when staging fails', async () => {
rmSync(join(installDir, 'out', 'main', 'chunks'), { recursive: true, force: true })
const logs: string[] = []
const staged = await stageDaemonRuntime({
installEntryPath,
stagingRoot,
appVersion: '1.0.0',
platform: 'darwin',
log: (message) => logs.push(message)
})
expect(staged.staged).toBe(false)
expect(staged.execPath).toBeNull()
expect(staged.entryPath).toBe(installEntryPath)
expect(logs.some((message) => message.includes('falling back'))).toBe(true)
})
it('stages a renamed executable copy with its boot support files on win32', async () => {
const execDir = join(dir, 'winInstall')
mkdirSync(execDir, { recursive: true })
const execPath = join(execDir, 'Orca.exe')
for (const name of [
'Orca.exe',
'ffmpeg.dll',
'icudtl.dat',
'snapshot_blob.bin',
'v8_context_snapshot.bin',
'libEGL.dll',
'Uninstall Orca.exe'
]) {
writeFileSync(join(execDir, name), name, 'utf8')
}
const staged = await stageDaemonRuntime({
installEntryPath,
stagingRoot,
appVersion: '1.0.0',
platform: 'win32',
execPath,
log: () => {}
})
expect(staged.staged).toBe(true)
const versionDir = getDaemonRuntimeVersionDir(stagingRoot, '1.0.0')
expect(staged.execPath).toBe(join(versionDir, STAGED_DAEMON_EXECUTABLE_FILENAME))
expect(readFileSync(staged.execPath as string, 'utf8')).toBe('Orca.exe')
expect(existsSync(join(versionDir, 'ffmpeg.dll'))).toBe(true)
expect(existsSync(join(versionDir, 'icudtl.dat'))).toBe(true)
expect(existsSync(join(versionDir, 'snapshot_blob.bin'))).toBe(true)
expect(existsSync(join(versionDir, 'v8_context_snapshot.bin'))).toBe(true)
// GPU-only DLLs and the uninstaller are not part of the node-mode boot set.
expect(existsSync(join(versionDir, 'libEGL.dll'))).toBe(false)
expect(existsSync(join(versionDir, 'Uninstall Orca.exe'))).toBe(false)
expect(existsSync(join(versionDir, 'Orca.exe'))).toBe(false)
})
it('tolerates missing optional executable support files', async () => {
const execDir = join(dir, 'winInstall')
mkdirSync(execDir, { recursive: true })
const execPath = join(execDir, 'Orca.exe')
writeFileSync(execPath, 'Orca.exe', 'utf8')
const staged = await stageDaemonRuntime({
installEntryPath,
stagingRoot,
appVersion: '1.0.0',
platform: 'win32',
execPath,
log: () => {}
})
expect(staged.staged).toBe(true)
expect(staged.execPath).toBe(
join(getDaemonRuntimeVersionDir(stagingRoot, '1.0.0'), STAGED_DAEMON_EXECUTABLE_FILENAME)
)
})
it('prunes stale version dirs but keeps the current and pid-referenced ones', async () => {
for (const version of ['0.9.0', '1.0.0', '1.0.1']) {
await stageDaemonRuntime({
installEntryPath,
stagingRoot,
appVersion: version,
platform: 'darwin',
log: () => {}
})
}
const keepDir = getDaemonRuntimeVersionDir(stagingRoot, '1.0.1')
const referencedDir = getDaemonRuntimeVersionDir(stagingRoot, '1.0.0')
const staleDir = getDaemonRuntimeVersionDir(stagingRoot, '0.9.0')
await pruneDaemonRuntimeStaging({
stagingRoot,
keepVersionDir: keepDir,
// A pid file for a still-running (legacy) daemon points into 1.0.0.
referencedEntryPaths: [join(referencedDir, 'daemon-entry.js')]
})
expect(existsSync(keepDir)).toBe(true)
expect(existsSync(referencedDir)).toBe(true)
expect(existsSync(staleDir)).toBe(false)
})
})
+333
View File
@@ -0,0 +1,333 @@
/* Why this module exists: the Windows NSIS updater (electron-builder
oneClick) stops every current-user process whose executable path is inside
the install directory (PowerShell CIM sweep), falls back to
`taskkill /IM Orca.exe`, and then overwrites the install dir — including
app.asar.unpacked/out/main/daemon-entry.js and node_modules/node-pty that the
detached terminal daemon is running from. Staging the daemon runtime under
userData removes every file the daemon needs (and every lock it holds) from
the install dir, and on Windows a renamed executable copy gives the daemon a
process identity the installer's kill sweep does not match. */
import { builtinModules } from 'node:module'
import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
/** Image name for the staged Windows executable. Distinct from Orca.exe so
* the NSIS `taskkill /IM Orca.exe` fallback cannot match the daemon. */
export const STAGED_DAEMON_EXECUTABLE_FILENAME = 'orca-daemon.exe'
const STAGING_MANIFEST_FILENAME = 'runtime-manifest.json'
export type DaemonRuntimeStagingInput = {
/** The daemon entry file inside the install dir (or dev out/ dir). */
installEntryPath: string
/** Version-stable parent for staged runtimes, e.g. <userData>/daemon/runtime. */
stagingRoot: string
appVersion: string
platform?: NodeJS.Platform
/** The executable that fork() would run (process.execPath). */
execPath?: string
log?: (message: string) => void
}
export type StagedDaemonRuntime = {
/** Entry to fork. Falls back to installEntryPath when staging failed. */
entryPath: string
/** Staged executable to fork with, or null to use process.execPath. */
execPath: string | null
staged: boolean
}
type StagingManifest = {
appVersion: string
sourceEntryPath: string
entryFile: string
execFile: string | null
}
const NODE_BUILTINS = new Set(builtinModules)
function requireSpecifiers(source: string): string[] {
// Why: the daemon bundle is CommonJS emitted by Rollup, so its complete
// import surface is statically visible as require("...") calls. Walking it
// at runtime keeps the copy correct across content-hashed chunk renames.
return [...source.matchAll(/require\("([^"\\]+)"\)/g)].map((match) => match[1])
}
function packageNameFromSpecifier(specifier: string): string {
const segments = specifier.split('/')
return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]
}
function findPackageDir(startDir: string, packageName: string): string | null {
let dir = startDir
for (let depth = 0; depth < 10; depth++) {
const candidate = join(dir, 'node_modules', ...packageName.split('/'))
if (existsSync(candidate)) {
return candidate
}
const parent = dirname(dir)
if (parent === dir) {
break
}
dir = parent
}
return null
}
async function collectRelativeClosure(entryPath: string): Promise<{
relativeFiles: string[]
externalPackages: string[]
}> {
const entryDir = dirname(entryPath)
const visited = new Set<string>()
const externals = new Set<string>()
const queue = [entryPath]
while (queue.length > 0) {
const file = queue.pop() as string
if (visited.has(file)) {
continue
}
visited.add(file)
const source = await readFile(file, 'utf8')
for (const specifier of requireSpecifiers(source)) {
if (specifier.startsWith('.')) {
queue.push(resolve(dirname(file), specifier))
continue
}
const bare = specifier.startsWith('node:') ? specifier.slice(5) : specifier
if (!NODE_BUILTINS.has(bare)) {
externals.add(packageNameFromSpecifier(specifier))
}
}
}
return {
relativeFiles: [...visited].map((file) => relativeWithin(entryDir, file)),
externalPackages: [...externals].sort()
}
}
function relativeWithin(rootDir: string, file: string): string {
const normalizedRoot = resolve(rootDir)
const normalizedFile = resolve(file)
if (!normalizedFile.startsWith(normalizedRoot)) {
throw new Error(`Daemon bundle requires a file outside its directory: ${file}`)
}
return normalizedFile.slice(normalizedRoot.length + 1)
}
async function copyExternalPackages(
packageNames: string[],
sourceStartDir: string,
targetNodeModulesDir: string
): Promise<void> {
const copied = new Set<string>()
const queue = [...packageNames]
while (queue.length > 0) {
const packageName = queue.pop() as string
if (copied.has(packageName)) {
continue
}
copied.add(packageName)
const sourceDir = findPackageDir(sourceStartDir, packageName)
if (!sourceDir) {
throw new Error(`Cannot locate daemon runtime dependency package: ${packageName}`)
}
const targetDir = join(targetNodeModulesDir, ...packageName.split('/'))
await mkdir(dirname(targetDir), { recursive: true })
// Why: dereference resolves pnpm symlinks so the staged copy stays valid
// after the source store is pruned or the install dir is replaced.
await cp(sourceDir, targetDir, { recursive: true, dereference: true })
try {
const packageJson = JSON.parse(await readFile(join(sourceDir, 'package.json'), 'utf8')) as {
dependencies?: Record<string, string>
}
for (const dependency of Object.keys(packageJson.dependencies ?? {})) {
queue.push(dependency)
}
} catch {
// No package.json — nothing further to trace.
}
}
}
/** Sibling files the Electron binary hard-requires to boot in
* ELECTRON_RUN_AS_NODE mode: ffmpeg.dll is its only statically imported
* non-system DLL, icudtl.dat feeds ICU init, and the two .bin files are the
* V8 startup snapshots. GPU DLLs (libEGL/libGLESv2/dx*/vk_*) and .pak UI
* resources are loaded only by renderer/GPU processes, never in node mode.
* Measured against Electron 42.3.3 win32-x64: this set is ~246.5MB (exe
* 231.5MB) vs ~306MB for all top-level install files. */
const WINDOWS_EXEC_RUNTIME_FILES = [
'ffmpeg.dll',
'icudtl.dat',
'snapshot_blob.bin',
'v8_context_snapshot.bin'
]
async function stageWindowsExecutable(execPath: string, targetDir: string): Promise<string> {
const sourceDir = dirname(execPath)
const stagedExecPath = join(targetDir, STAGED_DAEMON_EXECUTABLE_FILENAME)
await cp(execPath, stagedExecPath, { dereference: true })
for (const fileName of WINDOWS_EXEC_RUNTIME_FILES) {
const source = join(sourceDir, fileName)
if (!existsSync(source)) {
// Why tolerated: a future Electron may drop one of these files; the
// fork ladder falls back to the default executable if the staged copy
// cannot boot, so a lean copy degrades gracefully instead of failing
// the whole staging.
continue
}
await cp(source, join(targetDir, fileName), { dereference: true })
}
return stagedExecPath
}
async function readStagingManifest(versionDir: string): Promise<StagingManifest | null> {
try {
const parsed = JSON.parse(
await readFile(join(versionDir, STAGING_MANIFEST_FILENAME), 'utf8')
) as Partial<StagingManifest>
if (typeof parsed.appVersion !== 'string' || typeof parsed.entryFile !== 'string') {
return null
}
return {
appVersion: parsed.appVersion,
sourceEntryPath: typeof parsed.sourceEntryPath === 'string' ? parsed.sourceEntryPath : '',
entryFile: parsed.entryFile,
execFile: typeof parsed.execFile === 'string' ? parsed.execFile : null
}
} catch {
return null
}
}
function stagedRuntimeFromManifest(
versionDir: string,
manifest: StagingManifest
): StagedDaemonRuntime | null {
const entryPath = join(versionDir, manifest.entryFile)
if (!existsSync(entryPath)) {
return null
}
const execPath = manifest.execFile ? join(versionDir, manifest.execFile) : null
if (execPath && !existsSync(execPath)) {
return null
}
return { entryPath, execPath, staged: true }
}
export function getDaemonRuntimeVersionDir(stagingRoot: string, appVersion: string): string {
return join(stagingRoot, `v${appVersion}`)
}
export async function stageDaemonRuntime(
input: DaemonRuntimeStagingInput
): Promise<StagedDaemonRuntime> {
const platform = input.platform ?? process.platform
const log = input.log ?? ((message: string) => console.warn(message))
const versionDir = getDaemonRuntimeVersionDir(input.stagingRoot, input.appVersion)
const fallback: StagedDaemonRuntime = {
entryPath: input.installEntryPath,
execPath: null,
staged: false
}
try {
// Fast path: a completed staging for this app version is reused as-is so
// relaunches cost one manifest read instead of a copy.
const existingManifest = await readStagingManifest(versionDir)
if (existingManifest && existingManifest.appVersion === input.appVersion) {
const reused = stagedRuntimeFromManifest(versionDir, existingManifest)
if (reused) {
return reused
}
}
const buildDir = `${versionDir}.staging-${process.pid}`
await rm(buildDir, { recursive: true, force: true })
await mkdir(buildDir, { recursive: true })
const closure = await collectRelativeClosure(input.installEntryPath)
const entryDir = dirname(input.installEntryPath)
for (const relativeFile of closure.relativeFiles) {
const target = join(buildDir, relativeFile)
await mkdir(dirname(target), { recursive: true })
await cp(join(entryDir, relativeFile), target, { dereference: true })
}
if (closure.externalPackages.length > 0) {
await copyExternalPackages(
closure.externalPackages,
entryDir,
join(buildDir, 'node_modules')
)
}
let execFile: string | null = null
if (platform === 'win32' && input.execPath) {
await stageWindowsExecutable(input.execPath, buildDir)
execFile = STAGED_DAEMON_EXECUTABLE_FILENAME
}
const manifest: StagingManifest = {
appVersion: input.appVersion,
sourceEntryPath: input.installEntryPath,
entryFile: basename(input.installEntryPath),
execFile
}
await writeFile(join(buildDir, STAGING_MANIFEST_FILENAME), JSON.stringify(manifest), 'utf8')
// Why: build-then-rename keeps half-copied runtimes invisible; a crash
// mid-copy leaves only a .staging-* dir that the next attempt removes.
await rm(versionDir, { recursive: true, force: true })
await rename(buildDir, versionDir)
const staged = stagedRuntimeFromManifest(versionDir, manifest)
if (!staged) {
return fallback
}
return staged
} catch (error) {
log(
`[daemon] Failed to stage install-independent daemon runtime — falling back to the install dir entry: ${error instanceof Error ? error.message : String(error)}`
)
return fallback
}
}
export type PruneDaemonRuntimeStagingInput = {
stagingRoot: string
/** The version dir for the currently staged runtime; never pruned. */
keepVersionDir: string
/** Entry paths recorded in daemon pid files. Any staged dir that still
* backs a (possibly live) daemon is preserved so adoption keeps working. */
referencedEntryPaths: readonly string[]
}
export async function pruneDaemonRuntimeStaging(
input: PruneDaemonRuntimeStagingInput
): Promise<void> {
let entries
try {
entries = await readdir(input.stagingRoot, { withFileTypes: true })
} catch {
return
}
const keep = resolve(input.keepVersionDir)
const referenced = input.referencedEntryPaths.map((entryPath) => resolve(entryPath))
for (const entry of entries) {
if (!entry.isDirectory()) {
continue
}
const dir = resolve(join(input.stagingRoot, entry.name))
if (dir === keep) {
continue
}
if (referenced.some((entryPath) => entryPath.startsWith(dir + '/') || entryPath.startsWith(dir + '\\'))) {
continue
}
// Why best-effort: on Windows a still-running older daemon holds locks on
// its pty.node — the partial failure is harmless and retried next launch.
await rm(dir, { recursive: true, force: true }).catch(() => {})
}
}
+51
View File
@@ -0,0 +1,51 @@
import { existsSync } from 'node:fs'
import { connect } from 'node:net'
export const DEFAULT_SOCKET_PROBE_TIMEOUT_MS = 1000
// Why: before spawning a new daemon, check if an existing one is alive by
// attempting a connection to the socket. If it connects, the daemon survived
// from a previous app session — reuse it instead of spawning. On Windows the
// endpoint is a named pipe with no filesystem presence, so there is no
// existsSync shortcut and a connect attempt is the only liveness signal.
export function probeSocket(
socketPath: string,
timeoutMs = DEFAULT_SOCKET_PROBE_TIMEOUT_MS
): Promise<boolean> {
return new Promise((resolve) => {
if (process.platform !== 'win32' && !existsSync(socketPath)) {
resolve(false)
return
}
const sock = connect({ path: socketPath })
let settled = false
let timer: ReturnType<typeof setTimeout>
function finish(alive: boolean, options?: { destroy?: boolean }): void {
if (settled) {
return
}
settled = true
clearTimeout(timer)
sock.removeListener('connect', onConnect)
sock.removeListener('error', onError)
if (options?.destroy) {
sock.destroy()
}
resolve(alive)
}
function onConnect(): void {
finish(true, { destroy: true })
}
function onError(): void {
finish(false)
}
timer = setTimeout(() => {
finish(false, { destroy: true })
}, timeoutMs)
sock.on('connect', onConnect)
sock.on('error', onError)
})
}
+10
View File
@@ -106,6 +106,7 @@ import { useWebSessionTabsSync } from './runtime/web-session-tabs-sync'
import { useGlobalFileDrop } from './hooks/useGlobalFileDrop'
import { useRadixBodyPointerEventsRecovery } from './hooks/useRadixBodyPointerEventsRecovery'
import { registerUpdaterBeforeUnloadBypass } from './lib/updater-beforeunload'
import { startSleepingAgentPeriodicCapture } from './lib/sleeping-agent-periodic-capture'
import {
buildWorkspaceSessionPayload,
shouldPersistWorkspaceSession
@@ -1306,6 +1307,15 @@ function App(): React.JSX.Element {
return () => window.removeEventListener('beforeunload', captureAndFlush)
}, [])
// Why: beforeunload never fires when the app is hard-killed (Windows NSIS
// updater kill sweep, crashes), so also refresh resumable-agent quit-state
// on a timer. The capture skips the store write when nothing changed.
useEffect(() => {
return startSleepingAgentPeriodicCapture({
capture: () => useAppStore.getState().captureAllSleepingAgentSessions({ origin: 'live' })
})
}, [])
// Own the single window-close-request subscription at the always-mounted App
// root. Why: the rich confirmation flow lives in Terminal, which is not
// mounted on the no-workspace landing page (and is lazy-loaded elsewhere), so
@@ -0,0 +1,22 @@
export const SLEEPING_AGENT_PERIODIC_CAPTURE_INTERVAL_MS = 60_000
/** Drive the resumable-agent quit-state capture on a timer.
*
* Why: beforeunload never fires when the app is hard-killed — the Windows
* NSIS updater stops every Orca process before replacing the install dir,
* and crashes skip teardown entirely. A periodic capture keeps a recent
* resume record on disk for that case. A plain interval (not an on-change
* debounce) is enough because captureAllSleepingAgentSessions skips the
* store write when nothing changed, making idle ticks free. */
export function startSleepingAgentPeriodicCapture(options: {
capture: () => void
intervalMs?: number
}): () => void {
const intervalMs = options.intervalMs ?? SLEEPING_AGENT_PERIODIC_CAPTURE_INTERVAL_MS
const timer: ReturnType<typeof setInterval> = setInterval(() => {
options.capture()
}, intervalMs)
return () => {
clearInterval(timer)
}
}
+34 -5
View File
@@ -189,8 +189,10 @@ export type AgentStatusSlice = {
captureSleepingAgentSessionsByWorktree: (worktreeId: string, paneKeys?: string[]) => void
/** Capture resumable agent sessions across every worktree. Called from the
* quit flush so provider session ids survive an app restart. */
captureAllSleepingAgentSessions: () => void
* quit flush (origin 'quit') and from the periodic safety-net capture
* (origin 'live') so provider session ids survive an app restart — even a
* hard kill (updater/installer/crash) that never fires beforeunload. */
captureAllSleepingAgentSessions: (options?: { origin?: 'quit' | 'live' }) => void
clearSleepingAgentSession: (paneKey: string) => void
clearSleepingAgentSessionsByWorktree: (worktreeId: string) => void
pruneSleepingAgentSessions: (validWorktreeIds: Set<string>) => void
@@ -560,6 +562,33 @@ function recoveryRecordMatches(
)
}
// Why capturedAt is ignored: the periodic safety-net capture runs on a timer,
// so identical session state must not count as a change — otherwise every
// tick would rewrite the record and trigger a session persist for nothing.
function sleepingRecordsEquivalent(
existing: SleepingAgentSessionRecord | undefined,
next: SleepingAgentSessionRecord
): boolean {
if (!existing) {
return false
}
return (
existing.origin === next.origin &&
existing.agent === next.agent &&
existing.worktreeId === next.worktreeId &&
existing.tabId === next.tabId &&
existing.providerSession.key === next.providerSession.key &&
existing.providerSession.id === next.providerSession.id &&
existing.prompt === next.prompt &&
existing.state === next.state &&
existing.updatedAt === next.updatedAt &&
existing.terminalTitle === next.terminalTitle &&
existing.lastAssistantMessage === next.lastAssistantMessage &&
existing.interrupted === next.interrupted &&
launchConfigsEqual(existing.launchConfig, next.launchConfig)
)
}
function copyLaunchConfig(config: SleepingAgentLaunchConfig): SleepingAgentLaunchConfig {
return {
...(config.agentCommand ? { agentCommand: config.agentCommand } : {}),
@@ -1981,7 +2010,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
})
},
captureAllSleepingAgentSessions: () => {
captureAllSleepingAgentSessions: (options) => {
// Why: the quit flush must persist provider session ids for every live
// agent pane — otherwise agents whose daemon PTYs die while the app is
// closed have nothing to `--resume` from (#5232). Only live entries are
@@ -2007,9 +2036,9 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
worktreeId,
capturedAt,
launchConfig: getLaunchConfigForEntry(s, entry),
origin: 'quit'
origin: options?.origin ?? 'quit'
})
if (record && next[record.paneKey] !== record) {
if (record && !sleepingRecordsEquivalent(next[record.paneKey], record)) {
next[record.paneKey] = record
changed = true
}