From 6cb33167cbd2c7f3673d74be0f6b1d91609501a2 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 7 Oct 2026 14:50:33 +0000 Subject: [PATCH 01/13] feat: find the agent sessions herdr's panes hold in a workspace dl recreate ends every agent in the container. herdr already saves, per pane, the line that starts the pane's agent again. agent_sessions::collect reads it for each pane attached to the workspace, and herdr agent get decides whether the agent in that pane is still live. Claude-Session: https://claude.ai/code/session_01Ro7QyyAtHmrh9oxyyK6h9m --- rust/devlaunch-core/src/clients/herdr.rs | 75 ++- .../src/flows/agent_sessions.rs | 434 ++++++++++++++++++ rust/devlaunch-core/src/flows/mod.rs | 1 + .../src/flows/session_manager.rs | 13 +- 4 files changed, 517 insertions(+), 6 deletions(-) create mode 100644 rust/devlaunch-core/src/flows/agent_sessions.rs diff --git a/rust/devlaunch-core/src/clients/herdr.rs b/rust/devlaunch-core/src/clients/herdr.rs index baed0a64..02417002 100644 --- a/rust/devlaunch-core/src/clients/herdr.rs +++ b/rust/devlaunch-core/src/clients/herdr.rs @@ -1382,7 +1382,7 @@ pub(crate) struct Visibility<'a> { } /// A value that is present and not just whitespace. -fn non_empty(value: Option<&str>) -> Option { +pub(crate) fn non_empty(value: Option<&str>) -> Option { let value = crate::osext::strip(value?); (!value.is_empty()).then(|| value.to_owned()) } @@ -1778,6 +1778,77 @@ pub(crate) fn process_info_in(answer: &str) -> Option { .map(|envelope| envelope.result.process_info) } +/// The argv that asks herdr which agent one pane holds, and in what state. +pub(crate) fn agent_get_argv(pane_id: &str) -> Vec { + vec!["agent".to_owned(), "get".to_owned(), pane_id.to_owned()] +} + +/// An agent's state, as herdr reports it in `agent get`'s `agent_status`. +/// +/// `Unknown` stands for herdr's own `unknown` and for any word this build does +/// not know, so a herdr that grows a state reads as one nobody can vouch for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AgentStatus { + /// Waiting for the user. + Idle, + Working, + /// Waiting on a permission prompt. + Blocked, + /// The agent has ended. herdr keeps a finished agent queryable in its pane. + Done, + Unknown, +} + +/// One pane's agent, as `herdr agent get` describes it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaneAgent { + /// herdr's name for the agent: `claude`, `codex`, ... + pub agent: String, + pub status: AgentStatus, +} + +/// What `herdr agent get` said about one pane. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AgentReading { + /// No answer that reads as herdr's: a refusal, a timeout, a herdr too old for + /// the command. + Unanswered, + /// herdr answered, and the pane holds no agent. + NoAgent, + Agent(PaneAgent), +} + +/// The agent in `herdr agent get`'s answer: `/result/agent`, with its `agent` +/// name and its `agent_status`. +pub(crate) fn agent_reading_in(answer: &str) -> AgentReading { + let Ok(value) = serde_json::from_str::(answer) else { + return AgentReading::Unanswered; + }; + let Some(result) = value.get("result") else { + return AgentReading::Unanswered; + }; + let Some(agent) = result.get("agent").filter(|agent| !agent.is_null()) else { + return AgentReading::NoAgent; + }; + let Some(name) = agent.get("agent").and_then(serde_json::Value::as_str) else { + return AgentReading::NoAgent; + }; + let status = match agent + .get("agent_status") + .and_then(serde_json::Value::as_str) + { + Some("idle") => AgentStatus::Idle, + Some("working") => AgentStatus::Working, + Some("blocked") => AgentStatus::Blocked, + Some("done") => AgentStatus::Done, + _ => AgentStatus::Unknown, + }; + AgentReading::Agent(PaneAgent { + agent: name.to_owned(), + status, + }) +} + // =========================================================================== // starting the agent again after herdr restarts // =========================================================================== @@ -2014,7 +2085,7 @@ pub(crate) enum Saved { /// /// herdr's pane ids are `w:p`, and the file keys panes by /// an internal number that `public_pane_numbers` maps to the public one. -fn saved_argv(session_json: &str, pane_id: &str) -> Option>> { +pub(crate) fn saved_argv(session_json: &str, pane_id: &str) -> Option>> { let (workspace_id, public) = pane_id.split_once(":p")?; let public: u64 = public.parse().ok()?; let session: serde_json::Value = serde_json::from_str(session_json).ok()?; diff --git a/rust/devlaunch-core/src/flows/agent_sessions.rs b/rust/devlaunch-core/src/flows/agent_sessions.rs new file mode 100644 index 00000000..e6c6f8a7 --- /dev/null +++ b/rust/devlaunch-core/src/flows/agent_sessions.rs @@ -0,0 +1,434 @@ +//! Keeping the agent sessions a workspace holds across a recreate (devlaunch#673). +//! +//! `dl recreate` replaces the container, and with it every process in it: +//! the agent in each herdr pane attached to the workspace ends. Its conversation +//! does not, because the transcript lives in `~/.claude`, which outlives the +//! container. So a recreate can bring the agent back, if it knows how each one +//! was started and where. +//! +//! herdr already knows both. It keeps, per pane, the argv it types to start that +//! pane's agent again after herdr itself restarts (`agent_resume.argv` in +//! `session.json`): `dl -- ... claude ... --resume `. dl reports that +//! argv when it launches an agent, and the container hook reports it again when +//! the session id changes (`/clear`, an in-agent `/resume`). That is the line to +//! type after a recreate too, into the same pane, once the old transport has +//! exited and the pane is back at its shell. +//! +//! Two steps, so a caller can put the recreate between them: +//! +//! 1. [`collect`] asks herdr which panes hold a live session into this workspace +//! and reads the line each one would be started again with. +//! 2. [`restart`] types each line into its pane with `herdr pane run`. +//! +//! **Every failure is a notice, never a refusal of the recreate.** A herdr that +//! does not answer, a pane with no saved line, a pane that will not take the +//! line: each costs that agent its automatic restart, and the caller says so with +//! the line to type by hand. A recreate asked for is a recreate done. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use crate::clients::herdr; +use crate::flows::session_manager; +use crate::runner::Runner; + +/// What one question to herdr may take. +/// +/// Per question rather than one budget for the scan, because the scan is not on +/// a pane's path the way [`session_manager::pane_destination`] is: a recreate +/// takes minutes, and a pane skipped because an earlier one was slow would be an +/// agent ended with nobody told. +const ASK_WITHIN: Duration = Duration::from_secs(2); + +/// The herdr this process can ask, and where it keeps its saved session. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Manager { + binary: String, + /// The pane this process runs in. Nothing types into it: its foreground is + /// this `dl`. + own_pane: Option, + session_file: PathBuf, +} + +impl Manager { + /// The herdr whose pane `host` runs in, or `None` outside one. + pub fn from_host(host: &crate::flows::launch::Host) -> Option { + Self::resolve(&host.herdr, host.herdr_bin.as_deref()) + } + + /// The herdr whose pane `env` describes, or `None` outside one. + /// + /// The socket is required because its directory is where herdr saves the + /// session, which is the one record of how each pane's agent starts again. + /// `binary` falls back to a `PATH` lookup, the rule every other herdr call in + /// dl follows. + pub(crate) fn resolve(env: &herdr::HostEnv, binary: Option<&str>) -> Option { + if !crate::flows::provision::provisioning_disabled(env.in_pane.as_deref()) { + return None; + } + let socket = herdr::non_empty(env.socket.as_deref())?; + let session_file = Path::new(&socket).parent()?.join("session.json"); + Some(Self { + binary: herdr::non_empty(binary) + .unwrap_or_else(|| crate::flows::launch::HERDR_BIN_FALLBACK.to_owned()), + own_pane: herdr::non_empty(env.pane_id.as_deref()), + session_file, + }) + } + + fn ask(&self, runner: &dyn Runner, args: &[String]) -> Option { + session_manager::ask( + runner, + &self.binary, + args, + &mut session_manager::Budget::of(ASK_WITHIN), + ) + } +} + +/// One pane's agent, and the line that starts it again. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HeldSession { + pub pane_id: String, + /// herdr's saved resume argv for the pane, as herdr would type it. + pub line: Vec, +} + +/// What [`collect`] found in herdr's panes for one workspace. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct HeldSessions { + /// The sessions that can be started again. + pub sessions: Vec, + /// Panes that hold a live agent in this workspace and no saved line for it. + pub unresumable: Vec, +} + +/// The agent sessions herdr's panes hold in `workspace_id`, or `None` when herdr +/// did not answer the question of which panes there are. +/// +/// A pane counts when its foreground is a dl transport into this workspace (the +/// reading the pane shell does, [`session_manager::workspace_among`]) and herdr +/// does not say its agent has ended. Its line is herdr's saved resume argv, taken +/// only when the words after its `--` start an agent dl knows by name. +/// +/// `herdr agent get` decides the two doubtful cases. A saved line in a pane whose +/// agent herdr calls `done`, or where herdr names no agent, is a line left over +/// from an agent the user already quit; typing it would start one nobody asked +/// for. A pane with no saved line and a live agent is an agent this recreate ends +/// with no way back, which the caller says. A herdr that does not answer +/// `agent get` is no reason to drop a line it saved. +pub fn collect( + runner: &dyn Runner, + manager: &Manager, + workspace_id: &str, + read: &dyn Fn(&Path) -> Option, +) -> Option { + let panes = manager + .ask(runner, &herdr::pane_list_argv()) + .as_deref() + .and_then(herdr::panes_in)?; + let session = read(&manager.session_file); + let mut held = HeldSessions::default(); + for pane in panes { + let Some(info) = manager + .ask(runner, &herdr::process_info_argv(&pane.pane_id)) + .as_deref() + .and_then(herdr::process_info_in) + else { + continue; + }; + if session_manager::workspace_among(&info).as_deref() != Some(workspace_id) { + continue; + } + let line = session + .as_deref() + .and_then(|json| herdr::saved_argv(json, &pane.pane_id)) + .flatten() + .filter(|argv| starts_an_agent(argv)); + let live = match agent_reading(runner, manager, &pane.pane_id) { + herdr::AgentReading::Unanswered => None, + herdr::AgentReading::NoAgent => Some(false), + herdr::AgentReading::Agent(agent) => Some(agent.status != herdr::AgentStatus::Done), + }; + match (line, live) { + (Some(line), Some(true) | None) => held.sessions.push(HeldSession { + pane_id: pane.pane_id, + line, + }), + (None, Some(true)) => held.unresumable.push(pane.pane_id), + (Some(_) | None, _) => {} + } + } + Some(held) +} + +/// What herdr says one pane's agent is doing. +pub fn agent_reading(runner: &dyn Runner, manager: &Manager, pane_id: &str) -> herdr::AgentReading { + manager + .ask(runner, &herdr::agent_get_argv(pane_id)) + .as_deref() + .map_or(herdr::AgentReading::Unanswered, herdr::agent_reading_in) +} + +/// Whether a saved line starts an agent: `dl [...] -- [NAME=value ...] ...`. +fn starts_an_agent(argv: &[String]) -> bool { + argv.iter() + .skip_while(|word| *word != "--") + .skip(1) + .find(|word| !herdr::is_assignment(word)) + .is_some_and(|program| herdr::agent_named(program).is_some()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::ScriptedRunner; + use devlaunch_test_support::Response; + + const HERDR: &str = "/opt/herdr/bin/herdr"; + + fn env(pane: &str) -> herdr::HostEnv { + herdr::HostEnv { + enabled: None, + in_pane: Some("1".to_owned()), + pane_id: Some(pane.to_owned()), + socket: Some("/run/herdr/herdr.sock".to_owned()), + binary: Some(HERDR.to_owned()), + } + } + + fn manager() -> Manager { + Manager::resolve(&env("w1:p9"), Some(HERDR)).expect("a herdr pane") + } + + fn pane_list(panes: &[&str]) -> String { + let rows: Vec = panes + .iter() + .map(|pane| { + format!( + r#"{{"pane_id":"{pane}","terminal_id":"t","workspace_id":"w1","tab_id":"w1:t1","focused":false,"agent_status":"idle"}}"# + ) + }) + .collect(); + format!( + r#"{{"id":"cli:pane:list","result":{{"type":"pane_list","panes":[{}]}}}}"#, + rows.join(",") + ) + } + + fn process_info(chain: &[&[&str]]) -> String { + let rows: Vec = chain + .iter() + .map(|argv| { + let words: Vec = argv.iter().map(|word| format!("\"{word}\"")).collect(); + format!(r#"{{"pid":100,"argv":[{}]}}"#, words.join(",")) + }) + .collect(); + format!( + r#"{{"id":"cli:pane:process_info","result":{{"type":"pane_process_info","process_info":{{"foreground_processes":[{}]}}}}}}"#, + rows.join(",") + ) + } + + /// herdr 0.9.2's session file: panes `w1:p1`..`w1:pN`, each with its argv. + fn saved(argvs: &[Option<&str>]) -> String { + let numbers: Vec = (1..=argvs.len()).map(|n| format!(r#""{n}":{n}"#)).collect(); + let panes: Vec = argvs + .iter() + .enumerate() + .map(|(index, argv)| { + let resume = argv.map_or(String::new(), |argv| { + format!( + r#","agent_resume":{{"source":"devlaunch:claude","agent":"claude","argv":{argv}}}"# + ) + }); + format!(r#""{}":{{"cwd":"/"{resume}}}"#, index + 1) + }) + .collect(); + format!( + r#"{{"workspaces":[{{"id":"w1","public_pane_numbers":{{{}}},"tabs":[{{"panes":{{{}}}}}]}}]}}"#, + numbers.join(","), + panes.join(",") + ) + } + + const CLAUDE_LINE: &str = + r#"["dl","myws","--","IS_SANDBOX=1","claude","--remote-control=myws","--resume","4b1e"]"#; + const OTHER_LINE: &str = r#"["dl","other","--","claude","--resume","77aa"]"#; + + fn words(json: &str) -> Vec { + serde_json::from_str(json).expect("an argv") + } + + const TRANSPORT: &[&str] = &["ssh", "-F", "/tmp/c", "-t", "myws.devpod", "claude"]; + + #[test] + fn the_panes_in_this_workspace_are_collected_with_their_saved_lines() { + let runner = ScriptedRunner::new(); + runner.script( + [HERDR, "pane", "list"], + Response::stdout(pane_list(&["w1:p1", "w1:p2", "w1:p3"])), + ); + runner.script( + [HERDR, "pane", "process-info", "--pane", "w1:p1"], + Response::stdout(process_info(&[&["dl", "myws", "--", "claude"], TRANSPORT])), + ); + runner.script( + [HERDR, "pane", "process-info", "--pane", "w1:p2"], + Response::stdout(process_info(&[&["ssh", "-t", "other.devpod", "claude"]])), + ); + // A plain shell in this workspace: nothing saved, and herdr names no + // agent in it, so there is nothing to start again. + runner.script( + [HERDR, "pane", "process-info", "--pane", "w1:p3"], + Response::stdout(process_info(&[TRANSPORT])), + ); + let file = saved(&[Some(CLAUDE_LINE), Some(OTHER_LINE), None]); + + let held = collect(&runner, &manager(), "myws", &|path| { + assert_eq!(path, Path::new("/run/herdr/session.json")); + Some(file.clone()) + }); + + assert_eq!( + held, + Some(HeldSessions { + sessions: vec![HeldSession { + pane_id: "w1:p1".to_owned(), + line: words(CLAUDE_LINE), + }], + unresumable: Vec::new(), + }) + ); + } + + fn agent(name: &str, status: &str) -> String { + format!( + r#"{{"id":"cli:agent:get","result":{{"type":"agent","agent":{{"pane_id":"w1:p1","agent":"{name}","agent_status":"{status}","state_change_seq":4}}}}}}"# + ) + } + + /// herdr's word on the agent decides the two doubtful panes: a line saved for + /// an agent that has ended is not typed again, and a live agent with no line + /// is named so the caller can say it ends. + #[test] + fn herdr_says_which_saved_lines_still_stand_for_a_live_agent() { + let runner = ScriptedRunner::new(); + runner.script( + [HERDR, "pane", "list"], + Response::stdout(pane_list(&["w1:p1", "w1:p2", "w1:p3"])), + ); + runner.script( + [HERDR, "pane", "process-info"], + Response::stdout(process_info(&[TRANSPORT])), + ); + runner.script( + [HERDR, "agent", "get", "w1:p1"], + Response::stdout(agent("claude", "done")), + ); + runner.script( + [HERDR, "agent", "get", "w1:p2"], + Response::stdout(agent("claude", "working")), + ); + runner.script( + [HERDR, "agent", "get", "w1:p3"], + Response::stdout(agent("claude", "idle")), + ); + let file = saved(&[Some(CLAUDE_LINE), None, Some(CLAUDE_LINE)]); + + let held = collect(&runner, &manager(), "myws", &|_| Some(file.clone())); + + assert_eq!( + held, + Some(HeldSessions { + sessions: vec![HeldSession { + pane_id: "w1:p3".to_owned(), + line: words(CLAUDE_LINE), + }], + unresumable: vec!["w1:p2".to_owned()], + }) + ); + } + + /// A saved line that does not start an agent is a plain attach, and typing it + /// again would bring back nothing a recreate took. + #[test] + fn a_saved_line_that_starts_no_agent_is_not_a_session() { + let runner = ScriptedRunner::new(); + runner.script( + [HERDR, "pane", "list"], + Response::stdout(pane_list(&["w1:p1"])), + ); + runner.script( + [HERDR, "pane", "process-info"], + Response::stdout(process_info(&[TRANSPORT])), + ); + let file = saved(&[Some(r#"["dl","myws","--","make","claude"]"#)]); + + let held = collect(&runner, &manager(), "myws", &|_| Some(file.clone())); + + assert_eq!(held, Some(HeldSessions::default())); + } + + /// What part 3 of devlaunch#673 reads to tell an idle workspace from a busy + /// one, word for word as herdr 0.9 spells `agent_status`. + #[test] + fn each_agent_status_herdr_reports_is_read_as_itself() { + use herdr::{AgentReading, AgentStatus, PaneAgent}; + for (word, status) in [ + ("idle", AgentStatus::Idle), + ("working", AgentStatus::Working), + ("blocked", AgentStatus::Blocked), + ("done", AgentStatus::Done), + ("unknown", AgentStatus::Unknown), + ("thinking-hard", AgentStatus::Unknown), + ] { + let runner = ScriptedRunner::new().with_script( + [HERDR, "agent", "get"], + Response::stdout(agent("codex", word)), + ); + assert_eq!( + agent_reading(&runner, &manager(), "w1:p1"), + AgentReading::Agent(PaneAgent { + agent: "codex".to_owned(), + status, + }), + "{word}" + ); + } + let none = ScriptedRunner::new().with_script( + [HERDR, "agent", "get"], + Response::stdout(r#"{"id":"x","result":{"type":"agent","agent":null}}"#), + ); + assert_eq!( + agent_reading(&none, &manager(), "w1:p1"), + AgentReading::NoAgent + ); + let refused = ScriptedRunner::new().with_script( + [HERDR, "agent", "get"], + Response::failed(1, "").and_stdout(r#"{"error":{"code":"pane_not_found"}}"#), + ); + assert_eq!( + agent_reading(&refused, &manager(), "w1:p1"), + AgentReading::Unanswered + ); + } + + #[test] + fn a_herdr_that_will_not_list_its_panes_is_no_answer() { + let runner = + ScriptedRunner::new().with_script([HERDR, "pane", "list"], Response::exited(1)); + + assert_eq!(collect(&runner, &manager(), "myws", &|_| None), None); + } + + #[test] + fn outside_a_herdr_pane_there_is_no_manager_to_ask() { + let mut outside = env("w1:p9"); + outside.in_pane = None; + assert_eq!(Manager::resolve(&outside, Some(HERDR)), None); + let mut no_socket = env("w1:p9"); + no_socket.socket = None; + assert_eq!(Manager::resolve(&no_socket, Some(HERDR)), None); + } +} diff --git a/rust/devlaunch-core/src/flows/mod.rs b/rust/devlaunch-core/src/flows/mod.rs index 3e2bf87f..ed6d7201 100644 --- a/rust/devlaunch-core/src/flows/mod.rs +++ b/rust/devlaunch-core/src/flows/mod.rs @@ -1,4 +1,5 @@ // binary surface — not part of the frozen wf API (#251 §7) +pub mod agent_sessions; pub mod agent_worktrees; // binary surface — not part of the frozen wf API (#251 §7): the branch decision's // refusal travels inside a launch refusal, and the words for it are the binary's. diff --git a/rust/devlaunch-core/src/flows/session_manager.rs b/rust/devlaunch-core/src/flows/session_manager.rs index 8c0427fc..83f607ab 100644 --- a/rust/devlaunch-core/src/flows/session_manager.rs +++ b/rust/devlaunch-core/src/flows/session_manager.rs @@ -586,12 +586,12 @@ fn send_report(runner: &dyn Runner, report: &herdr::ResumeReport, seq: u128) -> /// per call and knows nothing about wall-clock instants -- which is also what /// makes this testable without a clock: a test reads the durations the calls were /// given and adds them up. -struct Budget { +pub(crate) struct Budget { left: Duration, } impl Budget { - fn of(total: Duration) -> Self { + pub(crate) fn of(total: Duration) -> Self { Self { left: total } } @@ -618,7 +618,12 @@ impl Budget { /// well-formed JSON on a non-zero exit, so a caller that read those would be /// parsing an envelope with no `result` in it to reach the same `None` this /// returns for free. -fn ask(runner: &dyn Runner, program: &str, args: &[String], budget: &mut Budget) -> Option { +pub(crate) fn ask( + runner: &dyn Runner, + program: &str, + args: &[String], + budget: &mut Budget, +) -> Option { let spec = SpawnSpec::new(Invocation::new(program.to_owned()).with_args(args.iter().cloned())) .with_timeout(budget.next()?); let started = Instant::now(); @@ -651,7 +656,7 @@ fn in_tab<'a>(tab_id: &str, panes: &'a [herdr::PaneInfo]) -> Vec<&'a herdr::Pane /// chain: the pane's own foreground process is `dl`, which names a *spec* and not /// an id, and the transport that names the id is its child. herdr publishes the /// chain, which is the same reading it does to identify an agent. -fn workspace_among(info: &herdr::PaneProcessInfo) -> Option { +pub(crate) fn workspace_among(info: &herdr::PaneProcessInfo) -> Option { info.foreground_processes .iter() .filter_map(|process| process.argv.as_deref()) From e30d09855e0bcbe2cd435a12f25b168fef22c41e Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 7 Oct 2026 14:51:32 +0000 Subject: [PATCH 02/13] feat: type each held agent line back into its pane agent_sessions::restart runs herdr pane run with the saved line, and asks again for up to ten seconds while the old transport exits. The pane this dl runs in is handed its line instead of typed into. Claude-Session: https://claude.ai/code/session_01Ro7QyyAtHmrh9oxyyK6h9m --- rust/devlaunch-core/src/clients/herdr.rs | 13 ++ .../src/flows/agent_sessions.rs | 153 ++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/rust/devlaunch-core/src/clients/herdr.rs b/rust/devlaunch-core/src/clients/herdr.rs index 02417002..9bac3140 100644 --- a/rust/devlaunch-core/src/clients/herdr.rs +++ b/rust/devlaunch-core/src/clients/herdr.rs @@ -1778,6 +1778,19 @@ pub(crate) fn process_info_in(answer: &str) -> Option { .map(|envelope| envelope.result.process_info) } +/// The argv that has herdr type `command` into one pane's shell and run it. +/// +/// One string, as herdr takes it: herdr types it as written, so `command` is +/// already a shell line. herdr refuses a pane whose foreground is not its shell. +pub(crate) fn pane_run_argv(pane_id: &str, command: &str) -> Vec { + vec![ + "pane".to_owned(), + "run".to_owned(), + pane_id.to_owned(), + command.to_owned(), + ] +} + /// The argv that asks herdr which agent one pane holds, and in what state. pub(crate) fn agent_get_argv(pane_id: &str) -> Vec { vec!["agent".to_owned(), "get".to_owned(), pane_id.to_owned()] diff --git a/rust/devlaunch-core/src/flows/agent_sessions.rs b/rust/devlaunch-core/src/flows/agent_sessions.rs index e6c6f8a7..e6a7f2ef 100644 --- a/rust/devlaunch-core/src/flows/agent_sessions.rs +++ b/rust/devlaunch-core/src/flows/agent_sessions.rs @@ -179,6 +179,64 @@ fn starts_an_agent(argv: &[String]) -> bool { .is_some_and(|program| herdr::agent_named(program).is_some()) } +/// What became of one held session after the recreate. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Restarted { + /// herdr typed the line into the pane. + Typed { pane_id: String }, + /// The pane is the one this `dl` runs in, so nothing was typed into it. + /// `line` is the command to run there, as one shell line. + OwnPane { pane_id: String, line: String }, + /// herdr would not take the line in the time allowed. + NotTyped { pane_id: String, line: String }, +} + +/// How many times one pane is asked to take its line, and how far apart. +/// +/// herdr refuses `pane run` while the pane's foreground is not its shell, and +/// the old transport exits on its own schedule once the container it spoke to is +/// gone. Ten seconds per pane is well past that, and short enough that a pane +/// herdr closed, or one that never returns to a shell, costs little. +pub(crate) const RUN_TRIES: u32 = 20; +pub(crate) const RUN_TICK: Duration = Duration::from_millis(500); + +/// Type each held session's line into the pane it came from. +/// +/// Call it once the recreate's `up` has finished: the line runs `dl` again, and +/// that `dl` attaches to whatever container is up when it starts. `wait` sleeps +/// between tries. +/// +/// The pane this `dl` runs in is never typed into. Its foreground is this +/// process, so `pane run` would refuse it for as long as this `dl` runs; its line +/// is handed back for the caller to say. +pub fn restart( + runner: &dyn Runner, + manager: &Manager, + held: &HeldSessions, + wait: &dyn Fn(Duration), +) -> Vec { + held.sessions + .iter() + .map(|session| { + let pane_id = session.pane_id.clone(); + let line = crate::shell::join(session.line.iter().map(String::as_str)); + if manager.own_pane.as_deref() == Some(pane_id.as_str()) { + return Restarted::OwnPane { pane_id, line }; + } + let run = herdr::pane_run_argv(&pane_id, &line); + for tick in 0..RUN_TRIES { + if tick > 0 { + wait(RUN_TICK); + } + if manager.ask(runner, &run).is_some() { + return Restarted::Typed { pane_id }; + } + } + Restarted::NotTyped { pane_id, line } + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -422,6 +480,101 @@ mod tests { assert_eq!(collect(&runner, &manager(), "myws", &|_| None), None); } + fn held(panes: &[&str]) -> HeldSessions { + HeldSessions { + sessions: panes + .iter() + .map(|pane| HeldSession { + pane_id: (*pane).to_owned(), + line: words(CLAUDE_LINE), + }) + .collect(), + unresumable: Vec::new(), + } + } + + const TYPED: &str = "dl myws -- IS_SANDBOX=1 claude --remote-control=myws --resume 4b1e"; + + #[test] + fn each_line_is_typed_into_the_pane_it_came_from() { + let runner = ScriptedRunner::new(); + runner.script([HERDR, "pane", "run"], Response::ok()); + + let restarted = restart(&runner, &manager(), &held(&["w1:p1", "w1:p4"]), &|_| { + panic!("nothing was refused, so nothing waits") + }); + + assert_eq!( + restarted, + vec![ + Restarted::Typed { + pane_id: "w1:p1".to_owned() + }, + Restarted::Typed { + pane_id: "w1:p4".to_owned() + }, + ] + ); + assert_eq!( + runner.args_to(HERDR), + vec![ + vec!["pane", "run", "w1:p1", TYPED], + vec!["pane", "run", "w1:p4", TYPED], + ] + ); + } + + /// The pane this `dl` runs in has this `dl` in its foreground, and a line + /// typed there would wait behind the shell the recreate attaches to. + #[test] + fn the_pane_this_dl_runs_in_is_handed_its_line_rather_than_typed_into() { + let runner = ScriptedRunner::new(); + + let restarted = restart(&runner, &manager(), &held(&["w1:p9"]), &|_| {}); + + assert_eq!( + restarted, + vec![Restarted::OwnPane { + pane_id: "w1:p9".to_owned(), + line: TYPED.to_owned(), + }] + ); + assert!(runner.args_to(HERDR).is_empty()); + } + + /// herdr refuses `pane run` into a pane whose foreground is not its shell, and + /// the old transport takes a moment to exit once its container is gone. So a + /// refusal is asked again, a bounded number of times, and then given up. + #[test] + fn a_pane_that_stays_busy_is_given_up_on_after_a_bounded_wait() { + let runner = ScriptedRunner::new(); + runner.script( + [HERDR, "pane", "run"], + Response::failed(1, "").and_stdout(r#"{"error":{"code":"pane_busy"}}"#), + ); + let waited = std::cell::Cell::new(Duration::ZERO); + + let restarted = restart(&runner, &manager(), &held(&["w1:p1"]), &|pause| { + waited.set(waited.get() + pause); + }); + + assert_eq!( + restarted, + vec![Restarted::NotTyped { + pane_id: "w1:p1".to_owned(), + line: TYPED.to_owned(), + }] + ); + let tries = runner.args_to(HERDR).len(); + assert!(tries > 1, "a refusal was not asked again"); + assert_eq!(waited.get(), RUN_TICK * u32::try_from(tries - 1).unwrap()); + assert!( + waited.get() <= Duration::from_secs(30), + "{:?}", + waited.get() + ); + } + #[test] fn outside_a_herdr_pane_there_is_no_manager_to_ask() { let mut outside = env("w1:p9"); From 36340826feb1297f330c0ce09793f6d0ca843095 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 7 Oct 2026 14:58:03 +0000 Subject: [PATCH 03/13] feat: dl recreate starts the agents it ended again Before the up, the recreate reads which herdr panes hold an agent in the workspace. After the up, it types each saved line back into its pane, and then attaches. Outside herdr, or when herdr does not answer, or when the up fails, it says so, and hands over any line it did not type. None of this refuses the recreate. Claude-Session: https://claude.ai/code/session_01Ro7QyyAtHmrh9oxyyK6h9m --- rust/devlaunch-core/src/flows/launch.rs | 361 +++++++++++++++++++++++- rust/dl/src/render.rs | 77 +++++ 2 files changed, 437 insertions(+), 1 deletion(-) diff --git a/rust/devlaunch-core/src/flows/launch.rs b/rust/devlaunch-core/src/flows/launch.rs index e2251e7e..89d6e241 100644 --- a/rust/devlaunch-core/src/flows/launch.rs +++ b/rust/devlaunch-core/src/flows/launch.rs @@ -81,6 +81,7 @@ use crate::domain::workspace_id::{ NamePart, UnsafeName, WorkspaceId, identity_of, validate_ref_name, }; use crate::domain::workspace_state::NonEmpty; +use crate::flows::agent_sessions; use crate::flows::kept_copies::KeptCopies; use crate::flows::kill; use crate::flows::launch_locks::LaunchLocks; @@ -672,6 +673,34 @@ pub enum LaunchNotice { /// running, and switching config means recreating it. DevcontainerIgnoredRunning { workspace_id: String, spec: String }, + // --- the agents a recreate ends (devlaunch#673, `flows::agent_sessions`) + /// This `dl` is not in a herdr pane, so it cannot see which agents run in the + /// workspace, and the recreate ends them with nothing to start them again. + AgentSessionsUnseen { workspace_id: String }, + /// herdr did not say which panes it has, so the same as + /// [`Self::AgentSessionsUnseen`]. + AgentSessionsUnanswered { workspace_id: String }, + /// These panes hold an agent in the workspace, and each will be started again + /// in its pane once the new container is up. Said before the recreate. + AgentSessionsHeld { + workspace_id: String, + panes: Vec, + }, + /// This pane holds a live agent in the workspace, and herdr saved no line to + /// start it again. The recreate ends it. + AgentSessionUnresumable { + workspace_id: String, + pane_id: String, + }, + /// The agent's line was typed back into its pane. + AgentSessionRestarted { pane_id: String }, + /// The agent's line could not be typed into its pane; `line` is what to run + /// there by hand. + AgentSessionNotRestarted { pane_id: String, line: String }, + /// The agent's pane is the one this `dl` runs in; `line` is what to run there + /// once this session ends. + AgentSessionInThisPane { line: String }, + // --- devpod's own lock (devlaunch#600, devlaunch#602) /// This launch's `devpod up` is waiting on devpod's workspace lock, which /// something else holds, and devpod's acquire has no deadline behind it: the @@ -5103,7 +5132,18 @@ impl<'a, 'r, 'l> Launch<'a, 'r, 'l> { devcontainer: Option<&DevcontainerPath>, placement: &Placement, ) -> Result { - if let Some(refused) = self.bring_up(verb, devcontainer, placement)? { + // Only a recreate: `code` keeps the container when nothing changed, and a + // `reset` drops the volumes, so neither is the case this is for. + let held = if matches!(verb, LaunchVerb::Recreate) && placement.is_running() { + self.hold_agent_sessions(placement.workspace_id()) + } else { + None + }; + let up = self.bring_up(verb, devcontainer, placement); + if let Some((manager, held)) = held { + self.restart_agent_sessions(&manager, &held, matches!(up, Ok(None))); + } + if let Some(refused) = up? { return Ok(Launched::Refused(refused)); } if !verb.attaches() { @@ -5115,6 +5155,82 @@ impl<'a, 'r, 'l> Launch<'a, 'r, 'l> { session } + /// Find the agents a recreate of `workspace_id` is about to end, and say what + /// will become of each (devlaunch#673). + /// + /// Every way this cannot find them is a notice. The recreate goes ahead. + fn hold_agent_sessions( + &mut self, + workspace_id: &str, + ) -> Option<(agent_sessions::Manager, agent_sessions::HeldSessions)> { + let workspace_id = workspace_id.to_owned(); + let Some(manager) = agent_sessions::Manager::from_host(self.host) else { + self.notices + .say(LaunchNotice::AgentSessionsUnseen { workspace_id }); + return None; + }; + let Some(held) = + agent_sessions::collect(self.context.runner(), &manager, &workspace_id, &|path| { + std::fs::read_to_string(path).ok() + }) + else { + self.notices + .say(LaunchNotice::AgentSessionsUnanswered { workspace_id }); + return None; + }; + for pane_id in &held.unresumable { + self.notices.say(LaunchNotice::AgentSessionUnresumable { + workspace_id: workspace_id.clone(), + pane_id: pane_id.clone(), + }); + } + if !held.sessions.is_empty() { + self.notices.say(LaunchNotice::AgentSessionsHeld { + workspace_id, + panes: held + .sessions + .iter() + .map(|session| session.pane_id.clone()) + .collect(), + }); + } + Some((manager, held)) + } + + /// Start each held agent again in its pane, or, when the recreate's `up` did + /// not finish, hand each one's line to the user to run by hand. + fn restart_agent_sessions( + &mut self, + manager: &agent_sessions::Manager, + held: &agent_sessions::HeldSessions, + up: bool, + ) { + if !up { + for session in &held.sessions { + self.notices.say(LaunchNotice::AgentSessionNotRestarted { + pane_id: session.pane_id.clone(), + line: crate::shell::join(session.line.iter().map(String::as_str)), + }); + } + return; + } + let restarted = + agent_sessions::restart(self.context.runner(), manager, held, &std::thread::sleep); + for outcome in restarted { + self.notices.say(match outcome { + agent_sessions::Restarted::Typed { pane_id } => { + LaunchNotice::AgentSessionRestarted { pane_id } + } + agent_sessions::Restarted::NotTyped { pane_id, line } => { + LaunchNotice::AgentSessionNotRestarted { pane_id, line } + } + agent_sessions::Restarted::OwnPane { line, .. } => { + LaunchNotice::AgentSessionInThisPane { line } + } + }); + } + } + /// `dl restart`: stop and start without rebuilding. fn run_restart( &mut self, @@ -13545,6 +13661,249 @@ mod tests { assert!(up.contains(&"--recreate".to_owned()), "{up:?}"); } + /// A scene whose launch runs in herdr pane `w1:p9`, with one other pane, `w1:p1`, + /// running Claude in `myws` and a saved line to start it again (devlaunch#673). + fn in_herdr_beside_claude(scene: &mut Scene) -> Vec { + const HERDR: &str = "/opt/herdr/bin/herdr"; + let socket = scene.cache_dir().join("herdr.sock"); + scene.host.herdr = herdr::HostEnv { + in_pane: Some("1".to_owned()), + pane_id: Some("w1:p9".to_owned()), + socket: Some(socket.display().to_string()), + ..herdr::HostEnv::default() + }; + scene.host.herdr_bin = Some(HERDR.to_owned()); + let line = vec![ + "dl", + "myws", + "--", + "claude", + "--remote-control=myws", + "--resume", + "4b1e", + ]; + std::fs::write( + scene.cache_dir().join("session.json"), + format!( + r#"{{"workspaces":[{{"id":"w1","public_pane_numbers":{{"1":1}},"tabs":[{{"panes":{{"1":{{"agent_resume":{{"argv":{}}}}}}}}}]}}]}}"#, + serde_json::to_string(&line).expect("json") + ), + ) + .expect("a session file"); + scene.runner.script( + [HERDR, "pane", "list"], + Response::stdout( + r#"{"id":"l","result":{"type":"pane_list","panes":[{"pane_id":"w1:p1","tab_id":"w1:t1","focused":false},{"pane_id":"w1:p9","tab_id":"w1:t2","focused":true}]}}"#, + ), + ); + scene.runner.script( + [HERDR, "pane", "process-info", "--pane", "w1:p1"], + Response::stdout( + r#"{"id":"p","result":{"type":"pane_process_info","process_info":{"foreground_processes":[{"argv":["devpod","ssh","myws"]}]}}}"#, + ), + ); + scene.runner.script( + [HERDR, "agent", "get", "w1:p1"], + Response::stdout( + r#"{"id":"a","result":{"type":"agent","agent":{"agent":"claude","agent_status":"idle"}}}"#, + ), + ); + scene.runner.script([HERDR, "pane", "run"], Response::ok()); + scene.runner.script([HERDR], Response::exited(1)); + line.into_iter().map(str::to_owned).collect() + } + + /// The recreate ends the agent in `w1:p1`, and the launch types its line back + /// in once the new container is up and before its own shell takes this pane. + #[test] + fn a_recreate_starts_the_agents_it_ended_again_in_their_panes() { + let mut scene = Scene::new().with_running("myws"); + let line = in_herdr_beside_claude(&mut scene); + let updater = SelfInvocation::new("dl"); + let completion = scene.cache_dir().join("completion.json"); + let mut parts = launching(&scene.runner, &updater, &completion); + let mut cold = NeverCold; + let launched = { + let mut launch = Launch::new( + &mut parts.context, + &mut parts.refresh, + &mut cold, + &parts.provision, + &scene.host, + &mut parts.chatter, + &mut parts.said, + ); + launch.run("myws", &LaunchVerb::Recreate, None) + }; + + assert_eq!( + launched, + Ok(Launched::Session(Session::RemoteExit { status: 0 })) + ); + let argvs = scene.runner.argvs(); + let at = |wanted: &dyn Fn(&[String]) -> bool| { + argvs + .iter() + .position(|argv| wanted(argv)) + .unwrap_or_else(|| panic!("not run: {argvs:?}")) + }; + let up = at(&|argv| argv.get(1).map(String::as_str) == Some("up")); + let listed = at(&|argv| argv.get(1..3) == Some(&["pane".to_owned(), "list".to_owned()])); + let typed = at(&|argv| argv.get(1..3) == Some(&["pane".to_owned(), "run".to_owned()])); + let session = at(&|argv| { + argv.first().map(String::as_str) == Some("devpod") + && argv.get(1).map(String::as_str) == Some("ssh") + }); + assert!(listed < up, "the panes were read after the container went"); + assert!( + up < typed, + "the line was typed before the new container was up" + ); + assert!(typed < session, "the line waited behind this pane's shell"); + assert_eq!( + argvs[typed][3..], + [ + "w1:p1".to_owned(), + crate::shell::join(line.iter().map(String::as_str)) + ] + ); + assert!( + parts.said.contains(&LaunchNotice::AgentSessionsHeld { + workspace_id: "myws".to_owned(), + panes: vec!["w1:p1".to_owned()], + }), + "{:?}", + parts.said + ); + assert!( + parts.said.contains(&LaunchNotice::AgentSessionRestarted { + pane_id: "w1:p1".to_owned(), + }), + "{:?}", + parts.said + ); + } + + /// Outside herdr there are no panes to read, so the recreate says it ends the + /// agents and goes ahead. + #[test] + fn a_recreate_outside_herdr_says_it_cannot_see_the_agents_and_goes_ahead() { + let scene = Scene::new().with_running("myws"); + let updater = SelfInvocation::new("dl"); + let completion = scene.cache_dir().join("completion.json"); + let mut parts = launching(&scene.runner, &updater, &completion); + let mut cold = NeverCold; + let launched = { + let mut launch = Launch::new( + &mut parts.context, + &mut parts.refresh, + &mut cold, + &parts.provision, + &scene.host, + &mut parts.chatter, + &mut parts.said, + ); + launch.run("myws", &LaunchVerb::Recreate, None) + }; + + assert_eq!( + launched, + Ok(Launched::Session(Session::RemoteExit { status: 0 })) + ); + assert!( + parts.said.contains(&LaunchNotice::AgentSessionsUnseen { + workspace_id: "myws".to_owned(), + }), + "{:?}", + parts.said + ); + } + + /// A recreate whose `up` fails types nothing, and hands each line over to be + /// run by hand. + #[test] + fn a_recreate_that_fails_hands_over_the_lines_it_did_not_type() { + let mut scene = Scene::new().with_running("myws"); + let line = in_herdr_beside_claude(&mut scene); + scene + .runner + .script(["devpod", "up"], Response::failed(7, "devpod: no\n")); + let updater = SelfInvocation::new("dl"); + let completion = scene.cache_dir().join("completion.json"); + let mut parts = launching(&scene.runner, &updater, &completion); + let mut cold = NeverCold; + let launched = { + let mut launch = Launch::new( + &mut parts.context, + &mut parts.refresh, + &mut cold, + &parts.provision, + &scene.host, + &mut parts.chatter, + &mut parts.said, + ); + launch.run("myws", &LaunchVerb::Recreate, None) + }; + + assert_eq!( + launched, + Ok(Launched::Refused(LaunchRefusal::UpRefused { + exit: Exit::Code(7) + })) + ); + assert!( + !scene + .runner + .argvs() + .iter() + .any(|argv| argv.get(1..3) == Some(&["pane".to_owned(), "run".to_owned()])), + "a line was typed after a failed recreate" + ); + assert!( + parts + .said + .contains(&LaunchNotice::AgentSessionNotRestarted { + pane_id: "w1:p1".to_owned(), + line: crate::shell::join(line.iter().map(String::as_str)), + }), + "{:?}", + parts.said + ); + } + + /// A `code` or a `reset` is not a recreate: herdr is not asked. + #[test] + fn only_a_recreate_asks_herdr_for_the_agents_it_would_end() { + for verb in [LaunchVerb::Code, LaunchVerb::Reset] { + let mut scene = Scene::new().with_running("myws"); + in_herdr_beside_claude(&mut scene); + let updater = SelfInvocation::new("dl"); + let completion = scene.cache_dir().join("completion.json"); + let mut parts = launching(&scene.runner, &updater, &completion); + let mut cold = NeverCold; + let mut launch = Launch::new( + &mut parts.context, + &mut parts.refresh, + &mut cold, + &parts.provision, + &scene.host, + &mut parts.chatter, + &mut parts.said, + ); + let _ = launch.run("myws", &verb, None); + + assert!( + !scene + .runner + .argvs() + .iter() + .any(|argv| argv.get(1).map(String::as_str) == Some("pane")), + "{verb:?}: {:?}", + scene.runner.argvs() + ); + } + } + #[test] fn a_reset_asks_for_a_clean_slate_and_then_attaches() { let scene = Scene::new().with_running("myws"); diff --git a/rust/dl/src/render.rs b/rust/dl/src/render.rs index aa5f95d6..98b0a6ab 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -3266,6 +3266,52 @@ pub(crate) fn launch_notice(notice: &LaunchNotice) -> Option { --devcontainer ...' to switch config." ), + // --- the agents a recreate ends (devlaunch#673; warnings, bar the two + // that report what was done, which are info) + LaunchNotice::AgentSessionsUnseen { workspace_id } => format!( + "This dl is not in a herdr pane, so it cannot see the agents running in \ + {workspace_id}. The recreate ends them. Run 'claude --resume' in the new \ + container to pick a conversation up again." + ), + LaunchNotice::AgentSessionsUnanswered { workspace_id } => format!( + "herdr did not say which panes it has, so the agents running in {workspace_id} \ + end with the recreate. Run 'claude --resume' in the new container to pick a \ + conversation up again." + ), + LaunchNotice::AgentSessionsHeld { + workspace_id, + panes, + } => { + let (noun, pronoun) = if panes.len() == 1 { + ("pane", "it") + } else { + ("panes", "each") + }; + format!( + "The recreate ends the agent in {noun} {} of {workspace_id}; dl starts {pronoun} \ + again, resumed, once the new container is up.", + panes.join(", ") + ) + } + LaunchNotice::AgentSessionUnresumable { + workspace_id, + pane_id, + } => format!( + "Pane {pane_id} runs an agent in {workspace_id}, and herdr saved no line to start \ + it again. The recreate ends it. Run 'claude --resume' there to pick the \ + conversation up again." + ), + LaunchNotice::AgentSessionRestarted { pane_id } => { + format!("Started the agent in pane {pane_id} again.") + } + LaunchNotice::AgentSessionNotRestarted { pane_id, line } => format!( + "Could not start the agent in pane {pane_id} again. Run this in that pane: {line}" + ), + LaunchNotice::AgentSessionInThisPane { line } => format!( + "The agent that ran in this pane ended with the recreate. Run this here to start it \ + again: {line}" + ), + // --- devpod's own lock (devlaunch#600, devlaunch#602; no Python line, // Python never watched) // @@ -5091,6 +5137,37 @@ mod tests { ); } + /// devlaunch#673. A line dl could not type is only worth anything if it can be + /// pasted, so it is given whole, at the end, with nothing after it. + #[test] + fn an_agent_line_dl_could_not_type_is_handed_over_whole() { + let line = "dl myws -- claude --remote-control=myws --resume 4b1e"; + let said = launch_notice(&LaunchNotice::AgentSessionNotRestarted { + pane_id: "w1:p3".to_owned(), + line: line.to_owned(), + }) + .expect("a sentence"); + assert!(said.contains("pane w1:p3"), "{said}"); + assert!(said.ends_with(&format!(": {line}")), "{said}"); + + let here = launch_notice(&LaunchNotice::AgentSessionInThisPane { + line: line.to_owned(), + }) + .expect("a sentence"); + assert!(here.ends_with(&format!(": {line}")), "{here}"); + } + + #[test] + fn the_agents_a_recreate_will_start_again_are_named_by_pane() { + let said = launch_notice(&LaunchNotice::AgentSessionsHeld { + workspace_id: "myws".to_owned(), + panes: vec!["w1:p1".to_owned(), "w1:p4".to_owned()], + }) + .expect("a sentence"); + assert!(said.contains("panes w1:p1, w1:p4 of myws"), "{said}"); + assert!(said.contains("starts each again"), "{said}"); + } + #[test] fn the_stale_checkout_line_claims_the_clone_and_never_the_remote() { // devlaunch#560. The wording is the deliverable here: the fact was From 5cf18c610df54c6d5083414396092dc8591760db Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 7 Oct 2026 14:58:57 +0000 Subject: [PATCH 04/13] docs: say what a recreate does with the agents it ends Claude-Session: https://claude.ai/code/session_01Ro7QyyAtHmrh9oxyyK6h9m --- CHANGELOG.md | 8 ++++++++ docs/cli.md | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42b0ee7a..49c8d578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 reads devpod's `workspace_result.json` and asks docker for the container's image and the reference's current one; where devpod built a derived image on top of the declared one, it compares layers. Every doubt reads as current. +- **`dl recreate` starts the agents it ends again, in the panes they held** (#673). + A recreate ended every agent session in the container, which is why a stale workspace + was left alone. In a herdr pane, the recreate now reads which panes hold a live agent in + the workspace and the line herdr saved to start each one again + (`dl -- ... claude ... --resume `). Once the new container is up, it types + each line back into its pane with `herdr pane run`, and then attaches. Outside herdr, or + for a pane that will not take its line, it prints the line to run by hand. No failure + here refuses the recreate. See docs/cli.md, "A recreate starts the agents it ends again". ## [0.59.4] - 2026-10-05 diff --git a/docs/cli.md b/docs/cli.md index 39f5e43c..51d99360 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -914,6 +914,41 @@ only difference is the `--session-id` on the line. [docs/workspace-tools.md](workspace-tools.md#coming-back-after-herdr-restarts) has what herdr needs and what it does not cover. +### A recreate starts the agents it ends again + +`dl recreate` replaces the container, so every process in it ends, the agents +included. Their conversations do not: the transcripts live in `~/.claude`, which outlives +the container. In a herdr pane, the recreate uses the same saved line as a herdr restore +to bring each agent back (devlaunch#673): + +1. Before the `up`, `dl` asks herdr for its panes. A pane counts when its foreground is a + `dl` session into this workspace, herdr saved a line for it that starts an agent, and + `herdr agent get` does not report the agent as `done`. `dl` names the panes it found. +2. After the `up` succeeds, `dl` types each pane's line back into that pane with + `herdr pane run`. herdr refuses a pane until the old session in it has exited and its + shell is back, so `dl` asks again every half second, for up to ten seconds per pane. +3. Then `dl` attaches to the new container as a recreate always did. + +Each line is the one herdr saved for the pane, for example +`dl -- ... claude ... --remote-control= --resume `. It holds the session +the pane is in now, because the container hook reports a new id after `/clear`. + +Nothing here can refuse the recreate. Each failure is one line on stderr, and the +recreate goes ahead: + +- Outside a herdr pane there are no panes to read. `dl` says the recreate ends the + agents and that `claude --resume` in the new container picks a conversation up again. + The same applies when herdr does not answer. +- A pane where herdr reports a live agent and saved no line ends with the recreate, and + `dl` names it. +- A pane that does not take its line in ten seconds, or any pane at all when the `up` + fails, gets the line printed, to run there by hand. +- The pane that `dl` itself runs in is never typed into, because its foreground is that + `dl`. Its line is printed instead. + +`reset` and `code` do none of this. A `reset` drops the volumes, and `code` keeps the +container when nothing changed. + ## `kill`: the workspace that will not answer `dl stop` asks devpod to stop a workspace, and it is the right thing to type From 154d7cd0cb3be79a9fb78c073c03bad7cbc99147 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 7 Oct 2026 15:05:45 +0000 Subject: [PATCH 05/13] fix: a recreate typed another workspace's saved line into a pane A pane attached to the workspace being recreated can still hold the resume line an earlier `dl other -- claude` saved in it, because a plain attach saves nothing. collect took that line as the pane's session, so the restart started an agent in the other workspace and the notice said the agent here was kept. A saved line now counts only when its workspace word is the id of the workspace being recreated, which is the word dl writes there from the launch and the container hook. A live agent with a line for another workspace is reported as unresumable. Claude-Session: https://claude.ai/code/session_01Ro7QyyAtHmrh9oxyyK6h9m --- .../src/flows/agent_sessions.rs | 59 ++++++++++++++++--- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/rust/devlaunch-core/src/flows/agent_sessions.rs b/rust/devlaunch-core/src/flows/agent_sessions.rs index e6a7f2ef..e9494e3f 100644 --- a/rust/devlaunch-core/src/flows/agent_sessions.rs +++ b/rust/devlaunch-core/src/flows/agent_sessions.rs @@ -109,7 +109,8 @@ pub struct HeldSessions { /// A pane counts when its foreground is a dl transport into this workspace (the /// reading the pane shell does, [`session_manager::workspace_among`]) and herdr /// does not say its agent has ended. Its line is herdr's saved resume argv, taken -/// only when the words after its `--` start an agent dl knows by name. +/// only when it names this workspace and the words after its `--` start an agent +/// dl knows by name. /// /// `herdr agent get` decides the two doubtful cases. A saved line in a pane whose /// agent herdr calls `done`, or where herdr names no agent, is a line left over @@ -144,7 +145,7 @@ pub fn collect( .as_deref() .and_then(|json| herdr::saved_argv(json, &pane.pane_id)) .flatten() - .filter(|argv| starts_an_agent(argv)); + .filter(|argv| starts_an_agent(argv, workspace_id)); let live = match agent_reading(runner, manager, &pane.pane_id) { herdr::AgentReading::Unanswered => None, herdr::AgentReading::NoAgent => Some(false), @@ -170,13 +171,21 @@ pub fn agent_reading(runner: &dyn Runner, manager: &Manager, pane_id: &str) -> h .map_or(herdr::AgentReading::Unanswered, herdr::agent_reading_in) } -/// Whether a saved line starts an agent: `dl [...] -- [NAME=value ...] ...`. -fn starts_an_agent(argv: &[String]) -> bool { - argv.iter() - .skip_while(|word| *word != "--") - .skip(1) - .find(|word| !herdr::is_assignment(word)) - .is_some_and(|program| herdr::agent_named(program).is_some()) +/// Whether a saved line starts an agent in `workspace_id`: +/// `dl [...] -- [NAME=value ...] ...`. +/// +/// The workspace word is compared with the id because that is what dl writes +/// there, both from the launch and through the container hook. A line naming +/// another workspace was saved by an earlier session in the same pane, and would +/// start its agent there. +fn starts_an_agent(argv: &[String], workspace_id: &str) -> bool { + argv.get(1).map(String::as_str) == Some(workspace_id) + && argv + .iter() + .skip_while(|word| *word != "--") + .skip(1) + .find(|word| !herdr::is_assignment(word)) + .is_some_and(|program| herdr::agent_named(program).is_some()) } /// What became of one held session after the recreate. @@ -428,6 +437,38 @@ mod tests { assert_eq!(held, Some(HeldSessions::default())); } + /// A pane attached to this workspace can still hold a line saved by an + /// earlier `dl other -- claude` in it, since a plain attach saves nothing. + /// Typing that line would start an agent in the other workspace, so the live + /// agent here is one the recreate ends with no way back. + #[test] + fn a_saved_line_into_another_workspace_is_not_this_workspaces_session() { + let runner = ScriptedRunner::new(); + runner.script( + [HERDR, "pane", "list"], + Response::stdout(pane_list(&["w1:p1"])), + ); + runner.script( + [HERDR, "pane", "process-info"], + Response::stdout(process_info(&[TRANSPORT])), + ); + runner.script( + [HERDR, "agent", "get", "w1:p1"], + Response::stdout(agent("claude", "idle")), + ); + let file = saved(&[Some(OTHER_LINE)]); + + let held = collect(&runner, &manager(), "myws", &|_| Some(file.clone())); + + assert_eq!( + held, + Some(HeldSessions { + sessions: Vec::new(), + unresumable: vec!["w1:p1".to_owned()], + }) + ); + } + /// What part 3 of devlaunch#673 reads to tell an idle workspace from a busy /// one, word for word as herdr 0.9 spells `agent_status`. #[test] From 68df8a775f86b7b9c820f5f4ad0319d0607826f4 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 7 Oct 2026 15:05:41 +0000 Subject: [PATCH 06/13] fix: a recreate dropped a saved line when herdr named an agent this build could not read herdr's agent get answer with /result/agent present but no string name read as NoAgent, so collect dropped the pane's saved line. Such an answer now reads as Unanswered, which keeps the line; NoAgent stays for an agent that is absent or null. Claude-Session: https://claude.ai/code/session_01Ro7QyyAtHmrh9oxyyK6h9m --- rust/devlaunch-core/src/clients/herdr.rs | 4 +- .../src/flows/agent_sessions.rs | 81 +++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/rust/devlaunch-core/src/clients/herdr.rs b/rust/devlaunch-core/src/clients/herdr.rs index 9bac3140..70cf5279 100644 --- a/rust/devlaunch-core/src/clients/herdr.rs +++ b/rust/devlaunch-core/src/clients/herdr.rs @@ -1824,7 +1824,7 @@ pub struct PaneAgent { #[derive(Clone, Debug, PartialEq, Eq)] pub enum AgentReading { /// No answer that reads as herdr's: a refusal, a timeout, a herdr too old for - /// the command. + /// the command, an agent named in a shape this build cannot read. Unanswered, /// herdr answered, and the pane holds no agent. NoAgent, @@ -1844,7 +1844,7 @@ pub(crate) fn agent_reading_in(answer: &str) -> AgentReading { return AgentReading::NoAgent; }; let Some(name) = agent.get("agent").and_then(serde_json::Value::as_str) else { - return AgentReading::NoAgent; + return AgentReading::Unanswered; }; let status = match agent .get("agent_status") diff --git a/rust/devlaunch-core/src/flows/agent_sessions.rs b/rust/devlaunch-core/src/flows/agent_sessions.rs index e9494e3f..6a19ea11 100644 --- a/rust/devlaunch-core/src/flows/agent_sessions.rs +++ b/rust/devlaunch-core/src/flows/agent_sessions.rs @@ -350,6 +350,10 @@ mod tests { [HERDR, "pane", "process-info", "--pane", "w1:p3"], Response::stdout(process_info(&[TRANSPORT])), ); + runner.script( + [HERDR, "agent", "get", "w1:p3"], + Response::stdout(r#"{"id":"x","result":{"type":"agent","agent":null}}"#), + ); let file = saved(&[Some(CLAUDE_LINE), Some(OTHER_LINE), None]); let held = collect(&runner, &manager(), "myws", &|path| { @@ -417,6 +421,83 @@ mod tests { ); } + /// herdr says the pane holds no agent, so the line it saved is left over + /// from one the user already quit. + #[test] + fn a_saved_line_in_a_pane_herdr_says_holds_no_agent_is_dropped() { + let runner = ScriptedRunner::new(); + runner.script( + [HERDR, "pane", "list"], + Response::stdout(pane_list(&["w1:p1"])), + ); + runner.script( + [HERDR, "pane", "process-info"], + Response::stdout(process_info(&[TRANSPORT])), + ); + runner.script( + [HERDR, "agent", "get", "w1:p1"], + Response::stdout(r#"{"id":"x","result":{"type":"agent","agent":null}}"#), + ); + let file = saved(&[Some(CLAUDE_LINE)]); + + let held = collect(&runner, &manager(), "myws", &|_| Some(file.clone())); + + assert_eq!(held, Some(HeldSessions::default())); + } + + /// An agent this build cannot read is no word from herdr that the agent + /// ended, so the saved line is kept. + #[test] + fn a_saved_line_is_kept_when_herdr_names_an_agent_this_build_cannot_read() { + let runner = ScriptedRunner::new(); + runner.script( + [HERDR, "pane", "list"], + Response::stdout(pane_list(&["w1:p1"])), + ); + runner.script( + [HERDR, "pane", "process-info"], + Response::stdout(process_info(&[TRANSPORT])), + ); + runner.script( + [HERDR, "agent", "get", "w1:p1"], + Response::stdout( + r#"{"id":"x","result":{"type":"agent","agent":{"pane_id":"w1:p1","agent_status":"idle"}}}"#, + ), + ); + let file = saved(&[Some(CLAUDE_LINE)]); + + let held = collect(&runner, &manager(), "myws", &|_| Some(file.clone())); + + assert_eq!( + held, + Some(HeldSessions { + sessions: vec![HeldSession { + pane_id: "w1:p1".to_owned(), + line: words(CLAUDE_LINE), + }], + unresumable: Vec::new(), + }) + ); + } + + #[test] + fn an_agent_herdr_names_in_a_shape_this_build_cannot_read_is_no_answer() { + for agent in [ + r#"{"pane_id":"w1:p1","agent_status":"idle"}"#, + r#"{"pane_id":"w1:p1","agent":7,"agent_status":"idle"}"#, + r#"{"pane_id":"w1:p1","agent":null,"agent_status":"idle"}"#, + r#""claude""#, + "[]", + ] { + let answer = format!(r#"{{"id":"x","result":{{"type":"agent","agent":{agent}}}}}"#); + assert_eq!( + herdr::agent_reading_in(&answer), + herdr::AgentReading::Unanswered, + "{agent}" + ); + } + } + /// A saved line that does not start an agent is a plain attach, and typing it /// again would bring back nothing a recreate took. #[test] From 3e8e3c9885a275963dc2c27653fd51b1c9f9513e Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 7 Oct 2026 15:06:49 +0000 Subject: [PATCH 07/13] fix: a recreate whose devpod never started told the user to start a live agent again When the OS refused to start `devpod up` for a recreate, the old container and its agents were untouched, yet every held pane got a line to run `claude --resume` by hand: a second claude on a session that still ran. A spawn refusal (`NotRun::Blocked`) now says nothing about the held agents. `NotInstalled` and a devpod refusal keep the hand-back, since both can follow a container the recreate already removed. Claude-Session: https://claude.ai/code/session_01Ro7QyyAtHmrh9oxyyK6h9m --- rust/devlaunch-core/src/flows/launch.rs | 61 ++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/rust/devlaunch-core/src/flows/launch.rs b/rust/devlaunch-core/src/flows/launch.rs index 89d6e241..3178450b 100644 --- a/rust/devlaunch-core/src/flows/launch.rs +++ b/rust/devlaunch-core/src/flows/launch.rs @@ -5140,7 +5140,12 @@ impl<'a, 'r, 'l> Launch<'a, 'r, 'l> { None }; let up = self.bring_up(verb, devcontainer, placement); - if let Some((manager, held)) = held { + // `Blocked` comes only from the OS refusing to start `devpod up`, so the old + // container and its agents still run. `NotInstalled` is not here: it also + // stands for a devpod gone after an `up` that did recreate, and a refusal + // is a devpod that ran and may have removed the container before failing. + let untouched = matches!(up, Err(LaunchAborted::DevpodNotRun(NotRun::Blocked(_)))); + if let Some((manager, held)) = held.filter(|_| !untouched) { self.restart_agent_sessions(&manager, &held, matches!(up, Ok(None))); } if let Some(refused) = up? { @@ -13871,6 +13876,60 @@ mod tests { ); } + /// A recreate whose `devpod up` the OS would not start never touched the + /// container, so the agent in `w1:p1` still runs: nothing is typed and no line + /// is handed over to start a second one. + #[test] + fn a_recreate_whose_up_never_started_leaves_the_agents_alone() { + let mut scene = Scene::new().with_running("myws"); + in_herdr_beside_claude(&mut scene); + let failure = crate::runner::OsFailure { + kind: std::io::ErrorKind::PermissionDenied, + errno: Some(13), + }; + scene + .runner + .script(["devpod", "up"], Response::NotStarted(failure)); + let updater = SelfInvocation::new("dl"); + let completion = scene.cache_dir().join("completion.json"); + let mut parts = launching(&scene.runner, &updater, &completion); + let mut cold = NeverCold; + let launched = { + let mut launch = Launch::new( + &mut parts.context, + &mut parts.refresh, + &mut cold, + &parts.provision, + &scene.host, + &mut parts.chatter, + &mut parts.said, + ); + launch.run("myws", &LaunchVerb::Recreate, None) + }; + + assert_eq!( + launched, + Err(LaunchAborted::DevpodNotRun(NotRun::Blocked(failure))) + ); + assert!( + !scene + .runner + .argvs() + .iter() + .any(|argv| argv.get(1..3) == Some(&["pane".to_owned(), "run".to_owned()])), + "a line was typed into a pane whose agent still runs" + ); + assert!( + !parts.said.iter().any(|notice| matches!( + notice, + LaunchNotice::AgentSessionNotRestarted { .. } + | LaunchNotice::AgentSessionInThisPane { .. } + )), + "{:?}", + parts.said + ); + } + /// A `code` or a `reset` is not a recreate: herdr is not asked. #[test] fn only_a_recreate_asks_herdr_for_the_agents_it_would_end() { From b47d65d89740307f8796636a5fdad37b7fff30bb Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 7 Oct 2026 15:06:52 +0000 Subject: [PATCH 08/13] fix: a recreate told a pane running codex to run 'claude --resume' Claude-Session: https://claude.ai/code/session_01Ro7QyyAtHmrh9oxyyK6h9m --- .../src/flows/agent_sessions.rs | 70 ++++++++++++++++--- rust/devlaunch-core/src/flows/launch.rs | 7 +- rust/dl/src/render.rs | 34 ++++++++- 3 files changed, 99 insertions(+), 12 deletions(-) diff --git a/rust/devlaunch-core/src/flows/agent_sessions.rs b/rust/devlaunch-core/src/flows/agent_sessions.rs index 6a19ea11..2cf6d0ff 100644 --- a/rust/devlaunch-core/src/flows/agent_sessions.rs +++ b/rust/devlaunch-core/src/flows/agent_sessions.rs @@ -100,7 +100,15 @@ pub struct HeldSessions { /// The sessions that can be started again. pub sessions: Vec, /// Panes that hold a live agent in this workspace and no saved line for it. - pub unresumable: Vec, + pub unresumable: Vec, +} + +/// A pane with a live agent and no saved line to start it again. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UnresumablePane { + pub pane_id: String, + /// herdr's name for the agent in the pane: `claude`, `codex`, ... + pub agent: String, } /// The agent sessions herdr's panes hold in `workspace_id`, or `None` when herdr @@ -146,17 +154,22 @@ pub fn collect( .and_then(|json| herdr::saved_argv(json, &pane.pane_id)) .flatten() .filter(|argv| starts_an_agent(argv, workspace_id)); - let live = match agent_reading(runner, manager, &pane.pane_id) { + let live_agent = match agent_reading(runner, manager, &pane.pane_id) { herdr::AgentReading::Unanswered => None, - herdr::AgentReading::NoAgent => Some(false), - herdr::AgentReading::Agent(agent) => Some(agent.status != herdr::AgentStatus::Done), + herdr::AgentReading::NoAgent => Some(None), + herdr::AgentReading::Agent(agent) => { + Some((agent.status != herdr::AgentStatus::Done).then_some(agent.agent)) + } }; - match (line, live) { - (Some(line), Some(true) | None) => held.sessions.push(HeldSession { + match (line, live_agent) { + (Some(line), Some(Some(_)) | None) => held.sessions.push(HeldSession { pane_id: pane.pane_id, line, }), - (None, Some(true)) => held.unresumable.push(pane.pane_id), + (None, Some(Some(agent))) => held.unresumable.push(UnresumablePane { + pane_id: pane.pane_id, + agent, + }), (Some(_) | None, _) => {} } } @@ -416,7 +429,43 @@ mod tests { pane_id: "w1:p3".to_owned(), line: words(CLAUDE_LINE), }], - unresumable: vec!["w1:p2".to_owned()], + unresumable: vec![UnresumablePane { + pane_id: "w1:p2".to_owned(), + agent: "claude".to_owned(), + }], + }) + ); + } + + /// The agent named for a pane with no line is the one herdr says runs there, + /// so a pane running codex is not told to run claude's resume. + #[test] + fn a_pane_with_no_line_is_named_with_the_agent_herdr_reports() { + let runner = ScriptedRunner::new(); + runner.script( + [HERDR, "pane", "list"], + Response::stdout(pane_list(&["w1:p1"])), + ); + runner.script( + [HERDR, "pane", "process-info"], + Response::stdout(process_info(&[TRANSPORT])), + ); + runner.script( + [HERDR, "agent", "get", "w1:p1"], + Response::stdout(agent("codex", "idle")), + ); + let file = saved(&[None]); + + let held = collect(&runner, &manager(), "myws", &|_| Some(file.clone())); + + assert_eq!( + held, + Some(HeldSessions { + sessions: Vec::new(), + unresumable: vec![UnresumablePane { + pane_id: "w1:p1".to_owned(), + agent: "codex".to_owned(), + }], }) ); } @@ -545,7 +594,10 @@ mod tests { held, Some(HeldSessions { sessions: Vec::new(), - unresumable: vec!["w1:p1".to_owned()], + unresumable: vec![UnresumablePane { + pane_id: "w1:p1".to_owned(), + agent: "claude".to_owned(), + }], }) ); } diff --git a/rust/devlaunch-core/src/flows/launch.rs b/rust/devlaunch-core/src/flows/launch.rs index 3178450b..abc2ad1f 100644 --- a/rust/devlaunch-core/src/flows/launch.rs +++ b/rust/devlaunch-core/src/flows/launch.rs @@ -691,6 +691,8 @@ pub enum LaunchNotice { AgentSessionUnresumable { workspace_id: String, pane_id: String, + /// herdr's name for the agent in the pane: `claude`, `codex`, ... + agent: String, }, /// The agent's line was typed back into its pane. AgentSessionRestarted { pane_id: String }, @@ -5183,10 +5185,11 @@ impl<'a, 'r, 'l> Launch<'a, 'r, 'l> { .say(LaunchNotice::AgentSessionsUnanswered { workspace_id }); return None; }; - for pane_id in &held.unresumable { + for pane in &held.unresumable { self.notices.say(LaunchNotice::AgentSessionUnresumable { workspace_id: workspace_id.clone(), - pane_id: pane_id.clone(), + pane_id: pane.pane_id.clone(), + agent: pane.agent.clone(), }); } if !held.sessions.is_empty() { diff --git a/rust/dl/src/render.rs b/rust/dl/src/render.rs index 98b0a6ab..75e86648 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -3296,11 +3296,21 @@ pub(crate) fn launch_notice(notice: &LaunchNotice) -> Option { LaunchNotice::AgentSessionUnresumable { workspace_id, pane_id, - } => format!( + agent, + } if agent == "claude" => format!( "Pane {pane_id} runs an agent in {workspace_id}, and herdr saved no line to start \ it again. The recreate ends it. Run 'claude --resume' there to pick the \ conversation up again." ), + LaunchNotice::AgentSessionUnresumable { + workspace_id, + pane_id, + agent, + } => format!( + "Pane {pane_id} runs {agent} in {workspace_id}, and herdr saved no line to start \ + it again. The recreate ends it. Start {agent} there again to pick the \ + conversation up." + ), LaunchNotice::AgentSessionRestarted { pane_id } => { format!("Started the agent in pane {pane_id} again.") } @@ -5157,6 +5167,28 @@ mod tests { assert!(here.ends_with(&format!(": {line}")), "{here}"); } + /// devlaunch#673. A pane herdr says runs codex is not told to run + /// 'claude --resume'; claude's own pane keeps the words it had. + #[test] + fn a_pane_with_no_line_is_told_about_the_agent_it_runs() { + let unresumable = |agent: &str| { + launch_notice(&LaunchNotice::AgentSessionUnresumable { + workspace_id: "myws".to_owned(), + pane_id: "w1:p2".to_owned(), + agent: agent.to_owned(), + }) + .expect("a sentence") + }; + let codex = unresumable("codex"); + assert!(codex.contains("codex"), "{codex}"); + assert!(!codex.contains("claude"), "{codex}"); + assert_eq!( + unresumable("claude"), + "Pane w1:p2 runs an agent in myws, and herdr saved no line to start it again. \ + The recreate ends it. Run 'claude --resume' there to pick the conversation up again." + ); + } + #[test] fn the_agents_a_recreate_will_start_again_are_named_by_pane() { let said = launch_notice(&LaunchNotice::AgentSessionsHeld { From a38f656cbfdfd1af23ffd17dd9da3a9800cff963 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 7 Oct 2026 15:05:25 +0000 Subject: [PATCH 09/13] docs: the recreate docs promised a line outside herdr and miscounted panes The CHANGELOG said a recreate outside herdr prints the line to run by hand; there is no line there, and dl says to run 'claude --resume'. docs/cli.md said a pane counts unless herdr reports its agent done; collect also drops a pane where herdr reports no agent. The module doc made the same no-line promise, and a render.rs comment gave the agent notices severities that nothing maps. Docs and comments only: no red test is possible beyond the existing prose guards. Claude-Session: https://claude.ai/code/session_01Ro7QyyAtHmrh9oxyyK6h9m --- CHANGELOG.md | 8 +++++--- docs/cli.md | 6 ++++-- rust/devlaunch-core/src/flows/agent_sessions.rs | 5 +++-- rust/dl/src/render.rs | 3 +-- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49c8d578..aa739930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,9 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 was left alone. In a herdr pane, the recreate now reads which panes hold a live agent in the workspace and the line herdr saved to start each one again (`dl -- ... claude ... --resume `). Once the new container is up, it types - each line back into its pane with `herdr pane run`, and then attaches. Outside herdr, or - for a pane that will not take its line, it prints the line to run by hand. No failure - here refuses the recreate. See docs/cli.md, "A recreate starts the agents it ends again". + each line back into its pane with `herdr pane run`, and then attaches. For a pane that + will not take its line, it prints the line to run by hand. Outside herdr there is no + line to print, so it says that the recreate ends the agents and that `claude --resume` + in the new container picks a conversation up again. No failure here refuses the + recreate. See docs/cli.md, "A recreate starts the agents it ends again". ## [0.59.4] - 2026-10-05 diff --git a/docs/cli.md b/docs/cli.md index 51d99360..8b112318 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -922,8 +922,10 @@ the container. In a herdr pane, the recreate uses the same saved line as a herdr to bring each agent back (devlaunch#673): 1. Before the `up`, `dl` asks herdr for its panes. A pane counts when its foreground is a - `dl` session into this workspace, herdr saved a line for it that starts an agent, and - `herdr agent get` does not report the agent as `done`. `dl` names the panes it found. + `dl` session into this workspace, and herdr saved a line for it that starts an agent. + A pane drops out when `herdr agent get` reports no agent in it, or reports the agent + `done`. A pane whose agent herdr does not report on still counts. `dl` names the + panes it found. 2. After the `up` succeeds, `dl` types each pane's line back into that pane with `herdr pane run`. herdr refuses a pane until the old session in it has exited and its shell is back, so `dl` asks again every half second, for up to ten seconds per pane. diff --git a/rust/devlaunch-core/src/flows/agent_sessions.rs b/rust/devlaunch-core/src/flows/agent_sessions.rs index 2cf6d0ff..cf8b577c 100644 --- a/rust/devlaunch-core/src/flows/agent_sessions.rs +++ b/rust/devlaunch-core/src/flows/agent_sessions.rs @@ -22,8 +22,9 @@ //! //! **Every failure is a notice, never a refusal of the recreate.** A herdr that //! does not answer, a pane with no saved line, a pane that will not take the -//! line: each costs that agent its automatic restart, and the caller says so with -//! the line to type by hand. A recreate asked for is a recreate done. +//! line: each costs that agent its automatic restart, and the caller says so, +//! with the line to type by hand where herdr saved one. A recreate asked for is a +//! recreate done. use std::path::{Path, PathBuf}; use std::time::Duration; diff --git a/rust/dl/src/render.rs b/rust/dl/src/render.rs index 75e86648..92d0fe66 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -3266,8 +3266,7 @@ pub(crate) fn launch_notice(notice: &LaunchNotice) -> Option { --devcontainer ...' to switch config." ), - // --- the agents a recreate ends (devlaunch#673; warnings, bar the two - // that report what was done, which are info) + // --- the agents a recreate ends (devlaunch#673; no Python line) LaunchNotice::AgentSessionsUnseen { workspace_id } => format!( "This dl is not in a herdr pane, so it cannot see the agents running in \ {workspace_id}. The recreate ends them. Run 'claude --resume' in the new \ From e0fca22082ab5ce5be7fe19bfff1b41f8454c0c5 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 7 Oct 2026 15:09:16 +0000 Subject: [PATCH 10/13] test: nothing covered the retry, quoting and failure branches of a recreate's agent restart Pins a pane run refused twice then taken (two ticks of waiting), a saved word with a space or a quote typed back as the same word, a pane herdr will not describe skipped without losing the rest, a live agent with no session file reported unresumable, a stopped workspace's recreate asking herdr nothing, a pane list that fails saying AgentSessionsUnanswered, and the four agent-session notices that had no word test. Claude-Session: https://claude.ai/code/session_01Ro7QyyAtHmrh9oxyyK6h9m --- .../src/flows/agent_sessions.rs | 117 ++++++++++++++++++ rust/devlaunch-core/src/flows/launch.rs | 78 ++++++++++++ rust/dl/src/render.rs | 45 +++++++ 3 files changed, 240 insertions(+) diff --git a/rust/devlaunch-core/src/flows/agent_sessions.rs b/rust/devlaunch-core/src/flows/agent_sessions.rs index cf8b577c..d9bd63bb 100644 --- a/rust/devlaunch-core/src/flows/agent_sessions.rs +++ b/rust/devlaunch-core/src/flows/agent_sessions.rs @@ -750,6 +750,123 @@ mod tests { ); } + /// A refusal is asked again after one tick, and a pane that frees up in the + /// meantime is typed into with no more waiting than it took. + #[test] + fn a_pane_that_comes_back_to_its_shell_is_typed_into_on_a_later_try() { + let runner = ScriptedRunner::new(); + runner.script( + [HERDR, "pane", "run"], + Response::failed(1, "").and_stdout(r#"{"error":{"code":"pane_busy"}}"#), + ); + let waits = std::cell::RefCell::new(Vec::new()); + + let restarted = restart(&runner, &manager(), &held(&["w1:p1"]), &|pause| { + waits.borrow_mut().push(pause); + if waits.borrow().len() == 2 { + runner.clear_scripts(); + runner.script([HERDR, "pane", "run"], Response::ok()); + } + }); + + assert_eq!( + restarted, + vec![Restarted::Typed { + pane_id: "w1:p1".to_owned() + }] + ); + assert_eq!(waits.into_inner(), vec![RUN_TICK, RUN_TICK]); + assert_eq!(runner.args_to(HERDR).len(), 3); + } + + /// The line goes to `pane run` as one shell line, so a word with a space or a + /// quote in it has to be quoted to come back as the same word. + #[test] + fn a_saved_word_with_a_space_or_a_quote_is_typed_back_as_the_same_word() { + let runner = ScriptedRunner::new(); + runner.script([HERDR, "pane", "run"], Response::ok()); + let line = words( + r#"["dl","myws","--","claude","--append-system-prompt","be brief","--name","it's","--resume","4b1e"]"#, + ); + let sessions = HeldSessions { + sessions: vec![HeldSession { + pane_id: "w1:p1".to_owned(), + line: line.clone(), + }], + unresumable: Vec::new(), + }; + + restart(&runner, &manager(), &sessions, &|_| {}); + + let typed = crate::shell::join(line.iter().map(String::as_str)); + assert_eq!( + typed, + r#"dl myws -- claude --append-system-prompt 'be brief' --name 'it'"'"'s' --resume 4b1e"# + ); + assert_eq!( + runner.args_to(HERDR), + vec![vec![ + "pane".to_owned(), + "run".to_owned(), + "w1:p1".to_owned(), + typed.clone() + ]] + ); + assert_eq!(shlex::split(&typed), Some(line)); + } + + /// One pane herdr will not describe costs that pane, not the scan. + #[test] + fn a_pane_herdr_will_not_describe_is_skipped_and_the_rest_are_collected() { + let runner = ScriptedRunner::new(); + runner.script( + [HERDR, "pane", "list"], + Response::stdout(pane_list(&["w1:p1", "w1:p2", "w1:p3"])), + ); + runner.script( + [HERDR, "pane", "process-info", "--pane", "w1:p2"], + Response::exited(1), + ); + runner.script( + [HERDR, "pane", "process-info"], + Response::stdout(process_info(&[TRANSPORT])), + ); + let file = saved(&[Some(CLAUDE_LINE), Some(CLAUDE_LINE), Some(CLAUDE_LINE)]); + + let collected = collect(&runner, &manager(), "myws", &|_| Some(file.clone())); + + assert_eq!(collected, Some(held(&["w1:p1", "w1:p3"]))); + } + + /// No session file is no saved line for any pane, so a live agent in this + /// workspace is one the recreate ends with no way back. + #[test] + fn a_live_agent_with_no_session_file_to_read_is_unresumable() { + let runner = ScriptedRunner::new(); + runner.script( + [HERDR, "pane", "list"], + Response::stdout(pane_list(&["w1:p1"])), + ); + runner.script( + [HERDR, "pane", "process-info"], + Response::stdout(process_info(&[TRANSPORT])), + ); + runner.script( + [HERDR, "agent", "get", "w1:p1"], + Response::stdout(agent("claude", "working")), + ); + + let held = collect(&runner, &manager(), "myws", &|_| None); + + assert_eq!( + held, + Some(HeldSessions { + sessions: Vec::new(), + unresumable: vec!["w1:p1".to_owned()], + }) + ); + } + #[test] fn outside_a_herdr_pane_there_is_no_manager_to_ask() { let mut outside = env("w1:p9"); diff --git a/rust/devlaunch-core/src/flows/launch.rs b/rust/devlaunch-core/src/flows/launch.rs index abc2ad1f..7f661ec2 100644 --- a/rust/devlaunch-core/src/flows/launch.rs +++ b/rust/devlaunch-core/src/flows/launch.rs @@ -13966,6 +13966,84 @@ mod tests { } } + /// A stopped workspace has no container for an agent to run in, so there is + /// nothing a recreate ends and herdr is not asked. + #[test] + fn a_recreate_of_a_stopped_workspace_asks_herdr_nothing() { + let mut scene = Scene::new().with_stopped("myws"); + in_herdr_beside_claude(&mut scene); + let updater = SelfInvocation::new("dl"); + let completion = scene.cache_dir().join("completion.json"); + let mut parts = launching(&scene.runner, &updater, &completion); + let mut cold = NeverCold; + let mut launch = Launch::new( + &mut parts.context, + &mut parts.refresh, + &mut cold, + &parts.provision, + &scene.host, + &mut parts.chatter, + &mut parts.said, + ); + let _ = launch.run("myws", &LaunchVerb::Recreate, None); + + assert!( + !scene + .runner + .argvs() + .iter() + .any(|argv| argv.get(1).map(String::as_str) == Some("pane")), + "{:?}", + scene.runner.argvs() + ); + } + + /// herdr that will not list its panes is a recreate that cannot see the + /// agents, said as such, and the recreate goes ahead. + #[test] + fn a_recreate_whose_herdr_will_not_list_its_panes_says_so_and_goes_ahead() { + const HERDR: &str = "/opt/herdr/bin/herdr"; + let mut scene = Scene::new().with_running("myws"); + scene.host.herdr = herdr::HostEnv { + in_pane: Some("1".to_owned()), + pane_id: Some("w1:p9".to_owned()), + socket: Some(scene.cache_dir().join("herdr.sock").display().to_string()), + ..herdr::HostEnv::default() + }; + scene.host.herdr_bin = Some(HERDR.to_owned()); + scene + .runner + .script([HERDR, "pane", "list"], Response::exited(1)); + let updater = SelfInvocation::new("dl"); + let completion = scene.cache_dir().join("completion.json"); + let mut parts = launching(&scene.runner, &updater, &completion); + let mut cold = NeverCold; + let launched = { + let mut launch = Launch::new( + &mut parts.context, + &mut parts.refresh, + &mut cold, + &parts.provision, + &scene.host, + &mut parts.chatter, + &mut parts.said, + ); + launch.run("myws", &LaunchVerb::Recreate, None) + }; + + assert_eq!( + launched, + Ok(Launched::Session(Session::RemoteExit { status: 0 })) + ); + assert!( + parts.said.contains(&LaunchNotice::AgentSessionsUnanswered { + workspace_id: "myws".to_owned(), + }), + "{:?}", + parts.said + ); + } + #[test] fn a_reset_asks_for_a_clean_slate_and_then_attaches() { let scene = Scene::new().with_running("myws"); diff --git a/rust/dl/src/render.rs b/rust/dl/src/render.rs index 92d0fe66..92d10718 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -5199,6 +5199,51 @@ mod tests { assert!(said.contains("starts each again"), "{said}"); } + #[test] + fn the_agents_a_recreate_cannot_start_again_are_said_word_for_word() { + assert_eq!( + launch_notice(&LaunchNotice::AgentSessionsUnseen { + workspace_id: "myws".to_owned(), + }) + .as_deref(), + Some( + "This dl is not in a herdr pane, so it cannot see the agents running in myws. \ + The recreate ends them. Run 'claude --resume' in the new container to pick a \ + conversation up again." + ) + ); + assert_eq!( + launch_notice(&LaunchNotice::AgentSessionsUnanswered { + workspace_id: "myws".to_owned(), + }) + .as_deref(), + Some( + "herdr did not say which panes it has, so the agents running in myws end with \ + the recreate. Run 'claude --resume' in the new container to pick a \ + conversation up again." + ) + ); + assert_eq!( + launch_notice(&LaunchNotice::AgentSessionUnresumable { + workspace_id: "myws".to_owned(), + pane_id: "w1:p2".to_owned(), + }) + .as_deref(), + Some( + "Pane w1:p2 runs an agent in myws, and herdr saved no line to start it again. \ + The recreate ends it. Run 'claude --resume' there to pick the conversation up \ + again." + ) + ); + assert_eq!( + launch_notice(&LaunchNotice::AgentSessionRestarted { + pane_id: "w1:p1".to_owned(), + }) + .as_deref(), + Some("Started the agent in pane w1:p1 again.") + ); + } + #[test] fn the_stale_checkout_line_claims_the_clone_and_never_the_remote() { // devlaunch#560. The wording is the deliverable here: the fact was From d02845322261468fa57c18d63b8a1ea777c26dde Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 7 Oct 2026 15:14:16 +0000 Subject: [PATCH 11/13] fix: the unresumable word and collect tests did not build after the pane gained its agent e0fca220 wrote a word-for-word test of the AgentSessionUnresumable line and a collect test of a live agent with no session file, both against the shape before b47d65d8 gave the notice an `agent` field and made `HeldSessions.unresumable` a list of `UnresumablePane`. Cherry-picked together, neither test compiled. Name the agent in both. The claude sentence keeps its exact words, and the word test now also pins the sentence a codex pane gets, so the test still says every unresumable line word for word. Claude-Session: https://claude.ai/code/session_01Ro7QyyAtHmrh9oxyyK6h9m --- rust/devlaunch-core/src/flows/agent_sessions.rs | 5 ++++- rust/dl/src/render.rs | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/rust/devlaunch-core/src/flows/agent_sessions.rs b/rust/devlaunch-core/src/flows/agent_sessions.rs index d9bd63bb..9e69bf7f 100644 --- a/rust/devlaunch-core/src/flows/agent_sessions.rs +++ b/rust/devlaunch-core/src/flows/agent_sessions.rs @@ -862,7 +862,10 @@ mod tests { held, Some(HeldSessions { sessions: Vec::new(), - unresumable: vec!["w1:p1".to_owned()], + unresumable: vec![UnresumablePane { + pane_id: "w1:p1".to_owned(), + agent: "claude".to_owned(), + }], }) ); } diff --git a/rust/dl/src/render.rs b/rust/dl/src/render.rs index 92d10718..64405586 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -5227,6 +5227,7 @@ mod tests { launch_notice(&LaunchNotice::AgentSessionUnresumable { workspace_id: "myws".to_owned(), pane_id: "w1:p2".to_owned(), + agent: "claude".to_owned(), }) .as_deref(), Some( @@ -5235,6 +5236,18 @@ mod tests { again." ) ); + assert_eq!( + launch_notice(&LaunchNotice::AgentSessionUnresumable { + workspace_id: "myws".to_owned(), + pane_id: "w1:p2".to_owned(), + agent: "codex".to_owned(), + }) + .as_deref(), + Some( + "Pane w1:p2 runs codex in myws, and herdr saved no line to start it again. \ + The recreate ends it. Start codex there again to pick the conversation up." + ) + ); assert_eq!( launch_notice(&LaunchNotice::AgentSessionRestarted { pane_id: "w1:p1".to_owned(), From 208e7bfd7cb84409addd8ffa64ea6b355bb13cd8 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 7 Oct 2026 15:29:32 +0000 Subject: [PATCH 12/13] fix: the public-api snapshot did not list the recreate's agent notices Copied from the public-api CI job's diff. public-api.api.txt gains the seven LaunchNotice variants, which is a change to the promised API: a caller that matches LaunchNotice exhaustively needs an arm for each, as for every notice added before them. Claude-Session: https://claude.ai/code/session_01Ro7QyyAtHmrh9oxyyK6h9m --- rust/devlaunch-core/public-api.api.txt | 36 +++++++++++++ rust/devlaunch-core/public-api.rest.txt | 67 +++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/rust/devlaunch-core/public-api.api.txt b/rust/devlaunch-core/public-api.api.txt index 988b7e30..1402ed52 100644 --- a/rust/devlaunch-core/public-api.api.txt +++ b/rust/devlaunch-core/public-api.api.txt @@ -34,6 +34,24 @@ pub fn devlaunch_core::flows::lifecycle::Insistence::fmt(&self, &mut core::fmt:: impl core::marker::Copy for devlaunch_core::flows::lifecycle::Insistence impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::Insistence pub enum devlaunch_core::api::LaunchNotice +pub devlaunch_core::api::LaunchNotice::AgentSessionInThisPane +pub devlaunch_core::api::LaunchNotice::AgentSessionInThisPane::line: alloc::string::String +pub devlaunch_core::api::LaunchNotice::AgentSessionNotRestarted +pub devlaunch_core::api::LaunchNotice::AgentSessionNotRestarted::line: alloc::string::String +pub devlaunch_core::api::LaunchNotice::AgentSessionNotRestarted::pane_id: alloc::string::String +pub devlaunch_core::api::LaunchNotice::AgentSessionRestarted +pub devlaunch_core::api::LaunchNotice::AgentSessionRestarted::pane_id: alloc::string::String +pub devlaunch_core::api::LaunchNotice::AgentSessionUnresumable +pub devlaunch_core::api::LaunchNotice::AgentSessionUnresumable::agent: alloc::string::String +pub devlaunch_core::api::LaunchNotice::AgentSessionUnresumable::pane_id: alloc::string::String +pub devlaunch_core::api::LaunchNotice::AgentSessionUnresumable::workspace_id: alloc::string::String +pub devlaunch_core::api::LaunchNotice::AgentSessionsHeld +pub devlaunch_core::api::LaunchNotice::AgentSessionsHeld::panes: alloc::vec::Vec +pub devlaunch_core::api::LaunchNotice::AgentSessionsHeld::workspace_id: alloc::string::String +pub devlaunch_core::api::LaunchNotice::AgentSessionsUnanswered +pub devlaunch_core::api::LaunchNotice::AgentSessionsUnanswered::workspace_id: alloc::string::String +pub devlaunch_core::api::LaunchNotice::AgentSessionsUnseen +pub devlaunch_core::api::LaunchNotice::AgentSessionsUnseen::workspace_id: alloc::string::String pub devlaunch_core::api::LaunchNotice::AlreadyRunning pub devlaunch_core::api::LaunchNotice::AlreadyRunning::workspace_id: alloc::string::String pub devlaunch_core::api::LaunchNotice::AlreadyRunningAttaching @@ -591,6 +609,24 @@ impl core::fmt::Debug for devlaunch_core::flows::launch::ColdRefused pub fn devlaunch_core::flows::launch::ColdRefused::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::flows::launch::ColdRefused pub enum devlaunch_core::flows::launch::LaunchNotice +pub devlaunch_core::flows::launch::LaunchNotice::AgentSessionInThisPane +pub devlaunch_core::flows::launch::LaunchNotice::AgentSessionInThisPane::line: alloc::string::String +pub devlaunch_core::flows::launch::LaunchNotice::AgentSessionNotRestarted +pub devlaunch_core::flows::launch::LaunchNotice::AgentSessionNotRestarted::line: alloc::string::String +pub devlaunch_core::flows::launch::LaunchNotice::AgentSessionNotRestarted::pane_id: alloc::string::String +pub devlaunch_core::flows::launch::LaunchNotice::AgentSessionRestarted +pub devlaunch_core::flows::launch::LaunchNotice::AgentSessionRestarted::pane_id: alloc::string::String +pub devlaunch_core::flows::launch::LaunchNotice::AgentSessionUnresumable +pub devlaunch_core::flows::launch::LaunchNotice::AgentSessionUnresumable::agent: alloc::string::String +pub devlaunch_core::flows::launch::LaunchNotice::AgentSessionUnresumable::pane_id: alloc::string::String +pub devlaunch_core::flows::launch::LaunchNotice::AgentSessionUnresumable::workspace_id: alloc::string::String +pub devlaunch_core::flows::launch::LaunchNotice::AgentSessionsHeld +pub devlaunch_core::flows::launch::LaunchNotice::AgentSessionsHeld::panes: alloc::vec::Vec +pub devlaunch_core::flows::launch::LaunchNotice::AgentSessionsHeld::workspace_id: alloc::string::String +pub devlaunch_core::flows::launch::LaunchNotice::AgentSessionsUnanswered +pub devlaunch_core::flows::launch::LaunchNotice::AgentSessionsUnanswered::workspace_id: alloc::string::String +pub devlaunch_core::flows::launch::LaunchNotice::AgentSessionsUnseen +pub devlaunch_core::flows::launch::LaunchNotice::AgentSessionsUnseen::workspace_id: alloc::string::String pub devlaunch_core::flows::launch::LaunchNotice::AlreadyRunning pub devlaunch_core::flows::launch::LaunchNotice::AlreadyRunning::workspace_id: alloc::string::String pub devlaunch_core::flows::launch::LaunchNotice::AlreadyRunningAttaching diff --git a/rust/devlaunch-core/public-api.rest.txt b/rust/devlaunch-core/public-api.rest.txt index 5d5a911a..f43b3066 100644 --- a/rust/devlaunch-core/public-api.rest.txt +++ b/rust/devlaunch-core/public-api.rest.txt @@ -829,6 +829,73 @@ pub fn devlaunch_core::domain::xdg::clone_root_in(&std::path::Path) -> std::path pub fn devlaunch_core::domain::xdg::config_home() -> core::result::Result pub fn devlaunch_core::domain::xdg::devlaunch_cache() -> core::result::Result pub mod devlaunch_core::flows +pub mod devlaunch_core::flows::agent_sessions +pub enum devlaunch_core::flows::agent_sessions::Restarted +pub devlaunch_core::flows::agent_sessions::Restarted::NotTyped +pub devlaunch_core::flows::agent_sessions::Restarted::NotTyped::line: alloc::string::String +pub devlaunch_core::flows::agent_sessions::Restarted::NotTyped::pane_id: alloc::string::String +pub devlaunch_core::flows::agent_sessions::Restarted::OwnPane +pub devlaunch_core::flows::agent_sessions::Restarted::OwnPane::line: alloc::string::String +pub devlaunch_core::flows::agent_sessions::Restarted::OwnPane::pane_id: alloc::string::String +pub devlaunch_core::flows::agent_sessions::Restarted::Typed +pub devlaunch_core::flows::agent_sessions::Restarted::Typed::pane_id: alloc::string::String +impl core::clone::Clone for devlaunch_core::flows::agent_sessions::Restarted +pub fn devlaunch_core::flows::agent_sessions::Restarted::clone(&self) -> devlaunch_core::flows::agent_sessions::Restarted +impl core::cmp::Eq for devlaunch_core::flows::agent_sessions::Restarted +impl core::cmp::PartialEq for devlaunch_core::flows::agent_sessions::Restarted +pub fn devlaunch_core::flows::agent_sessions::Restarted::eq(&self, &devlaunch_core::flows::agent_sessions::Restarted) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_sessions::Restarted +pub fn devlaunch_core::flows::agent_sessions::Restarted::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_sessions::Restarted +pub struct devlaunch_core::flows::agent_sessions::HeldSession +pub devlaunch_core::flows::agent_sessions::HeldSession::line: alloc::vec::Vec +pub devlaunch_core::flows::agent_sessions::HeldSession::pane_id: alloc::string::String +impl core::clone::Clone for devlaunch_core::flows::agent_sessions::HeldSession +pub fn devlaunch_core::flows::agent_sessions::HeldSession::clone(&self) -> devlaunch_core::flows::agent_sessions::HeldSession +impl core::cmp::Eq for devlaunch_core::flows::agent_sessions::HeldSession +impl core::cmp::PartialEq for devlaunch_core::flows::agent_sessions::HeldSession +pub fn devlaunch_core::flows::agent_sessions::HeldSession::eq(&self, &devlaunch_core::flows::agent_sessions::HeldSession) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_sessions::HeldSession +pub fn devlaunch_core::flows::agent_sessions::HeldSession::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_sessions::HeldSession +pub struct devlaunch_core::flows::agent_sessions::HeldSessions +pub devlaunch_core::flows::agent_sessions::HeldSessions::sessions: alloc::vec::Vec +pub devlaunch_core::flows::agent_sessions::HeldSessions::unresumable: alloc::vec::Vec +impl core::clone::Clone for devlaunch_core::flows::agent_sessions::HeldSessions +pub fn devlaunch_core::flows::agent_sessions::HeldSessions::clone(&self) -> devlaunch_core::flows::agent_sessions::HeldSessions +impl core::cmp::Eq for devlaunch_core::flows::agent_sessions::HeldSessions +impl core::cmp::PartialEq for devlaunch_core::flows::agent_sessions::HeldSessions +pub fn devlaunch_core::flows::agent_sessions::HeldSessions::eq(&self, &devlaunch_core::flows::agent_sessions::HeldSessions) -> bool +impl core::default::Default for devlaunch_core::flows::agent_sessions::HeldSessions +pub fn devlaunch_core::flows::agent_sessions::HeldSessions::default() -> devlaunch_core::flows::agent_sessions::HeldSessions +impl core::fmt::Debug for devlaunch_core::flows::agent_sessions::HeldSessions +pub fn devlaunch_core::flows::agent_sessions::HeldSessions::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_sessions::HeldSessions +pub struct devlaunch_core::flows::agent_sessions::Manager +impl devlaunch_core::flows::agent_sessions::Manager +pub fn devlaunch_core::flows::agent_sessions::Manager::from_host(&devlaunch_core::flows::launch::Host) -> core::option::Option +impl core::clone::Clone for devlaunch_core::flows::agent_sessions::Manager +pub fn devlaunch_core::flows::agent_sessions::Manager::clone(&self) -> devlaunch_core::flows::agent_sessions::Manager +impl core::cmp::Eq for devlaunch_core::flows::agent_sessions::Manager +impl core::cmp::PartialEq for devlaunch_core::flows::agent_sessions::Manager +pub fn devlaunch_core::flows::agent_sessions::Manager::eq(&self, &devlaunch_core::flows::agent_sessions::Manager) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_sessions::Manager +pub fn devlaunch_core::flows::agent_sessions::Manager::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_sessions::Manager +pub struct devlaunch_core::flows::agent_sessions::UnresumablePane +pub devlaunch_core::flows::agent_sessions::UnresumablePane::agent: alloc::string::String +pub devlaunch_core::flows::agent_sessions::UnresumablePane::pane_id: alloc::string::String +impl core::clone::Clone for devlaunch_core::flows::agent_sessions::UnresumablePane +pub fn devlaunch_core::flows::agent_sessions::UnresumablePane::clone(&self) -> devlaunch_core::flows::agent_sessions::UnresumablePane +impl core::cmp::Eq for devlaunch_core::flows::agent_sessions::UnresumablePane +impl core::cmp::PartialEq for devlaunch_core::flows::agent_sessions::UnresumablePane +pub fn devlaunch_core::flows::agent_sessions::UnresumablePane::eq(&self, &devlaunch_core::flows::agent_sessions::UnresumablePane) -> bool +impl core::fmt::Debug for devlaunch_core::flows::agent_sessions::UnresumablePane +pub fn devlaunch_core::flows::agent_sessions::UnresumablePane::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::agent_sessions::UnresumablePane +pub fn devlaunch_core::flows::agent_sessions::agent_reading(&dyn devlaunch_runner::Runner, &devlaunch_core::flows::agent_sessions::Manager, &str) -> devlaunch_core::clients::herdr::AgentReading +pub fn devlaunch_core::flows::agent_sessions::collect(&dyn devlaunch_runner::Runner, &devlaunch_core::flows::agent_sessions::Manager, &str, &dyn core::ops::function::Fn(&std::path::Path) -> core::option::Option) -> core::option::Option +pub fn devlaunch_core::flows::agent_sessions::restart(&dyn devlaunch_runner::Runner, &devlaunch_core::flows::agent_sessions::Manager, &devlaunch_core::flows::agent_sessions::HeldSessions, &dyn core::ops::function::Fn(core::time::Duration)) -> alloc::vec::Vec pub mod devlaunch_core::flows::agent_worktrees pub enum devlaunch_core::flows::agent_worktrees::AgentOwner pub devlaunch_core::flows::agent_worktrees::AgentOwner::CouldNotTell(alloc::string::String) From 6b158582c70a6de61ae2942306c2d190ad5518ca Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 7 Oct 2026 15:42:46 +0000 Subject: [PATCH 13/13] fix: a recreate ended an agent that herdr called done and did not start it again herdr's own docs say idle and done both mean the agent is ready for input. done only says nobody has looked at the pane since the turn ended. collect read done as an agent the user had quit, so it dropped the pane's saved line, and the recreate ended that agent for good. Claude-Session: https://claude.ai/code/session_01Ro7QyyAtHmrh9oxyyK6h9m --- docs/cli.md | 7 ++-- .../src/flows/agent_sessions.rs | 33 ++++++++++++------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 8b112318..04af30b7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -923,9 +923,10 @@ to bring each agent back (devlaunch#673): 1. Before the `up`, `dl` asks herdr for its panes. A pane counts when its foreground is a `dl` session into this workspace, and herdr saved a line for it that starts an agent. - A pane drops out when `herdr agent get` reports no agent in it, or reports the agent - `done`. A pane whose agent herdr does not report on still counts. `dl` names the - panes it found. + A pane drops out when `herdr agent get` reports no agent in it. An agent that herdr + reports `done` still counts, because herdr uses `done` for an idle agent that nobody + has looked at yet. A pane whose agent herdr does not report on still counts. `dl` + names the panes it found. 2. After the `up` succeeds, `dl` types each pane's line back into that pane with `herdr pane run`. herdr refuses a pane until the old session in it has exited and its shell is back, so `dl` asks again every half second, for up to ten seconds per pane. diff --git a/rust/devlaunch-core/src/flows/agent_sessions.rs b/rust/devlaunch-core/src/flows/agent_sessions.rs index 9e69bf7f..73deae74 100644 --- a/rust/devlaunch-core/src/flows/agent_sessions.rs +++ b/rust/devlaunch-core/src/flows/agent_sessions.rs @@ -121,10 +121,11 @@ pub struct UnresumablePane { /// only when it names this workspace and the words after its `--` start an agent /// dl knows by name. /// -/// `herdr agent get` decides the two doubtful cases. A saved line in a pane whose -/// agent herdr calls `done`, or where herdr names no agent, is a line left over -/// from an agent the user already quit; typing it would start one nobody asked -/// for. A pane with no saved line and a live agent is an agent this recreate ends +/// `herdr agent get` decides the two doubtful cases. A saved line in a pane where +/// herdr names no agent is a line left over from an agent the user already quit; +/// typing it would start one nobody asked for. A pane whose agent herdr calls +/// `done` still holds a live agent, because herdr's `done` is `idle` that nobody +/// has looked at yet. A pane with no saved line and a live agent is an agent this recreate ends /// with no way back, which the caller says. A herdr that does not answer /// `agent get` is no reason to drop a line it saved. pub fn collect( @@ -158,9 +159,9 @@ pub fn collect( let live_agent = match agent_reading(runner, manager, &pane.pane_id) { herdr::AgentReading::Unanswered => None, herdr::AgentReading::NoAgent => Some(None), - herdr::AgentReading::Agent(agent) => { - Some((agent.status != herdr::AgentStatus::Done).then_some(agent.agent)) - } + // Every status, `done` included: herdr's `done` is an agent ready for + // input that nobody has looked at yet, not one that has exited. + herdr::AgentReading::Agent(agent) => Some(Some(agent.agent)), }; match (line, live_agent) { (Some(line), Some(Some(_)) | None) => held.sessions.push(HeldSession { @@ -395,7 +396,9 @@ mod tests { /// herdr's word on the agent decides the two doubtful panes: a line saved for /// an agent that has ended is not typed again, and a live agent with no line - /// is named so the caller can say it ends. + /// is named so the caller can say it ends. `done` is a live agent: herdr says + /// `idle` and `done` both mean ready for input, and differ only in whether + /// anybody has looked at the pane since. #[test] fn herdr_says_which_saved_lines_still_stand_for_a_live_agent() { let runner = ScriptedRunner::new(); @@ -426,10 +429,16 @@ mod tests { assert_eq!( held, Some(HeldSessions { - sessions: vec![HeldSession { - pane_id: "w1:p3".to_owned(), - line: words(CLAUDE_LINE), - }], + sessions: vec![ + HeldSession { + pane_id: "w1:p1".to_owned(), + line: words(CLAUDE_LINE), + }, + HeldSession { + pane_id: "w1:p3".to_owned(), + line: words(CLAUDE_LINE), + }, + ], unresumable: vec![UnresumablePane { pane_id: "w1:p2".to_owned(), agent: "claude".to_owned(),