hive-agent: dashboard Cancel and idle stalls for ACP agents
`/api/cancel` stopped a turn only by SIGINTing a child process whose argv0 is `claude`, so on an ACP agent it reported "no claude process to interrupt" and the turn ran on. The serve loop now stores the session's canceller on the `Bus` when the runtime has one, and `/api/cancel` uses it: the agent is sent `session/cancel`, and the next wake prompt carries the interrupted hint, as for a signalled claude turn. Without a canceller (claude), the SIGINT path is untouched. An ACP turn stopped by the idle watchdog (`HIVE_TURN_IDLE_SECS`) becomes `TurnError::AgentStall(note)`, handled like `ApiStall`: park for `HIVE_STALL_SLEEP_SECS`, requeue, and record `api_stall`. The TurnEnd note is the runtime's message (how long the agent was silent, whether it had to be killed, and that a silently retried provider error such as HTTP 429 looks like this), not the claude-specific `ApiStall` text. A cancel the agent ignores is a `Failed` turn. Refs #4391
This commit is contained in:
parent
bcbeb8ac9c
commit
91e47732a6
7 changed files with 115 additions and 9 deletions
|
|
@ -195,6 +195,12 @@ pub enum TurnError {
|
|||
/// The serve loop parks for `stall_sleep_secs()` and requeues, like the
|
||||
/// rate-limit path — NOT a crash.
|
||||
ApiStall,
|
||||
/// The ACP backend's idle watchdog stopped a turn that sent nothing for
|
||||
/// `turn_idle_secs()`: the agent was sent `session/cancel`, and killed if
|
||||
/// it ignored that. Carries what happened. An ACP agent retrying a failing
|
||||
/// provider (e.g. HTTP 429) without reporting it ends up here. Handled
|
||||
/// like [`Self::ApiStall`].
|
||||
AgentStall(String),
|
||||
/// A hard failure with no recovery — the serve loop escalates it to the
|
||||
/// operator (`send_to_operator`).
|
||||
Failed(anyhow::Error),
|
||||
|
|
@ -565,6 +571,13 @@ pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) {
|
|||
});
|
||||
tracing::warn!("turn killed: API stall (idle watchdog)");
|
||||
}
|
||||
Err(TurnError::AgentStall(note)) => {
|
||||
bus.emit(LiveEvent::TurnEnd {
|
||||
ok: false,
|
||||
note: Some(format!("{note} — parking + requeueing")),
|
||||
});
|
||||
tracing::warn!(note = %note, "ACP turn stopped by the idle watchdog");
|
||||
}
|
||||
Err(TurnError::Failed(e)) => {
|
||||
let note = format!("{e:#}");
|
||||
bus.emit(LiveEvent::TurnEnd {
|
||||
|
|
@ -694,10 +707,15 @@ fn error_to_turn(err: hive_claude::Error) -> TurnOutcome {
|
|||
}
|
||||
|
||||
/// [`error_to_turn`] for a runtime error: the claude backend's errors map as
|
||||
/// above, every other runtime's as `Failed`.
|
||||
/// above, the ACP idle watchdog's as `AgentStall`, everything else as
|
||||
/// `Failed`.
|
||||
fn runtime_error_to_turn(err: hive_runtime::Error) -> TurnOutcome {
|
||||
use hive_runtime::{AcpError, Error};
|
||||
match err {
|
||||
hive_runtime::Error::Claude(err) => error_to_turn(err),
|
||||
Error::Claude(err) => error_to_turn(err),
|
||||
Error::Acp(err @ (AcpError::IdleTimeout { .. } | AcpError::IdleKilled { .. })) => {
|
||||
Err(TurnError::AgentStall(err.to_string()))
|
||||
}
|
||||
other => Err(TurnError::Failed(other.into())),
|
||||
}
|
||||
}
|
||||
|
|
@ -809,7 +827,10 @@ fn archive_session(bus: &Bus, session: &AgentSession) {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{PermissionAsk, acp_permits, nonzero_or, parse_u64};
|
||||
use super::{
|
||||
PermissionAsk, TurnError, acp_permits, nonzero_or, parse_u64, runtime_error_to_turn,
|
||||
};
|
||||
use hive_runtime::{AcpError, Error};
|
||||
|
||||
#[test]
|
||||
fn an_absent_or_blank_value_is_not_a_number() {
|
||||
|
|
@ -909,4 +930,40 @@ mod tests {
|
|||
assert!(acp_permits(&ask("fetch"), true));
|
||||
assert!(!acp_permits(&ask("fetch"), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stalled_acp_turn_parks_and_requeues_with_what_happened() {
|
||||
for err in [
|
||||
AcpError::IdleTimeout { silent_secs: 600 },
|
||||
AcpError::IdleKilled {
|
||||
silent_secs: 600,
|
||||
grace_secs: 10,
|
||||
},
|
||||
] {
|
||||
let expected = err.to_string();
|
||||
match runtime_error_to_turn(Error::Acp(err)) {
|
||||
Err(TurnError::AgentStall(note)) => assert_eq!(note, expected),
|
||||
other => panic!("{expected}: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rate_limited_acp_turn_says_so_when_it_stalls() {
|
||||
let Err(TurnError::AgentStall(note)) =
|
||||
runtime_error_to_turn(Error::Acp(AcpError::IdleTimeout { silent_secs: 600 }))
|
||||
else {
|
||||
panic!("not a stall");
|
||||
};
|
||||
assert!(note.contains("600s"), "{note}");
|
||||
assert!(note.contains("HTTP 429"), "{note}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancel_the_agent_ignored_is_a_failure() {
|
||||
assert!(matches!(
|
||||
runtime_error_to_turn(Error::Acp(AcpError::CancelIgnored { grace_secs: 10 })),
|
||||
Err(TurnError::Failed(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue