diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c0639ab..e0cf1a89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,16 @@ 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. 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.60.1] - 2026-10-16 diff --git a/docs/cli.md b/docs/cli.md index 39f5e43c..04af30b7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -914,6 +914,44 @@ 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, and herdr saved a line for it that starts an agent. + 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. +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 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) diff --git a/rust/devlaunch-core/src/clients/herdr.rs b/rust/devlaunch-core/src/clients/herdr.rs index baed0a64..70cf5279 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,90 @@ 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()] +} + +/// 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, an agent named in a shape this build cannot read. + 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::Unanswered; + }; + 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 +2098,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..73deae74 --- /dev/null +++ b/rust/devlaunch-core/src/flows/agent_sessions.rs @@ -0,0 +1,891 @@ +//! 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 where herdr saved one. 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, +} + +/// 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 +/// 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 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 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( + 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, workspace_id)); + let live_agent = match agent_reading(runner, manager, &pane.pane_id) { + herdr::AgentReading::Unanswered => None, + herdr::AgentReading::NoAgent => Some(None), + // 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 { + pane_id: pane.pane_id, + line, + }), + (None, Some(Some(agent))) => held.unresumable.push(UnresumablePane { + pane_id: pane.pane_id, + agent, + }), + (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 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. +#[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::*; + 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])), + ); + 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| { + 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. `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(); + 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: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(), + }], + }) + ); + } + + /// 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(), + }], + }) + ); + } + + /// 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] + 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())); + } + + /// 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![UnresumablePane { + pane_id: "w1:p1".to_owned(), + agent: "claude".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] + 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); + } + + 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() + ); + } + + /// 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![UnresumablePane { + pane_id: "w1:p1".to_owned(), + agent: "claude".to_owned(), + }], + }) + ); + } + + #[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/launch.rs b/rust/devlaunch-core/src/flows/launch.rs index e2251e7e..7f661ec2 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,36 @@ 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, + /// 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 }, + /// 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 +5134,23 @@ 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); + // `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? { return Ok(Launched::Refused(refused)); } if !verb.attaches() { @@ -5115,6 +5162,83 @@ 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 in &held.unresumable { + self.notices.say(LaunchNotice::AgentSessionUnresumable { + workspace_id: workspace_id.clone(), + pane_id: pane.pane_id.clone(), + agent: pane.agent.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 +13669,381 @@ 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 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() { + 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() + ); + } + } + + /// 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/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()) diff --git a/rust/dl/src/render.rs b/rust/dl/src/render.rs index aa5f95d6..64405586 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -3266,6 +3266,61 @@ pub(crate) fn launch_notice(notice: &LaunchNotice) -> Option { --devcontainer ...' to switch config." ), + // --- 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 \ + 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, + 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.") + } + 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 +5146,117 @@ 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}"); + } + + /// 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 { + 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_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(), + agent: "claude".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::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(), + }) + .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