From f196d75f3dfaf829857c941a946dfe6bb51370ca Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 20 May 2026 12:05:22 -0400 Subject: [PATCH 1/2] fix(sprout-acp): surface agent prompt errors in warn logs Application-class errors (IdleTimeout, HardTimeout, Json, etc.) were logged at debug level without the error value, making them invisible at the default sprout_acp=info log level. Transport errors had the right level but also dropped the error content. --- crates/sprout-acp/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/sprout-acp/src/lib.rs b/crates/sprout-acp/src/lib.rs index c9878a997..e0f695875 100644 --- a/crates/sprout-acp/src/lib.rs +++ b/crates/sprout-acp/src/lib.rs @@ -2101,6 +2101,7 @@ fn handle_prompt_result( tracing::warn!( agent = agent_index, outcome = outcome_label, + error = %e, "transport/protocol error — respawning agent" ); let index = result.agent.index; @@ -2119,9 +2120,10 @@ fn handle_prompt_result( return LoopAction::Exit; } } else { - tracing::debug!( + tracing::warn!( agent = agent_index, outcome = outcome_label, + error = %e, "agent_returned (application error — pipe intact)" ); pool.return_agent(result.agent); From 2c59cd63700ad391ce15d3c6b73cf7dbafe5df87 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 20 May 2026 13:38:48 -0400 Subject: [PATCH 2/2] fix: surface swallowed errors and emit turn failures to activity log Errors across sprout-acp, sprout-relay, and sprout-media were logged at debug level or silently discarded, making failures invisible at the default info log level. Upgrades critical error paths to warn/info and adds error values where they were missing. Introduces turn_error and agent_panic observer events so prompt failures and agent crashes appear as red lifecycle entries in the Agent Activity Log panel, closing the gap where failed turns simply vanished from the UI timeline. --- crates/sprout-acp/src/lib.rs | 40 ++++++++++++++- crates/sprout-acp/src/pool.rs | 8 +-- crates/sprout-media/src/upload.rs | 2 +- crates/sprout-relay/src/api/mod.rs | 6 +-- crates/sprout-relay/src/handlers/auth.rs | 4 +- .../sprout-relay/src/handlers/side_effects.rs | 49 ++++++++++++++++--- crates/sprout-relay/src/main.rs | 2 +- .../agents/ui/agentSessionTranscript.ts | 15 ++++++ 8 files changed, 106 insertions(+), 20 deletions(-) diff --git a/crates/sprout-acp/src/lib.rs b/crates/sprout-acp/src/lib.rs index e0f695875..0a521b471 100644 --- a/crates/sprout-acp/src/lib.rs +++ b/crates/sprout-acp/src/lib.rs @@ -474,7 +474,7 @@ async fn publish_relay_observer_event( } }; if let Err(error) = publisher.publish_event(signed).await { - tracing::debug!("relay observer event dropped: {error}"); + tracing::warn!("relay observer event dropped: {error}"); } } @@ -2032,6 +2032,24 @@ fn handle_prompt_result( }; let agent_index = result.agent.index; + let channel_id = match &result.source { + PromptSource::Channel(ch) => Some(*ch), + PromptSource::Heartbeat => None, + }; + let emit_turn_error = |error_msg: &str| { + if let Some(ref observer) = observer { + observer.emit( + "turn_error", + Some(agent_index), + &observer::context_for(channel_id, None, None), + serde_json::json!({ + "outcome": outcome_label, + "error": error_msg, + }), + ); + } + }; + match result.outcome { // Successful prompt — return agent to pool. PromptOutcome::Ok(_) => { @@ -2044,11 +2062,15 @@ fn handle_prompt_result( } // Fatal outcomes: the agent subprocess is dead or poisoned — respawn it. PromptOutcome::AgentExited | PromptOutcome::Timeout => { - tracing::debug!( + tracing::warn!( agent = agent_index, outcome = outcome_label, "agent_returned — respawning" ); + emit_turn_error(match outcome_label { + "exited" => "Agent process exited unexpectedly", + _ => "Agent timed out", + }); let index = result.agent.index; let slot_history = &mut crash_history[index]; if !spawn_respawn_task( @@ -2104,6 +2126,7 @@ fn handle_prompt_result( error = %e, "transport/protocol error — respawning agent" ); + emit_turn_error(&e.to_string()); let index = result.agent.index; let slot_history = &mut crash_history[index]; if !spawn_respawn_task( @@ -2126,6 +2149,7 @@ fn handle_prompt_result( error = %e, "agent_returned (application error — pipe intact)" ); + emit_turn_error(&e.to_string()); pool.return_agent(result.agent); } } @@ -2180,6 +2204,18 @@ fn recover_panicked_agent( tracing::warn!("cleared wedged heartbeat_in_flight from panicked agent {i}"); } + if let Some(ref observer) = observer { + observer.emit( + "agent_panic", + Some(i), + &observer::context_for(meta.channel_id, None, None), + serde_json::json!({ + "outcome": "panic", + "error": format!("Agent task panicked: {join_error}"), + }), + ); + } + // Panics count as crashes for the circuit breaker. // The panicked task already dropped the AcpClient, so we just need to // check the circuit and spawn a fresh agent in the background. diff --git a/crates/sprout-acp/src/pool.rs b/crates/sprout-acp/src/pool.rs index 80a6936ea..e3b41cdb7 100644 --- a/crates/sprout-acp/src/pool.rs +++ b/crates/sprout-acp/src/pool.rs @@ -1945,14 +1945,14 @@ pub(crate) async fn reaction_add(rest: &crate::relay::RestClient, event_id: &str let builder = match sprout_sdk::build_reaction(target_id, emoji) { Ok(b) => b, Err(e) => { - tracing::debug!(event_id, emoji, "reaction add: build failed: {e}"); + tracing::warn!(event_id, emoji, "reaction add: build failed: {e}"); return; } }; let event = match builder.sign_with_keys(&rest.keys) { Ok(e) => e, Err(e) => { - tracing::debug!(event_id, emoji, "reaction add: sign failed: {e}"); + tracing::warn!(event_id, emoji, "reaction add: sign failed: {e}"); return; } }; @@ -2026,14 +2026,14 @@ pub(crate) async fn reaction_remove(rest: &crate::relay::RestClient, event_id: & let builder = match sprout_sdk::build_remove_reaction(target_id) { Ok(b) => b, Err(e) => { - tracing::debug!(event_id, emoji, "reaction remove: build failed: {e}"); + tracing::warn!(event_id, emoji, "reaction remove: build failed: {e}"); return; } }; let event = match builder.sign_with_keys(&rest.keys) { Ok(e) => e, Err(e) => { - tracing::debug!(event_id, emoji, "reaction remove: sign failed: {e}"); + tracing::warn!(event_id, emoji, "reaction remove: sign failed: {e}"); return; } }; diff --git a/crates/sprout-media/src/upload.rs b/crates/sprout-media/src/upload.rs index 222de4598..ec6294be7 100644 --- a/crates/sprout-media/src/upload.rs +++ b/crates/sprout-media/src/upload.rs @@ -82,7 +82,7 @@ pub async fn process_upload( uploaded_at, )), Err(e) => { - tracing::warn!(sha256 = %sha256, "metadata generation failed; orphan blob left for GC"); + tracing::warn!(sha256 = %sha256, error = %e, "metadata generation failed; orphan blob left for GC"); Err(e) } } diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index bd799b19a..4231489ab 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -34,7 +34,7 @@ pub(crate) fn not_found(msg: &str) -> (StatusCode, Json) { /// `git/transport.rs`, and `audio/handler.rs`. pub mod relay_members { use axum::{http::StatusCode, response::Json}; - use tracing::debug; + use tracing::{debug, info}; use crate::state::AppState; @@ -95,7 +95,7 @@ pub mod relay_members { } } Err(e) => { - debug!(agent = %pubkey_hex, "NIP-OA auth tag invalid: {e}"); + info!(agent = %pubkey_hex, "NIP-OA auth tag invalid: {e}"); } } } @@ -126,7 +126,7 @@ pub mod relay_members { match sprout_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) { Ok(owner) => Some(owner), Err(e) => { - debug!("extract_nip_oa_owner: invalid auth tag: {e}"); + info!("extract_nip_oa_owner: invalid auth tag: {e}"); None } } diff --git a/crates/sprout-relay/src/handlers/auth.rs b/crates/sprout-relay/src/handlers/auth.rs index 54e559c47..049ae62e4 100644 --- a/crates/sprout-relay/src/handlers/auth.rs +++ b/crates/sprout-relay/src/handlers/auth.rs @@ -118,8 +118,8 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: .await { Ok(owner) => owner, - Err(_) => { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "not a relay member"); + Err(e) => { + warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = ?e, "not a relay member"); metrics::counter!("sprout_auth_failures_total", "reason" => "not_relay_member") .increment(1); *conn.auth_state.write().await = AuthState::Failed; diff --git a/crates/sprout-relay/src/handlers/side_effects.rs b/crates/sprout-relay/src/handlers/side_effects.rs index c26c331fe..6876da2ba 100644 --- a/crates/sprout-relay/src/handlers/side_effects.rs +++ b/crates/sprout-relay/src/handlers/side_effects.rs @@ -1472,10 +1472,16 @@ async fn handle_standard_deletion_event( 0, ) .unwrap_or_else(chrono::Utc::now); - let _ = state + if let Err(e) = state .db .remove_reaction(&react_target_id, react_target_ts, &actor, emoji) - .await; + .await + { + tracing::warn!( + error = %e, + "failed to remove reaction from DB during NIP-09 deletion" + ); + } } } } @@ -1763,7 +1769,7 @@ async fn handle_git_repo_announcement(event: &Event, state: &Arc) -> a ("uploadpack.allowTipSHA1InWant", "true"), ("uploadpack.allowReachableSHA1InWant", "true"), ] { - let _ = Command::new("git") + match Command::new("git") .args(["config", "--file"]) .arg(repo_dir.join("config")) .args([key, value]) @@ -1773,7 +1779,26 @@ async fn handle_git_repo_announcement(event: &Event, state: &Arc) -> a .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("HOME", "/dev/null") .output() - .await; + .await + { + Ok(output) if !output.status.success() => { + tracing::warn!( + key, + value, + status = %output.status, + "git config failed for bare repo" + ); + } + Err(e) => { + tracing::warn!( + key, + value, + error = %e, + "git config command failed for bare repo" + ); + } + _ => {} + } } // Install pre-receive hook for permission enforcement. @@ -1938,7 +1963,7 @@ pub async fn reconcile_channel_events(state: &Arc) -> anyhow::Result<( for channel in &channels { // Check if kind:39000 event already exists for this channel. let channel_id_str = channel.id.to_string(); - let existing = state + let existing = match state .db .query_events(&EventQuery { kinds: Some(vec![39000]), @@ -1947,12 +1972,22 @@ pub async fn reconcile_channel_events(state: &Arc) -> anyhow::Result<( ..Default::default() }) .await - .unwrap_or_default(); + { + Ok(v) => v, + Err(e) => { + tracing::warn!( + channel_id = %channel.id, + error = %e, + "reconcile: failed to query existing discovery events" + ); + continue; + } + }; if existing.is_empty() { // No discovery event — emit one. if let Err(e) = emit_group_discovery_events(state, channel.id).await { - tracing::debug!( + tracing::warn!( channel_id = %channel.id, error = %e, "reconcile: failed to emit discovery events" diff --git a/crates/sprout-relay/src/main.rs b/crates/sprout-relay/src/main.rs index 5ee724b4f..b74c091c2 100644 --- a/crates/sprout-relay/src/main.rs +++ b/crates/sprout-relay/src/main.rs @@ -261,7 +261,7 @@ async fn main() -> anyhow::Result<()> { { Ok(()) => {} Err(e) => { - tracing::debug!(error = %e, "channel reconciliation attempt failed"); + tracing::warn!(error = %e, "channel reconciliation attempt failed"); } } } diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index b3c248a10..ab9de6fbd 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -286,6 +286,21 @@ export function processTranscriptEvent( event.timestamp, channelId, ); + } else if (event.kind === "turn_error" || event.kind === "agent_panic") { + const payload = asRecord(event.payload); + const outcome = asString(payload.outcome) ?? "error"; + const error = asString(payload.error) ?? "Unknown error"; + const title = + event.kind === "agent_panic" ? "Agent error (crash)" : "Turn error"; + upsertTextItem( + d, + `${event.kind}:${ch}:${event.turnId ?? event.seq}`, + "lifecycle", + title, + `${outcome}: ${error}`, + event.timestamp, + channelId, + ); } else if (event.kind === "acp_read" || event.kind === "acp_write") { const payload = asRecord(event.payload); const method = asString(payload.method);