diff --git a/.changeset/clever-pillows-cough.md b/.changeset/clever-pillows-cough.md new file mode 100644 index 0000000..a4d90b2 --- /dev/null +++ b/.changeset/clever-pillows-cough.md @@ -0,0 +1,5 @@ +--- +'agendex-cli': patch +--- + +Updates to how we render daemon registry info diff --git a/packages/cli/src/pid.test.ts b/packages/cli/src/pid.test.ts index efde74a..2b5ae69 100644 --- a/packages/cli/src/pid.test.ts +++ b/packages/cli/src/pid.test.ts @@ -131,6 +131,67 @@ test('daemon PID ownership accepts only CLI or marked desktop daemon commands', ).toBe(false); }); +test('desktop launcher ownership accepts Electron utility processes without visible Node args', () => { + const info = { + pid: process.pid, + hostname: 'current-host', + bootId: 'boot-current', + launcher: 'desktop' as const, + parentPid: process.pid, + }; + const runtime = { + currentHostname: 'current-host', + currentBootId: 'boot-current', + processRunning: true, + parentProcessRunning: true, + }; + + // Real Electron utilityProcess listings often omit fork() Node args from `ps`/WMI. + expect( + isDaemonPidInfoRunning(info, { + ...runtime, + processCommand: + 'Agendex Helper (Plugin) --type=utility --utility-sub-type=node.mojom.NodeService', + }), + ).toBe(true); + expect( + isDaemonPidInfoRunning(info, { + ...runtime, + processCommand: 'RenamedDesktop.exe --type=utility --utility-sub-type=network', + }), + ).toBe(false); + expect( + isDaemonPidInfoRunning(info, { + ...runtime, + processCommand: 'Agendex.exe --type=utility', + }), + ).toBe(false); + expect( + isDaemonPidInfoRunning(info, { + ...runtime, + parentProcessRunning: false, + processCommand: + 'Agendex Helper (Plugin) --type=utility --utility-sub-type=node.mojom.NodeService', + }), + ).toBe(false); + expect( + isDaemonPidInfoRunning(info, { + ...runtime, + processCommand: 'unrelated-process --serve', + }), + ).toBe(false); + expect( + isDaemonPidInfoRunning( + { ...info, launcher: 'cli' }, + { + ...runtime, + processCommand: + 'Agendex Helper (Plugin) --type=utility --utility-sub-type=node.mojom.NodeService', + }, + ), + ).toBe(false); +}); + test('legacy PID files retain metadata and require daemon process ownership', () => { useTempConfigDir(); const configDir = process.env.AGENDEX_CONFIG_DIR as string; diff --git a/packages/cli/src/pid.ts b/packages/cli/src/pid.ts index 34a24ae..9138f54 100644 --- a/packages/cli/src/pid.ts +++ b/packages/cli/src/pid.ts @@ -145,6 +145,8 @@ export interface DaemonPidFreshnessOptions { currentBootId?: string | null; processCommand?: string | null; processRunning?: boolean; + /** Override for desktop launcher parent liveness checks (tests). */ + parentProcessRunning?: boolean; } /** Checks record provenance only; validate each live PID separately before signaling it. */ @@ -170,7 +172,13 @@ export function isDaemonPidInfoRunning( if (!running) return false; const command = options.processCommand !== undefined ? options.processCommand : readProcessCommand(info.pid); - return isAgendexDaemonCommand(command); + if (isAgendexDaemonCommand(command)) return true; + + // Electron utilityProcess.fork Node args are process.argv inside the worker, but often + // do not appear in OS process listings (`ps` / WMI). Trust desktop pid-file provenance + // while the recorded Electron parent is still alive and the live process still looks + // like an Electron utility worker — avoids false "not running" in `agendex status`. + return isDesktopDaemonOwnership(info, command, options); } export function isAgendexDaemonProcess(pid: number): boolean { @@ -277,6 +285,24 @@ function isAgendexDaemonCommand(command: string | null): boolean { return normalized.includes('--daemon') || normalized.includes('--worker'); } +function isElectronUtilityCommand(command: string | null): boolean { + if (!command) return false; + // Node utilityProcess.fork workers only — not Chromium network/audio/GPU helpers. + return command.toLowerCase().includes('--utility-sub-type=node.mojom.nodeservice'); +} + +function isDesktopDaemonOwnership( + info: DaemonPidInfo, + command: string | null, + options: DaemonPidFreshnessOptions, +): boolean { + if (info.launcher !== 'desktop') return false; + if (!Number.isInteger(info.parentPid) || (info.parentPid as number) <= 0) return false; + const parentRunning = options.parentProcessRunning ?? isRunning(info.parentPid as number); + if (!parentRunning) return false; + return isElectronUtilityCommand(command); +} + export function requestDaemonStop(pid: number, options: DaemonPathOptions = {}): void { const path = getStopRequestPath(pid, options); mkdirSync(dirname(path), { recursive: true }); diff --git a/packages/cli/src/status.test.ts b/packages/cli/src/status.test.ts index 6fb0236..4a6b36e 100644 --- a/packages/cli/src/status.test.ts +++ b/packages/cli/src/status.test.ts @@ -49,7 +49,7 @@ test('renders grouped status with daemon, cloud, and source summaries', () => { const output = renderStatus({ config: config(), configPath: '/tmp/agendex/config.json', - pidInfo: { pid: 123, startedAtMs: NOW - 90_000, hostname: 'workstation' }, + pidInfo: { pid: 123, startedAtMs: NOW - 90_000, hostname: 'workstation', launcher: 'cli' }, running: true, cliVersion: '2.0.0', devices, @@ -62,7 +62,7 @@ test('renders grouped status with daemon, cloud, and source summaries', () => { expect(output).toContain('Cloud:'); expect(output).toContain('Plan sources:'); expect(output).toContain('✓ running'); - expect(output).toContain('PID 123 • up 1m 30s • host workstation'); + expect(output).toContain('PID 123 • up 1m 30s • host workstation • via CLI'); expect(output).toContain('✓ 2 devices'); expect(output).toContain('1 alive • 1 stale'); expect(output).toContain('workstation (this machine)'); @@ -74,6 +74,28 @@ test('renders grouped status with daemon, cloud, and source summaries', () => { expect(output).toContain('agendex cleanup --stale'); }); +test('renders desktop spawn origin for Electron-launched daemons', () => { + const output = renderStatus({ + config: config(), + configPath: '/tmp/agendex/config.json', + pidInfo: { + pid: 456, + startedAtMs: NOW - 30_000, + hostname: 'workstation', + launcher: 'desktop', + parentPid: 100, + }, + running: true, + cliVersion: '2.0.0', + devices: [], + now: NOW, + color: false, + }); + + expect(output).toContain('✓ running'); + expect(output).toContain('PID 456 • up 30s • host workstation • via desktop app'); +}); + test('renders actionable setup guidance when config is missing', () => { const output = renderStatus({ config: null, diff --git a/packages/cli/src/status.ts b/packages/cli/src/status.ts index b1ab9d0..4a16032 100644 --- a/packages/cli/src/status.ts +++ b/packages/cli/src/status.ts @@ -121,6 +121,12 @@ export function formatDuration(ms: number): string { return `${days}d ${remainingHours}h`; } +function formatLauncherOrigin(launcher: DaemonPidInfo['launcher']): string | null { + if (launcher === 'desktop') return 'via desktop app'; + if (launcher === 'cli') return 'via CLI'; + return null; +} + function localDaemonDetail(options: RenderStatusOptions, now: number): string { if (!options.running) return 'run `agendex start` to begin background sync'; @@ -133,6 +139,8 @@ function localDaemonDetail(options: RenderStatusOptions, now: number): string { } if (options.pidInfo?.hostname) parts.push(`host ${options.pidInfo.hostname}`); else parts.push('host unknown'); + const origin = formatLauncherOrigin(options.pidInfo?.launcher); + if (origin) parts.push(origin); return parts.join(' • '); } diff --git a/packages/desktop/src/main/desktop-daemon-manager.test.ts b/packages/desktop/src/main/desktop-daemon-manager.test.ts index 2aef054..4027601 100644 --- a/packages/desktop/src/main/desktop-daemon-manager.test.ts +++ b/packages/desktop/src/main/desktop-daemon-manager.test.ts @@ -96,12 +96,20 @@ afterEach(() => { test('starts one utility worker without putting credentials in its environment', async () => { useTempConfigDir('agendex daemon path with spaces '); const child = new FakeUtilityProcess(); - const forkCalls: Array<{ path: string; options: { env?: NodeJS.ProcessEnv } }> = []; + const forkCalls: Array<{ + path: string; + args: string[]; + options: { env?: NodeJS.ProcessEnv; execArgv?: string[] }; + }> = []; const manager = new DesktopDaemonManager({ isDev: true, workerEntry: join(tempRoot, 'worker path with spaces', 'daemon-worker.js'), - forkWorker: ((path: string, _args: string[], options: { env?: NodeJS.ProcessEnv }) => { - forkCalls.push({ path, options }); + forkWorker: (( + path: string, + args: string[], + options: { env?: NodeJS.ProcessEnv; execArgv?: string[] }, + ) => { + forkCalls.push({ path, args, options }); queueMicrotask(() => child.emit('spawn')); return child; }) as never, @@ -120,8 +128,10 @@ test('starts one utility worker without putting credentials in its environment', expect(second).toBe('started'); expect(forkCalls).toHaveLength(1); expect(forkCalls[0]?.path).toContain('worker path with spaces'); + expect(forkCalls[0]?.args).toEqual(['--agendex-daemon-worker']); expect(forkCalls[0]?.options.env?.AGENDEX_DEV).toBe('1'); expect(forkCalls[0]?.options.env?.AGENDEX_CLOUD_TOKEN).toBeUndefined(); + expect(forkCalls[0]?.options.execArgv).toBeUndefined(); expect( child.messages.some( (message) => diff --git a/packages/desktop/src/main/desktop-daemon-manager.ts b/packages/desktop/src/main/desktop-daemon-manager.ts index 01c5c07..94374da 100644 --- a/packages/desktop/src/main/desktop-daemon-manager.ts +++ b/packages/desktop/src/main/desktop-daemon-manager.ts @@ -4,6 +4,7 @@ import { acquireDaemonStartLock, isAgendexDaemonProcess, isDaemonPidInfoCurrent, + isDaemonPidInfoRunning, isRunning, readPidInfo, removePid, @@ -164,7 +165,8 @@ export class DesktopDaemonManager { const orphanPid = observedIsCurrent && observed?.launcher === 'desktop' && - this.processIsDaemon(observed.pid) && + this.processIsRunning(observed.pid) && + this.processLooksLikeDaemon(observed) && observed.parentPid !== undefined && !this.processIsRunning(observed.parentPid) ? observed.pid @@ -194,7 +196,7 @@ export class DesktopDaemonManager { try { const current = readPidInfo(pathOptions); - if (current && this.pidInfoIsCurrent(current) && this.processIsDaemon(current.pid)) { + if (current && this.pidInfoMatchesRunningDaemon(current)) { return 'already-running'; } if (current) removePid(current.pid, pathOptions); @@ -207,6 +209,8 @@ export class DesktopDaemonManager { if (this.options.isDev) env.AGENDEX_DEV = '1'; else delete env.AGENDEX_DEV; + // Script args land on the worker's process.argv. Do not put the marker in execArgv — + // Node rejects unrecognized -- flags at startup ("bad option"). const child = this.options.forkWorker(workerEntry, ['--agendex-daemon-worker'], { env, serviceName: 'Agendex Sync', @@ -314,6 +318,27 @@ export class DesktopDaemonManager { return (this.options.isPidInfoCurrent ?? isDaemonPidInfoCurrent)(info); } + private pidInfoMatchesRunningDaemon(info: DaemonPidInfo): boolean { + if (!this.pidInfoIsCurrent(info)) return false; + if (this.options.isDaemonProcess) { + return this.processIsDaemon(info.pid); + } + // Honor injected liveness/freshness; force provenance checks already satisfied above. + return isDaemonPidInfoRunning(info, { + processRunning: this.processIsRunning(info.pid), + currentHostname: info.hostname, + currentBootId: info.bootId ?? null, + }); + } + + /** Orphan workers may still look like Electron utilities after the parent exits. */ + private processLooksLikeDaemon(info: DaemonPidInfo): boolean { + if (this.options.isDaemonProcess) return this.processIsDaemon(info.pid); + if (this.processIsDaemon(info.pid)) return true; + // Force parent-alive so desktop utility ownership can match after Electron exits. + return isDaemonPidInfoRunning(info, { parentProcessRunning: true }); + } + private postTo(child: UtilityProcess, message: DesktopDaemonParentMessage): boolean { try { child.postMessage(message);