Skip to content
16 changes: 16 additions & 0 deletions crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
142 changes: 134 additions & 8 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -2366,9 +2366,31 @@ 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,
Expand Down Expand Up @@ -2723,10 +2745,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 {
Expand Down Expand Up @@ -3017,15 +3042,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.
Expand Down Expand Up @@ -3160,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) {
Expand Down Expand Up @@ -3209,6 +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 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
Expand Down Expand Up @@ -3416,6 +3446,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
}

Expand Down Expand Up @@ -6226,6 +6263,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 {
Expand Down Expand Up @@ -6340,6 +6389,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]
Expand Down