From c708f698eef3b24fc7aeb566eb552f3b9de20a7c Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 4 Jul 2026 12:42:39 +0200 Subject: [PATCH] fix(#2175): return 409/400 not 500 for turn-in-flight + validation errors --- hive-ag3nt/src/web_ui.rs | 72 ++++++++++++++++++++++++++++++---------- 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 982d05b0..694d6dba 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -531,11 +531,29 @@ async fn api_loose_ends(State(state): State) -> Response { { Ok(Ok(hive_sh4re::Response::LooseEnds { loose_ends })) => loose_ends, Ok(Ok(hive_sh4re::Response::Err { message })) => { - return error_response(&format!("get_loose_ends: {message}")); + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("get_loose_ends: {message}"), + ); + } + Ok(Ok(other)) => { + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("unexpected response: {other:?}"), + ); + } + Ok(Err(e)) => { + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("transport: {e:#}"), + ); + } + Err(_) => { + return error_response( + StatusCode::CONFLICT, + "get_loose_ends: timed out — hive-c0re busy, retry", + ); } - Ok(Ok(other)) => return error_response(&format!("unexpected response: {other:?}")), - Ok(Err(e)) => return error_response(&format!("transport: {e:#}")), - Err(_) => return error_response("get_loose_ends: timed out — hive-c0re busy, retry"), }; axum::Json(serde_json::json!({ "loose_ends": loose_ends })).into_response() } @@ -877,7 +895,7 @@ struct SendForm { async fn post_send(State(state): State, Form(form): Form) -> Response { let body = form.body.trim().to_owned(); if body.is_empty() { - return error_response("send: `body` required"); + return error_response(StatusCode::BAD_REQUEST, "send: `body` required"); } let result = match tokio::time::timeout( SOCKET_FETCH_TIMEOUT, @@ -902,7 +920,10 @@ async fn post_send(State(state): State, Form(form): Form) -> // inbox row gets consumed by the time `TurnEnd` fires the // existing turn-end refresh. Ok(()) => (axum::http::StatusCode::OK, "ok").into_response(), - Err(e) => error_response(&format!("send failed: {e}")), + Err(e) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("send failed: {e}"), + ), } } @@ -984,7 +1005,10 @@ async fn post_login_start(State(state): State) -> Response { state.bus.emit_status("needs_login_in_progress"); (axum::http::StatusCode::OK, "ok").into_response() } - Err(e) => error_response(&format!("login start failed: {e:#}")), + Err(e) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("login start failed: {e:#}"), + ), } } @@ -996,10 +1020,13 @@ struct CodeForm { async fn post_login_code(State(state): State, Form(form): Form) -> Response { let session = state.session.lock().unwrap().clone(); let Some(session) = session else { - return error_response("no login session running"); + return error_response(StatusCode::CONFLICT, "no login session running"); }; if let Err(e) = session.submit_code(&form.code).await { - return error_response(&format!("submit code failed: {e:#}")); + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("submit code failed: {e:#}"), + ); } (axum::http::StatusCode::OK, "ok").into_response() } @@ -1035,7 +1062,7 @@ struct ModelForm { async fn post_set_model(State(state): State, Form(form): Form) -> Response { let name = form.model.trim(); if name.is_empty() { - return error_response("model: name required"); + return error_response(StatusCode::BAD_REQUEST, "model: name required"); } state.bus.set_model(name); state.bus.emit(crate::events::LiveEvent::Note { @@ -1059,10 +1086,13 @@ struct EffortForm { async fn post_set_effort(State(state): State, Form(form): Form) -> Response { let level = form.effort.trim(); if !crate::events::is_valid_effort(level) { - return error_response(&format!( - "effort: level must be one of {}", - crate::events::EFFORT_LEVELS.join(", ") - )); + return error_response( + StatusCode::BAD_REQUEST, + &format!( + "effort: level must be one of {}", + crate::events::EFFORT_LEVELS.join(", ") + ), + ); } state.bus.set_effort(level); state.bus.emit(crate::events::LiveEvent::Note { @@ -1079,7 +1109,10 @@ async fn post_compact(State(state): State) -> Response { // 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("turn in flight — wait for it to finish before compacting"); + 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(); @@ -1211,10 +1244,13 @@ async fn post_cancel_turn(State(state): State) -> Response { (axum::http::StatusCode::OK, "ok").into_response() } -fn error_response(message: &str) -> Response { +fn error_response(status: StatusCode, message: &str) -> Response { // Plain text — JS app surfaces in `alert()`, HTML wrapping would just - // be noise. - (StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response() + // be noise. Status is per-caller: 400 for bad input, 409 for a + // retryable state conflict (turn in flight / hive-c0re busy), 500 only + // for a genuine server/transport failure — the frontend shows the code + // in its alert, so a benign "busy, retry" must not read as a 500. + (status, message.to_owned()).into_response() } /// Read `HIVE_AVAILABLE_MODELS` (comma-separated short names injected by