From a533257866272621a9fc66045f533ea218afc68f Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 11:18:43 +0300 Subject: [PATCH 1/8] refactor(acp): add PresenceOffline loop action for auth expiry Give the event loop a distinct signal when credentials are dead so it can stop advertising online without exiting the process. Signed-off-by: Taksh --- crates/buzz-acp/src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 403512a322..e3793419f0 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2723,10 +2723,13 @@ async fn tokio_main() -> Result<()> { Ok(()) } -#[derive(PartialEq)] +#[derive(PartialEq, Debug)] enum LoopAction { Continue, Exit, + /// Auth is permanently broken for this process — stop advertising online + /// presence so operators/sidebar stop treating the agent as reachable. + PresenceOffline, } fn event_mentions_agent(event: &nostr::Event, agent_pubkey_hex: &str) -> bool { From 19ef2dcf16cdf72128c0e6be307c43e7b0c24cda Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 11:18:43 +0300 Subject: [PATCH 2/8] fix(acp): classify Authentication required as a non-retryable auth error Headless Claude expiry reports -32000 with this message; dead-letter it with the existing Re-authenticate / 401 patterns instead of retrying forever. Signed-off-by: Taksh --- crates/buzz-acp/src/lib.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index e3793419f0..b4dd534982 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3020,15 +3020,19 @@ fn dispatch_pending( /// Specific to the auth-expiry flow; does not appear in unrelated errors. /// - `"API Error: 401"` — present in Claude/Codex HTTP-401 responses; 401 is /// the standard auth-failure status and does not arise from network blips. +/// - `"Authentication required"` — ACP `-32000` surface when the CLI reports +/// `loggedIn: false` with no remaining credentials (headless expiry). /// /// False positives (misclassifying a transient error as non-retryable) silently /// drop a user message, which is worse than a false negative (extra retries on -/// an auth error). Both patterns are therefore chosen for high precision. +/// an auth error). These patterns are therefore chosen for high precision. fn is_auth_error(error: &acp::AcpError) -> bool { let acp::AcpError::AgentError { message, .. } = error else { return false; }; - message.contains("Re-authenticate") || message.contains("API Error: 401") + message.contains("Re-authenticate") + || message.contains("API Error: 401") + || message.contains("Authentication required") } /// Spawn a task that posts a user-visible failure notice to the relay. From f8e768dcaf1b7145ed7a14a0afc1dff80dcf3e3a Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 11:18:59 +0300 Subject: [PATCH 3/8] fix(acp): return PresenceOffline when a turn hits auth expiry Surface credential death to the event loop after dead-lettering the batch and posting the re-auth notice. Signed-off-by: Taksh --- crates/buzz-acp/src/lib.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index b4dd534982..4fec380524 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3216,6 +3216,8 @@ fn handle_prompt_result( PromptOutcome::Cancelled => "cancelled", PromptOutcome::CancelDrainTimeout(_) => "cancel_drain_timeout", }; + let auth_expired = + matches!(&result.outcome, PromptOutcome::Error(e) if is_auth_error(e)); let agent_index = result.agent.index; // Capture the spawn-time configured model and our PID before the agent is // moved into match arms below. `desired_model` reflects the config/persona @@ -3423,6 +3425,13 @@ fn handle_prompt_result( } } } + if auth_expired { + tracing::error!( + agent = agent_index, + "agent authentication expired — taking presence offline until restart" + ); + return LoopAction::PresenceOffline; + } LoopAction::Continue } From 486b5f558a80dd5fdd2698fd58fa9c4c5c0c1708 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 11:19:00 +0300 Subject: [PATCH 4/8] fix(acp): go offline and stop presence heartbeats on auth expiry Keeps the harness process up for logs/restart, but stops looking online in the sidebar once Claude/Codex credentials are dead (#3831). Signed-off-by: Taksh --- crates/buzz-acp/src/lib.rs | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 4fec380524..6d2c523c88 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2354,7 +2354,7 @@ async fn tokio_main() -> Result<()> { if let PromptSource::Channel(ch) = &result.source { typing_channels.remove(ch); } - if handle_prompt_result( + match handle_prompt_result( &mut pool, &mut queue, &config, @@ -2366,9 +2366,33 @@ async fn tokio_main() -> Result<()> { &mut respawn_tasks, observer.clone(), Some(&ctx.rest_client), - ) == LoopAction::Exit - { - break; + ) { + LoopAction::Exit => break, + LoopAction::PresenceOffline => { + // Stop renewing online presence and advertise offline so + // the sidebar stops treating this agent as reachable + // after Claude/Codex credentials expire (#3831). + presence_heartbeat = None; + if let Some(h) = presence_task.take() { + h.abort(); + } + if config.presence_enabled { + let pp = presence_publisher.clone(); + let pk = presence_keys.clone(); + presence_task = Some(tokio::spawn(async move { + if let Err(e) = publish_presence(&pp, &pk, "offline").await { + tracing::warn!( + "failed to set presence offline after auth expiry: {e}" + ); + } else { + tracing::info!( + "presence set to offline after auth expiry" + ); + } + })); + } + } + LoopAction::Continue => {} } if drain_ready_join_results( &mut pool, From 28e5c4cd7f9b3e2c1ffb77ca4fbabef089fee94e Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 11:19:42 +0300 Subject: [PATCH 5/8] test(acp): cover Authentication required auth-error classification Pin the headless Claude expiry message so a future precision tweak cannot quietly drop it from the non-retryable set. Signed-off-by: Taksh --- crates/buzz-acp/src/lib.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 6d2c523c88..0ef057eaf1 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -6266,6 +6266,18 @@ mod error_outcome_emission_tests { ); } + #[test] + fn is_auth_error_matches_authentication_required_message() { + let e = acp::AcpError::AgentError { + code: -32000, + message: "Authentication required".to_string(), + }; + assert!( + is_auth_error(&e), + "headless 'Authentication required' must be classified as auth error" + ); + } + #[test] fn is_auth_error_rejects_other_agent_error_message() { let e = acp::AcpError::AgentError { From 4c1285ab678786e6f0a411b3953c849fbd68f424 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 11:19:42 +0300 Subject: [PATCH 6/8] test(acp): assert auth expiry returns PresenceOffline Regression pin for the #3831 sidebar-online-after-credential-death path. Signed-off-by: Taksh --- crates/buzz-acp/src/lib.rs | 77 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 0ef057eaf1..193f57ab64 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -6392,6 +6392,83 @@ mod error_outcome_emission_tests { ); } + /// Auth expiry must also signal PresenceOffline so the event loop can stop + /// advertising the agent as online (#3831). + #[tokio::test] + async fn auth_error_returns_presence_offline_loop_action() { + let keys = nostr::Keys::generate(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "test") + .sign_with_keys(&keys) + .unwrap(); + let channel_id = uuid::Uuid::new_v4(); + let batch = FlushBatch { + channel_id, + events: vec![BatchEvent { + event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + + let auth_error = acp::AcpError::AgentError { + code: -32000, + message: "Authentication required".to_string(), + }; + + let agent = dummy_agent(0).await; + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + channel_id: None, + turn_id: "test-turn-id".to_string(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + }, + ); + let mut queue = EventQueue::new(config::DedupMode::Queue); + let config = test_config(); + let mut heartbeat_in_flight = false; + let removed_channels = std::collections::HashSet::new(); + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + let result = PromptResult { + agent, + source: PromptSource::Channel(channel_id), + turn_id: "test-turn-id".to_string(), + outcome: PromptOutcome::Error(auth_error), + batch: Some(batch), + }; + let action = handle_prompt_result( + &mut pool, + &mut queue, + &config, + result, + &mut heartbeat_in_flight, + &removed_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + None, + None, + ); + assert_eq!( + action, + LoopAction::PresenceOffline, + "auth expiry must take presence offline so the agent stops looking online" + ); + } + /// A non-auth application error (e.g. usage credits) must still follow the /// standard requeue path so today's behavior is unchanged. #[tokio::test] From f81477df8464a6d725d6223a94b2165e385ada5d Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 11:20:04 +0300 Subject: [PATCH 7/8] fix(acp): tell users to restart after auth expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel notice now matches presence behavior — re-login alone is not enough until the harness process is restarted. Signed-off-by: Taksh --- crates/buzz-acp/src/lib.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 193f57ab64..b63f24cc38 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2385,9 +2385,7 @@ async fn tokio_main() -> Result<()> { "failed to set presence offline after auth expiry: {e}" ); } else { - tracing::info!( - "presence set to offline after auth expiry" - ); + tracing::info!("presence set to offline after auth expiry"); } })); } @@ -3191,7 +3189,7 @@ fn handle_prompt_result( ); let content = "⚠️ I couldn't process the last request: authentication failed. \ Please re-authenticate the CLI (e.g. run `claude /login` or `codex login`) \ - and then re-send." + and restart this agent. I'll stay offline until then." .to_string(); spawn_failure_notice(rest_client, &batch, content); } else if let Some(dead) = queue.requeue(batch) { @@ -3240,8 +3238,7 @@ fn handle_prompt_result( PromptOutcome::Cancelled => "cancelled", PromptOutcome::CancelDrainTimeout(_) => "cancel_drain_timeout", }; - let auth_expired = - matches!(&result.outcome, PromptOutcome::Error(e) if is_auth_error(e)); + let auth_expired = matches!(&result.outcome, PromptOutcome::Error(e) if is_auth_error(e)); let agent_index = result.agent.index; // Capture the spawn-time configured model and our PID before the agent is // moved into match arms below. `desired_model` reflects the config/persona From 4b600cd6dd0b289450afc168e20f4c9812f2fdeb Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 11:20:04 +0300 Subject: [PATCH 8/8] docs(acp): document offline presence after credential expiry Operators running headless Claude agents hit this often; put the recovery steps next to the other harness docs. Signed-off-by: Taksh --- crates/buzz-acp/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd..3d417e6008 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -335,6 +335,22 @@ Set `BUZZ_ACP_AGENT_COMMAND` and `BUZZ_ACP_AGENT_ARGS` to point at your agent bi See the [root TESTING.md](../../TESTING.md) for the full integration testing guide — automated test suites, multi-agent E2E testing via the ACP harness, and troubleshooting. +## Troubleshooting + +### Agent stays "online" but every mention fails with `-32000` + +Claude / Codex subscription credentials can expire while the harness process is +still up. When the agent returns a non-retryable auth error (`Authentication +required`, `Re-authenticate`, or `API Error: 401`), buzz-acp: + +1. Dead-letters the failed turn and posts a channel notice asking you to re-login +2. Stops renewing presence heartbeats +3. Publishes presence `offline` so the sidebar stops looking live + +Re-authenticate the CLI (`claude /login` / `codex login`) and **restart** +`buzz-acp` — presence does not automatically flip back to online inside the +same process after auth death. + ## License Apache-2.0