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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.39.0] - 2026-09-09

### Fixed

- **A launch cut short between `devpod up` and the end of dl's setup pass left a
workspace that never got provisioned, and no later launch ever noticed.** The
`up` finishes, devpod writes its create result, and what runs next is dl's own
`devpod ssh --command`: the hostname, the terminal title, the onboarding memo,
`gh` and `claude`. Ctrl-C there and the container is left running with none of
it. Nothing about that container says so. `devpod status` answers `Running` and
devpod's create record is complete, which is exactly what the fast attach asks,
so `dl <workspace>` attaches in one round trip and provisions nothing, and does
the same on every launch after that. The workspace is permanently half built and
the recovery is knowing to type `dl <workspace> up`, which nothing says either.
That is the "sometimes it works" of it: whether you get a whole workspace
depends on which second the Ctrl-C landed in.

A pass now writes down that it is running, before its first trip, in
`${XDG_CACHE_HOME:-~/.cache}/devlaunch/tool-verdicts/<workspace>.pass`, and
removes it after its last. dl's signal handler `_exit`s without unwinding, so a
record left standing is the one thing on the host that says a pass was cut
short, and a launch that finds one runs the pass before it hands over a shell.
The record carries the same `workspace_result.json` mtime the verdict marker
does, so one left by a container that has since been rebuilt describes nothing
standing and is ignored.

**After the last trip and not the first**, which is most of the fix rather than
a detail of it. A pass is up to three trips and the probe is the ~1.7s one; the
lend streams the host's binaries and the install fetches a `claude` of a few
hundred megabytes. A record closed when the probe answered would be open for the
window nobody interrupts and shut for the window everybody does. The one outcome
that leaves it standing is a trip the OS would not make at all, because nothing
was learned about the container and nothing was done to it.

It is positive evidence, never the absence of it. A workspace brought up by VS
Code, by a hand-typed `devpod up`, or by a build older than the record has no
record either, and those go on attaching in one round trip as before.

## [0.38.0] - 2026-09-09

