feat(web_ui): defer operator /compact to turn end, run when idle too
This commit is contained in:
parent
80d819e444
commit
faa7f982af
5 changed files with 77 additions and 79 deletions
|
|
@ -454,13 +454,10 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
|
||||||
login_state.clone(),
|
login_state.clone(),
|
||||||
bus.clone(),
|
bus.clone(),
|
||||||
socket.to_path_buf(),
|
socket.to_path_buf(),
|
||||||
files.clone(),
|
|
||||||
turn_lock.clone(),
|
|
||||||
);
|
);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let (label, port, login_state, bus, socket, files, turn_lock) = web_ui_args;
|
let (label, port, login_state, bus, socket) = web_ui_args;
|
||||||
if let Err(e) = web_ui::serve(label, port, login_state, bus, socket, files, turn_lock).await
|
if let Err(e) = web_ui::serve(label, port, login_state, bus, socket).await {
|
||||||
{
|
|
||||||
tracing::error!(error = %e, "web_ui::serve exited with error");
|
tracing::error!(error = %e, "web_ui::serve exited with error");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -517,7 +514,16 @@ async fn serve_loop<S: Surface>(
|
||||||
None => match S::recv_next(socket).await {
|
None => match S::recv_next(socket).await {
|
||||||
RecvOutcome::Message(first) => first,
|
RecvOutcome::Message(first) => first,
|
||||||
RecvOutcome::Empty => {
|
RecvOutcome::Empty => {
|
||||||
tokio::time::sleep(interval).await;
|
// Idle: no message this poll. Service a queued operator
|
||||||
|
// `/compact` here so it runs even when no turn is driving
|
||||||
|
// (the in-flight case is handled at the end of drive_turn).
|
||||||
|
let compacted = {
|
||||||
|
let _guard = turn_lock.lock().await;
|
||||||
|
turn::run_pending_compact(files, &bus).await
|
||||||
|
};
|
||||||
|
if !compacted {
|
||||||
|
tokio::time::sleep(interval).await;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
RecvOutcome::TransportError => {
|
RecvOutcome::TransportError => {
|
||||||
|
|
|
||||||
|
|
@ -688,6 +688,10 @@ pub struct Bus {
|
||||||
/// serialized by the serve loop, so the turn boundary is the only point
|
/// serialized by the serve loop, so the turn boundary is the only point
|
||||||
/// where no session file is open.
|
/// where no session file is open.
|
||||||
session_reset_pending: Arc<AtomicBool>,
|
session_reset_pending: Arc<AtomicBool>,
|
||||||
|
/// One-shot: run `/compact` after the next turn ends. Consumed at the end
|
||||||
|
/// of the current/next turn by `turn::drive_turn`. Deferring to the turn
|
||||||
|
/// boundary keeps compaction from racing a live claude process mid-turn.
|
||||||
|
compact_pending: Arc<AtomicBool>,
|
||||||
/// Current fresh-claude-session id (FK to `sessions.id`). Set by the
|
/// Current fresh-claude-session id (FK to `sessions.id`). Set by the
|
||||||
/// bin loop after minting a session row on a fresh start; stamped onto
|
/// bin loop after minting a session row on a fresh start; stamped onto
|
||||||
/// every `turn_stats` row until the next fresh session. `None` before
|
/// every `turn_stats` row until the next fresh session. `None` before
|
||||||
|
|
@ -777,6 +781,7 @@ impl Bus {
|
||||||
last_cost_usage: Arc::new(Mutex::new(None)),
|
last_cost_usage: Arc::new(Mutex::new(None)),
|
||||||
rate_limited: Arc::new(AtomicBool::new(was_rate_limited)),
|
rate_limited: Arc::new(AtomicBool::new(was_rate_limited)),
|
||||||
session_reset_pending: Arc::new(AtomicBool::new(false)),
|
session_reset_pending: Arc::new(AtomicBool::new(false)),
|
||||||
|
compact_pending: Arc::new(AtomicBool::new(false)),
|
||||||
session_id: Arc::new(Mutex::new(None)),
|
session_id: Arc::new(Mutex::new(None)),
|
||||||
fresh_session: Arc::new(AtomicBool::new(false)),
|
fresh_session: Arc::new(AtomicBool::new(false)),
|
||||||
tool_calls: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
tool_calls: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
||||||
|
|
@ -816,6 +821,19 @@ impl Bus {
|
||||||
self.session_reset_pending.swap(false, Ordering::SeqCst)
|
self.session_reset_pending.swap(false, Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request a compaction after the next turn ends (deferred to the turn
|
||||||
|
/// boundary). Idempotent.
|
||||||
|
pub fn request_compact(&self) {
|
||||||
|
self.compact_pending.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take + clear the compact one-shot. Returns true iff `drive_turn` should
|
||||||
|
/// compact at the end of this turn.
|
||||||
|
#[must_use]
|
||||||
|
pub fn take_compact(&self) -> bool {
|
||||||
|
self.compact_pending.swap(false, Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
/// Mark that the current turn started a fresh claude session.
|
/// Mark that the current turn started a fresh claude session.
|
||||||
/// `run_claude` calls this when it creates a new titled session.
|
/// `run_claude` calls this when it creates a new titled session.
|
||||||
pub fn mark_fresh_session(&self) {
|
pub fn mark_fresh_session(&self) {
|
||||||
|
|
|
||||||
|
|
@ -2330,8 +2330,10 @@ pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> Str
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod recv_hint_tests {
|
mod tests {
|
||||||
use super::{IDLE_WAIT_HINT, SocketReply, format_recv};
|
use super::{IDLE_WAIT_HINT, SocketReply, format_recv};
|
||||||
|
use super::{SERVER_NAME, allowed_mcp_tools};
|
||||||
|
use hive_sh4re::ToolGroup;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_recv_after_wait_appends_idle_hint() {
|
fn empty_recv_after_wait_appends_idle_hint() {
|
||||||
|
|
@ -2345,12 +2347,6 @@ mod recv_hint_tests {
|
||||||
let out = format_recv(Ok(SocketReply::Messages(vec![])), false);
|
let out = format_recv(Ok(SocketReply::Messages(vec![])), false);
|
||||||
assert_eq!(out, "(empty)");
|
assert_eq!(out, "(empty)");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod allowed_tools_tests {
|
|
||||||
use super::{SERVER_NAME, allowed_mcp_tools};
|
|
||||||
use hive_sh4re::ToolGroup;
|
|
||||||
|
|
||||||
fn qualified(tool: &str) -> String {
|
fn qualified(tool: &str) -> String {
|
||||||
format!("mcp__{SERVER_NAME}__{tool}")
|
format!("mcp__{SERVER_NAME}__{tool}")
|
||||||
|
|
|
||||||
|
|
@ -150,8 +150,9 @@ pub enum TurnOutcome {
|
||||||
/// as `result_kind = "compacted"` in turn stats so the stats page can
|
/// as `result_kind = "compacted"` in turn stats so the stats page can
|
||||||
/// distinguish normal turns from turns that triggered a compaction.
|
/// distinguish normal turns from turns that triggered a compaction.
|
||||||
Compacted,
|
Compacted,
|
||||||
/// claude saw "Prompt is too long" — the session needs compacting.
|
/// claude saw "Prompt is too long" and even a reactive compact + retry
|
||||||
/// Run `compact_session()` then retry the same wake-up prompt.
|
/// (inside [`InfiniteSession::run`]) couldn't bring it back under the
|
||||||
|
/// window. Rare; the serve loop treats it like `Ok` (acks the turn).
|
||||||
PromptTooLong,
|
PromptTooLong,
|
||||||
/// The Anthropic API refused the request due to a rate limit, per-account
|
/// The Anthropic API refused the request due to a rate limit, per-account
|
||||||
/// usage cap, or exhausted credit balance. The serve loop should park for
|
/// usage cap, or exhausted credit balance. The serve loop should park for
|
||||||
|
|
@ -282,7 +283,7 @@ pub async fn drive_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutco
|
||||||
});
|
});
|
||||||
result = session.run(&config, prompt, &sink).await;
|
result = session.run(&config, prompt, &sink).await;
|
||||||
}
|
}
|
||||||
match result {
|
let outcome = match result {
|
||||||
Ok(progress) => {
|
Ok(progress) => {
|
||||||
if progress.created {
|
if progress.created {
|
||||||
// Fresh session minted this turn → flag it so the bin loop
|
// Fresh session minted this turn → flag it so the bin loop
|
||||||
|
|
@ -299,7 +300,19 @@ pub async fn drive_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutco
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => error_to_turn(e),
|
Err(e) => error_to_turn(e),
|
||||||
|
};
|
||||||
|
// Operator `/compact` (`POST /api/compact`) deferred to the turn boundary:
|
||||||
|
// run it now that the turn is done, so it works mid-turn rather than only
|
||||||
|
// when the agent is idle. Only on a healthy turn — no point spawning a
|
||||||
|
// compaction after a rate-limited / auth-failed / crashed one.
|
||||||
|
if bus.take_compact() && matches!(outcome, TurnOutcome::Ok | TurnOutcome::Compacted) {
|
||||||
|
bus.emit(LiveEvent::Note {
|
||||||
|
text: "operator: /compact — running at turn end".into(),
|
||||||
|
});
|
||||||
|
let _ = session.compact(&config, &sink).await;
|
||||||
|
return TurnOutcome::Compacted;
|
||||||
}
|
}
|
||||||
|
outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pre-turn auto-reset check. If context is large AND the prompt cache has
|
/// Pre-turn auto-reset check. If context is large AND the prompt cache has
|
||||||
|
|
@ -377,30 +390,32 @@ pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Operator-initiated `/compact` on the durable session (from the web UI).
|
/// Service a pending operator `/compact` (`Bus::request_compact`) while the
|
||||||
/// Resume-only via [`InfiniteSession::compact`]: a missing session is a
|
/// agent is idle — the serve loop calls this when a `recv` returns no message,
|
||||||
/// harmless no-op (never mints an empty session just to compact it). Surfaces
|
/// so a queued `/compact` runs even when no turn is driving. (The in-flight
|
||||||
/// the result as a Note and the usual `TurnOutcome`.
|
/// case is handled at the end of [`drive_turn`].) Resume-only via
|
||||||
pub async fn compact_session(files: &TurnFiles, bus: &Bus) -> TurnOutcome {
|
/// [`InfiniteSession::compact`]: a missing session is a harmless no-op. Returns
|
||||||
|
/// `true` if a compaction ran.
|
||||||
|
pub async fn run_pending_compact(files: &TurnFiles, bus: &Bus) -> bool {
|
||||||
|
if !bus.take_compact() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
bus.emit(LiveEvent::Note {
|
bus.emit(LiveEvent::Note {
|
||||||
text: "running /compact on the session".into(),
|
text: "operator: /compact — running on idle session".into(),
|
||||||
});
|
});
|
||||||
|
bus.set_state(crate::events::TurnState::Compacting);
|
||||||
let config = claude_config(bus, files);
|
let config = claude_config(bus, files);
|
||||||
let sink = BusSink::new(bus);
|
let sink = BusSink::new(bus);
|
||||||
match infinite_session(bus).compact(&config, &sink).await {
|
match infinite_session(bus).compact(&config, &sink).await {
|
||||||
Ok(()) => {
|
Ok(()) => bus.emit(LiveEvent::Note {
|
||||||
bus.emit(LiveEvent::Note {
|
text: "/compact done".into(),
|
||||||
text: "/compact done".into(),
|
}),
|
||||||
});
|
Err(e) => bus.emit(LiveEvent::Note {
|
||||||
TurnOutcome::Compacted
|
text: format!("/compact failed: {e}"),
|
||||||
}
|
}),
|
||||||
Err(e) => {
|
|
||||||
bus.emit(LiveEvent::Note {
|
|
||||||
text: format!("/compact failed: {e}"),
|
|
||||||
});
|
|
||||||
error_to_turn(e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
bus.set_state(crate::events::TurnState::Idle);
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The constant session title for this agent. `HIVE_SESSION_TITLE` overrides
|
/// The constant session title for this agent. `HIVE_SESSION_TITLE` overrides
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ use crate::client;
|
||||||
use crate::events::Bus;
|
use crate::events::Bus;
|
||||||
use crate::login::LoginState;
|
use crate::login::LoginState;
|
||||||
use crate::login_session::{LoginSession, drop_if_finished};
|
use crate::login_session::{LoginSession, drop_if_finished};
|
||||||
use crate::turn::TurnFiles;
|
|
||||||
|
|
||||||
/// Deadline for broker-backed fetches on web-UI request paths. The
|
/// Deadline for broker-backed fetches on web-UI request paths. The
|
||||||
/// page's critical fields (status, turn state, usage) are all
|
/// page's critical fields (status, turn state, usage) are all
|
||||||
|
|
@ -58,13 +57,6 @@ struct AppState {
|
||||||
session: Arc<Mutex<Option<Arc<LoginSession>>>>,
|
session: Arc<Mutex<Option<Arc<LoginSession>>>>,
|
||||||
bus: Bus,
|
bus: Bus,
|
||||||
socket: PathBuf,
|
socket: PathBuf,
|
||||||
/// Same `TurnFiles` the harness's turn loop uses. Shared so
|
|
||||||
/// `/api/compact` re-uses the exact MCP config / system prompt
|
|
||||||
/// claude saw on the last regular turn — keeps the session shape
|
|
||||||
/// identical across compact + normal turns.
|
|
||||||
files: TurnFiles,
|
|
||||||
/// Prevents `/api/compact` from racing with an in-flight normal turn.
|
|
||||||
turn_lock: TurnLock,
|
|
||||||
/// VNC port from the `HIVE_GUI_VNC_PORT` env var at startup.
|
/// VNC port from the `HIVE_GUI_VNC_PORT` env var at startup.
|
||||||
/// `None` when unset (gui not enabled for this agent).
|
/// `None` when unset (gui not enabled for this agent).
|
||||||
gui_vnc_port: Option<u16>,
|
gui_vnc_port: Option<u16>,
|
||||||
|
|
@ -88,8 +80,6 @@ pub async fn serve(
|
||||||
login: LoginStateCell,
|
login: LoginStateCell,
|
||||||
bus: Bus,
|
bus: Bus,
|
||||||
socket: PathBuf,
|
socket: PathBuf,
|
||||||
files: TurnFiles,
|
|
||||||
turn_lock: TurnLock,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let gui_vnc_port = read_gui_vnc_port();
|
let gui_vnc_port = read_gui_vnc_port();
|
||||||
let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
|
let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
|
||||||
|
|
@ -111,8 +101,6 @@ pub async fn serve(
|
||||||
session: Arc::new(Mutex::new(None)),
|
session: Arc::new(Mutex::new(None)),
|
||||||
bus,
|
bus,
|
||||||
socket,
|
socket,
|
||||||
files,
|
|
||||||
turn_lock,
|
|
||||||
gui_vnc_port,
|
gui_vnc_port,
|
||||||
};
|
};
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
|
|
@ -1042,12 +1030,6 @@ async fn post_login_cancel(State(state): State<AppState>) -> Response {
|
||||||
(axum::http::StatusCode::OK, "ok").into_response()
|
(axum::http::StatusCode::OK, "ok").into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Operator-initiated session compaction. Spawns `turn::compact_session`
|
|
||||||
/// in the background — the HTTP handler returns immediately so the
|
|
||||||
/// async-form spinner can clear. Output (claude's compaction stream,
|
|
||||||
/// the "/compact done" note) lands in the live event panel like any
|
|
||||||
/// other turn. If a regular turn is in flight, claude's own session
|
|
||||||
/// lock will reject this one and we surface the error as a Note.
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct ModelForm {
|
struct ModelForm {
|
||||||
model: String,
|
model: String,
|
||||||
|
|
@ -1102,34 +1084,15 @@ async fn post_set_effort(State(state): State<AppState>, Form(form): Form<EffortF
|
||||||
(axum::http::StatusCode::OK, "ok").into_response()
|
(axum::http::StatusCode::OK, "ok").into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Operator-initiated `/compact`. Deferred: sets the `compact_pending` flag
|
||||||
|
/// that `turn::drive_turn` consumes at the end of the current/next turn, so it
|
||||||
|
/// works while a turn is in flight (a mid-turn compaction would race the live
|
||||||
|
/// claude process) rather than only when the agent is idle. Returns 200
|
||||||
|
/// immediately; the compaction stream lands in the live panel when it runs.
|
||||||
async fn post_compact(State(state): State<AppState>) -> Response {
|
async fn post_compact(State(state): State<AppState>) -> Response {
|
||||||
// Clone the Arc before locking so the guard's lifetime is tied to the
|
state.bus.request_compact();
|
||||||
// clone (which we can move into the spawn) rather than to `state`.
|
state.bus.emit(crate::events::LiveEvent::Note {
|
||||||
let lock = state.turn_lock.clone();
|
text: "operator: /compact queued — runs at the end of the current turn".into(),
|
||||||
// Reject immediately if a normal turn is in flight — concurrent access
|
|
||||||
// to the claude session is unsafe and produces garbled output.
|
|
||||||
let Ok(guard) = lock.try_lock_owned() else {
|
|
||||||
return error_response(
|
|
||||||
StatusCode::CONFLICT,
|
|
||||||
"turn in flight — wait for it to finish before compacting",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
let bus = state.bus.clone();
|
|
||||||
let files = state.files.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let _guard = guard; // keep lock alive for the duration of compaction
|
|
||||||
bus.emit(crate::events::LiveEvent::Note {
|
|
||||||
text: "operator: /compact — running on persistent session".into(),
|
|
||||||
});
|
|
||||||
bus.set_state(crate::events::TurnState::Compacting);
|
|
||||||
let outcome = crate::turn::compact_session(&files, &bus).await;
|
|
||||||
bus.set_state(crate::events::TurnState::Idle);
|
|
||||||
// Best-effort manual /compact from the operator: compact_session
|
|
||||||
// already emits a Note per outcome, so we don't need to re-emit
|
|
||||||
// here — just record any underlying error to the harness log.
|
|
||||||
if let crate::turn::TurnOutcome::Failed(e) = outcome {
|
|
||||||
tracing::warn!(error = %format!("{e:#}"), "operator /compact failed");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
(axum::http::StatusCode::OK, "ok").into_response()
|
(axum::http::StatusCode::OK, "ok").into_response()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue