(sessionId: string, sessionMetadata: Metadata)
| 214 | |
| 215 | // Handle webhook from HAPI session reporting itself |
| 216 | const onHappySessionWebhook = (sessionId: string, sessionMetadata: Metadata) => { |
| 217 | logger.debugLargeJson(`[RUNNER RUN] Session reported`, sessionMetadata); |
| 218 | |
| 219 | const pid = sessionMetadata.hostPid; |
| 220 | if (!pid) { |
| 221 | logger.debug(`[RUNNER RUN] Session webhook missing hostPid for sessionId: ${sessionId}`); |
| 222 | return; |
| 223 | } |
| 224 | |
| 225 | logger.debug(`[RUNNER RUN] Session webhook: ${sessionId}, PID: ${pid}, started by: ${sessionMetadata.startedBy || 'unknown'}`); |
| 226 | logger.debug(`[RUNNER RUN] Current tracked sessions before webhook: ${Array.from(pidToTrackedSession.keys()).join(', ')}`); |
| 227 | |
| 228 | // Check if we already have this PID (runner-spawned) |
| 229 | const existingSession = pidToTrackedSession.get(pid); |
| 230 | |
| 231 | if (existingSession && existingSession.startedBy === 'runner') { |
| 232 | // Update runner-spawned session with reported data |
| 233 | existingSession.happySessionId = sessionId; |
| 234 | existingSession.happySessionMetadataFromLocalWebhook = sessionMetadata; |
| 235 | logger.debug(`[RUNNER RUN] Updated runner-spawned session ${sessionId} with metadata`); |
| 236 | |
| 237 | // Resolve any awaiter for this PID |
| 238 | const awaiter = pidToAwaiter.get(pid); |
| 239 | if (awaiter) { |
| 240 | pidToAwaiter.delete(pid); |
| 241 | pidToErrorAwaiter.delete(pid); |
| 242 | awaiter(existingSession); |
| 243 | logger.debug(`[RUNNER RUN] Resolved session awaiter for PID ${pid}`); |
| 244 | } |
| 245 | } else if (!existingSession) { |
| 246 | // No tracked session for this PID. Two possibilities: |
| 247 | // 1. The child was spawned externally from a terminal (legitimate). |
| 248 | // 2. The child was runner-spawned but already had its tracking |
| 249 | // entry removed because its webhook arrived after the timeout |
| 250 | // (orphaned / ghost-session case). |
| 251 | // |
| 252 | // Differentiate via the webhook's own `startedBy` field: genuine |
| 253 | // terminal-launched children report `startedBy: 'terminal'`, so |
| 254 | // anything claiming `'runner'` here must be the second case and |
| 255 | // should be ignored + terminated instead of silently promoted. |
| 256 | if (sessionMetadata.startedBy === 'runner') { |
| 257 | logger.debug( |
| 258 | `[RUNNER RUN] Ignoring late webhook from orphaned runner-spawned PID ${pid} (session ${sessionId}). Terminating child.` |
| 259 | ); |
| 260 | // Use killProcess (SIGTERM → SIGKILL escalation) rather than a |
| 261 | // bare process.kill() so the orphan is reliably reaped even if |
| 262 | // it ignores SIGTERM. We don't have a ChildProcess reference |
| 263 | // here (tracking entry was already removed by the timeout |
| 264 | // handler), so tree-kill via killProcessByChildProcess is not |
| 265 | // available — but the timeout handler should have already |
| 266 | // tree-killed the process group; this is defence-in-depth. |
| 267 | void killProcess(pid); |
| 268 | return; |
| 269 | } |
| 270 | |
| 271 | // New session started externally (terminal) |
| 272 | const trackedSession: TrackedSession = { |
| 273 | startedBy: 'hapi directly - likely by user from terminal', |
no test coverage detected