mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
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:
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user