Skip to content

Handover: validate the #673 stack (#674–#676) on a real host #677

Closed

Description

@blooop

A handover for the agent that continues this work on another machine.

Goal

Finish validating the stack for
#673 — A workspace keeps its old image after a pull, and a recreate ends its agent
on a real host (docker + devpod + herdr), and turn the defects found so far into failing tests.
Report only. Do not push, comment on GitHub, or merge unless the user says so.

Stack (bottom first), all on blooop/devlaunch:

  1. #674 — feat: dl --ls says which workspaces run an older image, feat/stale-image-ls
  2. #675 — feat: dl recreate starts the agents it ends again, feat/recreate-keeps-agent
  3. #676 — feat: dl --refresh-stale recreates the stale workspaces that are idle, feat/refresh-stale (holds all three; was at f5551ced)

Use a machine with no real workspaces or agents you care about — --refresh-stale and recreate
end every process in a container. Still use a scratch DEVPOD_HOME and XDG_CACHE_HOME.

State: what the first run (2026-10-08, another host) found

Two one-line defects that make the stack do nothing on a real host. The unit tests miss both because
their fakes use the same wrong shape.

  1. rust/devlaunch-core/src/flows/stale_images.rs:216 reads ContainerDetails["Id"]; devpod writes
    ContainerDetails.ID. Result: part 1 flags nothing, so --ls and --refresh-stale never act.
    Fakes with "Id": tests in stale_images.rs, rust/dl/tests/read_side.rs:426,965,
    rust/devlaunch-core/src/flows/lifecycle/tests.rs:94.
  2. rust/devlaunch-core/src/clients/herdr.rs:2101 saved_argv parses the pane-id suffix as decimal.
    herdr pane ids are Crockford base32 (w3:pAX = 10×32+29 = 349, matching
    public_pane_numbers: {"226": 349} in herdr's session.json). Every real id fails, so a recreate says
    "herdr saved no line to start it again" and ends the agent. Tests use w1:p9, which hides it.

With both fixed, these passed: --ls matched a by-hand check on 29 real workspaces (a compose devcontainer whose service
names a plain local tag included: declared is null, createdFrom is that tag, and that works); recreate with
the agent done restarted it with the same session id and --remote-control=<ws>; /clear + recreate
brought back the new id (needs DEVLAUNCH_HERDR=1); recreate outside herdr warned and did not hang;
--refresh-stale refreshed an idle agent and skipped correctly for: agent running a command, a make
process, a stopped workspace, an agent started outside herdr. No false "Refreshing" was seen.

Other open findings to confirm and turn into tests:

  1. A restarted agent loses its saved line. The typed line is dl <ws> -- … claude … --resume <id>; a
    --resume launch gets no DEVLAUNCH_HERDR_RESUME, so the container hook exits early and herdr ends up
    with {"cwd": …} only. The second recreate drops the agent; the second --refresh-stale skips with
    "no saved line". Seen with and without DEVLAUNCH_HERDR=1.
  2. The typed line's argv[0] is dl, so the released dl runs the resumed session, not dl-next.
  3. With "features" in devcontainer.json, recreate reuses the cached <ws>-xxxx:devpod-<hash> image, so
    the container stays on the old base and stays stale; --refresh-stale would recreate it every run.
  4. With docker's containerd image store, a locally built image whose only tag moved is gone from
    docker image inspect while a container still runs it, so part 1 calls the workspace current.
    Workaround used in testing: docker tag <ref> <ref-prev-N> before each rebuild.
  5. created.get(..19) compares Created as text; local builds print +01:00, pulls print Z.
  6. Without a persistent ~/.claude, the resumed agent hits Claude's Bypass Permissions prompt and
    --resume finds no conversation. Bind a scratch dir to /home/vscode/.claude (a compose setup that binds the
    host's ~/.claude is fine). Side effect seen: with that bind, dl stopped sending CLAUDE_CODE_OAUTH_TOKEN, so the agent
    needed /login — not diagnosed.

The first run's full report stays on the first host; everything needed to continue is in this issue.

The WIP fix (not pushed; apply the diff below)

Apply on top of feat/refresh-stale:

--- a/rust/devlaunch-core/src/clients/herdr.rs
+++ b/rust/devlaunch-core/src/clients/herdr.rs
@@ -2100,7 +2100,9 @@ pub(crate) enum Saved {
 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 public: u64 = public.chars().try_fold(0u64, |n, c| {
+        "0123456789ABCDEFGHJKMNPQRSTVWXYZ".find(c.to_ascii_uppercase()).map(|d| n * 32 + d as u64)
+    })?;
     let session: serde_json::Value = serde_json::from_str(session_json).ok()?;
--- a/rust/devlaunch-core/src/flows/stale_images.rs
+++ b/rust/devlaunch-core/src/flows/stale_images.rs
@@ -213,7 +213,7 @@ fn created(devpod_home: Option<&DevpodHome>, workspace_id: &str) -> Option<Creat
     let created_from = text(&details["Config"]["Image"])?;
     Some(Created {
-        container: text(&details["Id"])?,
+        container: text(&details["ID"])?,

Check the base32 alphabet against herdr's source before trusting it; it fit every id seen (p2G=80, pA1=321, pAX=349).

Runner

git clone --branch feat/refresh-stale https://github.com/blooop/devlaunch ~/dl673/src
cd ~/dl673/src && git apply <the diff above>   # or edit the two lines by hand
./dev.sh                                       # installs dl-next / aid-next; needs cargo
dl-next --version                              # "dl <version>-dev"
cd rust && cargo test -p devlaunch-core        # unit tests

Load the herdr skill; run inside a herdr pane (HERDR_ENV=1). Gotchas from the first run:

  • herdr tab create --env K=V reaches only that tab's root pane; a split pane needs the exports again.
  • Recreate attaches a shell at the end. Run it under script -qfc "dl-next <ws> recreate" log; piping it to
    tee hangs.
  • pane wait-output --match 'RC=' matches the typed command line; use --regex '(?m)^RC=\d'.
  • herdr agent prompt … --timeout needs --wait.
  • Claude Code blocks a standalone sleep 120; ask for timeout 120 tail -f /dev/null; echo finished.
  • devcontainers/base:ubuntu has multi-call coreutils, so cp /bin/sleep /tmp/make fails. Use
    printf '#!/bin/sh\nsleep 300\n' > /tmp/make; chmod +x /tmp/make; /tmp/make & in the container.
  • A new workspace stops aid-next at the folder-trust prompt and the Bypass Permissions prompt; answer them
    with herdr agent send-keys <pane> down enter / enter.
  • After each restart, start a fresh agent (DEVLAUNCH_HERDR=1 aid-next <ws> 'Say hi.'), or finding 3 makes
    the next row skip for "no saved line".

Test project (scratch env exported):

export DEVPOD_HOME=~/dl673/devpod XDG_CACHE_HOME=~/dl673/cache
devpod provider add docker && devpod provider use docker
docker pull mcr.microsoft.com/devcontainers/base:ubuntu
docker tag mcr.microsoft.com/devcontainers/base:ubuntu dl673-test:latest
mkdir -p ~/dl673/proj/.devcontainer ~/dl673/claude-home && cd ~/dl673/proj && git init -q
echo '{"image":"dl673-test:latest","mounts":["source='$HOME'/dl673/claude-home,target=/home/vscode/.claude,type=bind"]}' > .devcontainer/devcontainer.json
git add -A && git commit -qm init && dl-next ~/dl673/proj
# make it stale, keeping the old image inspectable (finding 6):
docker tag dl673-test:latest dl673-test:prev-1
printf 'FROM dl673-test:latest\nRUN touch /stale-marker\n' | docker build -t dl673-test:latest -

Next steps

  1. Run the one row not done: --refresh-stale with nothing stale → expect "No workspace runs an older
    image…", exit 0.
  2. Re-run step 4 with an idle agent (only done was tested with the fixes) and ask the agent what you
    said before, to prove the conversation came back.
  3. Write failing tests for findings 1 and 2 (real shapes: "ID", ids like w3:pAX), then fix. Use the
    tdd skill.
  4. Confirm findings 3, 5 and 6 and decide with the user whether each blocks the stack.
  5. Report back with a ledger; push nothing without the user's word.
  6. Clean up: dl-next proj rm --force under the scratch env, docker rmi every dl673-test:* and
    proj-*:devpod-* image, close your herdr tab, rm -rf ~/dl673 (it may hold a .credentials.json).

🤖 Generated with Claude Code

Activity

  1. blooop commented on Oct 9, 2026

    @blooop
    OwnerAuthor

    Update: the tests for defects 1 and 2 are written. This diff replaces the one in the issue body; it holds both fixes and the tests, on top of feat/refresh-stale at f5551ced.

    • Red: with the two fix lines undone, 6 tests fail (a_pane_id_is_read_as_herdrs_base32_number and five stale_images tests, whose fakes now say "ID").
    • Green: cargo test --locked -p devlaunch-core -p dl passes except four flows::lifecycle::tests (commits_pushed_by_url_do_not_stop_a_plain_rm and three others). Those four fail the same way without this diff, and CI rust passes them at f5551ced, so they depend on the first host. Re-check them on the new machine.

    Apply with git apply from the repo root:

    diff --git a/rust/devlaunch-core/src/clients/herdr.rs b/rust/devlaunch-core/src/clients/herdr.rs
    index 70cf5279..1ccc4665 100644
    --- a/rust/devlaunch-core/src/clients/herdr.rs
    +++ b/rust/devlaunch-core/src/clients/herdr.rs
    @@ -2100,7 +2100,9 @@ pub(crate) enum Saved {
     /// an internal number that `public_pane_numbers` maps to the public one.
     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 public: u64 = public.chars().try_fold(0u64, |n, c| {
    +        "0123456789ABCDEFGHJKMNPQRSTVWXYZ".find(c.to_ascii_uppercase()).map(|d| n * 32 + d as u64)
    +    })?;
         let session: serde_json::Value = serde_json::from_str(session_json).ok()?;
         let Some(workspace) = session
             .get("workspaces")?
    @@ -2411,6 +2413,18 @@ mod resume_tests {
             assert_eq!(report.saved_in(&session(r#"["dl","other"]"#)), Saved::Other);
         }
     
    +    /// herdr numbers panes in Crockford base32: `w3:pAX` is public number 349,
    +    /// as a live herdr's `session.json` showed (`"226": 349`). Ids up to `p9`
    +    /// read the same in base 10, which is how a decimal parse passed the tests above.
    +    #[test]
    +    fn a_pane_id_is_read_as_herdrs_base32_number() {
    +        let json = session(r#"["dl","ws"]"#)
    +            .replace(r#""id":"w1","public_pane_numbers":{"1":1,"4":3}"#, r#""id":"w3","public_pane_numbers":{"1":1,"4":349}"#);
    +        assert_eq!(saved_argv(&json, "w3:pAX"), Some(Some(vec!["dl".to_owned(), "ws".to_owned()])));
    +        assert_eq!(saved_argv(&json, "w3:pax"), Some(Some(vec!["dl".to_owned(), "ws".to_owned()])));
    +        assert_eq!(saved_argv(&json, "w3:pAY"), Some(None));
    +    }
    +
         #[test]
         fn a_pane_with_no_argv_lacks_one() {
             let json = session(r#"["dl","ws"]"#).replace("\"agent_resume\"", "\"other\"");
    diff --git a/rust/devlaunch-core/src/flows/lifecycle/tests.rs b/rust/devlaunch-core/src/flows/lifecycle/tests.rs
    index 5311436e..27d99631 100644
    --- a/rust/devlaunch-core/src/flows/lifecycle/tests.rs
    +++ b/rust/devlaunch-core/src/flows/lifecycle/tests.rs
    @@ -91,7 +91,7 @@ fn devpod_home_recording(
         std::fs::write(
             &result,
             serde_json::json!({
    -            "ContainerDetails": { "Id": "container-id" },
    +            "ContainerDetails": { "ID": "container-id" },
                 "MergedConfig": {},
                 "SubstitutionContext": {
                     "LocalWorkspaceFolder": local_workspace_folder,
    diff --git a/rust/devlaunch-core/src/flows/stale_images.rs b/rust/devlaunch-core/src/flows/stale_images.rs
    index 2957684c..35bd21e9 100644
    --- a/rust/devlaunch-core/src/flows/stale_images.rs
    +++ b/rust/devlaunch-core/src/flows/stale_images.rs
    @@ -213,7 +213,7 @@ fn created(devpod_home: Option<&DevpodHome>, workspace_id: &str) -> Option<Creat
         let details = &document["ContainerDetails"];
         let created_from = text(&details["Config"]["Image"])?;
         Some(Created {
    -        container: text(&details["Id"])?,
    +        container: text(&details["ID"])?,
             reference: text(&document["MergedConfig"]["image"]).unwrap_or_else(|| created_from.clone()),
             created_from,
         })
    @@ -309,7 +309,7 @@ mod tests {
             std::fs::write(
                 home.result("default", workspace_id),
                 serde_json::json!({
    -                "ContainerDetails": { "Id": container, "Config": { "Image": created_from } },
    +                "ContainerDetails": { "ID": container, "Config": { "Image": created_from } },
                     "MergedConfig": merged,
                 })
                 .to_string(),
    diff --git a/rust/dl/tests/read_side.rs b/rust/dl/tests/read_side.rs
    index 0a5269b0..f505417b 100644
    --- a/rust/dl/tests/read_side.rs
    +++ b/rust/dl/tests/read_side.rs
    @@ -423,7 +423,7 @@ fn world_with_a_stale_workspace() -> World {
         std::fs::write(
             records.join("workspace_result.json"),
             format!(
    -            r#"{{"ContainerDetails": {{"Id": "c1", "Config": {{"Image": "{STALE_REFERENCE}"}}}}, "MergedConfig": {{}}}}"#
    +            r#"{{"ContainerDetails": {{"ID": "c1", "Config": {{"Image": "{STALE_REFERENCE}"}}}}, "MergedConfig": {{}}}}"#
             ),
         )
         .expect("a create result");
    @@ -962,7 +962,7 @@ fn world_with_stale(stale: &[(&str, &str, &str)]) -> World {
             std::fs::write(
                 records.join("workspace_result.json"),
                 format!(
    -                r#"{{"ContainerDetails": {{"Id": "{container}", "Config": {{"Image": "{STALE_REFERENCE}"}}}}, "MergedConfig": {{}}}}"#
    +                r#"{{"ContainerDetails": {{"ID": "{container}", "Config": {{"Image": "{STALE_REFERENCE}"}}}}, "MergedConfig": {{}}}}"#
                 ),
             )
             .expect("a create result");

    Steps left (from the issue body): row 6 (nothing stale), step 4 with an idle agent, and a decision on findings 3, 5 and 6.

    🤖 Generated with Claude Code

  2. blooop commented on Oct 9, 2026

    @blooop
    OwnerAuthor

    Update 2: findings 6 and 7 are fixed with tests, and findings 3 and 5 need a decision.

    Fixed, on top of the previous comment's diff (stale_images.rs):

    • Finding 6: when docker image inspect cannot find the running image, a container made from the reference itself is stale if its image id differs from the reference's id. A derived image is not judged without its layers.
    • Finding 7: Created is parsed as a jiff::Timestamp, not cut to 19 characters, so ...Z and ...+01:00 compare correctly.
    • Red: with the old behaviour, a_container_made_from_the_reference_is_stale_after_its_image_left_the_store and created_times_compare_across_utc_offsets fail. Green: all 15 stale_images tests pass. cargo clippy -p devlaunch-core --all-targets -D warnings is clean. The same four host-only lifecycle tests fail, as before.

    Apply after the previous comment's diff:

    diff --git a/rust/devlaunch-core/src/flows/stale_images.rs b/rust/devlaunch-core/src/flows/stale_images.rs
    index 35bd21e9..73db8011 100644
    --- a/rust/devlaunch-core/src/flows/stale_images.rs
    +++ b/rust/devlaunch-core/src/flows/stale_images.rs
    @@ -137,10 +137,10 @@ struct Created {
     #[derive(Clone, Debug, PartialEq, Eq)]
     struct Image {
         id: String,
    -    /// docker's `Created`, cut to whole seconds: `YYYY-MM-DDTHH:MM:SS`. docker
    -    /// writes it in UTC with a varying count of fractional digits, so the cut is
    -    /// what makes two of them compare in time order as strings.
    -    created: String,
    +    /// docker's `Created`, parsed. docker does not always write it in UTC: with
    +    /// the containerd image store a pulled image reads `...Z` and a local build
    +    /// `...+01:00`, so a cut-and-compare of the text is off by the offset.
    +    created: jiff::Timestamp,
         layers: Vec<String>,
     }
     
    @@ -177,9 +177,17 @@ pub fn stale_images<'w>(
         let by_workspace = created
             .into_iter()
             .filter_map(|(id, created)| {
    -            let running = running_images.get(running.get(&created.container)?)?;
    +            let running_id = running.get(&created.container)?;
                 let current = references.get(&created.reference)?;
    -            is_stale(&created, running, current).then(|| {
    +            let stale = match running_images.get(running_id) {
    +                Some(running) => is_stale(&created, running, current),
    +                // With the containerd image store, a local build whose only tag
    +                // moved is gone from `docker image inspect` while a container
    +                // still runs it. Its id is enough when the container was made
    +                // from the reference itself; a derived image needs its layers.
    +                None => created.created_from == created.reference && *running_id != current.id,
    +            };
    +            stale.then(|| {
                     (
                         id.to_owned(),
                         StaleImage {
    @@ -269,7 +277,7 @@ fn images(runner: &dyn Runner, names: impl IntoIterator<Item = String>) -> BTree
                     id.to_owned(),
                     Image {
                         id: id.to_owned(),
    -                    created: created.get(..19)?.to_owned(),
    +                    created: created.parse().ok()?,
                         layers,
                     },
                 ))
    @@ -582,6 +590,75 @@ mod tests {
             assert!(stale_images(&fake, Some(&home), ["ws"]).is_empty());
         }
     
    +    /// With the containerd image store, a local build whose only tag moved is
    +    /// gone from `docker image inspect` while the container still runs it.
    +    fn running_image_gone(fake: &FakeRunner, id: &str) {
    +        fake.script(
    +            [
    +                "docker",
    +                "inspect",
    +                "--type",
    +                "image",
    +                "--format",
    +                IMAGE_FORMAT,
    +                id,
    +            ],
    +            Response::failed(1, format!("Error: No such image: {id}\n")),
    +        );
    +    }
    +
    +    #[test]
    +    fn a_container_made_from_the_reference_is_stale_after_its_image_left_the_store() {
    +        let home = home_with("ws", "dl-test:latest", Some("dl-test:latest"));
    +        let fake = FakeRunner::new();
    +        inspect_container(&fake, "sha256:old");
    +        running_image_gone(&fake, "sha256:old");
    +        inspect_image(&fake, "dl-test:latest", line("sha256:new", &["l1", "l2"]));
    +
    +        let stale = stale_images(&fake, Some(&home), ["ws"]);
    +
    +        assert_eq!(
    +            stale.of("ws").map(StaleImage::reference),
    +            Some("dl-test:latest")
    +        );
    +    }
    +
    +    #[test]
    +    fn a_derived_image_that_left_the_store_is_not_judged_without_its_layers() {
    +        let home = home_with("ws", "devpod-abc", Some("ghcr.io/o/img:latest"));
    +        let fake = FakeRunner::new();
    +        inspect_container(&fake, "sha256:derived");
    +        running_image_gone(&fake, "sha256:derived");
    +        inspect_image(&fake, "ghcr.io/o/img:latest", line("sha256:base2", &["l1"]));
    +
    +        assert!(stale_images(&fake, Some(&home), ["ws"]).is_empty());
    +    }
    +
    +    /// 10:10 at +01:00 is 09:10 UTC, so a base pulled at 09:30 UTC is the newer
    +    /// one. Compared as text, "10:10" sorts after "09:30" and hid it.
    +    #[test]
    +    fn created_times_compare_across_utc_offsets() {
    +        let home = home_with("ws", "devpod-abc", Some("ghcr.io/o/img:latest"));
    +        let fake = FakeRunner::new();
    +        inspect_container(&fake, "sha256:derived");
    +        inspect_image(
    +            &fake,
    +            "sha256:derived",
    +            line_at(
    +                "sha256:derived",
    +                "2026-10-08T10:10:00.5+01:00",
    +                &["l1", "f1"],
    +            ),
    +        );
    +        inspect_image(
    +            &fake,
    +            "ghcr.io/o/img:latest",
    +            line_at("sha256:base2", "2026-10-08T09:30:00Z", &["l2"]),
    +        );
    +
    +        assert!(stale_images(&fake, Some(&home), ["ws"]).of("ws").is_some());
    +    }
    +
         #[test]
         fn a_missing_container_costs_the_others_nothing() {
             // docker prints a line for every container it found and exits 1 for the

    (cargo fmt also reflowed the herdr.rs lines from the previous diff. Run cargo fmt after you apply.)

    Finding 3, decision needed: a restarted agent loses its saved line.
    Cause: only aid gives dl a line to report (dl/src/lib.rs:573 passes None; aid/src/main.rs:251 is the only run_resumable caller). A typed-back dl ws -- … claude --resume <id> reports nothing and gets no DEVLAUNCH_HERDR_RESUME. This is deliberate: dl/src/lib.rs:579-581 says a dl that reads a line out of a command tail "would be guessing at another program's flags". Also, with DEVLAUNCH_HERDR=1, the old agent's SessionEnd hook runs pane release-agent (herdr.rs:1592), which can clear the saved line.
    Options:

    • (a) Recommended: recreate reports the exact line it typed into the pane again, after Restarted::Typed, and retries on resume_not_accepted. No guess, because the line is the one herdr held. Also check the order against the old agent's release.
    • (b) dl builds an AgentResume from a tail that ends in claude … --resume <id> (at the None arm of launch.rs:2888). This reverses the recorded decision. Nearest tests: an_agent_launched_in_a_pane_tells_herdr_how_to_start_it_again and a_consented_pane_hands_the_container_the_line_to_resume_by_id in launch.rs.

    Finding 5, decision needed: a recreate keeps a devpod-derived image.
    devpod v0.26.3 has no flag that forces the derived image to rebuild (up has --recreate and --reset only; build has no force flag). The derived tag is a hash of the config, not of the base image, so devpod reuses it every time.
    Options:

    • (a) Before up --recreate, when the workspace is stale and createdFrom ≠ the declared image, run docker rmi <createdFrom> without -f. If docker refuses (another container uses it), fall back to (b).
    • (b) --refresh-stale skips such a workspace and says that a recreate cannot move it, so it does not recreate it on every run.

    🤖 Generated with Claude Code

  3. blooop commented on Oct 12, 2026

    @blooop
    OwnerAuthor

    Update 3: findings 3 and 5 fixed (local commit 435a42da, on top of 2713b4c0)

    The user chose the recommended options.

    Finding 3. After restart types a line into a pane, the recreate reports the same line to herdr (pane report-agent-session, through session_manager::report_resume). It retries on resume_not_accepted (1 s, up to 20 tries), one thread per pane, and reads session.json back. When herdr does not keep the line, dl prints a new notice AgentSessionLineNotKept. Limit: an agent restarted from a line has no container hook, so after /clear in it the saved line still has the old id.

    Finding 5. StaleImage now carries derived_image(): the ContainerDetails.Config.Image tag, when it is <prefix>:devpod-<hash> and differs from the reference. Before up in recreate and in the unattended recreate, dl runs docker image rm --force <tag>. --force is required because the old container still uses the image; with a tag it only untags. If that fails: recreate goes ahead with notice DerivedImageKept; --refresh-stale refuses that workspace (LaunchRefusal::DerivedImageKept), so it is not recreated on every run.

    Tests: 3 new in agent_sessions, 2 in launch (finding 3; both fail without the wiring), 3 in stale_images, 3 in launch (finding 5; all fail without the untag step). Full suite green except the 5 host-only "pushed by URL" lifecycle tests, which also fail on the base. Clippy -D warnings and fmt clean. public-api.*.txt edited by hand (no cargo public-api on that host) — let CI's public-api job confirm.

    To confirm on the other machine:

    1. docker image rm --force <tag> with the old container running: only untags, the container keeps running, and devpod up --recreate then builds a new :devpod- image on the new base. After it, dl-next --ls says current.
    2. Recreate twice in a row with an idle agent: the second recreate still brings the agent back (herdr's session.json holds the line after the first).

    Apply with git am on top of the earlier diffs:

    patch
    From 435a42dafee70223a66fece107b081e1b364d404 Mon Sep 17 00:00:00 2001
    From: Austin Gregg-Smith <blooop@gmail.com>
    Date: Mon, 12 Oct 2026 17:10:08 +0100
    Subject: [PATCH] fix: a restarted agent keeps its line, and a recreate
     rebuilds devpod's image
    
    Two findings from the host run of #673.
    
    A recreate typed each agent's saved line back into its pane, and the
    `dl` that line runs saves no line of its own. herdr dropped the old line
    when the old agent ended, so the next recreate found none and ended the
    agent. The recreate now reports the line it typed to herdr again, retries
    while herdr has not yet seen the new agent, reads the saved session back,
    and says so when herdr did not keep it.
    
    devpod tags the image it builds for a devcontainer with features by a
    hash of the config, not of the base, and reuses that tag. A recreate of
    a stale workspace on such an image kept the older base, and
    --refresh-stale would recreate it on every run. A recreate of a stale
    workspace now removes devpod's `:devpod-` tag first with
    `docker image rm --force` (a tag, so the old container keeps its image),
    and devpod builds again. If the tag stays, a recreate the user asked for
    goes ahead with a notice, and an unattended one is refused.
    ---
     docs/cli.md                                   |  35 +-
     rust/devlaunch-core/public-api.api.txt        |  18 +
     rust/devlaunch-core/public-api.rest.txt       |   5 +
     rust/devlaunch-core/src/clients/docker.rs     |  12 +
     rust/devlaunch-core/src/clients/herdr.rs      |  20 +
     .../src/flows/agent_sessions.rs               | 154 +++++++-
     rust/devlaunch-core/src/flows/launch.rs       | 374 ++++++++++++++++++
     rust/devlaunch-core/src/flows/stale_images.rs |  74 ++++
     rust/dl/src/launch.rs                         |   3 +-
     rust/dl/src/render.rs                         |  34 ++
     10 files changed, 719 insertions(+), 10 deletions(-)
    
    diff --git a/docs/cli.md b/docs/cli.md
    index 2b327284..ad3fb6f0 100644
    --- a/docs/cli.md
    +++ b/docs/cli.md
    @@ -930,11 +930,19 @@ to bring each agent back (devlaunch#673):
     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.
    +3. `dl` reports each line it typed to herdr again, as that pane's saved line, and reads
    +   herdr's saved session to check that herdr kept it. The `dl` that the line runs saves
    +   no line of its own, and herdr drops the old line when the old agent ends. Without
    +   this step, the next recreate finds no line and ends the agent. herdr refuses the
    +   report until it sees the new agent, so `dl` asks again every second, for up to
    +   twenty seconds. All panes are asked at the same time.
    +4. 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`.
    +the pane is in now, because the container hook reports a new id after `/clear`. An
    +agent started again from a line has no container hook, so after a `/clear` in it the
    +saved line still names the session it was started with.
     
     Nothing here can refuse the recreate. Each failure is one line on stderr, and the
     recreate goes ahead:
    @@ -948,6 +956,24 @@ recreate goes ahead:
       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.
    +- A pane whose line herdr did not keep gets its agent back, and `dl` says that the next
    +  recreate ends it.
    +
    +#### A workspace on an image that devpod built
    +
    +With `"features"` (or anything else that makes devpod build), the container does not
    +run the reference itself but an image devpod built on it, tagged
    +`<prefix>:devpod-<hash>`. devpod names that tag by a hash of the config, not of the
    +reference, and uses a tag it finds. So a recreate alone builds nothing, and the
    +workspace stays on the older image.
    +
    +When the workspace is stale (as `dl --ls` reports it), and its container runs a
    +`:devpod-` tag that differs from the reference, `dl <ws> recreate` first removes that
    +tag with `docker image rm --force <tag>`. devpod then builds the image again from the
    +newer reference. `--force` is necessary because the old container still uses the
    +image; with a tag, it removes only the tag, and the old container keeps its image until
    +the `up` replaces it. `dl` removes no tag that devpod did not make. If the removal
    +fails, `dl` says so and the recreate goes ahead on the older image.
     
     `reset` and `code` do none of this. A `reset` drops the volumes, and `code` keeps the
     container when nothing changed.
    @@ -998,6 +1024,11 @@ is not a failure.
       of every kind on both sides. A session that was started from a terminal outside
       herdr has no line to start it again.
     
    +A workspace on an image that devpod built is refreshed as `dl <ws> recreate` does it,
    +by removing devpod's tag first. If the removal fails, the refresh does not recreate
    +that workspace, because it would stay stale and be recreated again on every run. It
    +counts as a failed recreate.
    +
     The second check is taken a moment before the recreate. An agent that starts work in
     that moment ends with the recreate, the same as with `dl <ws> recreate`.
     
    diff --git a/rust/devlaunch-core/public-api.api.txt b/rust/devlaunch-core/public-api.api.txt
    index 7de1d45b..da80e660 100644
    --- a/rust/devlaunch-core/public-api.api.txt
    +++ b/rust/devlaunch-core/public-api.api.txt
    @@ -36,6 +36,8 @@ impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::Ins
     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::AgentSessionLineNotKept
    +pub devlaunch_core::api::LaunchNotice::AgentSessionLineNotKept::pane_id: 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
    @@ -72,6 +74,13 @@ pub devlaunch_core::api::LaunchNotice::CodexStageMissing
     pub devlaunch_core::api::LaunchNotice::CodexStageMissing::workspace_id: alloc::string::String
     pub devlaunch_core::api::LaunchNotice::CreateNeverFinished
     pub devlaunch_core::api::LaunchNotice::CreateNeverFinished::workspace_id: alloc::string::String
    +pub devlaunch_core::api::LaunchNotice::DerivedImageKept
    +pub devlaunch_core::api::LaunchNotice::DerivedImageKept::image: alloc::string::String
    +pub devlaunch_core::api::LaunchNotice::DerivedImageKept::why: alloc::string::String
    +pub devlaunch_core::api::LaunchNotice::DerivedImageKept::workspace_id: alloc::string::String
    +pub devlaunch_core::api::LaunchNotice::DerivedImageRemoved
    +pub devlaunch_core::api::LaunchNotice::DerivedImageRemoved::image: alloc::string::String
    +pub devlaunch_core::api::LaunchNotice::DerivedImageRemoved::workspace_id: alloc::string::String
     pub devlaunch_core::api::LaunchNotice::DevcontainerIgnoredRunning
     pub devlaunch_core::api::LaunchNotice::DevcontainerIgnoredRunning::spec: alloc::string::String
     pub devlaunch_core::api::LaunchNotice::DevcontainerIgnoredRunning::workspace_id: alloc::string::String
    @@ -612,6 +621,8 @@ impl core::marker::StructuralPartialEq for devlaunch_core::flows::launch::ColdRe
     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::AgentSessionLineNotKept
    +pub devlaunch_core::flows::launch::LaunchNotice::AgentSessionLineNotKept::pane_id: 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
    @@ -648,6 +659,13 @@ pub devlaunch_core::flows::launch::LaunchNotice::CodexStageMissing
     pub devlaunch_core::flows::launch::LaunchNotice::CodexStageMissing::workspace_id: alloc::string::String
     pub devlaunch_core::flows::launch::LaunchNotice::CreateNeverFinished
     pub devlaunch_core::flows::launch::LaunchNotice::CreateNeverFinished::workspace_id: alloc::string::String
    +pub devlaunch_core::flows::launch::LaunchNotice::DerivedImageKept
    +pub devlaunch_core::flows::launch::LaunchNotice::DerivedImageKept::image: alloc::string::String
    +pub devlaunch_core::flows::launch::LaunchNotice::DerivedImageKept::why: alloc::string::String
    +pub devlaunch_core::flows::launch::LaunchNotice::DerivedImageKept::workspace_id: alloc::string::String
    +pub devlaunch_core::flows::launch::LaunchNotice::DerivedImageRemoved
    +pub devlaunch_core::flows::launch::LaunchNotice::DerivedImageRemoved::image: alloc::string::String
    +pub devlaunch_core::flows::launch::LaunchNotice::DerivedImageRemoved::workspace_id: alloc::string::String
     pub devlaunch_core::flows::launch::LaunchNotice::DevcontainerIgnoredRunning
     pub devlaunch_core::flows::launch::LaunchNotice::DevcontainerIgnoredRunning::spec: alloc::string::String
     pub devlaunch_core::flows::launch::LaunchNotice::DevcontainerIgnoredRunning::workspace_id: alloc::string::String
    diff --git a/rust/devlaunch-core/public-api.rest.txt b/rust/devlaunch-core/public-api.rest.txt
    index eb871499..ecd10e93 100644
    --- a/rust/devlaunch-core/public-api.rest.txt
    +++ b/rust/devlaunch-core/public-api.rest.txt
    @@ -1882,6 +1882,10 @@ pub devlaunch_core::flows::launch::LaunchRefusal::BranchNotNamed
     pub devlaunch_core::flows::launch::LaunchRefusal::BranchNotNamed::error: devlaunch_core::flows::launch::BranchNotNamed
     pub devlaunch_core::flows::launch::LaunchRefusal::BranchNotNamed::owner: alloc::string::String
     pub devlaunch_core::flows::launch::LaunchRefusal::BranchNotNamed::repo: alloc::string::String
    +pub devlaunch_core::flows::launch::LaunchRefusal::DerivedImageKept
    +pub devlaunch_core::flows::launch::LaunchRefusal::DerivedImageKept::image: alloc::string::String
    +pub devlaunch_core::flows::launch::LaunchRefusal::DerivedImageKept::why: alloc::string::String
    +pub devlaunch_core::flows::launch::LaunchRefusal::DerivedImageKept::workspace_id: alloc::string::String
     pub devlaunch_core::flows::launch::LaunchRefusal::IdCollision
     pub devlaunch_core::flows::launch::LaunchRefusal::IdCollision::branch: alloc::string::String
     pub devlaunch_core::flows::launch::LaunchRefusal::IdCollision::owner: alloc::string::String
    @@ -3334,6 +3338,7 @@ pub mod devlaunch_core::flows::stale_images
     pub struct devlaunch_core::flows::stale_images::StaleImage
     impl devlaunch_core::flows::stale_images::StaleImage
     pub fn devlaunch_core::flows::stale_images::StaleImage::container(&self) -> &str
    +pub fn devlaunch_core::flows::stale_images::StaleImage::derived_image(&self) -> core::option::Option<&str>
     pub fn devlaunch_core::flows::stale_images::StaleImage::reference(&self) -> &str
     impl core::clone::Clone for devlaunch_core::flows::stale_images::StaleImage
     pub fn devlaunch_core::flows::stale_images::StaleImage::clone(&self) -> devlaunch_core::flows::stale_images::StaleImage
    diff --git a/rust/devlaunch-core/src/clients/docker.rs b/rust/devlaunch-core/src/clients/docker.rs
    index 4b83d27d..5053918d 100644
    --- a/rust/devlaunch-core/src/clients/docker.rs
    +++ b/rust/devlaunch-core/src/clients/docker.rs
    @@ -89,6 +89,18 @@ pub(crate) fn remove_volumes(runner: &dyn Runner, names: &NonEmpty<String>) -> A
         answer(runner.capture(&spec))
     }
     
    +/// Remove one tag, and the image under it only when nothing uses that image.
    +///
    +/// `--force` because the container that runs the image is still there when this
    +/// is asked: without it docker refuses the tag of an image any container uses.
    +/// With it, and a tag rather than an id, docker removes the tag and keeps the
    +/// image for the container. Callers pass only a tag dl knows devpod made.
    +pub(crate) fn untag_image(runner: &dyn Runner, reference: &str) -> Answer {
    +    let spec =
    +        SpawnSpec::from(Invocation::new(PROGRAM).with_args(["image", "rm", "--force", reference]));
    +    answer(runner.capture(&spec))
    +}
    +
     /// The containers one compose project has running, or why docker could not say.
     ///
     /// Its own type rather than an [`Answer`] carrying stdout, because this is the
    diff --git a/rust/devlaunch-core/src/clients/herdr.rs b/rust/devlaunch-core/src/clients/herdr.rs
    index 5f133e53..8ab430c4 100644
    --- a/rust/devlaunch-core/src/clients/herdr.rs
    +++ b/rust/devlaunch-core/src/clients/herdr.rs
    @@ -2028,6 +2028,26 @@ impl ResumeReport {
             })
         }
     
    +    /// The report for a line that is already known, and the pane it belongs to.
    +    ///
    +    /// For the agent a recreate starts again: the `dl` that line runs is given no
    +    /// line of its own to report, so the recreate reports the one it typed.
    +    pub(crate) fn for_pane(
    +        binary: &str,
    +        pane_id: &str,
    +        session_file: &Path,
    +        agent: &'static str,
    +        argv: Vec<String>,
    +    ) -> Self {
    +        Self {
    +            binary: binary.to_owned(),
    +            pane_id: pane_id.to_owned(),
    +            session_file: session_file.to_owned(),
    +            agent,
    +            argv,
    +        }
    +    }
    +
         pub(crate) fn binary(&self) -> &str {
             &self.binary
         }
    diff --git a/rust/devlaunch-core/src/flows/agent_sessions.rs b/rust/devlaunch-core/src/flows/agent_sessions.rs
    index 73deae74..0aea028e 100644
    --- a/rust/devlaunch-core/src/flows/agent_sessions.rs
    +++ b/rust/devlaunch-core/src/flows/agent_sessions.rs
    @@ -194,13 +194,16 @@ pub fn agent_reading(runner: &dyn Runner, manager: &Manager, pane_id: &str) -> h
     /// 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())
    +    argv.get(1).map(String::as_str) == Some(workspace_id) && agent_started_by(argv).is_some()
    +}
    +
    +/// The agent the words after a line's `--` start, by herdr's name for it.
    +fn agent_started_by(argv: &[String]) -> Option<&'static str> {
    +    argv.iter()
    +        .skip_while(|word| *word != "--")
    +        .skip(1)
    +        .find(|word| !herdr::is_assignment(word))
    +        .and_then(|program| herdr::agent_named(program))
     }
     
     /// What became of one held session after the recreate.
    @@ -261,6 +264,53 @@ pub fn restart(
             .collect()
     }
     
    +/// Whether herdr kept the line of an agent that [`restart`] typed back.
    +#[derive(Clone, Copy, Debug, PartialEq, Eq)]
    +pub(crate) enum LineKept {
    +    Kept,
    +    /// herdr refused the report, or never took it in the time allowed. The next
    +    /// recreate finds no line for the pane, and ends its agent.
    +    NotKept,
    +}
    +
    +/// Report a typed-back line to herdr as the pane's line again, and check that
    +/// herdr kept it.
    +///
    +/// The `dl` the line runs is given no line to report: only `aid` hands one over,
    +/// and dl does not guess another program's flags from a command. Without this,
    +/// herdr drops the old line once the old agent ends, and the agent can be
    +/// started again once only. The line is the one herdr saved, so it is already a
    +/// line herdr accepts. `seq`, `read` and `wait` are as for
    +/// [`session_manager::report_resume`]; `wait` answers `true` here, because
    +/// nothing ends the wait early.
    +pub(crate) fn report_again(
    +    runner: &dyn Runner,
    +    manager: &Manager,
    +    session: &HeldSession,
    +    seq: &dyn Fn() -> u128,
    +    read: &dyn Fn(&Path) -> Option<String>,
    +    wait: &dyn Fn(Duration) -> bool,
    +) -> LineKept {
    +    let Some(agent) = agent_started_by(&session.line) else {
    +        return LineKept::NotKept;
    +    };
    +    let report = herdr::ResumeReport::for_pane(
    +        &manager.binary,
    +        &session.pane_id,
    +        &manager.session_file,
    +        agent,
    +        session.line.clone(),
    +    );
    +    match session_manager::report_resume(runner, &report, seq, read, wait) {
    +        session_manager::ResumeReported::Saved
    +        | session_manager::ResumeReported::Taken
    +        | session_manager::ResumeReported::Superseded => LineKept::Kept,
    +        session_manager::ResumeReported::Dropped | session_manager::ResumeReported::Stopped => {
    +            LineKept::NotKept
    +        }
    +    }
    +}
    +
     #[cfg(test)]
     mod tests {
         use super::*;
    @@ -888,4 +938,94 @@ mod tests {
             no_socket.socket = None;
             assert_eq!(Manager::resolve(&no_socket, Some(HERDR)), None);
         }
    +
    +    fn one_held() -> HeldSession {
    +        HeldSession {
    +            pane_id: "w1:p1".to_owned(),
    +            line: words(CLAUDE_LINE),
    +        }
    +    }
    +
    +    /// The `dl` a typed line runs reports no line of its own, so the recreate
    +    /// reports the one it typed, as the pane's line, and reads it back.
    +    #[test]
    +    fn a_typed_line_is_reported_to_herdr_again_as_the_panes_line() {
    +        let runner = ScriptedRunner::new().with_script([HERDR], Response::ok());
    +
    +        let kept = report_again(
    +            &runner,
    +            &manager(),
    +            &one_held(),
    +            &|| 7,
    +            &|path| {
    +                assert_eq!(path, Path::new("/run/herdr/session.json"));
    +                Some(saved(&[Some(CLAUDE_LINE)]))
    +            },
    +            &|_| panic!("herdr kept it at once"),
    +        );
    +
    +        assert_eq!(kept, LineKept::Kept);
    +        let mut expected: Vec<String> = [
    +            "pane",
    +            "report-agent-session",
    +            "w1:p1",
    +            "--source",
    +            "devlaunch:claude",
    +            "--agent",
    +            "claude",
    +            "--seq",
    +            "7",
    +            "--",
    +        ]
    +        .map(str::to_owned)
    +        .to_vec();
    +        expected.extend(words(CLAUDE_LINE));
    +        assert_eq!(runner.args_to(HERDR), vec![expected]);
    +    }
    +
    +    /// herdr refuses a report until it sees the new agent in the pane, and the
    +    /// typed `dl` takes seconds to start one.
    +    #[test]
    +    fn a_report_sent_before_the_new_agent_is_seen_is_sent_again() {
    +        let runner = ScriptedRunner::new().with_script(
    +            [HERDR],
    +            Response::failed(
    +                1,
    +                r#"{"error":{"code":"resume_not_accepted","message":"report its state first"}}"#,
    +            ),
    +        );
    +        let waits = std::cell::Cell::new(0);
    +
    +        let kept = report_again(
    +            &runner,
    +            &manager(),
    +            &one_held(),
    +            &|| 1,
    +            &|_| Some(saved(&[Some(CLAUDE_LINE)])),
    +            &|_| {
    +                waits.set(waits.get() + 1);
    +                if waits.get() == 2 {
    +                    runner.clear_scripts().script([HERDR], Response::ok());
    +                }
    +                true
    +            },
    +        );
    +
    +        assert_eq!(kept, LineKept::Kept);
    +        assert_eq!(runner.args_to(HERDR).len(), 3);
    +    }
    +
    +    #[test]
    +    fn a_report_herdr_refuses_for_good_is_a_line_not_kept() {
    +        let runner = ScriptedRunner::new().with_script(
    +            [HERDR],
    +            Response::failed(1, r#"{"error":{"code":"pane_not_found"}}"#),
    +        );
    +
    +        let kept = report_again(&runner, &manager(), &one_held(), &|| 1, &|_| None, &|_| {
    +            panic!("a refusal for good is not asked again")
    +        });
    +
    +        assert_eq!(kept, LineKept::NotKept);
    +    }
     }
    diff --git a/rust/devlaunch-core/src/flows/launch.rs b/rust/devlaunch-core/src/flows/launch.rs
    index fd3cdb9d..9979ce8a 100644
    --- a/rust/devlaunch-core/src/flows/launch.rs
    +++ b/rust/devlaunch-core/src/flows/launch.rs
    @@ -70,6 +70,7 @@ use crate::clients::devpod::{
         ProviderRegistration,
     };
     use crate::clients::devpod_home::{CreateRecord, DevpodHome, create_record};
    +use crate::clients::docker;
     use crate::clients::gh::{self, GhEvent, StagedToken, Token, TokenLookup};
     use crate::clients::herdr;
     use crate::clients::ssh;
    @@ -696,6 +697,20 @@ pub enum LaunchNotice {
         },
         /// The agent's line was typed back into its pane.
         AgentSessionRestarted { pane_id: String },
    +    /// The agent was started again, and herdr did not keep its line, so the next
    +    /// recreate ends it.
    +    AgentSessionLineNotKept { pane_id: String },
    +    /// The workspace is stale and its image is one devpod built on the older
    +    /// reference, so the recreate removed devpod's tag for it first: devpod
    +    /// reuses a tag it finds, and builds again only when it is gone.
    +    DerivedImageRemoved { workspace_id: String, image: String },
    +    /// The tag could not be removed, so the recreate builds the container from
    +    /// the older image again and the workspace stays stale.
    +    DerivedImageKept {
    +        workspace_id: String,
    +        image: String,
    +        why: 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 },
    @@ -2934,6 +2949,57 @@ pub(crate) fn workspace_ssh(
         session_outcome
     }
     
    +/// Report each line typed back into a pane as that pane's line again, and answer
    +/// the panes whose line herdr did not keep.
    +///
    +/// One thread per pane, because each one waits up to seconds for herdr to see
    +/// its new agent, and the waits overlap.
    +fn report_typed_lines_again(
    +    runner: &dyn Runner,
    +    manager: &agent_sessions::Manager,
    +    held: &agent_sessions::HeldSessions,
    +    restarted: &[agent_sessions::Restarted],
    +) -> Vec<String> {
    +    let typed: Vec<&agent_sessions::HeldSession> = held
    +        .sessions
    +        .iter()
    +        .filter(|session| {
    +            restarted.iter().any(|outcome| {
    +                matches!(outcome, agent_sessions::Restarted::Typed { pane_id } if *pane_id == session.pane_id)
    +            })
    +        })
    +        .collect();
    +    std::thread::scope(|scope| {
    +        let reports: Vec<_> = typed
    +            .iter()
    +            .map(|session| {
    +                scope.spawn(move || {
    +                    let kept = agent_sessions::report_again(
    +                        runner,
    +                        manager,
    +                        session,
    +                        &|| {
    +                            SystemTime::now()
    +                                .duration_since(SystemTime::UNIX_EPOCH)
    +                                .map_or(0, |since| since.as_nanos())
    +                        },
    +                        &|path| std::fs::read_to_string(path).ok(),
    +                        &|pause| {
    +                            std::thread::sleep(pause);
    +                            true
    +                        },
    +                    );
    +                    (kept == agent_sessions::LineKept::NotKept).then(|| session.pane_id.clone())
    +                })
    +            })
    +            .collect();
    +        reports
    +            .into_iter()
    +            .filter_map(|report| report.join().ok().flatten())
    +            .collect()
    +    })
    +}
    +
     /// Run `session` with the resume argv reported beside it, on a thread of its own.
     ///
     /// The report is retried for seconds and the session lasts hours, so the report
    @@ -4733,6 +4799,14 @@ pub enum LaunchRefusal {
             recorded_repo: String,
             recorded_branch: String,
         },
    +    /// An unattended recreate of a stale workspace whose image devpod built, and
    +    /// devpod's tag for it could not be removed. The recreate would build the
    +    /// container from the older image again, so it does not run.
    +    DerivedImageKept {
    +        workspace_id: String,
    +        image: String,
    +        why: String,
    +    },
         /// A bare `owner/repo` whose default branch could not be named.
         BranchNotNamed {
             owner: String,
    @@ -5140,6 +5214,14 @@ impl<'a, 'r, 'l> Launch<'a, 'r, 'l> {
             devcontainer: Option<&DevcontainerPath>,
             placement: &Placement,
         ) -> Result<Launched, LaunchAborted> {
    +        if matches!(verb, LaunchVerb::Recreate | LaunchVerb::RecreateUnattended)
    +            && let Some(refused) = self.drop_derived_image(
    +                placement.workspace_id(),
    +                matches!(verb, LaunchVerb::RecreateUnattended),
    +            )
    +        {
    +            return Ok(Launched::Refused(refused));
    +        }
             // 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 | LaunchVerb::RecreateUnattended)
    @@ -5175,6 +5257,57 @@ impl<'a, 'r, 'l> Launch<'a, 'r, 'l> {
             session
         }
     
    +    /// Remove the tag devpod built a stale workspace's image under, so that the
    +    /// recreate builds it again from the newer reference (devlaunch#673).
    +    ///
    +    /// devpod names that tag by a hash of the config, not of the reference, and
    +    /// reuses a tag it finds: without this a recreate leaves the workspace on the
    +    /// older image. Nothing to do for a workspace that is current, or whose
    +    /// container was made from the reference itself. When the tag stays, a
    +    /// recreate the user asked for goes ahead with a notice; an unattended one is
    +    /// refused, because it would recreate the workspace on every run and never
    +    /// make it current.
    +    fn drop_derived_image(
    +        &mut self,
    +        workspace_id: &str,
    +        unattended: bool,
    +    ) -> Option<LaunchRefusal> {
    +        let stale = crate::flows::stale_images::stale_images(
    +            self.context.runner(),
    +            self.host.devpod_home.as_ref(),
    +            [workspace_id],
    +        );
    +        let image = stale.of(workspace_id)?.derived_image()?.to_owned();
    +        let why = match docker::untag_image(self.context.runner(), &image) {
    +            docker::Answer::Ran { exit, .. } if exit.is_success() => {
    +                self.notices.say(LaunchNotice::DerivedImageRemoved {
    +                    workspace_id: workspace_id.to_owned(),
    +                    image,
    +                });
    +                return None;
    +            }
    +            docker::Answer::Ran { stderr, .. } => stderr.trim().to_owned(),
    +            docker::Answer::NotInstalled => "docker was not found".to_owned(),
    +            docker::Answer::NotStarted(failure) => {
    +                format!("docker did not start ({:?})", failure.kind)
    +            }
    +        };
    +        let workspace_id = workspace_id.to_owned();
    +        if unattended {
    +            return Some(LaunchRefusal::DerivedImageKept {
    +                workspace_id,
    +                image,
    +                why,
    +            });
    +        }
    +        self.notices.say(LaunchNotice::DerivedImageKept {
    +            workspace_id,
    +            image,
    +            why,
    +        });
    +        None
    +    }
    +
         /// Find the agents a recreate of `workspace_id` is about to end, and say what
         /// will become of each (devlaunch#673).
         ///
    @@ -5240,8 +5373,12 @@ impl<'a, 'r, 'l> Launch<'a, 'r, 'l> {
             }
             let restarted =
                 agent_sessions::restart(self.context.runner(), manager, held, &std::thread::sleep);
    +        let not_kept = report_typed_lines_again(self.context.runner(), manager, held, &restarted);
             for outcome in restarted {
                 self.notices.say(match outcome {
    +                agent_sessions::Restarted::Typed { pane_id } if not_kept.contains(&pane_id) => {
    +                    LaunchNotice::AgentSessionLineNotKept { pane_id }
    +                }
                     agent_sessions::Restarted::Typed { pane_id } => {
                         LaunchNotice::AgentSessionRestarted { pane_id }
                     }
    @@ -5826,6 +5963,47 @@ mod tests {
                 self
             }
     
    +        /// devpod's record for a workspace whose container runs an image devpod
    +        /// built, `proj-8260d:devpod-9409`, on an older `ghcr.io/o/img:latest`,
    +        /// and docker's answers that make it stale.
    +        fn with_stale_derived_image(self, workspace_id: &str) -> Self {
    +            let home = self.devpod_home();
    +            self.write_record(workspace_id);
    +            std::fs::write(
    +                home.result("default", workspace_id),
    +                serde_json::json!({
    +                    "ContainerDetails": {
    +                        "ID": "c1",
    +                        "Config": { "Image": "proj-8260d:devpod-9409" },
    +                    },
    +                    "MergedConfig": { "image": "ghcr.io/o/img:latest" },
    +                })
    +                .to_string(),
    +            )
    +            .expect("a create result");
    +            let format = |kind: &str, format: &str| {
    +                ["docker", "inspect", "--type", kind, "--format", format].map(str::to_owned)
    +            };
    +            self.runner.script(
    +                format("container", "{{.Id}} {{.Image}}"),
    +                Response::stdout("c1 sha256:derived\n"),
    +            );
    +            let image = format("image", "{{.Id}} {{.Created}} {{json .RootFS.Layers}}");
    +            let mut derived = image.to_vec();
    +            derived.push("sha256:derived".to_owned());
    +            self.runner.script(
    +                derived,
    +                Response::stdout("sha256:derived 2026-10-01T00:00:00Z [\"l1\",\"f1\"]\n"),
    +            );
    +            let mut reference = image.to_vec();
    +            reference.push("ghcr.io/o/img:latest".to_owned());
    +            self.runner.script(
    +                reference,
    +                Response::stdout("sha256:base2 2026-10-05T00:00:00Z [\"l2\"]\n"),
    +            );
    +            self
    +        }
    +
             /// devpod's record for a workspace whose create *aborted*: the record, and
             /// no result beside it. A `postCreateCommand` that exits non-zero leaves
             /// exactly this, with the container still up.
    @@ -13733,6 +13911,9 @@ mod tests {
                 ),
             );
             scene.runner.script([HERDR, "pane", "run"], Response::ok());
    +        scene
    +            .runner
    +            .script([HERDR, "pane", "report-agent-session"], Response::ok());
             scene.runner.script([HERDR], Response::exited(1));
             line.into_iter().map(str::to_owned).collect()
         }
    @@ -13791,6 +13972,28 @@ mod tests {
                     crate::shell::join(line.iter().map(String::as_str))
                 ]
             );
    +        // The typed `dl` reports no line, so the recreate reports the one it
    +        // typed: without it the next recreate finds none and ends the agent.
    +        let reported = at(&|argv| {
    +            argv.get(1..4)
    +                == Some(&[
    +                    "pane".to_owned(),
    +                    "report-agent-session".to_owned(),
    +                    "w1:p1".to_owned(),
    +                ])
    +        });
    +        assert!(
    +            typed < reported,
    +            "the line was reported before it was typed"
    +        );
    +        assert_eq!(argvs[reported][11..], line[..]);
    +        assert!(
    +            !parts.said.contains(&LaunchNotice::AgentSessionLineNotKept {
    +                pane_id: "w1:p1".to_owned(),
    +            }),
    +            "{:?}",
    +            parts.said
    +        );
             assert!(
                 parts.said.contains(&LaunchNotice::AgentSessionsHeld {
                     workspace_id: "myws".to_owned(),
    @@ -13808,6 +14011,52 @@ mod tests {
             );
         }
     
    +    /// herdr refuses the report of the typed line, so the next recreate would
    +    /// find no line for the pane. The recreate says so, rather than that all went
    +    /// well.
    +    #[test]
    +    fn a_recreate_says_when_herdr_did_not_keep_a_restarted_agents_line() {
    +        let mut scene = Scene::new().with_running("myws");
    +        scene.runner.script(
    +            ["/opt/herdr/bin/herdr", "pane", "report-agent-session"],
    +            Response::failed(1, r#"{"error":{"code":"pane_not_found"}}"#),
    +        );
    +        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,
    +            );
    +            launch
    +                .run("myws", &LaunchVerb::Recreate, None)
    +                .expect("a launch");
    +        }
    +
    +        assert!(
    +            parts.said.contains(&LaunchNotice::AgentSessionLineNotKept {
    +                pane_id: "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]
    @@ -13947,6 +14196,131 @@ mod tests {
             );
         }
     
    +    fn recreate(
    +        scene: &Scene,
    +        verb: &LaunchVerb,
    +    ) -> (Result<Launched, LaunchAborted>, Vec<LaunchNotice>) {
    +        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", verb, None)
    +        };
    +        (launched, parts.said)
    +    }
    +
    +    const UNTAG: [&str; 5] = ["docker", "image", "rm", "--force", "proj-8260d:devpod-9409"];
    +
    +    /// devpod reuses the tag it built a workspace's image under, so a recreate
    +    /// of a stale workspace removes that tag first, and devpod builds the image
    +    /// again from the newer reference.
    +    #[test]
    +    fn a_recreate_of_a_stale_derived_image_removes_devpods_tag_before_the_up() {
    +        let scene = Scene::new()
    +            .with_running("myws")
    +            .with_stale_derived_image("myws");
    +        scene.runner.script(UNTAG, Response::ok());
    +
    +        let (launched, said) = recreate(&scene, &LaunchVerb::RecreateUnattended);
    +
    +        assert_eq!(launched, Ok(Launched::Ready));
    +        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 untagged = at(&|argv| argv == UNTAG);
    +        let up = at(&|argv| argv.get(1).map(String::as_str) == Some("up"));
    +        assert!(untagged < up, "the tag was removed after the up");
    +        assert!(
    +            said.contains(&LaunchNotice::DerivedImageRemoved {
    +                workspace_id: "myws".to_owned(),
    +                image: "proj-8260d:devpod-9409".to_owned(),
    +            }),
    +            "{said:?}"
    +        );
    +    }
    +
    +    /// A tag that stays would make `dl --refresh-stale` recreate the workspace
    +    /// on every run and never make it current, so it does not recreate it.
    +    #[test]
    +    fn an_unattended_recreate_that_cannot_remove_devpods_tag_does_not_run() {
    +        let scene = Scene::new()
    +            .with_running("myws")
    +            .with_stale_derived_image("myws");
    +        scene.runner.script(
    +            UNTAG,
    +            Response::failed(1, "Error response from daemon: busy\n"),
    +        );
    +
    +        let (launched, _) = recreate(&scene, &LaunchVerb::RecreateUnattended);
    +
    +        assert_eq!(
    +            launched,
    +            Ok(Launched::Refused(LaunchRefusal::DerivedImageKept {
    +                workspace_id: "myws".to_owned(),
    +                image: "proj-8260d:devpod-9409".to_owned(),
    +                why: "Error response from daemon: busy".to_owned(),
    +            }))
    +        );
    +        assert!(
    +            !scene
    +                .runner
    +                .argvs()
    +                .iter()
    +                .any(|argv| argv.get(1).map(String::as_str) == Some("up")),
    +            "the recreate ran"
    +        );
    +    }
    +
    +    /// A recreate the user asked for goes ahead, and says the workspace stays
    +    /// on the older image.
    +    #[test]
    +    fn a_recreate_that_cannot_remove_devpods_tag_goes_ahead_and_says_so() {
    +        let scene = Scene::new()
    +            .with_running("myws")
    +            .with_stale_derived_image("myws");
    +        scene.runner.script(
    +            UNTAG,
    +            Response::failed(1, "Error response from daemon: busy\n"),
    +        );
    +
    +        let (launched, said) = recreate(&scene, &LaunchVerb::Recreate);
    +
    +        assert_eq!(
    +            launched,
    +            Ok(Launched::Session(Session::RemoteExit { status: 0 }))
    +        );
    +        assert!(
    +            scene
    +                .runner
    +                .argvs()
    +                .iter()
    +                .any(|argv| argv.get(1).map(String::as_str) == Some("up")),
    +            "the recreate did not run"
    +        );
    +        assert!(
    +            said.contains(&LaunchNotice::DerivedImageKept {
    +                workspace_id: "myws".to_owned(),
    +                image: "proj-8260d:devpod-9409".to_owned(),
    +                why: "Error response from daemon: busy".to_owned(),
    +            }),
    +            "{said:?}"
    +        );
    +    }
    +
         /// Outside herdr, `dl --refresh-stale` recreates only a workspace it has seen
         /// no Claude process in, so the warning that the recreate ends unseen agents
         /// would be false there.
    diff --git a/rust/devlaunch-core/src/flows/stale_images.rs b/rust/devlaunch-core/src/flows/stale_images.rs
    index 73db8011..c94eb12b 100644
    --- a/rust/devlaunch-core/src/flows/stale_images.rs
    +++ b/rust/devlaunch-core/src/flows/stale_images.rs
    @@ -65,6 +65,10 @@ pub struct StaleImage {
         /// The container devpod recorded for the workspace: the one that runs the
         /// older image.
         container: String,
    +    /// The tag devpod built the container's image under, from the reference:
    +    /// `<prefix>:devpod-<hash>`. devpod names it by a hash of the config, not of
    +    /// the reference, so a recreate reuses it and stays stale until it is gone.
    +    derived: Option<String>,
     }
     
     impl StaleImage {
    @@ -79,6 +83,11 @@ impl StaleImage {
         pub fn container(&self) -> &str {
             &self.container
         }
    +
    +    /// The tag devpod built the older image under, when it built one.
    +    pub fn derived_image(&self) -> Option<&str> {
    +        self.derived.as_deref()
    +    }
     }
     
     /// The stale workspaces among the ones asked about, by workspace id.
    @@ -113,6 +122,7 @@ impl StaleImages {
                             StaleImage {
                                 reference: reference.to_owned(),
                                 container: id.to_owned(),
    +                            derived: None,
                             },
                         )
                     })
    @@ -188,11 +198,15 @@ pub fn stale_images<'w>(
                     None => created.created_from == created.reference && *running_id != current.id,
                 };
                 stale.then(|| {
    +                let derived = (created.created_from != created.reference
    +                    && is_devpod_tag(&created.created_from))
    +                .then_some(created.created_from);
                     (
                         id.to_owned(),
                         StaleImage {
                             reference: created.reference,
                             container: created.container,
    +                        derived,
                         },
                     )
                 })
    @@ -201,6 +215,13 @@ pub fn stale_images<'w>(
         StaleImages { by_workspace }
     }
     
    +/// Whether `reference` is a tag devpod built: `<prefix>:devpod-<hash>`.
    +fn is_devpod_tag(reference: &str) -> bool {
    +    reference
    +        .rsplit_once(':')
    +        .is_some_and(|(name, tag)| !name.is_empty() && tag.starts_with("devpod-"))
    +}
    +
     /// Whether a container created as `created` and running `running` is behind
     /// the image its reference names now, `current`.
     fn is_stale(created: &Created, running: &Image, current: &Image) -> bool {
    @@ -692,4 +713,57 @@ mod tests {
             assert!(stale_images(&fake, Some(&home), ["ws"]).is_empty());
             assert!(fake.argvs().is_empty());
         }
    +
    +    /// A stale derived image whose base moved, as devpod names it on a real host.
    +    fn stale_derived(created_from: &str) -> StaleImages {
    +        let home = home_with("ws", created_from, Some("ghcr.io/o/img:latest"));
    +        let fake = FakeRunner::new();
    +        inspect_container(&fake, "sha256:derived");
    +        inspect_image(
    +            &fake,
    +            "sha256:derived",
    +            line("sha256:derived", &["l1", "l2", "f1"]),
    +        );
    +        inspect_image(
    +            &fake,
    +            "ghcr.io/o/img:latest",
    +            line_at("sha256:base2", "2026-10-05T09:00:00Z", &["l1", "l3"]),
    +        );
    +        stale_images(&fake, Some(&home), ["ws"])
    +    }
    +
    +    /// devpod tags the image it builds by a hash of the config, so a recreate
    +    /// reuses it. The tag is named so that the recreate can remove it first.
    +    #[test]
    +    fn a_stale_derived_image_names_the_tag_devpod_built_it_under() {
    +        let stale = stale_derived("proj-8260d:devpod-9409a1b2");
    +
    +        assert_eq!(
    +            stale.of("ws").and_then(StaleImage::derived_image),
    +            Some("proj-8260d:devpod-9409a1b2")
    +        );
    +    }
    +
    +    /// Only a tag devpod made is named: dl removes what it names.
    +    #[test]
    +    fn an_image_devpod_did_not_tag_is_not_named_as_derived() {
    +        let stale = stale_derived("ghcr.io/o/other:latest");
    +
    +        assert!(stale.of("ws").is_some());
    +        assert_eq!(stale.of("ws").and_then(StaleImage::derived_image), None);
    +    }
    +
    +    #[test]
    +    fn a_container_made_from_the_reference_names_no_derived_image() {
    +        let home = home_with("ws", "ghcr.io/o/img:latest", Some("ghcr.io/o/img:latest"));
    +        let fake = FakeRunner::new();
    +        inspect_container(&fake, "sha256:old");
    +        inspect_image(&fake, "sha256:old", line("sha256:old", &["l1"]));
    +        inspect_image(&fake, "ghcr.io/o/img:latest", line("sha256:new", &["l2"]));
    +
    +        let stale = stale_images(&fake, Some(&home), ["ws"]);
    +
    +        assert!(stale.of("ws").is_some());
    +        assert_eq!(stale.of("ws").and_then(StaleImage::derived_image), None);
    +    }
     }
    diff --git a/rust/dl/src/launch.rs b/rust/dl/src/launch.rs
    index 38f04ac1..2d6beff1 100644
    --- a/rust/dl/src/launch.rs
    +++ b/rust/dl/src/launch.rs
    @@ -317,7 +317,8 @@ fn ran(outcome: Result<Launched, LaunchAborted>, cache: &Path) -> Ran {
                     | LaunchRefusal::UnknownWorkspace { .. }
                     | LaunchRefusal::IdCollision { .. }
                     | LaunchRefusal::BranchNotNamed { .. }
    -                | LaunchRefusal::NotPrepared { .. } => Ran {
    +                | LaunchRefusal::NotPrepared { .. }
    +                | LaunchRefusal::DerivedImageKept { .. } => Ran {
                         ending: Ending::Refused,
                         reached: Reached::Nothing,
                     },
    diff --git a/rust/dl/src/render.rs b/rust/dl/src/render.rs
    index be87c2b5..879bf4b8 100644
    --- a/rust/dl/src/render.rs
    +++ b/rust/dl/src/render.rs
    @@ -3414,6 +3414,22 @@ pub(crate) fn launch_notice(notice: &LaunchNotice) -> Option<String> {
             LaunchNotice::AgentSessionRestarted { pane_id } => {
                 format!("Started the agent in pane {pane_id} again.")
             }
    +        LaunchNotice::DerivedImageRemoved {
    +            workspace_id,
    +            image,
    +        } => format!(
    +            "{workspace_id} runs an older image. Removed the tag {image} that devpod built it \
    +             under, so the recreate builds the image again from the newer one."
    +        ),
    +        LaunchNotice::DerivedImageKept {
    +            workspace_id,
    +            image,
    +            why,
    +        } => derived_image_kept(workspace_id, image, why),
    +        LaunchNotice::AgentSessionLineNotKept { pane_id } => format!(
    +            "Started the agent in pane {pane_id} again, and herdr did not keep the line that \
    +             starts it. The next recreate ends it."
    +        ),
             LaunchNotice::AgentSessionNotRestarted { pane_id, line } => format!(
                 "Could not start the agent in pane {pane_id} again. Run this in that pane: {line}"
             ),
    @@ -3663,6 +3679,16 @@ pub(crate) fn is_binary_missing(aborted: &LaunchAborted) -> bool {
     /// `None` is a refusal with nothing to say: `devpod up` and `devpod stop` write
     /// their own diagnostics to this process's stderr — the calls inherit the streams —
     /// so dl has nothing to add but the exit code.
    +/// devpod's tag for a stale workspace's image stayed, so a recreate keeps the
    +/// older image.
    +fn derived_image_kept(workspace_id: &str, image: &str, why: &str) -> String {
    +    format!(
    +        "{workspace_id} runs an older image, and dl could not remove the tag {image} that \
    +         devpod built it under ({why}). A recreate builds the container from the older image \
    +         again. Remove the tag with 'docker image rm --force {image}', then recreate."
    +    )
    +}
    +
     pub(crate) fn launch_refusal(refused: &LaunchRefusal) -> Option<String> {
         match refused {
             LaunchRefusal::UnsafeSpec(name) => Some(unsafe_name(name)),
    @@ -3680,6 +3706,14 @@ pub(crate) fn launch_refusal(refused: &LaunchRefusal) -> Option<String> {
                 &format!("{owner}/{repo}@{branch}"),
                 &format!("{recorded_owner}/{recorded_repo}@{recorded_branch}"),
             )),
    +        LaunchRefusal::DerivedImageKept {
    +            workspace_id,
    +            image,
    +            why,
    +        } => Some(format!(
    +            "{} The refresh does not recreate it.",
    +            derived_image_kept(workspace_id, image, why)
    +        )),
             LaunchRefusal::BranchNotNamed { owner, repo, error } => Some(format!(
                 "Repository '{owner}/{repo}': {}",
                 branch_not_named(error)
    -- 
    2.56.0
    
  4. blooop commented on Oct 15, 2026

    @blooop
    OwnerAuthor

    Takeover prompt for a real host (2026-10-15)

    The agent that reads this owns the rest of #673: test, fix, push, CI and review. The merge stays with the user.

    Take over the devlaunch #673 work fully on this machine: test the fixes on a real host, fix what fails, and bring the stack to a reviewable, green state. The user has handed the whole task to you; the previous agent will do no more. Do not merge: the merge stays with the user.

    Context: #677 is the handover. Read its body and its three comments first. The stack is PR #674 → #675 → #676 on blooop/devlaunch; branch feat/refresh-stale at f5551ce holds all three. The comments on #677 hold three patches (```diff blocks) with fixes found on a real host. Nobody has run fixes 3 and 5 against real docker, devpod and herdr yet. That is your job.

    Safety (read before you start)

    • Use a machine with no workspaces or agents anyone cares about. recreate and --refresh-stale end every process in a container.
    • Run every dl-next/aid-next command with the scratch env below. Make a wrapper ~/dl673/dln that exports DEVPOD_HOME=/dl673/devpod and XDG_CACHE_HOME=/dl673/cache, refuses to run if either is not under ~/dl673, then execs dl-next "$@". Use it for every call.
    • Leave the released dl and aid alone. Tag and remove only the images you create (dl673-test:, and the proj-:devpod-* tags devpod builds for your test workspace).
    • Do process experiments inside the test container. Kill only pids you recorded; never pkill or killall on the host.
    • Delete everything you made on the machine at step 6.

    1. Build

    git clone --branch feat/refresh-stale https://github.com/blooop/devlaunch ~/dl673/src && cd ~/dl673/src
    git checkout f5551ced
    for id in 6077679375 6084475333 6128046327; do
      gh api repos/blooop/devlaunch/issues/comments/$id --jq .body \
        | awk '/^```diff/{f=1;next} /^```$/{if(f)f=0} f' > ~/dl673/p$id.diff
      git apply ~/dl673/p$id.diff
    done
    (cd rust && cargo fmt && cargo test -p devlaunch-core --lib 2>&1 | grep "test result")
    ./dev.sh && dl-next --version      # "dl <version>-dev"
    

    The three patches applied in that order, then cargo fmt, give the tested tree (verified 2026-10-15). Expected unit-test result: all pass, except possibly 4–5 "pushed by URL" lifecycle tests, which fail on some hosts without these patches too. Say which fail.

    2. Test project (the derived-image case, finding 5)

    Run inside a herdr pane (HERDR_ENV=1). Load the herdr skill.

    export DEVPOD_HOME=~/dl673/devpod XDG_CACHE_HOME=~/dl673/cache
    devpod provider add docker; devpod provider use docker
    docker pull mcr.microsoft.com/devcontainers/base:ubuntu
    docker tag mcr.microsoft.com/devcontainers/base:ubuntu dl673-test:latest
    mkdir -p ~/dl673/proj/.devcontainer ~/dl673/claude-home && cd ~/dl673/proj && git init -q
    cat > .devcontainer/devcontainer.json <<EOF
    {"image":"dl673-test:latest",
     "features":{"ghcr.io/devcontainers/features/common-utils:2":{}},
     "mounts":["source=$HOME/dl673/claude-home,target=/home/vscode/.claude,type=bind"]}
    EOF
    git add -A && git commit -qm init && ~/dl673/dln ~/dl673/proj
    

    Record ContainerDetails.Config.Image from the workspace_result.json under ~/dl673/devpod (expect <prefix>:devpod-<hash>), and its image id.

    3. Finding 5: a recreate rebuilds devpod's image

    1. Make it stale: printf 'FROM dl673-test:latest\nRUN touch /stale-marker\n' | docker build -t dl673-test:latest -
    2. ~/dl673/dln --ls must name the workspace as stale.
    3. Run script -qfc "~/dl673/dln proj recreate" ~/dl673/recreate5.log (piping to tee hangs). Expect the line "proj runs an older image. Removed the tag that devpod built it under…".
    4. Check and report: the old container kept running until the up replaced it (no error from docker during the untag); devpod built a new :devpod- image (new image id); /stale-marker exists in the new container; ~/dl673/dln --ls no longer says stale.
    5. Failure path: make it stale again, start a second container from the derived tag by hand (docker run -d --name dl673-hold <tag> sleep 600), and confirm whether docker image rm --force <tag> still only untags. Report what docker says. Remove dl673-hold after.

    4. Finding 3: a restarted agent keeps its line

    1. In a new herdr pane with the scratch exports, start an agent: DEVLAUNCH_HERDR=1 aid-next proj 'Remember the word pelican. Reply ok.' Answer the folder-trust and Bypass prompts with herdr agent send-keys <pane> down enter / enter. If it says "Not logged in", ask the user to run /login there (known issue, finding 10). Wait until herdr reports it idle. Record the session id (agent_resume.argv in herdr's session.json beside $HERDR_SOCKET_PATH).
    2. Make the image stale again (step 3.1). From another pane run the recreate as in step 3.3.
    3. Expect "Started the agent in pane again." and no "herdr did not keep the line". Check session.json: the pane must still hold agent_resume.argv with the same --resume <id>.
    4. Wait until idle, make it stale again, recreate a second time. The agent must come back again — this is the bug the fix is for. Ask it "What word did I ask you to remember?" to prove the conversation came back.
    5. Gotchas: pane wait-output matches the typed command; use --regex '(?m)^RC=\d'. herdr agent prompt --timeout needs --wait. Claude Code refuses a bare sleep 120; use timeout 120 tail -f /dev/null.

    5. --refresh-stale

    1. With the agent idle and the image stale: ~/dl673/dln --refresh-stale; echo RC=$? → "Refreshing proj", agent back, exit 0. Then run it again after one more stale rebuild: it must refresh again (the line was kept), not skip with "no saved line".
    2. Nothing stale: ~/dl673/dln --refresh-stale; echo RC=$? → "No workspace runs an older image…", exit 0.

    6. Clean up

    ~/dl673/dln proj rm --force; DEVPOD_HOME=~/dl673/devpod devpod list must print []. docker rmi every dl673-test:* and proj-:devpod- image you made. Close your herdr tabs. rm -r ~/dl673 (it can hold a .credentials.json). Remove ~/.local/bin/dl-next, ~/.local/bin/aid-next and ~/.local/share/devlaunch-dev.

    7. Fix what failed

    For each check in steps 3–5 that failed: write a failing test first (the real shapes: devpod writes ContainerDetails.ID, herdr pane ids are base32 like w3:pAX), fix it, and run the test suite again. Run the host check again for that step.

    8. Push and bring the stack to green

    1. Commit the three patches (and your fixes) on top of feat/refresh-stale, the branch of PR feat: dl --refresh-stale recreates the stale workspaces that are idle #676, which holds the whole stack. Clear commit messages; one commit per finding is fine. Push it.
    2. Update the PR feat: dl --refresh-stale recreates the stale workspaces that are idle #676 description: add a section "Found on a real host" that lists findings 1–7 from Handover: validate the #673 stack (#674–#676) on a real host #677 and how each is fixed, with the host-check evidence.
    3. Watch CI with gh pr checks 676 --repo blooop/devlaunch --interval 60 or slower (the GitHub token quota is shared). Fix any failure. The public-api job checks rust/devlaunch-core/public-api.*.txt, which were edited by hand: if it fails, run scripts/public-api-snapshots.sh and commit the result.
    4. The gate job also needs review: "Nothing reviewed this pull request. Post a wf-review report and re-run this job, or label 'no-external-review'". Do the review properly (a wf-review report as the repo's docs describe it). Do not add the label to skip it.
    5. Do not merge.

    9. Report

    Post one comment on #677 with: the results of steps 1, 3, 4 and 5 (the exact dl output lines, exit codes, pass/fail per check), what you fixed, the commits you pushed, and the CI state of #676. Do not name the machine or company. End with a ledger: ✅ done (with evidence), ❌ failed (with the output), ⛔ blocked (on what). When the stack is green and reviewed, close #677 with a link to that comment.

  5. blooop commented on Oct 15, 2026

    @blooop
    OwnerAuthor

    Report: the real-host test of the #673 stack (2026-10-15)

    I followed the takeover prompt. The host has docker 29.8.0 with the containerd image store, devpod v0.26.3 and herdr 0.9.5. Every dl-next call went through the ~/dl673/dln wrapper, which sets a scratch DEVPOD_HOME and XDG_CACHE_HOME.

    Step 1: build — PASS

    The three patches applied cleanly to f5551ced in the given order, and cargo fmt ran after them. dl-next --version printed dl 0.59.4-dev.

    cargo test -p devlaunch-core --lib: 2232 passed, 4 failed. The 4 failures are flows::lifecycle::tests tests:

    • a_host_that_prunes_on_fetch_does_not_turn_a_merged_branch_into_unpushed_work
    • a_site_pushed_by_url_does_not_stop_a_plain_rm
    • commits_pushed_by_url_do_not_stop_a_plain_rm
    • a_remote_that_cannot_be_reached_leaves_the_refusal_standing

    The same 4 fail on the unpatched f5551ced, and they also fail with GIT_CONFIG_GLOBAL=/dev/null. CI's rust job passes them, so this host causes the failures.

    Step 2: test project — PASS

    The workspace came up on proj-736a5:devpod-1c27a15a38d2a6944d1f7f3d59e73834, image sha256:b4bedc152c49….

    Because of finding 6, I ran docker tag dl673-test:latest dl673-test:prev-N before each stale rebuild.

    Step 3: finding 5, a recreate rebuilds devpod's image — PASS

    • 3.2 --ls printed proj runs an older image than dl673-test:latest now names. Run 'dl proj recreate' to move it. and exited 0.
    • 3.3 The recreate printed proj runs an older image. Removed the tag proj-736a5:devpod-1c27…3834 that devpod built it under, so the recreate builds the image again from the newer one. and exited 0.
    • 3.4 docker events show this order: the untag of b4bedc15, the new image 474d8f8a built and tagged, the old container stopped and destroyed, the new container started. docker gave no error during the untag. /stale-marker is in the new container. --ls no longer says stale.
    • 3.5 With dl673-hold running on the tag, docker image rm --force <tag> printed only Untagged: proj-736a5:devpod-1c27…3834 and exited 0. Both containers kept running. The image stayed inspectable by id, with RepoTags [].

    Step 4: finding 3, a restarted agent keeps its line — PASS

    • 4.1 The agent replied ok, and the session id was 170467e0-…. herdr's agent_resume.argv held --resume 170467e0-….
    • 4.2/4.3 The first recreate printed The recreate ends the agent in pane wG:p4P of proj; dl starts it again, resumed, once the new container is up. and then Started the agent in pane wG:p4P again. It did not print "herdr did not keep the line". session.json still held --resume 170467e0-….
    • 4.4 The second recreate printed the same lines. Asked "What word did I ask you to remember?", the agent replied Pelican.

    Finding 4 is confirmed. The saved line starts with plain dl, so a restart runs the released dl. For the test, a dl shim on PATH in that pane only sent the call to dl-next.

    Step 5: --refresh-stale — PASS

    • 5.1 Two runs, each after a new stale build. Each run printed Refreshing proj: it runs an older image than dl673-test:latest now names., Started the agent in pane wG:p4P again. and Refreshed 1 stale workspace, skipped 0, and 0 failed., and exited 0. The second run refreshed again; it did not skip with "no saved line". Only proj was refreshed.
    • 5.2 With nothing stale, it printed No workspace runs an older image than its reference names. and exited 0.

    A check without the finding 6 workaround: after the old base left the store, --ls still named the workspace stale.

    Other things seen (not fixed)

    • Raw terminal-reset escapes ([?1047l[<u[?2004l…) appear in dl output before the hostname line and before "Started the agent…".
    • The LAST USED column of --ls shows UTC, not local time.
    • Every up printed proj: the hostname setup stage exited 1.

    Step 7: fixes

    No check in steps 3 to 5 failed, so step 7 had nothing to fix. The reviews found two doc defects, and I fixed both.

    Commits pushed to feat/refresh-stale

    The tree at c94b5e7e is byte-identical to the tree tested on the host.

    CI and review

    • #676 at 8b5fb9bb: all checks pass, including public-api, review and gate. The description has a new section, "Found on a real host". The wf-review report on #676 has the verdict Comment, with no blocking findings.
    • #675: all checks pass after a wf-review. The verdict is Comment, with no blocking findings, and the PR body is corrected about done panes.
    • #674: all CI jobs pass. The codecov/project status still says pending.

    Nothing is merged; the merge is for the maintainer.

    Ledger

    • ✅ Step 1: build and unit tests. 2232 pass; 4 lifecycle tests fail on this host only, and they fail on f5551ced too.
    • ✅ Step 2: test project. The derived image was recorded.
    • ✅ Step 3: finding 5, all of 3.1 to 3.5 pass. The untag only untags, even when a second container holds the tag.
    • ✅ Step 4: finding 3, all of 4.1 to 4.4 pass. The agent came back twice and answered "Pelican."
    • ✅ Step 5: --refresh-stale, 5.1 and 5.2 pass. Each run exited 0.
    • ✅ Step 6: cleanup. The scratch devpod list was [], and every test image, the herdr tab, ~/dl673 (copied credentials included), dl-next, aid-next and devlaunch-dev are removed.
    • ✅ Step 7: no host check failed, and the two review doc defects are fixed.
    • ✅ Step 8: the commits are pushed, the feat: dl --refresh-stale recreates the stale workspaces that are idle #676 description is updated, CI is green, and wf-review reports are on feat: dl --refresh-stale recreates the stale workspaces that are idle #676 and feat: dl <ws> recreate starts the agents it ends again #675.
    • ❌ Finding 4: not fixed. The saved line names dl, so a resume runs whichever dl is on PATH. This matters only with a dev build installed beside the released one.
    • ❌ Non-blocking review findings: left open as follow-ups. Examples are the second /proc/<pid>/stat parser, the flows layer reading docker stderr, a swallowed thread panic at launch.rs:2998, and Taken counted as Kept when session.json cannot be read.

    🤖 Generated with Claude Code

  6. blooop commented on Oct 15, 2026

    @blooop
    OwnerAuthor

    The stack is green and reviewed. Report: #677 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions