aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-11-05 19:21:43 +0200
committerIgor Soarez <igor@soarez.org>2026-11-05 19:21:43 +0200
commit5842b093cc502b46076423e4f8efdbbc568f86aa (patch)
tree35b5a2ab971a2b9abb3188e56e0416033b4f13ea /crates/dinggerd/src
parent3b7b7ed091bae3b0609c38b2d445201cc4b7a026 (diff)
pi: a failed run says so: a 'failed' alert with the error, and the error in red until seen
Diffstat (limited to 'crates/dinggerd/src')
-rw-r--r--crates/dinggerd/src/hooks.rs44
-rw-r--r--crates/dinggerd/src/main.rs8
-rw-r--r--crates/dinggerd/src/notify.rs7
-rw-r--r--crates/dinggerd/src/tmux.rs8
-rw-r--r--crates/dinggerd/src/watcher.rs41
5 files changed, 92 insertions, 16 deletions
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<String>,
/// How full its context window is: whole percent ("" when unknown).
context: Option<String>,
+ /// The error that ended its last failed run, as one line.
+ error: Option<String>,
/// When the last report arrived.
at: Option<Instant>,
}
@@ -183,6 +187,8 @@ struct HookParams {
context: Option<String>,
#[serde(default)]
aborted: Option<bool>,
+ #[serde(default)]
+ error: Option<String>,
}
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<String>,
/// The run this report ends was cancelled.
aborted: bool,
+ /// The error the run this report ends failed with.
+ error: Option<String>,
}
/// 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<AgentState>, 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<String> {
+ 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<String> {
@@ -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"
);
@@ -507,6 +528,23 @@ mod tests {
}
#[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() };
assert_eq!(super::context("%t6"), None, "an extension that does not report it");
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} {:<width$} {agent:<7} {cost:>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} {:<width$} {agent:<7} {cost:>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<Vec<Pane>, 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<Pane> = 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<Vec<Pane>, 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<String>)> = 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 {