Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ws> 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 <ws> -- ... claude ... --resume <uuid>`). 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

Expand Down
38 changes: 38 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ws> 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 <ws> -- ... claude ... --remote-control=<ws> --resume <uuid>`. 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 <ws> stop` asks devpod to stop a workspace, and it is the right thing to type
Expand Down
36 changes: 36 additions & 0 deletions rust/devlaunch-core/public-api.api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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<alloc::string::String>
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
Expand Down Expand Up @@ -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<alloc::string::String>
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
Expand Down
67 changes: 67 additions & 0 deletions rust/devlaunch-core/public-api.rest.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::path::PathBuf, devlaunch_core::domain::xdg::NoHomeDirectory>
pub fn devlaunch_core::domain::xdg::devlaunch_cache() -> core::result::Result<std::path::PathBuf, devlaunch_core::domain::xdg::NoHomeDirectory>
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<alloc::string::String>
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<devlaunch_core::flows::agent_sessions::HeldSession>
pub devlaunch_core::flows::agent_sessions::HeldSessions::unresumable: alloc::vec::Vec<devlaunch_core::flows::agent_sessions::UnresumablePane>
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<Self>
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<alloc::string::String>) -> core::option::Option<devlaunch_core::flows::agent_sessions::HeldSessions>
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<devlaunch_core::flows::agent_sessions::Restarted>
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)
Expand Down
88 changes: 86 additions & 2 deletions rust/devlaunch-core/src/clients/herdr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1382,7 +1382,7 @@
}

/// A value that is present and not just whitespace.
fn non_empty(value: Option<&str>) -> Option<String> {
pub(crate) fn non_empty(value: Option<&str>) -> Option<String> {
let value = crate::osext::strip(value?);
(!value.is_empty()).then(|| value.to_owned())
}
Expand Down Expand Up @@ -1778,6 +1778,90 @@
.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<String> {
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<String> {
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::<serde_json::Value>(answer) else {
return AgentReading::Unanswered;
};
let Some(result) = value.get("result") else {
return AgentReading::Unanswered;

Check warning on line 1841 in rust/devlaunch-core/src/clients/herdr.rs

View check run for this annotation

Codecov / codecov/patch

rust/devlaunch-core/src/clients/herdr.rs#L1841

Added line #L1841 was not covered by tests
};
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
// ===========================================================================
Expand Down Expand Up @@ -2014,7 +2098,7 @@
///
/// herdr's pane ids are `w<workspace>:p<public number>`, 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<Option<Vec<String>>> {
pub(crate) fn saved_argv(session_json: &str, pane_id: &str) -> Option<Option<Vec<String>>> {
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()?;
Expand Down
Loading
Loading