Skip to content

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

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'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/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

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