From 5842b093cc502b46076423e4f8efdbbc568f86aa Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Thu, 5 Nov 2026 19:21:43 +0200 Subject: pi: a failed run says so: a 'failed' alert with the error, and the error in red until seen --- crates/dinggerd/src/hooks.rs | 44 +++++++++++++++++++++++++++++++++++++++--- crates/dinggerd/src/main.rs | 8 +++++++- crates/dinggerd/src/notify.rs | 7 +++++-- crates/dinggerd/src/tmux.rs | 8 ++++++-- crates/dinggerd/src/watcher.rs | 41 +++++++++++++++++++++++++++++++-------- 5 files changed, 92 insertions(+), 16 deletions(-) (limited to 'crates/dinggerd/src') diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs index 709f62b..aeb3285 100644 --- a/crates/dinggerd/src/hooks.rs +++ b/crates/dinggerd/src/hooks.rs @@ -29,6 +29,8 @@ //! //! An idle report may say the run was `aborted` (cancelled, as with //! Escape): the agent is idle, not done, and nobody is told it finished. +//! Or that it failed, with the `error` as one readable line: the agent is +//! done, and the alert says it failed and why. //! //! It also carries the `draft` in the agent's input box ("" for none), for //! marking a draft you left behind; never logged. And `context`: how full @@ -81,6 +83,8 @@ struct PaneReports { draft: Option, /// How full its context window is: whole percent ("" when unknown). context: Option, + /// The error that ended its last failed run, as one line. + error: Option, /// When the last report arrived. at: Option, } @@ -183,6 +187,8 @@ struct HookParams { context: Option, #[serde(default)] aborted: Option, + #[serde(default)] + error: Option, } fn handle_stream(stream: UnixStream) { @@ -252,6 +258,7 @@ fn handle_stream(stream: UnixStream) { draft: params.draft, context: params.context, aborted: params.aborted == Some(true), + error: params.error, }; record(params.pane_id, state, reported); wake(); @@ -269,6 +276,8 @@ struct Reported { context: Option, /// The run this report ends was cancelled. aborted: bool, + /// The error the run this report ends failed with. + error: Option, } /// A reported state, and whether it ends a run the user cancelled. @@ -277,11 +286,13 @@ pub struct Observation { pub state: AgentState, /// Idle after a cancelled run: not done, and no "finished" alert. pub cancelled: bool, + /// Idle after a failed run: done, with a "failed" alert (see [`error`]). + pub failed: bool, } impl Observation { pub fn plain(state: AgentState) -> Self { - Observation { state, cancelled: false } + Observation { state, cancelled: false, failed: false } } } @@ -311,7 +322,12 @@ fn record(pane_id: String, state: Option, reported: Reported) { pane.at = Some(Instant::now()); if let Some(state) = state { pane.last = Some(state); - pane.pending.push(Observation { state, cancelled: reported.aborted && state == AgentState::Idle }); + let idle = state == AgentState::Idle; + let error = reported.error.as_deref().map(draft_line).filter(|error| idle && !error.is_empty()); + pane.pending.push(Observation { state, cancelled: reported.aborted && idle, failed: error.is_some() }); + if error.is_some() { + pane.error = error; + } } if let Some(session) = option_value(reported.session) { pane.session = Some(session); @@ -342,6 +358,11 @@ fn context_percent(value: &str) -> String { } } +/// The error the pane's agent's last failed run ended with. +pub fn error(pane_id: &str) -> Option { + reports().get(pane_id).and_then(|pane| pane.error.clone()) +} + /// How full the pane's agent's context window is ("" when unknown), if its /// extension reports it. pub fn context(pane_id: &str) -> Option { @@ -497,7 +518,7 @@ mod tests { take_observations("%t7"), Some(vec![ Observation::plain(AgentState::Working), - Observation { state: AgentState::Idle, cancelled: true }, + Observation { state: AgentState::Idle, cancelled: true, failed: false }, ]), "only an idle report ends a run" ); @@ -506,6 +527,23 @@ mod tests { assert_eq!(take_observations("%t7"), Some(vec![Observation::plain(AgentState::Idle)])); } + #[test] + fn a_failed_run_ends_in_a_failed_idle_observation_with_its_error() { + let failed = |error: &str| Reported { error: Some(error.into()), ..Default::default() }; + record("%t8".into(), Some(AgentState::Working), failed("not the end of a run")); + record("%t8".into(), Some(AgentState::Idle), failed("401: invalid\nx-api-key")); + assert_eq!( + take_observations("%t8"), + Some(vec![ + Observation::plain(AgentState::Working), + Observation { state: AgentState::Idle, cancelled: false, failed: true }, + ]) + ); + assert_eq!(super::error("%t8").as_deref(), Some("401: invalid x-api-key"), "one line"); + record("%t8".into(), Some(AgentState::Idle), failed("")); + assert_eq!(take_observations("%t8"), Some(vec![Observation::plain(AgentState::Idle)]), "no error, no failure"); + } + #[test] fn context_use_is_kept_as_whole_percent_until_reported_again() { let context = |text: &str| Reported { context: Some(text.into()), ..Default::default() }; diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs index 935da7d..0ace3a9 100644 --- a/crates/dinggerd/src/main.rs +++ b/crates/dinggerd/src/main.rs @@ -480,7 +480,13 @@ fn status() -> Result<(), String> { }; let cost = if pane.written_cost.is_empty() { String::new() } else { format!("${}", pane.written_cost) }; let context = if pane.written_context.is_empty() { String::new() } else { format!("{}%", pane.written_context) }; - println!("{state:<8} {:8} {context:>4} {draft}{}", place(pane), name.trim()); + // A failed run: done, and why. + let (state, why) = if pane.written_error.is_empty() || state != "done" { + (state, String::new()) + } else { + ("failed", format!(" ยท {}", pane.written_error)) + }; + println!("{state:<8} {:8} {context:>4} {draft}{}{why}", place(pane), name.trim()); } let global = |name: &str| tmux::show_global(name).unwrap_or_default(); let spent = [("today", "@dingger_cost_today"), ("this week", "@dingger_cost_week"), ("this month", "@dingger_cost_month")] diff --git a/crates/dinggerd/src/notify.rs b/crates/dinggerd/src/notify.rs index 11b37b0..ef2c721 100644 --- a/crates/dinggerd/src/notify.rs +++ b/crates/dinggerd/src/notify.rs @@ -8,6 +8,8 @@ use tracing::{info, warn}; pub enum NotificationKind { NeedsAttention, Finished, + /// A run that ended in an error: done, but you have to act. + Failed, } impl NotificationKind { @@ -15,6 +17,7 @@ impl NotificationKind { match self { Self::NeedsAttention => "needs attention", Self::Finished => "finished", + Self::Failed => "failed", } } @@ -22,7 +25,7 @@ impl NotificationKind { /// for completions. fn sound_bytes(&self) -> &'static [u8] { match self { - Self::NeedsAttention => include_bytes!("../assets/request.mp3"), + Self::NeedsAttention | Self::Failed => include_bytes!("../assets/request.mp3"), Self::Finished => include_bytes!("../assets/done.mp3"), } } @@ -34,7 +37,7 @@ pub fn play_sound(kind: &NotificationKind) { // Test seam: play through a command instead (tests/e2e.sh). if let Ok(command) = std::env::var("DINGGER_SOUND") { let name = match kind { - NotificationKind::NeedsAttention => "request", + NotificationKind::NeedsAttention | NotificationKind::Failed => "request", NotificationKind::Finished => "done", }; let _ = Command::new("sh").args(["-c", &command, name]).stdin(Stdio::null()).spawn(); diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs index 80adbb5..7845381 100644 --- a/crates/dinggerd/src/tmux.rs +++ b/crates/dinggerd/src/tmux.rs @@ -38,6 +38,8 @@ pub struct Pane { pub written_cost: String, /// How full the agent's context window is, in whole percent. pub written_context: String, + /// Why its last run failed, while it is done and unseen. + pub written_error: String, } @@ -124,14 +126,15 @@ pub fn list_panes() -> Result, String> { "#{@dingger_control}\t", "#{@dingger_draft}\t", "#{@dingger_cost}\t", - "#{@dingger_context}", + "#{@dingger_context}\t", + "#{@dingger_error}", ); let out = run_tmux(&["list-panes", "-a", "-F", format])?; let mut panes: Vec = Vec::new(); let mut process_tree = None; for line in out.lines() { let fields: Vec<&str> = line.split('\t').collect(); - if fields.len() < 25 { + if fields.len() < 26 { continue; } let attended = fields[4] == "1" && fields[5] != "0"; @@ -179,6 +182,7 @@ pub fn list_panes() -> Result, String> { written_draft: fields[22].to_string(), written_cost: fields[23].to_string(), written_context: fields[24].to_string(), + written_error: fields[25].to_string(), }); } Ok(panes) diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index aee8d5e..9bad1fe 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -29,6 +29,7 @@ //! - `@dingger_draft` the draft in the agent's input box, as one line //! - `@dingger_cost` what the agent's session spent today (see `cost`) //! - `@dingger_context` how full its context window is, in whole percent +//! - `@dingger_error` why its run failed, while it is done (unseen) use std::collections::HashMap; @@ -375,7 +376,15 @@ impl Watcher { // Apply every observation in order: a turn that started and // finished between two ticks is still a completion. let mut alerts = Vec::new(); + // Why its last run failed: kept while it is done. + let mut error = pane.written_error.clone(); for observed in observations { + if observed.state != AgentState::Idle { + error.clear(); + } + if observed.failed { + error = hooks::error(&pane.pane_id).unwrap_or_else(|| "error".into()); + } let mut next = advance(track, observed.state, pane.attended, acked, now); // A cancelled run did not finish: idle, as if seen. if observed.cancelled && next.state == AgentState::Idle { @@ -392,7 +401,12 @@ impl Watcher { // Notify on transitions observed by this process, not // when resuming from options written before a restart. if !restored && !observed.cancelled { - alerts.extend(notification_kind_for_transition(track.state, next.state)); + alerts.extend(notification_kind_for_transition(track.state, next.state).map(|kind| { + match kind { + NotificationKind::Finished if observed.failed => NotificationKind::Failed, + kind => kind, + } + })); } } track = next; @@ -408,14 +422,18 @@ impl Watcher { track = next; } self.tracks.insert(pane.pane_id.clone(), track); + // Seen, a new run, or gone: the failure is no longer news. + if track.status() != Status::Done { + error.clear(); + } // Update what the user sees first, then alert. if acked { let _ = tmux::unset_pane_option(&pane.pane_id, "@dingger_ack"); } - self.write_pane_options(pane, track); + self.write_pane_options(pane, track, &error); for kind in alerts { - self.notify(pane, kind); + self.notify(pane, kind, &error); } } @@ -465,7 +483,7 @@ impl Watcher { /// Write the pane's options that changed, in one tmux command list: /// tmux applies it as a unit, so a daemon killed mid-write never leaves /// a display state that disagrees with the hook state it came from. - fn write_pane_options(&self, pane: &tmux::Pane, track: PaneTrack) { + fn write_pane_options(&self, pane: &tmux::Pane, track: PaneTrack, error: &str) { let mut changes: Vec<(&str, Option)> = Vec::new(); let mut change = |name: &'static str, written: &str, value: String| { if written != value { @@ -483,6 +501,7 @@ impl Watcher { } change("@agent_state", &pane.written_state, track.status().label().to_string()); change("@dingger_since", &pane.written_since, track.since.map(|s| s.to_string()).unwrap_or_default()); + change("@dingger_error", &pane.written_error, error.to_string()); change("@agent", &pane.written_agent, pane.agent.map(detect_core::agent_label).unwrap_or("").to_string()); let name = crate::names::display_name(pane.agent, &pane.pane_title, &pane.cwd, host_name()) .unwrap_or_default(); @@ -544,16 +563,22 @@ impl Watcher { } } - fn notify(&self, pane: &tmux::Pane, kind: NotificationKind) { + fn notify(&self, pane: &tmux::Pane, kind: NotificationKind, error: &str) { // herdr: a blocked pane plays the alert even when the user is looking - // at it; a finished pane stays silent when they already are. + // at it; a finished (or failed) pane stays silent when they already + // are. let watching = pane.attended; - if matches!(kind, NotificationKind::Finished) && watching { + if matches!(kind, NotificationKind::Finished | NotificationKind::Failed) && watching { return; } let agent = pane.agent.map(detect_core::agent_label).unwrap_or("agent"); if self.notifications && !watching { - send_notification(agent, &kind, &format!("{} ({agent})", pane.display_target)); + let mut context = format!("{} ({agent})", pane.display_target); + if matches!(kind, NotificationKind::Failed) && !error.is_empty() { + context.push_str(": "); + context.push_str(error); + } + send_notification(agent, &kind, &context); } // A low agent makes no sound; it still notifies and waits in line. if self.sounds && importance_of(pane) != Importance::Low { -- cgit v1.2.3