hive-agent: surface an interrupted-turn banner in the wake prompt after /cancel
This commit is contained in:
parent
9f5ce4d941
commit
a11f945532
5 changed files with 72 additions and 6 deletions
|
|
@ -498,6 +498,11 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
|
|||
tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "harness boot");
|
||||
let login_state = Arc::new(Mutex::new(initial));
|
||||
let bus = Bus::new();
|
||||
// Set by the web UI's `/api/cancel` on a successful SIGINT, read-and-
|
||||
// cleared by `handle_turn` before building the next wake prompt — see
|
||||
// `hive_sh4re::INTERRUPTED_HINT`. Shared between the web server task and
|
||||
// the serve loop the same way `bus`/`todo_wake` are.
|
||||
let interrupted = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let stats = TurnStats::open_default();
|
||||
if let Some(s) = &stats {
|
||||
let (ctx, cost) = s.last_usage();
|
||||
|
|
@ -535,10 +540,11 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
|
|||
login_state.clone(),
|
||||
bus.clone(),
|
||||
socket.to_path_buf(),
|
||||
interrupted.clone(),
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
let (label, port, login_state, bus, socket) = web_ui_args;
|
||||
if let Err(e) = web_ui::serve(label, port, login_state, bus, socket).await {
|
||||
let (label, port, login_state, bus, socket, interrupted) = web_ui_args;
|
||||
if let Err(e) = web_ui::serve(label, port, login_state, bus, socket, interrupted).await {
|
||||
tracing::error!(error = %e, "web_ui::serve exited with error");
|
||||
}
|
||||
});
|
||||
|
|
@ -595,6 +601,7 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
|
|||
todo_wake,
|
||||
todos_store,
|
||||
reminder_rx,
|
||||
interrupted,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
@ -619,6 +626,7 @@ async fn serve_loop<S: Surface>(
|
|||
todo_wake: Arc<tokio::sync::Notify>,
|
||||
todos_store: Option<Arc<todos::Todos>>,
|
||||
mut reminder_rx: tokio::sync::mpsc::UnboundedReceiver<hive_sh4re::DeliveredMessage>,
|
||||
interrupted: Arc<std::sync::atomic::AtomicBool>,
|
||||
) -> Result<()> {
|
||||
tracing::info!(socket = %socket.display(), "harness serve");
|
||||
S::requeue_inflight(socket).await;
|
||||
|
|
@ -725,13 +733,23 @@ async fn serve_loop<S: Surface>(
|
|||
files,
|
||||
&session,
|
||||
graceful_stop_message(),
|
||||
&interrupted,
|
||||
)
|
||||
.await;
|
||||
S::graceful_stop_complete(socket).await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let ctrl = handle_turn::<S>(socket, &bus, stats.as_ref(), files, &session, next).await;
|
||||
let ctrl = handle_turn::<S>(
|
||||
socket,
|
||||
&bus,
|
||||
stats.as_ref(),
|
||||
files,
|
||||
&session,
|
||||
next,
|
||||
&interrupted,
|
||||
)
|
||||
.await;
|
||||
if ctrl.auth_failed {
|
||||
*login_state.lock().unwrap() = LoginState::NeedsLogin;
|
||||
login::wait_for_login(
|
||||
|
|
@ -756,6 +774,7 @@ async fn handle_turn<S: Surface>(
|
|||
files: &turn::TurnFiles,
|
||||
session: &turn::AgentSession,
|
||||
first: hive_sh4re::DeliveredMessage,
|
||||
interrupted: &std::sync::atomic::AtomicBool,
|
||||
) -> TurnControl {
|
||||
let from = first.from;
|
||||
let body = first.body;
|
||||
|
|
@ -773,7 +792,18 @@ async fn handle_turn<S: Surface>(
|
|||
let started_at = chrono::Utc::now().timestamp();
|
||||
let started_instant = std::time::Instant::now();
|
||||
let model_at_start = bus.model();
|
||||
let prompt = serve_common::format_wake_prompt(msg_id, &from, &body, unread, redelivered);
|
||||
// Read-and-clear: this wake prompt is the one turn that gets to carry
|
||||
// the "you were interrupted" banner, then the flag resets so a later
|
||||
// ordinary turn doesn't repeat a stale notice.
|
||||
let was_interrupted = interrupted.swap(false, std::sync::atomic::Ordering::Relaxed);
|
||||
let prompt = serve_common::format_wake_prompt(
|
||||
msg_id,
|
||||
&from,
|
||||
&body,
|
||||
unread,
|
||||
redelivered,
|
||||
was_interrupted,
|
||||
);
|
||||
let outcome = turn::drive_turn(&prompt, files, bus, session).await;
|
||||
turn::emit_turn_end(bus, &outcome);
|
||||
bus.set_state(TurnState::Idle);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@ use crate::turn_stats::TurnStatRow;
|
|||
/// system prompt; this is just the wake signal body. `id` is the broker row
|
||||
/// id, rendered as a `[msg #<id>]` marker so the agent can reference it in
|
||||
/// `ack_until`. `unread` is the inbox depth after this message was popped.
|
||||
/// `redelivered` prepends a "may already be handled" banner.
|
||||
/// `redelivered` prepends a "may already be handled" banner; `interrupted`
|
||||
/// prepends a "previous turn was /cancel'd" banner instead (the two are
|
||||
/// mutually exclusive in practice — a redelivered message means the harness
|
||||
/// itself restarted, which also clears the in-memory interrupted flag — so
|
||||
/// `redelivered` takes priority if both were somehow set).
|
||||
#[must_use]
|
||||
pub fn format_wake_prompt(
|
||||
id: i64,
|
||||
|
|
@ -20,9 +24,12 @@ pub fn format_wake_prompt(
|
|||
body: &str,
|
||||
unread: u64,
|
||||
redelivered: bool,
|
||||
interrupted: bool,
|
||||
) -> String {
|
||||
let banner = if redelivered {
|
||||
hive_sh4re::REDELIVERY_HINT
|
||||
} else if interrupted {
|
||||
hive_sh4re::INTERRUPTED_HINT
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
|
|
|||
|
|
@ -54,7 +54,17 @@ pub(super) async fn post_send(
|
|||
pub(super) async fn post_cancel_turn(State(state): State<AppState>) -> Response {
|
||||
let out = super::sigint_claude().await;
|
||||
let note = match out {
|
||||
Ok(o) if o.status.success() => "operator: /cancel — sent SIGINT to claude".to_owned(),
|
||||
Ok(o) if o.status.success() => {
|
||||
// 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.
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ mod stream;
|
|||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
|
@ -57,6 +58,13 @@ struct AppState {
|
|||
/// VNC port from the `HIVE_GUI_VNC_PORT` env var at startup.
|
||||
/// `None` when unset (gui not enabled for this agent).
|
||||
gui_vnc_port: Option<u16>,
|
||||
/// Set by `post_cancel_turn` on a successful SIGINT; read-and-cleared
|
||||
/// by the serve loop's next `handle_turn` to prepend
|
||||
/// `hive_sh4re::INTERRUPTED_HINT` to that turn's wake prompt. Shared
|
||||
/// with the serve loop via the same `Arc` (see `serve_main`) — an
|
||||
/// in-memory flag, not a marker file, since a `/cancel` from a prior
|
||||
/// process lifetime isn't meaningful once the harness restarts.
|
||||
interrupted: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// Bind the per-container web listener and serve the SPA.
|
||||
|
|
@ -77,6 +85,7 @@ pub async fn serve(
|
|||
login: LoginStateCell,
|
||||
bus: Bus,
|
||||
socket: PathBuf,
|
||||
interrupted: Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
let gui_vnc_port = read_gui_vnc_port();
|
||||
let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
|
||||
|
|
@ -99,6 +108,7 @@ pub async fn serve(
|
|||
bus,
|
||||
socket,
|
||||
gui_vnc_port,
|
||||
interrupted,
|
||||
};
|
||||
let app: Router<AppState> = Router::new()
|
||||
.route("/api/state", get(state::api_state))
|
||||
|
|
|
|||
Loading…
Reference in a new issue