diff --git a/packages/paperclip-runner/runner/crates/process-table/src/lib.rs b/packages/paperclip-runner/runner/crates/process-table/src/lib.rs index 99d3af9d337..01dd6d51bd7 100644 --- a/packages/paperclip-runner/runner/crates/process-table/src/lib.rs +++ b/packages/paperclip-runner/runner/crates/process-table/src/lib.rs @@ -68,6 +68,16 @@ pub fn read(pid: i32) -> Read { platform::read(pid) } +/// The session a process belongs to. `None` when the process cannot be read, +/// or when its session is not visible here: a kernel thread, or a process +/// whose session leader is outside this PID namespace, reports session 0. +pub fn session(pid: i32) -> Option { + if pid <= 0 { + return None; + } + platform::session(pid).filter(|sid| *sid > 0) +} + #[cfg(target_os = "macos")] mod platform { use super::{ProcessEntry, Read}; @@ -137,6 +147,12 @@ mod platform { zombie: info.pbi_status == SZOMB, }) } + + pub(super) fn session(pid: i32) -> Option { + // SAFETY: getsid reads no caller memory; it returns -1 on error. + let sid = unsafe { libc::getsid(pid) }; + (sid > 0).then_some(sid) + } } #[cfg(target_os = "linux")] @@ -181,6 +197,17 @@ mod platform { zombie: state == "Z" || state == "X", }) } + + pub(super) fn session(pid: i32) -> Option { + parse_session(&std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?) + } + + /// The session field of `/proc//stat`. The kernel writes 0 when the + /// session is not visible in the reader's PID namespace. + pub(super) fn parse_session(stat: &str) -> Option { + let rest = &stat[stat.rfind(')')? + 1..]; + rest.split_whitespace().nth(3)?.parse().ok() + } } #[cfg(not(any(target_os = "linux", target_os = "macos")))] @@ -194,6 +221,10 @@ mod platform { pub(super) fn read(_pid: i32) -> Read { Read::Unreadable } + + pub(super) fn session(_pid: i32) -> Option { + None + } } #[cfg(test)] @@ -239,5 +270,31 @@ mod tests { (parsed.ppid, parsed.pgid, parsed.started, parsed.zombie), (1, 4200, 987654, false) ); + assert_eq!(platform::parse_session(stat), Some(4200)); + } + + #[cfg(target_os = "linux")] + #[test] + fn reports_no_session_for_a_kernel_thread() { + // A kernel thread, or a process whose session leader is outside this + // PID namespace, reports session 0. rustix's getsid asserts a nonzero + // session in debug builds, which panicked runnerd's lineage sampler. + let stat = "2 (kthreadd) S 0 0 0 0 -1 2129984 0 0 0 0 0 0 0 0 20 0 1 0 9 0 0"; + assert_eq!(platform::parse_session(stat), Some(0)); + let kthreadd = std::fs::read_to_string("/proc/2/stat") + .ok() + .filter(|stat| stat.contains("(kthreadd)")); + if kthreadd.is_some() { + assert_eq!(session(2), None); + } + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn reads_this_process_session() { + let me = i32::try_from(std::process::id()).unwrap(); + assert!(session(me).is_some_and(|sid| sid > 0)); + assert_eq!(session(0), None); + assert_eq!(session(-1), None); } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/process_lineage.rs b/packages/paperclip-runner/runner/crates/runner-core/src/process_lineage.rs index 04893cfb35a..db9bfa68aea 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/process_lineage.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/process_lineage.rs @@ -59,10 +59,10 @@ impl ProcessTable for LiveProcessTable { } fn session_of(&self, pid: i32) -> Option { - let pid = rustix::process::Pid::from_raw(pid)?; - rustix::process::getsid(Some(pid)) - .ok() - .map(|sid| sid.as_raw_nonzero().get()) + // Not rustix's getsid: it asserts a nonzero session, and a kernel + // thread or a process whose session leader is outside this PID + // namespace reports 0. That assertion killed the sampler thread. + paperclip_process_table::session(pid) } } diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 5a577b4ab17..c08d42ab0c9 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -20548,6 +20548,22 @@ it("saves a runner-marked model rejection from a provider terminal as a model ch expect(nativeSessionFailureSourceCode(new NativeProviderTerminalFailure("provider_turn_failed", false, "openrouter/unavailable-model is not a valid model ID"))).toBe("native_provider_terminal_failed"); }); +it("records a runner process exit that closed the provider event stream as runner loss", () => { + // Shape the Codex/OpenCode drivers write when runnerd exits mid-turn. + const lost = new NativeProviderTerminalFailure("notification_transport_failed", false, + "Notification transport failed closed: native_runner_process_exited: runnerd exited unexpectedly with code 137"); + const code = nativeSessionFailureSourceCode(lost); + expect(code).toBe("native_runner_process_exited"); + expect(nativeSessionFailureDisposition(1, new Date(), code)).toMatchObject({ phase: "retryable_failure", failureCode: "native_session_interrupted" }); + expect(nativeSessionFailureDisposition(3, new Date(), code)).toEqual({ phase: "terminal_failure", failureCode: "native_session_retry_exhausted", nextAttemptAt: null }); + // Other closed-stream causes, and the runner code only inside provider text, stay provider failures. + expect(nativeSessionFailureSourceCode(new NativeProviderTerminalFailure("notification_transport_failed", false, + "Notification transport failed closed: provider stream ended"))).toBe("native_provider_terminal_failed"); + expect(nativeSessionFailureSourceCode(new NativeProviderTerminalFailure("provider_turn_failed", false, + "Notification transport failed closed: native_runner_process_exited: runnerd exited unexpectedly"))).toBe("native_provider_terminal_failed"); + expect(nativeSessionFailureSourceCode(new NativeProviderTerminalFailure("notification_transport_failed", false, + "Notification transport failed closed: tool said native_runner_process_exited: oops"))).toBe("native_provider_terminal_failed"); +}); it("keeps an ACPX missing runtime package failure actionable and stops automatic retries", () => { const failure = new Error('PRP command session.open failed: {"result":{"code":"command_execution_failed","message":"failed to start ACPX provider: ACPX sidecar command session.open was rejected (retryable=false, classification=runtime_package_missing)"},"status":"failed"}'); const code = nativeSessionFailureSourceCode(failure); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 9dfa84c231a..223f2b69a79 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -8078,6 +8078,12 @@ export function nativeSessionFailureSourceCode( // terminal message; it needs a model choice, not a generic provider failure. if (!error.recoverable && /^native_provider_model_rejected:/.test(error.message)) return "native_provider_model_rejected"; + // The event stream closed because the runner process itself exited. The + // runner transport names that cause first in the closed-stream message; it + // is runner loss, not a provider verdict on its session. + if (error.providerCode === "notification_transport_failed" + && /^Notification transport failed closed: native_runner_process_exited: /.test(error.message)) + return "native_runner_process_exited"; return "native_provider_terminal_failed"; } if (error instanceof NativeSessionProtocolIntegrityError)