diff options
Diffstat (limited to 'crates/dinggerd/src/watcher.rs')
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 148 |
1 files changed, 144 insertions, 4 deletions
diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index b76f53c..1c57609 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -24,10 +24,12 @@ //! - `@dingger_hook` last state an agent extension reported //! - `@dingger_ack` one-shot "the user jumped here" event from `dingger focus` //! - `@agent_session` the agent's session reference, for restore +//! - `@dingger_extension`, `@dingger_control` the version of pi's extension +//! and its control socket, for reloading agents on an old one use std::collections::HashMap; -use detect_core::{detect_agent_with_osc, AgentState}; +use detect_core::{detect_agent_with_osc, Agent, AgentState}; use tracing::{debug, info, warn}; use crate::hooks; @@ -218,13 +220,66 @@ pub fn importance_of(pane: &tmux::Pane) -> Importance { hooks::importance(&pane.pane_id).unwrap_or_else(|| Importance::from_option(&pane.written_importance)) } +/// An agent on an old dingger extension: dingger can reload it (its +/// extension takes requests), or only the user can (`/reload`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outdated { + Reloadable, + ByHand, +} + +/// Whether a pane runs an old extension: a pi that reports through one +/// (its hook state) with a version other than `current`, or none (an +/// extension from before versions, which takes no requests either). +pub fn outdated(agent: Option<Agent>, reports: bool, extension: &str, control: &str, current: &str) -> Option<Outdated> { + if agent != Some(Agent::Pi) || !reports || extension == current { + return None; + } + Some(if control.is_empty() { Outdated::ByHand } else { Outdated::Reloadable }) +} + +/// Whether to ask an outdated agent to reload now: it is idle (done +/// included: a reload keeps it done) and not on screen, where the user may +/// be typing into it. The extension itself refuses while busy or with a +/// draft in its input box. +pub fn reload_now(track: PaneTrack, attended: bool) -> bool { + track.state == AgentState::Idle && !attended +} + +/// How long before asking an agent that refused (or did not reload) again. +const RELOAD_RETRY: std::time::Duration = std::time::Duration::from_secs(10); + +/// The pane's extension version and control socket: as last reported, +/// else as written (they survive a daemon restart). +fn extension_of(pane: &tmux::Pane) -> (String, String) { + let (extension, control) = hooks::extension(&pane.pane_id); + ( + extension.unwrap_or_else(|| pane.written_extension.clone()), + control.unwrap_or_else(|| pane.written_control.clone()), + ) +} + +fn outdated_of(pane: &tmux::Pane) -> Option<Outdated> { + let reports = hooks::last_state(&pane.pane_id).is_some() || !pane.written_hook.is_empty(); + let (extension, control) = extension_of(pane); + outdated(pane.agent, reports, &extension, &control, current_extension()) +} + +fn current_extension() -> &'static str { + static CURRENT: std::sync::OnceLock<String> = std::sync::OnceLock::new(); + CURRENT.get_or_init(crate::integrations::pi_extension_version) +} + pub struct Watcher { tracks: HashMap<String, PaneTrack>, + /// When each outdated agent was last asked to reload. + reload_asked: HashMap<String, std::time::Instant>, snapshots: crate::snapshot::Recorder, offer: crate::restore::OfferPublisher, - /// Last published (queue, blocked, done, working); avoids rewriting the + /// Last published (queue, blocked, done, working, outdated reloadable, + /// outdated by hand); avoids rewriting the /// global options every tick. - last_published: Option<(String, usize, usize, usize)>, + last_published: Option<(String, usize, usize, usize, usize, usize)>, interval_secs: u64, sounds: bool, notifications: bool, @@ -234,6 +289,7 @@ impl Watcher { pub fn new(interval_secs: u64, sounds: bool, notifications: bool) -> Self { Self { tracks: HashMap::new(), + reload_asked: HashMap::new(), snapshots: Default::default(), offer: Default::default(), last_published: None, @@ -340,10 +396,49 @@ impl Watcher { } } + self.reload_outdated(&panes); self.publish(&panes); Ok(()) } + /// While reloading is wanted: ask each outdated agent that can reload + /// now, at most every `RELOAD_RETRY`; done when none is left. + fn reload_outdated(&mut self, panes: &[tmux::Pane]) { + self.reload_asked.retain(|pane_id, _| panes.iter().any(|pane| pane.pane_id == *pane_id)); + if !hooks::reload_wanted() { + return; + } + if !crate::integrations::pi_extension_installed_is_current() { + warn!("reload agents: the installed pi extension is not this dingger's; `dingger install pi` first"); + hooks::reload_done(); + return; + } + let mut left = 0; + for pane in panes { + if outdated_of(pane) != Some(Outdated::Reloadable) { + continue; + } + left += 1; + let Some(track) = self.tracks.get(&pane.pane_id).copied() else { continue }; + let asked = self.reload_asked.get(&pane.pane_id).is_some_and(|at| at.elapsed() < RELOAD_RETRY); + if asked || !reload_now(track, pane.attended) { + continue; + } + self.reload_asked.insert(pane.pane_id.clone(), std::time::Instant::now()); + let (_, control) = extension_of(pane); + let target = pane.display_target.clone(); + // The extension answers at once; off the tick all the same. + std::thread::spawn(move || match hooks::request_reload(&control) { + Ok(()) => info!(%target, "reload: reloading"), + Err(reason) => info!(%target, %reason, "reload: not now"), + }); + } + if left == 0 { + info!("reload agents on an old extension: none left"); + hooks::reload_done(); + } + } + /// 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. @@ -383,6 +478,21 @@ impl Watcher { (None, _) => change("@dingger_importance", &pane.written_importance, String::new()), _ => {} } + // The extension's version and control socket go with the agent too. + match (pane.agent, hooks::extension(&pane.pane_id)) { + (Some(_), (extension, control)) => { + if let Some(extension) = extension { + change("@dingger_extension", &pane.written_extension, extension); + } + if let Some(control) = control { + change("@dingger_control", &pane.written_control, control); + } + } + (None, _) => { + change("@dingger_extension", &pane.written_extension, String::new()); + change("@dingger_control", &pane.written_control, String::new()); + } + } if let Err(err) = tmux::set_pane_options(&pane.pane_id, &changes) { warn!(target = %pane.display_target, %err, "failed to write pane options"); } @@ -411,7 +521,13 @@ impl Watcher { fn publish(&mut self, panes: &[tmux::Pane]) { let mut queue = Vec::new(); let (mut blocked, mut done, mut working) = (0, 0, 0); + let (mut reloadable, mut by_hand) = (0, 0); for pane in panes { + match outdated_of(pane) { + Some(Outdated::Reloadable) => reloadable += 1, + Some(Outdated::ByHand) => by_hand += 1, + None => {} + } let Some(track) = self.tracks.get(&pane.pane_id) else { continue; }; @@ -433,7 +549,7 @@ impl Watcher { .collect::<Vec<_>>() .join(","); - let next = (queue, blocked, done, working); + let next = (queue, blocked, done, working, reloadable, by_hand); if self.last_published.as_ref() == Some(&next) { return; } @@ -442,6 +558,8 @@ impl Watcher { ("@dingger_blocked", next.1.to_string()), ("@dingger_done", next.2.to_string()), ("@dingger_working", next.3.to_string()), + ("@dingger_outdated", next.4.to_string()), + ("@dingger_outdated_by_hand", next.5.to_string()), ] { if let Err(err) = tmux::set_global(name, &value) { warn!(%err, "failed to write {name}"); @@ -730,6 +848,28 @@ mod tests { } #[test] + fn an_old_extension_is_a_pi_reporting_another_version_or_none() { + let pi = Some(Agent::Pi); + assert_eq!(outdated(pi, true, "v2", "/s.sock", "v2"), None, "current"); + assert_eq!(outdated(pi, true, "v1", "/s.sock", "v2"), Some(Outdated::Reloadable)); + assert_eq!(outdated(pi, true, "", "", "v2"), Some(Outdated::ByHand), "from before versions"); + assert_eq!(outdated(pi, false, "", "", "v2"), None, "no extension: nothing to reload"); + assert_eq!(outdated(Some(Agent::Claude), true, "", "", "v2"), None, "Claude's hooks run dingger itself"); + assert_eq!(outdated(None, true, "v1", "/s.sock", "v2"), None); + } + + #[test] + fn an_agent_reloads_when_idle_and_off_screen() { + let track = |state, seen| PaneTrack { state, seen, since: Some(1), held: false }; + assert!(reload_now(track(Idle, true), false)); + assert!(reload_now(track(Idle, false), false), "done too: it stays done"); + assert!(!reload_now(track(Idle, true), true), "on screen: the user may be typing"); + assert!(!reload_now(track(Working, true), false)); + assert!(!reload_now(track(Blocked, true), false)); + assert!(!reload_now(PaneTrack::NEW, false)); + } + + #[test] fn priority_matches_herdr() { assert!(Status::Blocked.priority() > Status::Done.priority()); assert!(Status::Done.priority() > Status::Working.priority()); |
