harness: flip into needs_login on 401 mid-turn (closes #419)

This commit is contained in:
damocles 2026-05-25 21:32:01 +02:00 committed by Mara
commit 799804e3d1
6 changed files with 164 additions and 22 deletions

View file

@ -99,6 +99,7 @@ async fn main() -> Result<()> {
&cli.socket,
Duration::from_millis(poll_ms),
login_state,
claude_dir,
bus,
stats,
&files,
@ -116,6 +117,7 @@ async fn main() -> Result<()> {
&cli.socket,
Duration::from_millis(poll_ms),
login_state,
claude_dir,
bus,
stats,
&files,
@ -153,7 +155,8 @@ async fn main() -> Result<()> {
async fn serve(
socket: &Path,
interval: Duration,
_login_state: Arc<Mutex<LoginState>>,
login_state: Arc<Mutex<LoginState>>,
claude_dir: std::path::PathBuf,
bus: Bus,
stats: Option<TurnStats>,
files: &turn::TurnFiles,
@ -178,7 +181,23 @@ async fn serve(
match recv {
Ok(AgentResponse::Messages { messages }) if !messages.is_empty() => {
let first = messages.into_iter().next().expect("checked non-empty");
handle_agent_turn(socket, &bus, stats.as_ref(), files, &turn_lock, label, first).await;
let auth_failed =
handle_agent_turn(socket, &bus, stats.as_ref(), files, &turn_lock, label, first)
.await;
if auth_failed {
// Park: flip LoginState + wait for the operator's
// re-auth to repopulate claude_dir. wait_for_login
// emits `online` on resume, which clears the
// needs_login sentinel.
*login_state.lock().unwrap() = LoginState::NeedsLogin;
turn::wait_for_login(
&claude_dir,
login_state.clone(),
&bus,
u64::try_from(interval.as_millis()).unwrap_or(2000),
)
.await;
}
}
Ok(AgentResponse::Messages { .. }) => {
// Idle: empty list = nothing pending. Brief sleep
@ -208,7 +227,9 @@ async fn serve(
}
}
/// Drive one turn for a received agent-inbox message.
/// Drive one turn for a received agent-inbox message. Returns `true`
/// when the turn ended with `AuthFailed` so the caller knows to park
/// in `wait_for_login`.
async fn handle_agent_turn(
socket: &Path,
bus: &Bus,
@ -217,7 +238,7 @@ async fn handle_agent_turn(
turn_lock: &TurnLock,
label: &str,
first: hive_sh4re::DeliveredMessage,
) {
) -> bool {
let from = first.from;
let body = first.body;
let redelivered = first.redelivered;
@ -251,6 +272,19 @@ async fn handle_agent_turn(
requeue_inflight(socket).await;
bus.emit_status("online");
}
// 401: flip into needs_login + requeue the message that triggered
// the turn so it survives the re-auth. The serve loop's outer
// login-state watcher parks until the operator's `/login` flow
// completes; once it does, the requeued message replays the turn
// (closes #419).
if matches!(outcome, turn::TurnOutcome::AuthFailed) {
bus.emit_status("needs_login_idle");
bus.emit(LiveEvent::Note {
text: "API 401 — waiting for re-login via web UI".into(),
});
tracing::warn!("auth-failed; parking until re-login");
requeue_inflight(socket).await;
}
// Real crash: PromptTooLong is absorbed by compaction inside drive_turn.
if let turn::TurnOutcome::Failed(e) = &outcome {
notify_manager_of_failure(socket, label, e).await;
@ -280,6 +314,7 @@ async fn handle_agent_turn(
// `request_next_turn` MCP tool: agent wrote a sentinel requesting
// an immediate self-continuation. Clear and inject synthetic wake.
check_and_inject_continue(socket, label).await;
matches!(outcome, turn::TurnOutcome::AuthFailed)
}
// Per-turn user prompt: the role/tools/etc. is in the system prompt

View file

@ -88,6 +88,8 @@ async fn main() -> Result<()> {
serve(
&cli.socket,
Duration::from_millis(poll_ms),
login_state,
claude_dir,
bus,
stats,
&files,
@ -96,10 +98,12 @@ async fn main() -> Result<()> {
.await
}
LoginState::NeedsLogin => {
turn::wait_for_login(&claude_dir, login_state, &bus, poll_ms).await;
turn::wait_for_login(&claude_dir, login_state.clone(), &bus, poll_ms).await;
serve(
&cli.socket,
Duration::from_millis(poll_ms),
login_state,
claude_dir,
bus,
stats,
&files,
@ -113,9 +117,12 @@ async fn main() -> Result<()> {
}
}
#[allow(clippy::too_many_arguments)]
async fn serve(
socket: &Path,
interval: Duration,
login_state: Arc<Mutex<LoginState>>,
claude_dir: std::path::PathBuf,
bus: Bus,
stats: Option<TurnStats>,
files: &turn::TurnFiles,
@ -144,7 +151,19 @@ async fn serve(
match recv {
Ok(ManagerResponse::Messages { messages }) if !messages.is_empty() => {
let first = messages.into_iter().next().expect("checked non-empty");
handle_manager_turn(socket, &bus, stats.as_ref(), files, &turn_lock, first).await;
let auth_failed =
handle_manager_turn(socket, &bus, stats.as_ref(), files, &turn_lock, first)
.await;
if auth_failed {
*login_state.lock().unwrap() = LoginState::NeedsLogin;
turn::wait_for_login(
&claude_dir,
login_state.clone(),
&bus,
u64::try_from(interval.as_millis()).unwrap_or(2000),
)
.await;
}
}
Ok(ManagerResponse::Messages { .. }) => {
// Idle: empty list = nothing pending. Brief sleep
@ -176,6 +195,8 @@ async fn serve(
/// Drive one turn for a received manager-inbox message. Called from the
/// serve loop for the non-empty-messages arm to keep that loop readable.
/// Returns `true` when the turn ended with `AuthFailed` so the caller
/// can park in `wait_for_login`.
async fn handle_manager_turn(
socket: &Path,
bus: &Bus,
@ -183,7 +204,7 @@ async fn handle_manager_turn(
files: &turn::TurnFiles,
turn_lock: &TurnLock,
first: hive_sh4re::DeliveredMessage,
) {
) -> bool {
let from = first.from;
let body = first.body;
let redelivered = first.redelivered;
@ -229,6 +250,14 @@ async fn handle_manager_turn(
requeue_inflight(socket).await;
bus.emit_status("online");
}
if matches!(outcome, turn::TurnOutcome::AuthFailed) {
bus.emit_status("needs_login_idle");
bus.emit(LiveEvent::Note {
text: "API 401 — waiting for re-login via web UI".into(),
});
tracing::warn!("auth-failed; parking until re-login");
requeue_inflight(socket).await;
}
if let Some(stats) = stats {
let ended_at = serve_common::now_unix();
let duration_ms =
@ -251,6 +280,7 @@ async fn handle_manager_turn(
if pending > 0 {
tracing::info!(%pending, "pending messages after turn; fetching next");
}
matches!(outcome, turn::TurnOutcome::AuthFailed)
}
/// Best-effort: tell the broker every message popped during the turn