### Changed
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ one argument instead of a clone, a config file and a build command.
[![GitHub pull-requests merged](https://badgen.net/github/merged-prs/blooop/devlaunch)](https://github.com/blooop/devlaunch/pulls?q=is%3Amerged)
[![GitHub release](https://img.shields.io/github/release/blooop/devlaunch.svg)](https://GitHub.com/blooop/devlaunch/releases/)
[![PyPI](https://img.shields.io/pypi/v/devlaunch)](https://pypi.org/project/devlaunch/)
[![Conda](https://img.shields.io/badge/conda-v0.38.0-brightgreen?logo=anaconda)](https://prefix.dev/channels/blooop/packages/devlaunch)
[![Conda](https://img.shields.io/badge/conda-v0.39.0-brightgreen?logo=anaconda)](https://prefix.dev/channels/blooop/packages/devlaunch)
[![License](https://img.shields.io/github/license/blooop/devlaunch)](https://opensource.org/license/mit/)
[![Platform](https://img.shields.io/badge/platform-linux--64-blue)](https://github.com/blooop/devlaunch/releases)
[![Pixi Badge](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/prefix-dev/pixi/main/assets/badge/v0.json)](https://pixi.sh)
Expand Down Expand Up @@ -276,7 +276,7 @@ clone, and [docs/cleanup.md](docs/cleanup.md) says what it carries one past and

```bash
$ dl --version
dl 0.38.0
dl 0.39.0
```

`--devcontainer <variant|path>` picks a non-default `devcontainer.json`. A bare name means
Expand Down
29 changes: 29 additions & 0 deletions docs/workspace-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,35 @@ Nothing has to be cleaned up, and there is nothing to invalidate by hand: the
markers are compared, never trusted on age, and deleting the whole directory costs
one round trip on each workspace's next launch.

### A pass that was interrupted

A pass is up to three trips and the slow ones are the last two: the lend streams
the host's binaries, and the install fetches a `claude` of a few hundred megabytes.
A Ctrl-C lands in the middle of that often. What it leaves is the awkward case:
`devpod up` has already finished, so devpod wrote its create result and the
container is running, and everything the Ctrl-C stopped was dl's. The container
looks finished to everything that can be asked about it, so a later launch takes
the fast path and provisions nothing, forever.

So a pass writes down that it is running before its first trip, in
`<workspace>.pass` beside the marker, and removes it after its last.
`dl`'s signal handler exits without unwinding, which is why the evidence has to be
a file left standing rather than one written on the way out: nothing runs on the
way out. A launch that finds one still standing for the container standing now runs
the pass before it hands over a shell, and says so.

The record is anchored to the same `workspace_result.json` mtime the marker is, so
one left by a container that has since been rebuilt describes nothing that exists
and is ignored. It is read as positive evidence and never as the absence of it: a
workspace brought up by VS Code, by a hand-typed `devpod up`, or by a build older
than the record has no record either, and those keep attaching in one round trip.

A pass whose install ran and *failed* closes the record like any other: it happened
and it said so, and re-running it on every attach afterwards would be a container
re-attempting one failing thing forever. The one outcome that leaves the record
standing is a trip the OS would not make at all, because nothing was learned about
the container and nothing was done to it, which is the state the record is for.

### What to bake so a launch does no work at all

To make every `dl` launch of an image stop at trip 1. The probe asks a **login**
Expand Down
10 changes: 5 additions & 5 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ members = [
# The single source of the version (docs/rust-rewrite-plan.md: cutover ships
# 0.1.0, version read from Cargo.toml).
[workspace.package]
version = "0.38.0"
version = "0.39.0"
edition = "2024"
license = "MIT"
repository = "https://github.com/blooop/devlaunch"
Expand Down
10 changes: 10 additions & 0 deletions rust/devlaunch-core/public-api.api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ pub devlaunch_core::api::LaunchNotice::SessionManagerReady::pane_id: alloc::stri
pub devlaunch_core::api::LaunchNotice::SessionManagerReady::socket: alloc::string::String
pub devlaunch_core::api::LaunchNotice::SessionManagerUnavailable
pub devlaunch_core::api::LaunchNotice::SessionManagerUnavailable::reason: alloc::string::String
pub devlaunch_core::api::LaunchNotice::SetupPassNeverFinished
pub devlaunch_core::api::LaunchNotice::SetupPassNeverFinished::workspace_id: alloc::string::String
pub devlaunch_core::api::LaunchNotice::SshCommand
pub devlaunch_core::api::LaunchNotice::SshCommand::argv: alloc::vec::Vec<alloc::string::String>
pub devlaunch_core::api::LaunchNotice::SshConfigUnlocatable
Expand Down Expand Up @@ -397,6 +399,7 @@ pub struct devlaunch_core::api::ToolProvisioning<'e>
impl<'e> devlaunch_core::flows::launch::ToolProvisioning<'e>
pub fn devlaunch_core::flows::launch::ToolProvisioning<'e>::from_env(&std::path::Path, &'e mut dyn devlaunch_core::notices::Notices<devlaunch_core::flows::provision::ProvisionEvent>) -> Self
impl devlaunch_core::flows::launch::Provision for devlaunch_core::flows::launch::ToolProvisioning<'_>
pub fn devlaunch_core::flows::launch::ToolProvisioning<'_>::pass_never_finished(&self, &str) -> bool
pub fn devlaunch_core::flows::launch::ToolProvisioning<'_>::provision_tools(&self, &dyn devlaunch_runner::Runner, &str, devlaunch_core::flows::provision::PassOccasion, core::option::Option<&str>) -> core::result::Result<core::option::Option<devlaunch_core::flows::provision::ClaudeConfig>, devlaunch_core::flows::provision::DevpodMissing>
pub fn devlaunch_core::flows::launch::ToolProvisioning<'_>::remembered_claude(&self, &str) -> core::option::Option<devlaunch_core::flows::provision::ClaudeConfig>
pub struct devlaunch_core::api::WorkspaceCloneManager<'r>
Expand All @@ -418,9 +421,11 @@ pub trait devlaunch_core::api::Notices<T>
pub fn devlaunch_core::api::Notices::say(&mut self, T)
impl<T> devlaunch_core::notices::Notices<T> for alloc::vec::Vec<T>
pub trait devlaunch_core::api::Provision
pub fn devlaunch_core::api::Provision::pass_never_finished(&self, &str) -> bool
pub fn devlaunch_core::api::Provision::provision_tools(&self, &dyn devlaunch_runner::Runner, &str, devlaunch_core::flows::provision::PassOccasion, core::option::Option<&str>) -> core::result::Result<core::option::Option<devlaunch_core::flows::provision::ClaudeConfig>, devlaunch_core::flows::provision::DevpodMissing>
pub fn devlaunch_core::api::Provision::remembered_claude(&self, &str) -> core::option::Option<devlaunch_core::flows::provision::ClaudeConfig>
impl devlaunch_core::flows::launch::Provision for devlaunch_core::flows::launch::ToolProvisioning<'_>
pub fn devlaunch_core::flows::launch::ToolProvisioning<'_>::pass_never_finished(&self, &str) -> bool
pub fn devlaunch_core::flows::launch::ToolProvisioning<'_>::provision_tools(&self, &dyn devlaunch_runner::Runner, &str, devlaunch_core::flows::provision::PassOccasion, core::option::Option<&str>) -> core::result::Result<core::option::Option<devlaunch_core::flows::provision::ClaudeConfig>, devlaunch_core::flows::provision::DevpodMissing>
pub fn devlaunch_core::flows::launch::ToolProvisioning<'_>::remembered_claude(&self, &str) -> core::option::Option<devlaunch_core::flows::provision::ClaudeConfig>
pub fn devlaunch_core::api::enriched_listing(&mut devlaunch_core::flows::listing::CommandContext<'_>, &devlaunch_core::flows::listing::DlView<'_>, devlaunch_core::flows::listing::Sizes) -> core::result::Result<alloc::vec::Vec<devlaunch_core::flows::listing::ListedWorkspace>, devlaunch_core::clients::devpod::ListingUnreadable>
Expand Down Expand Up @@ -573,6 +578,8 @@ pub devlaunch_core::flows::launch::LaunchNotice::SessionManagerReady::pane_id: a
pub devlaunch_core::flows::launch::LaunchNotice::SessionManagerReady::socket: alloc::string::String
pub devlaunch_core::flows::launch::LaunchNotice::SessionManagerUnavailable
pub devlaunch_core::flows::launch::LaunchNotice::SessionManagerUnavailable::reason: alloc::string::String
pub devlaunch_core::flows::launch::LaunchNotice::SetupPassNeverFinished
pub devlaunch_core::flows::launch::LaunchNotice::SetupPassNeverFinished::workspace_id: alloc::string::String
pub devlaunch_core::flows::launch::LaunchNotice::SshCommand
pub devlaunch_core::flows::launch::LaunchNotice::SshCommand::argv: alloc::vec::Vec<alloc::string::String>
pub devlaunch_core::flows::launch::LaunchNotice::SshConfigUnlocatable
Expand Down Expand Up @@ -641,6 +648,7 @@ pub struct devlaunch_core::flows::launch::ToolProvisioning<'e>
impl<'e> devlaunch_core::flows::launch::ToolProvisioning<'e>
pub fn devlaunch_core::flows::launch::ToolProvisioning<'e>::from_env(&std::path::Path, &'e mut dyn devlaunch_core::notices::Notices<devlaunch_core::flows::provision::ProvisionEvent>) -> Self
impl devlaunch_core::flows::launch::Provision for devlaunch_core::flows::launch::ToolProvisioning<'_>
pub fn devlaunch_core::flows::launch::ToolProvisioning<'_>::pass_never_finished(&self, &str) -> bool
pub fn devlaunch_core::flows::launch::ToolProvisioning<'_>::provision_tools(&self, &dyn devlaunch_runner::Runner, &str, devlaunch_core::flows::provision::PassOccasion, core::option::Option<&str>) -> core::result::Result<core::option::Option<devlaunch_core::flows::provision::ClaudeConfig>, devlaunch_core::flows::provision::DevpodMissing>
pub fn devlaunch_core::flows::launch::ToolProvisioning<'_>::remembered_claude(&self, &str) -> core::option::Option<devlaunch_core::flows::provision::ClaudeConfig>
pub trait devlaunch_core::flows::launch::ColdMachinery<'r>
Expand All @@ -650,9 +658,11 @@ impl<'r> devlaunch_core::flows::launch::ColdMachinery<'r> for devlaunch_core::fl
pub fn devlaunch_core::flows::launch::ColdPath<'r, '_>::open(&mut self) -> core::result::Result<devlaunch_core::flows::launch::Cold<'_, 'r>, devlaunch_core::flows::launch::ColdRefused>
pub fn devlaunch_core::flows::launch::ColdPath<'r, '_>::recorded(&mut self) -> core::option::Option<&devlaunch_core::domain::metadata::MetadataStorage>
pub trait devlaunch_core::flows::launch::Provision
pub fn devlaunch_core::flows::launch::Provision::pass_never_finished(&self, &str) -> bool
pub fn devlaunch_core::flows::launch::Provision::provision_tools(&self, &dyn devlaunch_runner::Runner, &str, devlaunch_core::flows::provision::PassOccasion, core::option::Option<&str>) -> core::result::Result<core::option::Option<devlaunch_core::flows::provision::ClaudeConfig>, devlaunch_core::flows::provision::DevpodMissing>
pub fn devlaunch_core::flows::launch::Provision::remembered_claude(&self, &str) -> core::option::Option<devlaunch_core::flows::provision::ClaudeConfig>
impl devlaunch_core::flows::launch::Provision for devlaunch_core::flows::launch::ToolProvisioning<'_>
pub fn devlaunch_core::flows::launch::ToolProvisioning<'_>::pass_never_finished(&self, &str) -> bool
pub fn devlaunch_core::flows::launch::ToolProvisioning<'_>::provision_tools(&self, &dyn devlaunch_runner::Runner, &str, devlaunch_core::flows::provision::PassOccasion, core::option::Option<&str>) -> core::result::Result<core::option::Option<devlaunch_core::flows::provision::ClaudeConfig>, devlaunch_core::flows::provision::DevpodMissing>
pub fn devlaunch_core::flows::launch::ToolProvisioning<'_>::remembered_claude(&self, &str) -> core::option::Option<devlaunch_core::flows::provision::ClaudeConfig>
pub enum devlaunch_core::flows::lifecycle::DeleteStalled
Expand Down
Loading
Loading