diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-30 17:27:21 +0200 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-30 17:27:21 +0200 |
| commit | b0cfd10b80443d3f67d263ff4db177bb53cc90ed (patch) | |
| tree | 957951350434f38da83457f5467b6c000e892867 | |
| parent | 91436536b69c10aa333c2b2978284b57cca3ed64 (diff) | |
reload pi agents on an old extension: versioned extension, control socket, menu item and reload-agents
| -rw-r--r-- | README.md | 36 | ||||
| -rw-r--r-- | ROADMAP.md | 8 | ||||
| -rw-r--r-- | crates/dinggerd/assets/pi/pi-agent-state.ts | 109 | ||||
| -rw-r--r-- | crates/dinggerd/src/hooks.rs | 141 | ||||
| -rw-r--r-- | crates/dinggerd/src/integrations.rs | 62 | ||||
| -rw-r--r-- | crates/dinggerd/src/main.rs | 50 | ||||
| -rw-r--r-- | crates/dinggerd/src/tmux.rs | 10 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 148 | ||||
| -rw-r--r-- | menubar/main.swift | 24 | ||||
| -rw-r--r-- | menubar/model.swift | 31 | ||||
| -rwxr-xr-x | tests/e2e.sh | 54 | ||||
| -rwxr-xr-x | tests/kill-race.sh | 2 | ||||
| -rwxr-xr-x | tests/pi-live.sh | 68 |
13 files changed, 709 insertions, 34 deletions
@@ -124,7 +124,8 @@ than a poll is still a completion. 1. **Agent hooks** (authoritative): `dingger install pi` installs a pi extension that reports working/blocked/idle over - `~/.local/state/dingger/agent.sock`. Restart running pi agents to load it. + `~/.local/state/dingger/agent.sock`. Running pi agents load it when + reloaded (see "Keeping pi agents on the current extension" below). `dingger install claude` adds a SessionStart hook to `~/.claude/settings.json` (keeping everything else; the original is backed up to `settings.json.bak-dingger`) that reports only Claude's @@ -133,8 +134,33 @@ than a poll is still a completion. manifests, for agents without hooks. All per-pane state lives in tmux pane options (`@agent_state`, -`@dingger_since`, `@dingger_hook`, `@dingger_importance`), so restarting -dingger loses nothing. +`@dingger_since`, `@dingger_hook`, `@dingger_importance`, +`@dingger_extension`, `@dingger_control`), so restarting dingger loses +nothing. + +### Keeping pi agents on the current extension + +When the daemon starts, it updates the installed pi extension if this +dingger's differs (only its own file, which says so on its first line). +Running agents keep the one they loaded, and report its version. While +some run an old one, the menu bar menu offers **Reload N Agents on an Old +Extension**; `dingger reload-agents` does the same. Each agent reloads +(pi's `/reload`) when: + +- it is idle (done included: it stays done), +- it is not on screen, where you may be typing into it, +- and its input box is empty: the extension checks this itself, at the + moment it reloads, so nothing you type is lost or sent. + +The others reload once they are; the request stays until all are, or the +daemon restarts. dingger sends no keystrokes: it asks the extension over a +socket of its own (`~/.local/state/dingger/pi-<pid>-<id>.sock`), and the +extension runs the reload as a command (`/dingger-reload`, which you can +also type). A reload also resets pi's other extensions: a pending alarm +is lost, MCP servers reconnect. + +An agent on an extension from before this (it reports no version) takes +no requests: the menu says how many need `/reload` by hand, once. The pi extension's reports carry the agent's importance. Other pi extensions can follow it on pi's event bus: `dingger:importance` with @@ -149,6 +175,7 @@ dingger status # agents in priority order dingger focus [--pane ID] # jump to an agent (default: the top waiter) dingger importance [LEVEL] # an agent's importance: high, normal, low dingger unread [--pane ID] # mark an idle agent unread (done again) +dingger reload-agents # reload pi agents on an old extension dingger install pi # install the pi state-reporting extension dingger install claude # report Claude Code sessions, for restore dingger install zsh # record typed command lines, for restore @@ -215,7 +242,8 @@ daemon of its own, with a fake local model (`tests/fake-model.py`) and its own pi agent directory: no network, no credentials, your pi untouched. It covers identification, the extension's state and session reports, a turn, `/reload`, an extension's blocked event, screen detection without the -extension, restore, and exit. To try a release without installing it: +extension, restore, reloading an agent on an old extension (and refusing +while busy or with a draft), and exit. To try a release without installing it: `npm install --prefix /tmp/pi-next @earendil-works/pi-coding-agent@<version>` (through `sfw`), then `tests/pi-live.sh /tmp/pi-next/node_modules/.bin/pi`. @@ -81,9 +81,11 @@ Ideas, not yet prioritized: session file. Show today's spend per agent (panel, menu, search) and a total in the menu; optionally warn when one agent passes a limit. One day's sample: $37.44 across 9 agents, 77% from two of them. -16. **Keep agents on the current extension.** The extension reports its - version; dingger shows which agents run an old one and reloads the - idle ones without a draft in one action (restore's check). +16. ~~**Keep agents on the current extension.**~~ Done (see the README): + the daemon updates the installed extension, and reloads agents on an + old one over a control socket, from the menu or `dingger + reload-agents`. The socket is also the way to items 11 (closing an + agent) and 12 (replying to one). 17. **Find an agent by what it discussed.** Search also matches the conversation text in session files (needs an index). 18. **Warn about uncommitted work.** Mark agents whose directory has diff --git a/crates/dinggerd/assets/pi/pi-agent-state.ts b/crates/dinggerd/assets/pi/pi-agent-state.ts index fd13b55..aae40cc 100644 --- a/crates/dinggerd/assets/pi/pi-agent-state.ts +++ b/crates/dinggerd/assets/pi/pi-agent-state.ts @@ -2,7 +2,9 @@ // managed by dingger; reinstalling or updating the integration overwrites this file. // add custom hooks/plugins beside this file instead of editing it. +import fs from "node:fs"; import net from "node:net"; +import path from "node:path"; // The dingger hook receiver listens here; override with DINGGER_AGENT_SOCKET. const socketPath = @@ -15,6 +17,18 @@ const socketPath = const paneId = process.env.DINGGER_PANE_ID || process.env.TMUX_PANE; const source = "dingger:pi"; +// Filled in by `dingger install pi`: the daemon reloads an agent that +// reports another version (`dingger reload-agents`). +const extensionVersion = "__DINGGER_EXTENSION_VERSION__"; + +// Where the daemon asks this extension to reload: one socket per extension +// instance (a reload starts a new one beside the old), next to the daemon's. +const controlPath = path.join( + path.dirname(socketPath), + `pi-${process.pid}-${Math.random().toString(36).slice(2, 8)}.sock`, +); +let controlListening = false; + function enabled() { return !!paneId; } @@ -112,6 +126,8 @@ async function report(state: AgentState | undefined, message?: string): Promise< ...(state ? { state } : {}), message, importance, + extension: extensionVersion, + ...(controlListening ? { control: controlPath } : {}), seq: Date.now() * 1000, ...(sessionFile ? { agent_session_path: sessionFile } : {}), }, @@ -253,11 +269,102 @@ export default function (pi) { }, }); + // The latest context, for the control socket's checks. + let latestCtx: any; + let control: net.Server | undefined; + + // Run only by the control socket below, which checked that pi is idle + // with an empty input box: extensions can reload pi only from a command. + pi.registerCommand("dingger-reload", { + description: "Reload pi (dingger uses it to update its extension; same as /reload)", + handler: async (_args: string, ctx: any) => { + await ctx.reload(); + }, + }); + + // Why this pi should not reload now, or undefined if it can. + function reloadRefusal(): string | undefined { + try { + if (agentActive || blockedCount > 0 || latestCtx?.isIdle?.() !== true || latestCtx?.hasPendingMessages?.()) { + return "busy"; + } + if ((latestCtx?.ui?.getEditorText?.() ?? "").trim() !== "") { + return "draft"; + } + } catch { + return "no context"; + } + return undefined; + } + + function startControl() { + if (control) { + return; + } + control = net.createServer((socket) => { + let buffer = ""; + socket.on("error", () => socket.destroy()); + socket.on("data", (data) => { + buffer += data.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) { + return; + } + let method: unknown; + try { + method = JSON.parse(buffer.slice(0, newline))?.method; + } catch { + method = undefined; + } + if (method !== "reload") { + socket.end(`${JSON.stringify({ ok: false, reason: "unknown method" })}\n`); + return; + } + const refusal = reloadRefusal(); + socket.end(`${JSON.stringify(refusal ? { ok: false, reason: refusal } : { ok: true })}\n`); + if (!refusal) { + // Checked and started in one go: no keystroke comes in between. + pi.sendUserMessage("/dingger-reload", { expandPromptTemplates: true }); + } + }); + }); + control.on("error", () => { + controlListening = false; + }); + try { + fs.unlinkSync(controlPath); + } catch { + // None left over. + } + control.listen(controlPath, () => { + controlListening = true; + void report(undefined); + }); + // The socket alone does not keep pi running. + control.unref(); + } + + pi.on("session_shutdown", () => { + if (!control) { + return; + } + control.close(); + control = undefined; + controlListening = false; + try { + fs.unlinkSync(controlPath); + } catch { + // Gone already. + } + }); + pi.on("session_start", async (event, ctx) => { // TUI only: RPC/JSON/print modes are headless. if (ctx?.mode !== "tui") { return; } + latestCtx = ctx; + startControl(); // A reload can replace this extension mid-run without emitting another agent_start. agentActive = ctx?.isIdle?.() === false; updateSessionFile(ctx); @@ -268,12 +375,14 @@ export default function (pi) { }); pi.on("agent_start", (_event, ctx) => { + latestCtx = ctx; agentActive = true; updateSessionFile(ctx); publishState(); }); pi.on("agent_settled", (_event, ctx) => { + latestCtx = ctx; if (ctx?.isIdle?.() !== true) { return; } diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs index b2f9e97..0074544 100644 --- a/crates/dinggerd/src/hooks.rs +++ b/crates/dinggerd/src/hooks.rs @@ -21,6 +21,12 @@ //! `{"method":"pane.mark_unread","params":{"pane_id":"%3"}}` marks an idle //! agent unread (`dingger unread`, pi's /unread): it shows as done again. //! +//! A pi report also carries the extension's `extension` version and its +//! `control` socket, where the daemon asks it to reload (`{"method": +//! "reload"}`; it answers whether it does): `dingger reload-agents` sends +//! `{"method":"agents.reload_outdated"}`, and the watcher reloads each agent +//! on an old extension once it is idle and off screen. +//! //! Each line is answered with `{"ok":true}`. //! //! `{"method":"poke"}` only wakes the watcher; `dingger focus` and tmux's @@ -33,6 +39,7 @@ use std::collections::{HashMap, HashSet}; use std::io::{BufRead, BufReader}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Condvar, Mutex, MutexGuard, OnceLock}; use std::time::{Duration, Instant}; @@ -59,6 +66,9 @@ struct PaneReports { session: Option<String>, /// The agent's importance, if reported. importance: Option<Importance>, + /// The version of the extension that reported, and its control socket. + extension: Option<String>, + control: Option<String>, /// When the last report arrived. at: Option<Instant>, } @@ -85,6 +95,18 @@ pub fn take_unread(pane_id: &str) -> bool { unread_marks().remove(pane_id) } +/// Whether agents on an old extension should be reloaded (`dingger +/// reload-agents`); the watcher clears it when none is left. +static RELOAD_WANTED: AtomicBool = AtomicBool::new(false); + +pub fn reload_wanted() -> bool { + RELOAD_WANTED.load(Ordering::Relaxed) +} + +pub fn reload_done() { + RELOAD_WANTED.store(false, Ordering::Relaxed); +} + fn wake_signal() -> &'static (Mutex<bool>, Condvar) { static WAKE: OnceLock<(Mutex<bool>, Condvar)> = OnceLock::new(); WAKE.get_or_init(|| (Mutex::new(false), Condvar::new())) @@ -139,6 +161,10 @@ struct HookParams { agent_session_id: Option<String>, #[serde(default)] importance: Option<String>, + #[serde(default)] + extension: Option<String>, + #[serde(default)] + control: Option<String>, } fn handle_stream(stream: UnixStream) { @@ -162,6 +188,12 @@ fn handle_stream(stream: UnixStream) { wake(); continue; } + if message.method == "agents.reload_outdated" { + info!("reload agents on an old extension: requested"); + RELOAD_WANTED.store(true, Ordering::Relaxed); + wake(); + continue; + } if message.method == "pane.mark_unread" { if let Some(params) = message.params { info!(pane = %params.pane_id, "marked unread"); @@ -194,12 +226,32 @@ fn handle_stream(stream: UnixStream) { Some(state) => info!(pane = %params.pane_id, ?state, ?importance, "hook report"), None => info!(pane = %params.pane_id, ?importance, "hook report without state"), } - record(params.pane_id, state, params.agent_session_path.or(params.agent_session_id), importance); + let reported = Reported { + session: params.agent_session_path.or(params.agent_session_id), + importance, + extension: params.extension, + control: params.control, + }; + record(params.pane_id, state, reported); wake(); } } -fn record(pane_id: String, state: Option<AgentState>, session: Option<String>, importance: Option<Importance>) { +/// What a report carries besides its state; each kept until reported again. +#[derive(Debug, Default)] +struct Reported { + session: Option<String>, + importance: Option<Importance>, + extension: Option<String>, + control: Option<String>, +} + +/// A value fit for a tmux option: not empty, no control characters. +fn option_value(value: Option<String>) -> Option<String> { + value.filter(|v| !v.is_empty() && !v.chars().any(char::is_control)) +} + +fn record(pane_id: String, state: Option<AgentState>, reported: Reported) { let mut reports = reports(); let pane = reports.entry(pane_id).or_default(); pane.at = Some(Instant::now()); @@ -207,11 +259,17 @@ fn record(pane_id: String, state: Option<AgentState>, session: Option<String>, i pane.last = Some(state); pane.pending.push(state); } - if let Some(session) = session.filter(|s| !s.is_empty() && !s.chars().any(char::is_control)) { + if let Some(session) = option_value(reported.session) { pane.session = Some(session); } - if importance.is_some() { - pane.importance = importance; + if reported.importance.is_some() { + pane.importance = reported.importance; + } + if let Some(extension) = option_value(reported.extension) { + pane.extension = Some(extension); + } + if let Some(control) = option_value(reported.control) { + pane.control = Some(control); } } @@ -265,6 +323,33 @@ pub fn importance(pane_id: &str) -> Option<Importance> { reports().get(pane_id).and_then(|pane| pane.importance) } +/// The version of the pane's extension and its control socket, if reported. +pub fn extension(pane_id: &str) -> (Option<String>, Option<String>) { + reports().get(pane_id).map(|pane| (pane.extension.clone(), pane.control.clone())).unwrap_or_default() +} + +/// Ask the extension behind `control` to reload; its answer: `Ok(())` when +/// it does, else why not (busy, a draft, or no answer). +pub fn request_reload(control: &str) -> Result<(), String> { + use std::io::Write; + let mut stream = UnixStream::connect(control).map_err(|err| format!("connect: {err}"))?; + stream.set_read_timeout(Some(Duration::from_secs(2))).map_err(|err| err.to_string())?; + stream.write_all(b"{\"method\":\"reload\"}\n").map_err(|err| format!("send: {err}"))?; + let mut answer = String::new(); + BufReader::new(stream).read_line(&mut answer).map_err(|err| format!("no answer: {err}"))?; + #[derive(Deserialize)] + struct Answer { + ok: bool, + #[serde(default)] + reason: Option<String>, + } + match serde_json::from_str::<Answer>(&answer) { + Ok(Answer { ok: true, .. }) => Ok(()), + Ok(Answer { reason, .. }) => Err(reason.unwrap_or_else(|| "refused".into())), + Err(_) => Err(format!("unexpected answer: {}", answer.trim())), + } +} + /// Drop a pane's reports: the agent was not running when the panes were /// listed at `listed`. A report since then is kept: the agent started /// after the listing (Claude reports its session once, as it starts). @@ -286,7 +371,7 @@ mod tests { #[test] fn a_session_report_records_only_the_session() { - record("%t1".into(), None, Some("/p/s.jsonl".into()), None); + record("%t1".into(), None, Reported { session: Some("/p/s.jsonl".into()), ..Default::default() }); assert_eq!(session("%t1").as_deref(), Some("/p/s.jsonl")); assert_eq!(take_observations("%t1"), None, "state stays with the screen"); assert_eq!(last_state("%t1"), None); @@ -295,7 +380,7 @@ mod tests { #[test] fn a_report_after_the_pane_listing_is_not_dropped() { let listed = Instant::now(); - record("%t2".into(), None, Some("/p/s.jsonl".into()), None); + record("%t2".into(), None, Reported { session: Some("/p/s.jsonl".into()), ..Default::default() }); drop_reports("%t2", listed); assert!(session("%t2").is_some(), "the agent started after the listing"); drop_reports("%t2", Instant::now()); @@ -304,11 +389,47 @@ mod tests { #[test] fn importance_is_kept_until_reported_again() { - record("%t3".into(), Some(AgentState::Working), None, Some(Importance::High)); - record("%t3".into(), Some(AgentState::Idle), None, None); + record("%t3".into(), Some(AgentState::Working), Reported { importance: Some(Importance::High), ..Default::default() }); + record("%t3".into(), Some(AgentState::Idle), Reported::default()); assert_eq!(importance("%t3"), Some(Importance::High), "a report without it keeps it"); - record("%t3".into(), None, None, Some(Importance::Normal)); + record("%t3".into(), None, Reported { importance: Some(Importance::Normal), ..Default::default() }); assert_eq!(importance("%t3"), Some(Importance::Normal)); assert_eq!(last_state("%t3"), Some(AgentState::Idle), "a report without state keeps the state"); } + + #[test] + fn the_extension_and_its_control_socket_are_kept_until_reported_again() { + let reported = || Reported { extension: Some("v1".into()), control: Some("/s/pi-1.sock".into()), ..Default::default() }; + record("%t4".into(), Some(AgentState::Idle), reported()); + record("%t4".into(), None, Reported { importance: Some(Importance::Low), ..Default::default() }); + assert_eq!(extension("%t4"), (Some("v1".into()), Some("/s/pi-1.sock".into()))); + record("%t4".into(), None, Reported { extension: Some("v2".into()), ..Default::default() }); + assert_eq!(extension("%t4").0.as_deref(), Some("v2")); + } + + #[test] + fn a_reload_answer_says_whether_it_reloads() { + let dir = std::env::temp_dir().join(format!("dingger-reload-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for (answer, want) in [ + ("{\"ok\":true}", Ok(())), + ("{\"ok\":false,\"reason\":\"draft\"}", Err("draft".to_string())), + ] { + let path = dir.join("pi.sock"); + let _ = std::fs::remove_file(&path); + let listener = UnixListener::bind(&path).unwrap(); + let server = std::thread::spawn(move || { + use std::io::Write; + let (mut stream, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap(); + assert_eq!(line, "{\"method\":\"reload\"}\n"); + stream.write_all(format!("{answer}\n").as_bytes()).unwrap(); + }); + assert_eq!(request_reload(path.to_str().unwrap()), want); + server.join().unwrap(); + } + assert!(request_reload(dir.join("none.sock").to_str().unwrap()).is_err(), "no extension there"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/dinggerd/src/integrations.rs b/crates/dinggerd/src/integrations.rs index b3daf1d..5d9cf7a 100644 --- a/crates/dinggerd/src/integrations.rs +++ b/crates/dinggerd/src/integrations.rs @@ -10,7 +10,28 @@ use serde_json::{json, Value}; /// The embedded pi extension, adapted from herdr's `herdr-agent-state.ts` /// (Apache-2.0, Copyright herdr contributors). +/// It carries a placeholder for its version, which install fills in. const PI_EXTENSION: &str = include_str!("../assets/pi/pi-agent-state.ts"); +const VERSION_PLACEHOLDER: &str = "__DINGGER_EXTENSION_VERSION__"; + +/// The extension's version: a fingerprint of its text (FNV-1a, so it does +/// not change with the Rust release). A running pi reports the version it +/// loaded; one that differs from this runs an old extension. +pub fn pi_extension_version() -> String { + let hash = PI_EXTENSION.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3) + }); + format!("{hash:016x}") +} + +/// The extension as installed: stamped with its version. +fn pi_extension_text() -> String { + PI_EXTENSION.replace(VERSION_PLACEHOLDER, &pi_extension_version()) +} + +fn pi_extension_path() -> Result<PathBuf, String> { + Ok(pi_extension_dir()?.join("dingger-agent-state.ts")) +} fn pi_extension_dir() -> Result<PathBuf, String> { let base = std::env::var_os("PI_CODING_AGENT_DIR") @@ -76,16 +97,40 @@ pub fn install(agent: &str) -> Result<(), String> { fn install_pi() -> Result<(), String> { let dir = pi_extension_dir()?; std::fs::create_dir_all(&dir).map_err(|err| format!("mkdir failed: {err}"))?; - let path = dir.join("dingger-agent-state.ts"); - std::fs::write(&path, PI_EXTENSION).map_err(|err| format!("write failed: {err}"))?; + let path = pi_extension_path()?; + std::fs::write(&path, pi_extension_text()).map_err(|err| format!("write failed: {err}"))?; tracing::info!(path = %path.display(), "installed pi extension"); println!( - "installed {} — new pi sessions report their state directly;\nrestart running pi agents (or reload them) to pick it up", + "installed {} — new pi sessions report their state directly;\nrunning pi agents pick it up when reloaded (`dingger reload-agents`)", path.display() ); Ok(()) } +/// Whether the installed pi extension is this dingger's: only then does a +/// reload bring an agent up to date (otherwise it loads the old one again). +pub fn pi_extension_installed_is_current() -> bool { + pi_extension_path() + .and_then(|path| std::fs::read_to_string(path).map_err(|err| err.to_string())) + .is_ok_and(|installed| installed == pi_extension_text()) +} + +/// At the daemon's start: if the pi extension is installed and differs +/// from this dingger's, update it, so a dingger update needs no reinstall. +/// Only dingger's own file (it says so on its first line). +pub fn update_pi_extension() { + let Ok(path) = pi_extension_path() else { return }; + let Ok(installed) = std::fs::read_to_string(&path) else { return }; + let current = pi_extension_text(); + if installed == current || !installed.starts_with("// installed by dingger") { + return; + } + match std::fs::write(&path, current) { + Ok(()) => tracing::info!(path = %path.display(), version = %pi_extension_version(), "updated pi extension"), + Err(err) => tracing::warn!(path = %path.display(), %err, "failed to update pi extension"), + } +} + /// Claude Code's user settings, where its hooks are configured. fn claude_settings_path() -> Result<PathBuf, String> { std::env::var_os("CLAUDE_CONFIG_DIR") @@ -217,6 +262,17 @@ mod tests { use super::*; #[test] + fn the_installed_extension_carries_its_version_once() { + let version = pi_extension_version(); + assert_eq!(version.len(), 16); + assert_eq!(PI_EXTENSION.matches(VERSION_PLACEHOLDER).count(), 1, "the asset has one placeholder"); + let text = pi_extension_text(); + assert!(!text.contains(VERSION_PLACEHOLDER)); + assert!(text.contains(&format!("\"{version}\"")), "stamped as a string literal"); + assert!(text.starts_with("// installed by dingger"), "update_pi_extension recognizes its own file"); + } + + #[test] fn claude_hook_is_added_beside_other_hooks_and_keys_in_order() { let settings: Value = serde_json::from_str( r#"{"model":"sonnet","hooks":{"SessionStart":[{"matcher":"^startup$","hooks":[{"type":"command","command":"bash herdr.sh session"}]}],"Stop":[]},"theme":"auto"}"#, diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs index 2c36198..1fc5cd2 100644 --- a/crates/dinggerd/src/main.rs +++ b/crates/dinggerd/src/main.rs @@ -90,6 +90,11 @@ enum Command { #[arg(long)] pane: Option<String>, }, + /// Reload the pi agents that run an old dingger extension: those idle + /// and off screen now, the others once they are (a reload while busy, + /// or with a draft in the input box, waits). Until all are reloaded or + /// the daemon restarts. + ReloadAgents, /// Restore tmux sessions saved before tmux last stopped (a crash, /// shutdown, or kill-server): windows, panes, layouts, directories. /// Only adds what is missing; a bare session of the same name (what a @@ -166,6 +171,7 @@ fn main() { no_sounds, no_notifications, } => { + integrations::update_pi_extension(); if let Err(err) = hooks::spawn_listener() { eprintln!("dingger: {err}"); std::process::exit(1); @@ -208,6 +214,12 @@ fn main() { std::process::exit(1); } } + Command::ReloadAgents => { + if let Err(err) = reload_agents() { + eprintln!("dingger: {err}"); + std::process::exit(1); + } + } Command::Poke => { // Best effort: a stopped watcher just means nothing to wake. let _ = hooks::poke(); @@ -355,6 +367,44 @@ fn set_importance(level: Option<&str>, pane: Option<String>) -> Result<(), Strin Ok(()) } +/// `dingger reload-agents`: ask the daemon to reload the agents on an old +/// extension; report what happened within a few seconds. +fn reload_agents() -> Result<(), String> { + let count = |name: &str| tmux::show_global(name).and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(0); + let by_hand = count("@dingger_outdated_by_hand"); + let by_hand_note = || { + if by_hand > 0 { + let agents = if by_hand == 1 { "agent runs" } else { "agents run" }; + println!("{by_hand} {agents} an extension too old to reload this way: type /reload in each"); + } + }; + let before = count("@dingger_outdated"); + if before == 0 { + if by_hand == 0 { + println!("every pi agent runs the current dingger extension"); + } + by_hand_note(); + return Ok(()); + } + if !integrations::pi_extension_installed_is_current() { + return Err("the installed pi extension is not this dingger's, so a reload would load the old one again: run `dingger install pi` first".into()); + } + hooks::send(r#"{"method":"agents.reload_outdated"}"#)?; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let mut left = before; + while left > 0 && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(200)); + left = count("@dingger_outdated"); + } + let agents = |n: usize| if n == 1 { "agent" } else { "agents" }; + println!("reloaded {} of {before} {} on an old dingger extension", before.saturating_sub(left), agents(before)); + if left > 0 { + println!("the other {left} reload once idle and off screen, with an empty input box"); + } + by_hand_note(); + Ok(()) +} + /// `dingger unread`: ask the daemon to mark the pane's idle agent unread, /// and wait until it shows as done. fn mark_unread(pane: Option<String>) -> Result<(), String> { diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs index 9fcccf3..b743256 100644 --- a/crates/dinggerd/src/tmux.rs +++ b/crates/dinggerd/src/tmux.rs @@ -30,6 +30,8 @@ pub struct Pane { pub written_session: String, /// `@dingger_importance`: unset for normal. pub written_importance: String, + pub written_extension: String, + pub written_control: String, } @@ -111,14 +113,16 @@ pub fn list_panes() -> Result<Vec<Pane>, String> { "#{pane_current_path}\t", "#{@agent_name}\t", "#{@agent_session}\t", - "#{@dingger_importance}", + "#{@dingger_importance}\t", + "#{@dingger_extension}\t", + "#{@dingger_control}", ); 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() < 20 { + if fields.len() < 22 { continue; } let attended = fields[4] == "1" && fields[5] != "0"; @@ -161,6 +165,8 @@ pub fn list_panes() -> Result<Vec<Pane>, String> { written_name: fields[17].to_string(), written_session: fields[18].to_string(), written_importance: fields[19].to_string(), + written_extension: fields[20].to_string(), + written_control: fields[21].to_string(), }); } Ok(panes) 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()); diff --git a/menubar/main.swift b/menubar/main.swift index 2230817..82931ab 100644 --- a/menubar/main.swift +++ b/menubar/main.swift @@ -236,6 +236,16 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { refresh() } + /// Reload the agents on an old extension: those that can now, the + /// others as they become idle and leave the screen (the daemon's job). + @objc private func reloadOutdated() { + log("reload agents: requested") + DispatchQueue.global().async { + let result = runReporting(dinggerBinary, ["reload-agents"]) + log("reload agents: \(result.ok ? "" : "failed: ")\(result.output.replacingOccurrences(of: "\n", with: "; "))") + } + } + @objc private func restoreFromMenu(_ sender: NSMenuItem) { restore(sessions: (sender.representedObject as? String).map { [$0] } ?? []) } @@ -304,6 +314,16 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { menu.addItem(item) menu.addItem(.separator()) } + let outdated = fetchOutdated() + if !outdated.isEmpty { + let item = NSMenuItem(title: "⟳ \(outdated.title)", action: outdated.canReload ? #selector(reloadOutdated) : nil, + keyEquivalent: "") + item.target = self + item.isEnabled = outdated.canReload + item.subtitle = outdated.detail + menu.addItem(item) + menu.addItem(.separator()) + } if rows.isEmpty { let none = NSMenuItem(title: "No agents", action: nil, keyEquivalent: "") none.isEnabled = false @@ -443,6 +463,10 @@ if arguments.contains("--selftest") { if arguments.contains("--print") { let rows = fetchRows() print("title: \(plainTitle(for: rows))") + let outdated = fetchOutdated() + if !outdated.isEmpty { + print("⟳ \(outdated.title)\t\(outdated.detail)\(outdated.canReload ? "" : "\t(disabled)")") + } for row in rows { print("\(row.status.symbol) \(row.status.rawValue)\t\(row.markedLabel)\t\(row.subtitle)") } diff --git a/menubar/model.swift b/menubar/model.swift index f0f977e..e4d179c 100644 --- a/menubar/model.swift +++ b/menubar/model.swift @@ -230,6 +230,37 @@ func fetchRestoreOffer() -> RestoreOffer? { return try? JSONDecoder().decode(RestoreOffer.self, from: data) } +// MARK: agents on an old extension + +/// pi agents on an old dingger extension, as the daemon counts them: +/// `@dingger_outdated` it can reload, `@dingger_outdated_by_hand` it cannot +/// (an extension from before reloads). +struct OutdatedAgents: Equatable { + let reloadable: Int + let byHand: Int + + var isEmpty: Bool { reloadable == 0 && byHand == 0 } + var canReload: Bool { reloadable > 0 } + + private func agents(_ n: Int) -> String { "\(n) \(n == 1 ? "Agent" : "Agents")" } + + /// "Reload 3 Agents on an Old Extension", or "2 Agents Need /reload". + var title: String { + canReload ? "Reload \(agents(reloadable)) on an Old Extension" : "\(agents(byHand)) \(byHand == 1 ? "Needs" : "Need") /reload" + } + + var detail: String { + guard canReload else { return "their dingger extension is too old to reload them" } + let when = "each when idle and off screen" + return byHand > 0 ? "\(when) · \(byHand) more need /reload" : when + } +} + +func fetchOutdated() -> OutdatedAgents { + let count = { (name: String) in Int(tmux(["show-options", "-gv", name])?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") ?? 0 } + return OutdatedAgents(reloadable: count("@dingger_outdated"), byHand: count("@dingger_outdated_by_hand")) +} + /// Run `dingger <args>` through the user's login shell, the way a terminal /// starts tmux: restored panes inherit this environment (PATH, LANG), and /// launchd's is bare. DINGGER_RESTORE_DIRECT runs it directly (tests). diff --git a/tests/e2e.sh b/tests/e2e.sh index 4b60316..7f72e3c 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -30,6 +30,8 @@ PASSES=0 # Every dingger invocation below (daemon, focus, poke, menu bar) targets the # isolated server and the test daemon's hook socket. export DINGGER_TMUX_SOCKET="$SOCK" DINGGER_AGENT_SOCKET="$HOOK" DINGGER_STATE_DIR="$DIR/state" +# The daemon updates an installed pi extension when it starts: not the user's. +export PI_CODING_AGENT_DIR="$DIR/pi-agent" t() { tmux -u -L "$SOCK" "$@"; } @@ -38,7 +40,7 @@ cleanup() { [ -n "${DAEMON2_PID:-}" ] && kill "$DAEMON2_PID" 2>/dev/null t kill-server 2>/dev/null tmux -L "$SOCK-restore-$$" kill-server 2>/dev/null - rm -rf "$DIR" + if [ -n "${E2E_KEEP:-}" ]; then echo "kept $DIR"; else rm -rf "$DIR"; fi } trap cleanup EXIT @@ -258,14 +260,14 @@ if [ -x "$MENUBAR" ]; then expect "A1 done" "opt $A1 @agent_state" done expect "menu title" "mb --print | head -1" "title: ×1 ✓1" expect "menu rows: herdr priority; idle most recent first (C1 since its jump in step 4, B1 since the start)" \ - "mb --print | tail -n +2 | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|pi;○ idle|Review PR 42;" + "mb --print | tail -n +2 | grep -v '^⟳' | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|pi;○ idle|Review PR 42;" # Wait for working: B1 was idle before, so idle alone would not show the turn. hook "$B1" working expect "B1 working" "opt $B1 @agent_state" working hook "$B1" idle expect "B1 finishes a turn while watched: idle" "opt $B1 @agent_state" idle expect "and is now the most recent idle agent: listed first" \ - "mb --print | tail -n +2 | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|Review PR 42;○ idle|pi;" + "mb --print | tail -n +2 | grep -v '^⟳' | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|Review PR 42;○ idle|pi;" expect "subtitle: location, harness, waiting time" "mb --print | grep 'Deploy the widget' | cut -f3" "a · wa1 · pi · done now" expect "idle subtitle has no waiting time" "mb --print | grep 'Review PR 42' | cut -f3" "b · wb1 · pi" mb --click "$A1" @@ -319,9 +321,12 @@ for (const arg of process.argv.slice(2)) { await new Promise((r) => setTimeout(r, 300)); } JS +# The extension as installed: stamped with its version. +mkdir -p "$DIR/pi-harness" +PI_CODING_AGENT_DIR="$DIR/pi-harness" "$DINGGER" install pi >/dev/null ext() { # pane events... (run "inside" a pane: tmux sets TMUX_PANE there) local pane=$1; shift - TMUX_PANE="$pane" EXTENSION="$ROOT/crates/dinggerd/assets/pi/pi-agent-state.ts" \ + TMUX_PANE="$pane" EXTENSION="$DIR/pi-harness/extensions/dingger-agent-state.ts" \ node --experimental-strip-types --no-warnings "$DIR/ext-harness.mjs" "$@" } if command -v node >/dev/null; then @@ -981,13 +986,52 @@ if command -v node >/dev/null; then : >"$EXTLOG"; iext "$U1" session_start "/unread" expect "/unread marks it" "opt $U1 @agent_state" done expect "and says so" "grep -c '^notify marked unread: dingger shows it as done until you come back to it$' $EXTLOG" 1 - look "$AWAY"; look "$U1" + # A held mark clears only once a tick sees the user away (a tick here + # takes ~0.3 s; two looks within one would never be seen apart). + look "$AWAY"; sleep 1; look "$U1" expect "unr: seen again" "opt $U1 @agent_state" idle : >"$EXTLOG"; iext "$U1" session_start agent_start "/unread" expect "/unread while working is refused" "grep -c '^notify only an idle agent can be marked unread$' $EXTLOG" 1 expect "and changes nothing" "opt $U1 @agent_state" working fi +# --- 23. agents on an old extension ------------------------------------------ +# pi's extension reports its version and control socket; the daemon counts +# the agents on another version, which the menu offers to reload. (The +# reload itself, against a real pi: tests/pi-live.sh.) +echo "23. agents on an old extension" +ext_report() { # pane version control + python3 - "$HOOK" "$1" "$2" "$3" <<'PY' +import json, socket, sys +params = {"pane_id": sys.argv[2], "agent": "pi", "state": "idle", "extension": sys.argv[3], "control": sys.argv[4]} +s = socket.socket(socket.AF_UNIX) +s.connect(sys.argv[1]) +s.sendall((json.dumps({"method": "pane.report_agent", "params": params}) + "\n").encode()) +s.close() +PY +} +mkdir -p "$DIR/pi-current" +PI_CODING_AGENT_DIR="$DIR/pi-current" $DINGGER install pi >/dev/null +CURRENT=$(grep -o 'extensionVersion = "[0-9a-f]*"' "$DIR/pi-current/extensions/dingger-agent-state.ts" | cut -d'"' -f2) +t new-session -d -s ext -n e1 -c "$DIR" "$DIR/bin/pi" +E1=$(t display -p -t ext:e1 '#{pane_id}') +# The agents reported so far sent no version: an extension from before +# versions, which only a /reload by hand updates. +expect "ext: the others are by hand" "[ \"\$(gopt @dingger_outdated_by_hand)\" -gt 0 ] && echo yes" yes +BY_HAND=$(gopt @dingger_outdated_by_hand) +ext_report "$E1" 0000000000000000 "$DIR/pi-0.sock" +expect "ext: the version is on the pane" "opt $E1 @dingger_extension" 0000000000000000 +expect "and its control socket" "opt $E1 @dingger_control" "$DIR/pi-0.sock" +expect "another version: reloadable" "gopt @dingger_outdated" 1 +expect "the menu offers to reload it" "mb --print | grep '^⟳'" \ + "$(printf '⟳ Reload 1 Agent on an Old Extension\teach when idle and off screen · %s more need /reload' "$BY_HAND")" +expect "reload-agents refuses without the current extension installed" "$DINGGER reload-agents 2>&1 | grep -c 'run .dingger install pi. first'" 1 +ext_report "$E1" "$CURRENT" "$DIR/pi-1.sock" +expect "the current version: not counted" "gopt @dingger_outdated" 0 +expect "the menu: only those by hand, not clickable" "mb --print | grep '^⟳'" \ + "$(printf '⟳ %s Agents Need /reload\ttheir dingger extension is too old to reload them\t(disabled)' "$BY_HAND")" +t kill-session -t ext + # Every daemon this run started must stop with it. kill "$DAEMON_PID" "$DAEMON2_PID" 2>/dev/null expect "the test daemons stop" "ps -o pid= -p $DAEMON_PID,$DAEMON2_PID | wc -l | tr -d ' '" 0 diff --git a/tests/kill-race.sh b/tests/kill-race.sh index 388c297..d85f654 100755 --- a/tests/kill-race.sh +++ b/tests/kill-race.sh @@ -8,7 +8,7 @@ r() { tmux -u -L $R "$@"; } mkdir -p $D/bin; printf '#!/bin/sh\nexec sleep 100000\n' > $D/bin/pi; chmod +x $D/bin/pi r -f /dev/null new-session -d -s a -n w1 $D/bin/pi; r new-session -d -s b -n w2 $D/bin/pi P=$(r display -p -t a:w1 '#{pane_id}') -start() { DINGGER_TMUX_SOCKET=$R DINGGER_AGENT_SOCKET=$D/hook.sock DINGGER_STATE_DIR=$D/state $BIN watch --interval-secs 30 --no-sounds --no-notifications >/dev/null 2>&1 & DP=$!; for _ in $(seq 50); do [ -S $D/hook.sock ] && break; sleep 0.05; done; } +start() { PI_CODING_AGENT_DIR=$D/pi-agent DINGGER_TMUX_SOCKET=$R DINGGER_AGENT_SOCKET=$D/hook.sock DINGGER_STATE_DIR=$D/state $BIN watch --interval-secs 30 --no-sounds --no-notifications >/dev/null 2>&1 & DP=$!; for _ in $(seq 50); do [ -S $D/hook.sock ] && break; sleep 0.05; done; } hook() { python3 -c " import json,socket; s=socket.socket(socket.AF_UNIX); s.connect('$D/hook.sock') s.sendall((json.dumps({'method':'pane.report_agent','params':{'pane_id':'$P','agent':'pi','state':'$1'}})+'\n').encode())"; } diff --git a/tests/pi-live.sh b/tests/pi-live.sh index 059a92b..4ea4caf 100755 --- a/tests/pi-live.sh +++ b/tests/pi-live.sh @@ -9,7 +9,8 @@ # Covers: identification, the extension's state and session reports, a turn # (working, then done), /reload, an extension's blocked event, /importance, /unread, # screen detection for a pi without the extension, restore (`pi --session`, -# importance included), exit. +# importance included), reloading an agent on an old extension (refused +# while busy or with a draft), exit. set -u PI_BIN=$(cd "$(dirname "$1")" && pwd)/$(basename "$1") ROOT=$(cd "$(dirname "$0")/.." && pwd) @@ -35,7 +36,7 @@ cleanup() { t kill-server 2>/dev/null [ -n "${DAEMON_PID:-}" ] && kill "$DAEMON_PID" 2>/dev/null [ -n "${FAKE_PID:-}" ] && kill "$FAKE_PID" 2>/dev/null - rm -rf "$D" + if [ -n "${PI_LIVE_KEEP:-}" ]; then echo "kept $D"; else rm -rf "$D"; fi } trap cleanup EXIT @@ -72,6 +73,12 @@ export PATH="$D/bin:$PATH" PI_CODING_AGENT_DIR="$D/agent" PI_OFFLINE=1 PI_SKIP_V export DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$D/hook.sock" DINGGER_STATE_DIR="$D/state" DINGGER_TMUX_CONFIG="$D/tmux.conf" t -f "$D/tmux.conf" new-session -d -s main -n agent -x 140 -y 40 -c "$D/work" t new-window -d -t main: -n plain -c "$D/work2" +EXT="$D/agent/extensions/dingger-agent-state.ts" +stamp() { grep -o 'extensionVersion = "[0-9a-f]*"' "$EXT" | cut -d'"' -f2; } +CURRENT=$(stamp) +# An installed extension of another version: the daemon updates it as it +# starts (a pi started after that loads the current one). +sed -i '' "s/extensionVersion = \"$CURRENT\"/extensionVersion = \"0000000000000000\"/" "$EXT" "$DINGGER" watch --interval-secs 1 --no-sounds --no-notifications >"$D/daemon.log" 2>&1 & DAEMON_PID=$! t send-keys -t main:agent "command -v pi > $D/which-pi" Enter @@ -84,6 +91,10 @@ NO_MCP=$("$PI_BIN" --help 2>&1 | grep -q -- --no-mcp && echo --no-mcp) st() { t show -pv -t "main:$1" @agent_state 2>/dev/null; } type_line() { t send-keys -t "main:$1" -l "$2"; sleep 0.3; t send-keys -t "main:$1" Enter; } +echo "0. the daemon updates the installed extension" +expect "to its own version" "stamp" "$CURRENT" 30 +[ ${#CURRENT} -eq 16 ] && pass "a 16-digit version" || fail "version [$CURRENT]" + echo "1. the extension's reports" t send-keys -t main:agent "clear; PI_CODING_AGENT_DIR=$D/agent pi $NO_MCP" Enter expect "identified as pi" "t show -pv -t main:agent @agent 2>/dev/null" pi 150 @@ -123,6 +134,59 @@ expect "/unread: done again" "st agent" done 50 expect "by a mark" "sed 's/\x1b\[[0-9;]*m//g' $D/daemon.log | grep -c 'mark unread target=main:agent applied=true'" 1 30 expect "pi says so" "t capture-pane -p -t main:agent | grep -c 'marked unread: dingger shows it as done'" 1 30 +echo "1b. reloading an agent on an old extension" +ctl() { t show -pv -t main:agent @dingger_control 2>/dev/null; } +ask_reload() { python3 -c ' +import socket, sys +s = socket.socket(socket.AF_UNIX); s.settimeout(3); s.connect(sys.argv[1]) +s.sendall(b"{\"method\":\"reload\"}\n"); print(s.makefile().readline().strip())' "$(ctl)"; } +reloads() { sed 's/\x1b\[[0-9;]*m//g' $D/daemon.log | grep -c "reload: $1"; } +expect "the agent reports the current version" "t show -pv -t main:agent @dingger_extension 2>/dev/null" "$CURRENT" +expect "and a control socket" "[ -S \"\$(ctl)\" ] && echo yes" yes +expect "nothing to reload" "\"$DINGGER\" reload-agents" "every pi agent runs the current dingger extension" 1 +# Run an old one: change the installed file, /reload by hand, then install +# the current one again (what a dingger update does). +sed -i '' "s/extensionVersion = \"$CURRENT\"/extensionVersion = \"0000000000000000\"/" "$EXT" +type_line agent "/reload" +expect "after /reload, it reports the old version" "t show -pv -t main:agent @dingger_extension 2>/dev/null" 0000000000000000 80 +expect "its new instance has a socket of its own" "[ -S \"\$(ctl)\" ] && echo yes" yes +PI_CODING_AGENT_DIR="$D/agent" "$DINGGER" install pi >/dev/null +expect "the daemon counts it outdated" "t show -gv @dingger_outdated 2>/dev/null" 1 30 +SOCK1=$(ctl) +# Reloading cannot help while the installed file is not the current one. +cp "$EXT" "$D/current.ts"; echo "// edited" >>"$EXT" +expect "reload-agents refuses: install first" "\"$DINGGER\" reload-agents 2>&1 | grep -c 'run .dingger install pi. first'" 1 1 +python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.connect(sys.argv[1]); s.sendall(b"{\"method\":\"agents.reload_outdated\"}\n"); s.recv(64)' "$D/hook.sock" +expect "and so does the daemon, asked directly" "sed 's/\x1b\[[0-9;]*m//g' $D/daemon.log | grep -c 'reload agents: the installed pi extension is not'" 1 30 +sleep 1.5 +expect "without reloading it" "reloads reloading" 0 1 +cp "$D/current.ts" "$EXT" +# The extension's own checks, asked directly. +type_line agent "slow 4 busy turn" +expect "working" "st agent" working 50 +expect "busy: it refuses" "ask_reload" '{"ok":false,"reason":"busy"}' +expect "and the turn ends" "st agent" done 80 +t send-keys -t main:agent -l "half typed" +sleep 0.5 +expect "a draft: it refuses" "ask_reload" '{"ok":false,"reason":"draft"}' +# Through dingger: refused for the draft, retried until it can. +"$DINGGER" reload-agents >"$D/reload.out" +expect "reload-agents: not yet" "head -1 $D/reload.out" "reloaded 0 of 1 agent on an old dingger extension" +expect "it says why it waits" "sed -n 2p $D/reload.out" "the other 1 reload once idle and off screen, with an empty input box" +expect "the daemon was told no: a draft" "sed 's/\x1b\[[0-9;]*m//g' $D/daemon.log | grep -c 'reload: not now target=main:agent reason=draft'" 1 30 +expect "the draft is untouched" "t capture-pane -p -t main:agent | grep -c 'half typed'" 1 +for _ in $(seq 10); do t send-keys -t main:agent BSpace; done +expect "once the input box is empty: reloaded" "t show -pv -t main:agent @dingger_extension 2>/dev/null" "$CURRENT" 150 +expect "by the daemon" "reloads reloading" 1 30 +expect "a new instance (a new socket)" "[ \"\$(ctl)\" != \"$SOCK1\" ] && [ -S \"\$(ctl)\" ] && echo yes" yes +expect "the old socket is gone" "[ -e \"$SOCK1\" ] && echo left || echo gone" gone +expect "nothing left to reload" "t show -gv @dingger_outdated 2>/dev/null" 0 30 +expect "still done (unseen)" "st agent" done +expect "no message reached the model" "grep -c dingger-reload \"\$(t show -pv -t main:agent @agent_session)\"" 0 +type_line agent "slow 1 after" +expect "it still works: working" "st agent" working 50 +expect "and done" "st agent" done 60 + echo "2. screen detection (pi without the extension)" t send-keys -t main:plain "PI_CODING_AGENT_DIR=$D/agent-plain pi $NO_MCP" Enter expect "identified as pi" "t show -pv -t main:plain @agent 2>/dev/null" pi 150 |
