From a6aec5ce3cf70eac96b9883d0f34cbb7e0851983 Mon Sep 17 00:00:00 2001 From: Aaron Fields Date: Sat, 28 Feb 2026 08:22:07 -0500 Subject: [PATCH] fix: match session webhook PID to parent when wrapper scripts are used When the daemon spawns a session through a wrapper script (e.g. a Node.js entrypoint that spawns the actual binary as a child process), the daemon tracks the wrapper's PID while the session reports the binary's PID via webhook. This mismatch causes: 1. The session to be registered as "externally-started" instead of correlating with the daemon-spawned entry 2. A ~90-second webhook timeout on the wrapper PID 3. Delayed spawn response to the client When an unknown PID reports via webhook, check if its parent PID (PPID) matches any daemon-tracked PID. If found, re-key the tracked session to the actual PID and resolve pending awaiters immediately. Uses a lightweight ps call (Unix only, <10ms, 1s timeout guard). On Windows the check is skipped and existing behavior is preserved. --- .../daemon/sessions/onHappySessionWebhook.ts | 62 ++++++++++++++++--- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/daemon/sessions/onHappySessionWebhook.ts b/apps/cli/src/daemon/sessions/onHappySessionWebhook.ts index 35a033c03..22657dfe8 100644 --- a/apps/cli/src/daemon/sessions/onHappySessionWebhook.ts +++ b/apps/cli/src/daemon/sessions/onHappySessionWebhook.ts @@ -4,6 +4,7 @@ import { logger } from '@/ui/logger'; import os from 'node:os'; import path from 'node:path'; +import { execSync } from 'node:child_process'; import { findHappyProcessByPid } from '../doctor'; import type { TrackedSession } from '../types'; @@ -17,6 +18,24 @@ function resolveTildePath(inputPath: string): string { return inputPath; } +/** + * Get the parent PID of a process. + * + * Used to detect wrapper-script scenarios where the daemon spawns a wrapper + * (e.g. Node.js entrypoint) that in turn spawns the actual session binary. + * Returns null on Windows or if the lookup fails. + */ +function getParentPid(pid: number): number | null { + if (process.platform === 'win32') return null; + try { + const stdout = execSync(`ps -o ppid= -p ${pid}`, { encoding: 'utf-8', timeout: 1000 }); + const ppid = parseInt(stdout.trim(), 10); + return Number.isFinite(ppid) ? ppid : null; + } catch { + return null; + } +} + export function createOnHappySessionWebhook(params: Readonly<{ pidToTrackedSession: Map; pidToAwaiter: Map void>; @@ -86,15 +105,40 @@ export function createOnHappySessionWebhook(params: Readonly<{ logger.debug(`[DAEMON RUN] Refreshed externally-started session ${sessionId}`); } } else if (!existingSession) { - // New session started externally - const trackedSession: TrackedSession = { - startedBy: 'happy directly - likely by user from terminal', - happySessionId: sessionId, - happySessionMetadataFromLocalWebhook: normalizedMetadata, - pid - }; - pidToTrackedSession.set(pid, trackedSession); - logger.debug(`[DAEMON RUN] Registered externally-started session ${sessionId}`); + // PID not in tracked map. Check if this is a child of a tracked PID — + // this happens when a wrapper script (e.g. Node.js entrypoint) spawns + // the actual session binary as a child process, causing a PID mismatch + // between what the daemon spawned and what the session reports. + const ppid = getParentPid(pid); + const parentSession = ppid ? pidToTrackedSession.get(ppid) : null; + + if (parentSession && parentSession.startedBy === 'daemon') { + // Re-key the tracked session from wrapper PID to actual session PID + pidToTrackedSession.delete(ppid); + parentSession.pid = pid; + parentSession.happySessionId = sessionId; + parentSession.happySessionMetadataFromLocalWebhook = normalizedMetadata; + pidToTrackedSession.set(pid, parentSession); + logger.debug(`[DAEMON RUN] Re-keyed daemon session from wrapper PID ${ppid} to actual PID ${pid}`); + + // Resolve any awaiter that was waiting on the wrapper PID + const awaiter = pidToAwaiter.get(ppid); + if (awaiter) { + pidToAwaiter.delete(ppid); + awaiter(parentSession); + logger.debug(`[DAEMON RUN] Resolved session awaiter via parent PID ${ppid}`); + } + } else { + // New session started externally (not by this daemon) + const trackedSession: TrackedSession = { + startedBy: 'happy directly - likely by user from terminal', + happySessionId: sessionId, + happySessionMetadataFromLocalWebhook: normalizedMetadata, + pid + }; + pidToTrackedSession.set(pid, trackedSession); + logger.debug(`[DAEMON RUN] Registered externally-started session ${sessionId}`); + } } // Best-effort: write/update marker so future daemon restarts can reattach.