diff --git a/hive-agent/src/web_ui/actions.rs b/hive-agent/src/web_ui/actions.rs index c9b2248e..56c7ea8d 100644 --- a/hive-agent/src/web_ui/actions.rs +++ b/hive-agent/src/web_ui/actions.rs @@ -9,7 +9,7 @@ use axum::{ }; use serde::Deserialize; -use super::{AppState, error_response}; +use super::{AppState, SigintOutcome, error_response}; #[derive(Deserialize)] pub(super) struct SendForm { @@ -54,26 +54,19 @@ pub(super) async fn post_send( pub(super) async fn post_cancel_turn(State(state): State) -> Response { let out = super::sigint_claude().await; let note = match out { - Ok(o) if o.status.success() => { + SigintOutcome::Signalled => { // A process actually got signalled — the *next* turn's wake // prompt should tell the agent it was cut off mid-work. Only - // set on an actual signal: a `pkill` exit 1 ("no process to - // interrupt") means /cancel raced an already-finished turn, so - // there's nothing to flag as interrupted. + // set on an actual signal: `NoProcess` means /cancel raced an + // already-finished turn, so there's nothing to flag as + // interrupted. state .interrupted .store(true, std::sync::atomic::Ordering::Relaxed); "operator: /cancel — sent SIGINT to claude".to_owned() } - Ok(o) if o.status.code() == Some(1) => { - "operator: /cancel — no claude process to interrupt".to_owned() - } - Ok(o) => format!( - "operator: /cancel — pkill exited {} stderr={}", - o.status, - String::from_utf8_lossy(&o.stderr).trim() - ), - Err(e) => format!("operator: /cancel — pkill failed: {e}"), + SigintOutcome::NoProcess => "operator: /cancel — no claude process to interrupt".to_owned(), + SigintOutcome::Failed(e) => format!("operator: /cancel — kill failed: {e}"), }; state .bus diff --git a/hive-agent/src/web_ui/mod.rs b/hive-agent/src/web_ui/mod.rs index 63d66fef..ebc3649e 100644 --- a/hive-agent/src/web_ui/mod.rs +++ b/hive-agent/src/web_ui/mod.rs @@ -273,15 +273,76 @@ fn read_gui_vnc_port() -> Option { std::env::var("HIVE_GUI_VNC_PORT").ok()?.parse().ok() } -/// SIGINT any running `claude` process in this container (best-effort). Shared -/// by `/api/cancel` and `/api/logout`. Returns the `pkill` `Output` so callers -/// can inspect the exit status (0 = signalled, 1 = no process matched) or -/// ignore it. -async fn sigint_claude() -> std::io::Result { - tokio::process::Command::new("pkill") - .args(["-INT", "claude"]) - .output() +/// Outcome of [`sigint_claude`] — shared by `/api/cancel` and `/api/logout` +/// so both can render the same three cases. +enum SigintOutcome { + /// The tracked pid was signalled. + Signalled, + /// No claude child is currently tracked (turn not in flight, or it + /// finished/exited between the check and the signal — same "nothing to + /// interrupt" outcome either way). + NoProcess, + /// The `kill` syscall itself failed for a reason other than "no such + /// process" (permission issue, etc). + Failed(std::io::Error), +} + +/// Find a `claude` process that is a direct OS child of this harness +/// process, if any. `hive-claude`'s driver spawns the turn's claude +/// directly (`Command::new(program).spawn()`, no shell in between), so the +/// harness is always the immediate parent of any claude turn it started — +/// scanning `/proc/*/status` for `Name: claude` + `PPid: ` +/// finds *that* specific process without needing the driver to surface its +/// pid through any extra plumbing. Distinguishes the harness's own tracked +/// turn from an unrelated `claude` someone is running interactively in the +/// same container (a manually shelled-in "choom" session) — that one's +/// parent is a login shell, not us. Best-effort: a process that exits +/// mid-scan (its `/proc//status` read fails, ESRCH) is just skipped. +fn find_claude_child() -> Option { + let own_pid = std::process::id(); + for entry in std::fs::read_dir("/proc").ok()?.flatten() { + let Ok(pid) = entry.file_name().to_string_lossy().parse::() else { + continue; // not a pid dir (self, cwd, net, ...) + }; + let Ok(status) = std::fs::read_to_string(entry.path().join("status")) else { + continue; + }; + let mut name = None; + let mut parent_pid = None; + for line in status.lines() { + if let Some(v) = line.strip_prefix("Name:") { + name = Some(v.trim()); + } else if let Some(v) = line.strip_prefix("PPid:") { + parent_pid = v.trim().parse::().ok(); + } + } + if name == Some("claude") && parent_pid == Some(own_pid) { + return Some(pid); + } + } + None +} + +/// SIGINT this container's harness-spawned claude child, if any +/// (best-effort). See [`find_claude_child`] for how it's identified — +/// **not** a name-based `pkill claude`, which would also hit an unrelated +/// `claude` process someone is running interactively in the container. +async fn sigint_claude() -> SigintOutcome { + let Some(pid) = find_claude_child() else { + return SigintOutcome::NoProcess; + }; + match tokio::process::Command::new("kill") + .args(["-INT", &pid.to_string()]) + .status() .await + { + Ok(status) if status.success() => SigintOutcome::Signalled, + // Non-zero from `kill` means "no such process" (ESRCH) — it already + // exited between the check above and the signal. Same as never + // having found it. + Ok(_) => SigintOutcome::NoProcess, + Err(e) => SigintOutcome::Failed(e), + } } fn error_response(status: StatusCode, message: &str) -> Response { diff --git a/nix/agent-modules/default.nix b/nix/agent-modules/default.nix index 5b282fb0..653fa20d 100644 --- a/nix/agent-modules/default.nix +++ b/nix/agent-modules/default.nix @@ -237,8 +237,9 @@ ++ (with pkgs; [ bashInteractive coreutils-full - # procps for pkill — used by the web UI's /api/cancel to SIGINT the - # in-flight claude turn. + # procps for kill — used by the web UI's /api/cancel and /api/logout + # to SIGINT the harness-spawned claude child found via a /proc scan + # (see hive-agent::web_ui::find_claude_child). procps # jq: JSON processing in shell — useful for parsing API responses, # forge REST calls, sqlite output, etc.