web_ui: scope /cancel and /logout's SIGINT to the harness's own claude child
This commit is contained in:
parent
29a11476a4
commit
3617578341
3 changed files with 79 additions and 24 deletions
|
|
@ -9,7 +9,7 @@ use axum::{
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use super::{AppState, error_response};
|
use super::{AppState, SigintOutcome, error_response};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub(super) struct SendForm {
|
pub(super) struct SendForm {
|
||||||
|
|
@ -54,26 +54,19 @@ pub(super) async fn post_send(
|
||||||
pub(super) async fn post_cancel_turn(State(state): State<AppState>) -> Response {
|
pub(super) async fn post_cancel_turn(State(state): State<AppState>) -> Response {
|
||||||
let out = super::sigint_claude().await;
|
let out = super::sigint_claude().await;
|
||||||
let note = match out {
|
let note = match out {
|
||||||
Ok(o) if o.status.success() => {
|
SigintOutcome::Signalled => {
|
||||||
// A process actually got signalled — the *next* turn's wake
|
// A process actually got signalled — the *next* turn's wake
|
||||||
// prompt should tell the agent it was cut off mid-work. Only
|
// prompt should tell the agent it was cut off mid-work. Only
|
||||||
// set on an actual signal: a `pkill` exit 1 ("no process to
|
// set on an actual signal: `NoProcess` means /cancel raced an
|
||||||
// interrupt") means /cancel raced an already-finished turn, so
|
// already-finished turn, so there's nothing to flag as
|
||||||
// there's nothing to flag as interrupted.
|
// interrupted.
|
||||||
state
|
state
|
||||||
.interrupted
|
.interrupted
|
||||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
"operator: /cancel — sent SIGINT to claude".to_owned()
|
"operator: /cancel — sent SIGINT to claude".to_owned()
|
||||||
}
|
}
|
||||||
Ok(o) if o.status.code() == Some(1) => {
|
SigintOutcome::NoProcess => "operator: /cancel — no claude process to interrupt".to_owned(),
|
||||||
"operator: /cancel — no claude process to interrupt".to_owned()
|
SigintOutcome::Failed(e) => format!("operator: /cancel — kill failed: {e}"),
|
||||||
}
|
|
||||||
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}"),
|
|
||||||
};
|
};
|
||||||
state
|
state
|
||||||
.bus
|
.bus
|
||||||
|
|
|
||||||
|
|
@ -273,15 +273,76 @@ fn read_gui_vnc_port() -> Option<u16> {
|
||||||
std::env::var("HIVE_GUI_VNC_PORT").ok()?.parse().ok()
|
std::env::var("HIVE_GUI_VNC_PORT").ok()?.parse().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SIGINT any running `claude` process in this container (best-effort). Shared
|
/// Outcome of [`sigint_claude`] — shared by `/api/cancel` and `/api/logout`
|
||||||
/// by `/api/cancel` and `/api/logout`. Returns the `pkill` `Output` so callers
|
/// so both can render the same three cases.
|
||||||
/// can inspect the exit status (0 = signalled, 1 = no process matched) or
|
enum SigintOutcome {
|
||||||
/// ignore it.
|
/// The tracked pid was signalled.
|
||||||
async fn sigint_claude() -> std::io::Result<std::process::Output> {
|
Signalled,
|
||||||
tokio::process::Command::new("pkill")
|
/// No claude child is currently tracked (turn not in flight, or it
|
||||||
.args(["-INT", "claude"])
|
/// finished/exited between the check and the signal — same "nothing to
|
||||||
.output()
|
/// 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: <our own pid>`
|
||||||
|
/// 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/<pid>/status` read fails, ESRCH) is just skipped.
|
||||||
|
fn find_claude_child() -> Option<u32> {
|
||||||
|
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::<u32>() 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::<u32>().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
|
.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 {
|
fn error_response(status: StatusCode, message: &str) -> Response {
|
||||||
|
|
|
||||||
|
|
@ -237,8 +237,9 @@
|
||||||
++ (with pkgs; [
|
++ (with pkgs; [
|
||||||
bashInteractive
|
bashInteractive
|
||||||
coreutils-full
|
coreutils-full
|
||||||
# procps for pkill — used by the web UI's /api/cancel to SIGINT the
|
# procps for kill — used by the web UI's /api/cancel and /api/logout
|
||||||
# in-flight claude turn.
|
# to SIGINT the harness-spawned claude child found via a /proc scan
|
||||||
|
# (see hive-agent::web_ui::find_claude_child).
|
||||||
procps
|
procps
|
||||||
# jq: JSON processing in shell — useful for parsing API responses,
|
# jq: JSON processing in shell — useful for parsing API responses,
|
||||||
# forge REST calls, sqlite output, etc.
|
# forge REST calls, sqlite output, etc.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue