Skip to content

Install devlaunch's tools into a pixi home of its own - #277

Merged
blooop merged 6 commits into
mainfrom
fix/tools-own-pixi-home
Aug 20, 2026
Merged

blooop merged 6 commits into
mainfrom
fix/tools-own-pixi-home

Conversation

@blooop

@blooop blooop commented Aug 20, 2026 •

Copy link
Copy Markdown
Owner

The bug

Launching kinisi-robotics/kinisi_ros with dl left the workspace's git status dirty, with two envs appended to a tracked file:

modified:   .docker/dev/files/pixi-global.toml
+ [envs.chezmoi]
+ [envs.zellij]

That repo's devcontainer symlinks the pixi global manifest onto a file in the checkout:

~/.pixi/manifests/pixi-global.toml -> ~/kinisi/kinisi_ros/.docker/dev/files/pixi-global.toml

(.devcontainer/on_create.sh:10, and .docker/dev/entrypoints/entrypoint_dev.sh:123, which re-makes the link on every container start.) So any pixi global install in that container writes into the work tree. [envs.zellij] is ours — the zellij setup stage. [envs.chezmoi] is the dotfiles' own bootstrap, fixed separately in blooop/dotfiles.

Why a separate home, rather than a special case for symlinks

The dirty work tree is a symptom. The defect is that pixi global install edits a declarative file devlaunch does not own, and that costs something in both directions:

  1. pixi global sync removes every env the manifest does not list. A dotfiles apply that rewrites the manifest and syncs therefore uninstalls the zellij we just installed — invisibly, and the next launch reinstalls it, forever.
  2. The manifest is not always a file, per above. dl launches arbitrary repos; an install that dirties the tree it was pointed at is the launch damaging the work it exists to serve.

$HOME/.devlaunch/pixi makes both unrepresentable rather than handled. Nothing syncs that manifest, and every path under it is one devlaunch created.

Two commits, because the Python fix reached nobody

0.1.0 onwards is the Rust rewrite and the released dl is an ELF binary, so the first commit fixes the reference implementation and the second fixes the build that actually ships. Both, or neither is worth having.

Three load-bearing details, each pinned by a test

  • The export precedes the bootstrap. pixi's own installer honours PIXI_HOME (${PIXI_HOME:-$HOME/.pixi}), so the other order leaves the binary in one home and its envs in another — the one shape of this that fails silently, since both halves work alone.
  • Not ~/.local/share/devlaunch/pixi, the conventional path. Containers bind-mount ~/.cache, ~/.config and ~/.local/share straight from the host, so a prefix tree under one would be shared by every container on the machine and written into the host's home. Prefixes are baked with absolute paths; two syncs sharing one tree is Error: failed to collect prefix records during concurrent execution on HPC (SLURM + Lustre) prefix-dev/pixi#5476 — the exact hazard PIXI_CACHE_TARGET refuses to let PIXI_HOME near.
  • Never exported into the login profile. A user's own pixi global install in a workspace still goes to their ~/.pixi. Only the bin directories go on PATH.

What did not move, and why that is the interesting half

.devcontainer/claude-code/install.sh still names ~/.pixi. It runs at image build time, where that path is the image's own, there is no checkout to dirty and no other owner to race — and editing it would invalidate the devcontainer prebuild for no gain.

So the build-time writer and the two runtime writers now append different lines under different marks, and both land. That is the dedupe working rather than failing: two pixi homes are two directories a login shell genuinely needs, and _profile_prepend's mark being a hash of the line is what keeps one writer from reading the other's work as its own.

Writer::prepended in the Rust tests had a _ => ".pixi/bin" catch-all — which is how the build-time and runtime writers came to share an answer they no longer share. Every arm is now spelled out.

Worth noting for the record: the Python test for this compares against the feature's own literals, the Rust test compared against the shared consts. That difference is why the Python suite stayed green here while the Rust one went red — the Rust form is the stronger of the two, and it is the one that caught it.

What it costs

A duplicate extracted prefix in the one case where a tool is installed but unreachable from a login shell. Disk only: PIXI_HOME does not move the download cache, so PIXI_CACHE_TARGET still shares every package. And that is the case where the old behaviour reinstalled on every launch anyway.

Verification

Against real pixi, not only the fakes. The rendered zellij_script(), run against a home whose manifest is symlinked onto a tracked file:

zellij 0.45.0 (installed)          -> ~/.devlaunch/pixi/
repo file changed?  NO
~/.profile          export PATH="$HOME/.devlaunch/pixi/bin:$PATH"

The same script before this change appends [envs.zellij] straight through the link. The profile line matters on its own: without it the next launch's command -v zellij misses and reinstalls forever.

The Rust goldens ("what devlaunch/tools.py renders, byte for byte") were regenerated from the Python module rather than hand-edited — the marks in them are content hashes, so retyping one is a duplicate PATH entry in every workspace forever.

  • pytest (full suite less e2e): 1884 passed. ruff format --check, ruff check, pylint (10.00/10) clean.
  • cargo test -p devlaunch-core: 1140 passed. clippy --all-targets -D warnings and cargo fmt --check clean.

Summary by Sourcery

Install devlaunch's runtime tools in an isolated pixi home while preserving user and build-time pixi environments.

Bug Fixes:

  • Prevent devlaunch-installed tools from modifying or conflicting with a container's existing pixi global manifest by installing them in a dedicated ~/.devlaunch/pixi home.
  • Keep devlaunch's runtime pixi tools discoverable through login shells while leaving user-managed pixi installs in ~/.pixi.

Enhancements:

  • Ensure pixi and its environments use the dedicated devlaunch home consistently, while preserving the build-time feature installer's image-local ~/.pixi setup.
  • Align Python and Rust script generation and strengthen cross-implementation golden coverage for pixi home and PATH behavior.

Build:

  • Bump the Rust workspace and support package version to 0.2.2.

Documentation:

  • Document the dedicated pixi home, cache separation, PATH behavior, and compatibility with existing containers.

Tests:

  • Add coverage verifying PIXI_HOME is set before pixi bootstrap and installation, runtime scripts avoid the container's shared pixi home, and Python-rendered scripts match Rust goldens.

Chores:

  • Update the changelog for the 0.2.2 fix.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @blooop, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR makes devlaunch install its pixi-based tools (gh, claude, zellij) into a dedicated pixi home at $HOME/.devlaunch/pixi instead of the container’s shared ~/.pixi, updates PATH/profile handling accordingly, and adds tests/docs to pin the new behavior and invariants around PIXI_HOME and cache sharing.

Sequence diagram for devlaunch tool provisioning with dedicated PIXI_HOME

sequenceDiagram
    actor User
    participant provision_script
    participant Shell
    participant pixi

    User->>provision_script: provision_script(tools)
    provision_script->>Shell: _all_present(tools)
    alt tools missing
        provision_script->>Shell: _pixi_home_export()
        Shell->>Shell: export PIXI_HOME="$HOME/.devlaunch/pixi"
        provision_script->>Shell: _pixi_bootstrap()
        Shell->>pixi: curl | bash (pixi installer)
        pixi-->>Shell: pixi in PIXI_HOME_TARGET/bin
        provision_script->>Shell: _install_line(tool) via pixi global install
        Shell->>pixi: pixi global install gh/claude/zellij
        provision_script->>Shell: _profile_prepend(PATH using PIXI_HOME_TARGET)
        Shell-->>User: tools installed in $HOME/.devlaunch/pixi
    else all_present
        provision_script-->>User: exit 0 (no pixi or profile changes)
    end
Loading

File-Level Changes

Change Details Files
Introduce a dedicated pixi home for devlaunch-installed tools and wire it through all tool provisioning scripts.
  • Add PIXI_HOME_TARGET constant pointing at $HOME/.devlaunch/pixi with detailed rationale in comments.
  • Add _pixi_home_export() helper that exports PIXI_HOME to PIXI_HOME_TARGET before any pixi-related operations.
  • Update provision_script() to use PIXI_HOME_TARGET/bin and envs/claude-shim/bin in PATH profile lines instead of ~/.pixi paths.
  • Ensure provision_script() and zellij_script() call _pixi_home_export() before bootstrapping pixi or running pixi global install.
  • Change _pixi_bootstrap() to add PIXI_HOME_TARGET/bin to PATH after installer runs, keeping pixi binary and envs in the same home.
devlaunch/tools.py
Update PATH/profile expectations and add tests that enforce the new PIXI_HOME behavior and constraints.
  • Adjust existing unit tests to assert PIXI_HOME_TARGET/bin is written into the profile instead of ~/.pixi/bin.
  • Update profile guard expectations so provision/zellij scripts refer to .devlaunch/pixi/bin while the feature installer still uses .pixi/bin.
  • Extend sandbox fake pixi helper to record PIXI_HOME into a separate env log for behavioral tests.
  • Add tests ensuring installs never touch the container’s own ~/.pixi, PIXI_HOME is set before any pixi runs, no rendered script references $HOME/.pixi, PIXI_HOME_TARGET avoids bind-mounted XDG dirs, and PIXI_HOME_TARGET is independent of PIXI_CACHE_TARGET.
test/unit/test_tools.py
Document the new pixi home behavior and its motivation in the README.
  • Add a “Where the tools themselves land” section explaining that devlaunch-installed tools go into ~/.devlaunch/pixi, not ~/.pixi.
  • Describe the problems with writing to the container’s ~/.pixi manifest (sync uninstalling tools, symlink into tracked repo file causing dirty git status).
  • Explain why ~/.local/share/devlaunch/pixi is avoided due to host bind mounts and prefix absolute path hazards.
  • Clarify that PIXI_HOME is only set for devlaunch scripts and is not exported in login profiles; users’ own pixi installs still go to ~/.pixi and only bin dirs are put on PATH.
README.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

`pixi global install` is not only an install: it is an edit to
`$PIXI_HOME/manifests/pixi-global.toml`, a *declarative* file that in a container
already has an owner -- an image's, or a dotfiles repo's. Writing there made
devlaunch a second author of a file with one owner, which cost something in both
directions:

  1. `pixi global sync` removes every env the manifest does not list, so a
     dotfiles apply that rewrites the manifest and syncs *uninstalls* the zellij
     the setup pass just installed -- on a schedule nothing here can see, and
     the next launch reinstalls it, forever.
  2. The manifest is not always a file. kinisi_ros's devcontainer symlinks
     `~/.pixi/manifests/pixi-global.toml` onto a tracked file inside the
     checkout (its `.devcontainer/on_create.sh`), so the append landed in the
     user's work tree and every `git status` in the workspace came up dirty.
     Nothing about that is one repo's quirk: dl launches arbitrary repos, and an
     install that dirties the tree it was pointed at is the launch damaging the
     work it exists to serve.

So PIXI_HOME_TARGET (`$HOME/.devlaunch/pixi`), exported by `_pixi_home_export()`
at the top of both scripts that run pixi, which makes both unrepresentable
rather than handled: nothing syncs that manifest, and every path under it is one
devlaunch created, so no repo state can be beneath it.

Three details are load-bearing, and each is pinned by a test:

- The export comes *before* the bootstrap, not after. pixi's own installer
  honours PIXI_HOME (`${PIXI_HOME:-$HOME/.pixi}`), so the other order leaves the
  binary in one home and every env it installs in another -- the one shape of
  this that fails silently, since both halves work alone.
- Not `$HOME/.local/share/devlaunch/pixi`, the conventional answer. Containers
  bind-mount `~/.cache`, `~/.config` and `~/.local/share` straight from the host,
  so a prefix tree under one would be shared by every container on the machine
  and written into the host's own home. Prefixes are baked with absolute paths
  and two syncs sharing one tree is prefix-dev/pixi#5476 -- exactly the hazard
  PIXI_CACHE_TARGET refuses to let PIXI_HOME near.
- Never exported into the login profile. A user's own `pixi global install` in a
  workspace still goes to their `~/.pixi`, which is the separation this exists to
  keep; only the bin directories go on PATH.

What it costs is a duplicate extracted prefix in the case where a tool is
installed but unreachable from a login shell -- disk only, since PIXI_HOME does
not move the download cache and `PIXI_CACHE_TARGET` still shares it -- and that
is the case where the old behaviour reinstalled on every launch anyway.

The devcontainer feature's installer still names `~/.pixi`, deliberately: it is
baked into an image at build time, where that path is the image's own and there
is no checkout to dirty. Its row in the profile-guard table says so.

Verified against real pixi rather than only the fakes: the rendered
`zellij_script()`, run against a home whose manifest is symlinked onto a tracked
file, installs zellij 0.45.0 into `~/.devlaunch/pixi`, leaves the file untouched,
and writes the profile line that keeps the next launch's `command -v` from
reinstalling. The same script before this change appends through the link.
@blooop
blooop force-pushed the fix/tools-own-pixi-home branch from 42fc196 to c9a992b Compare August 20, 2026 17:20
@codecov

codecov Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.26%. Comparing base (54c21f7) to head (00e488e).
⚠️ Report is 6 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main     #277   +/-   ##
=======================================
  Coverage   93.26%   93.26%           
=======================================
  Files          25       25           
  Lines        3771     3774    +3     
=======================================
+ Hits         3517     3520    +3     
  Misses        254      254           
Files with missing lines Coverage Δ
devlaunch/tools.py 96.83% <100.00%> (+0.04%) ⬆️

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The previous commit fixed devlaunch/tools.py, and `dl` on a user's machine is
not that: 0.1.0 onwards is the Rust rewrite, the released binary is an ELF, and
the Python tree is the reference implementation beside it. So the fix reached
nobody until this.

`rust/devlaunch-core/src/flows/provision.rs` gets PIXI_HOME_EXPORT ahead of the
bootstrap in both scripts, and PIXI_BOOTSTRAP / PIXI_BIN_LINE /
CLAUDE_SHIM_BIN_LINE move to `$HOME/.devlaunch/pixi`. The goldens in its test
module -- "what devlaunch/tools.py renders, byte for byte" -- were regenerated
from the Python module itself rather than hand-edited, which is the only way the
claim in their header stays true; the marks in them are content hashes, so
retyping one is a duplicate PATH entry in every workspace forever.

The interesting half is what did *not* move. `.devcontainer/claude-code/install.sh`
still names `~/.pixi`: it runs at image build time, where that path is the
image's own, there is no checkout to dirty and no other owner to race -- and
editing it would invalidate the devcontainer prebuild for no gain. So the
build-time writer and the two runtime writers now append different lines under
different marks, and both land. That is the dedupe working rather than failing:
two pixi homes are two directories a login shell genuinely needs, and
`_profile_prepend`'s mark being a hash of the line is exactly what keeps one
writer from reading the other's work as its own.

Three tests had encoded the old agreement and now state the new one:

- `Writer::prepended` had a `_ => ".pixi/bin"` catch-all, which is how the
  build-time and runtime writers came to share an answer they no longer share.
  Every arm is spelled out; a `_` here would let a fourth writer inherit
  whichever answer happened to be the default.
- `a_marks_digest_is_twelve_hex_characters_of_the_lines_sha256` pins the two new
  digests, and now also the feature installer's own two -- those are pasted into
  a shell script that cannot derive them, so nothing but a test holds them.
- `the_feature_installer_writes_the_very_fragments_devlaunch_renders` compares
  against the feature's own lines instead of the runtime consts, which is what
  the Python test beside it already did. That difference is why the Python suite
  stayed green on this while the Rust one went red -- worth knowing, because the
  Rust form is the stronger of the two and it is the one that caught this.

cargo test -p devlaunch-core: 1140 passed. clippy --all-targets -D warnings and
cargo fmt --check clean.
blooop added 2 commits August 20, 2026 18:48
Found reviewing the two commits before it, and it is the hole they walked
through. The goldens in the Rust test module are headed "what devlaunch/tools.py
renders, byte for byte" -- and that was a claim in a comment. They are hardcoded
strings compared against the *Rust* renderer, so nothing ever compared them to
the Python one they are named for.

Moving the tools into their own pixi home had to be carried into both trees by
hand. The Rust side's internal tests caught a divergence the Python side's did
not (the feature installer's PATH line, which deliberately did not move). Had
the edit gone the other way -- Python only -- nothing at all would have failed:
the goldens would have kept agreeing with the Rust renderer while both drifted
from the module they are named after, and the drift would have surfaced as a
container whose PATH line no later writer recognises.
`rust/tools/parity_cases.txt` does not cover it; it compares dl's observable
behaviour, and these scripts are payload strings inside a `devpod ssh --command`.

So the comparison is made in the tree that owns the definition: extract every
`PYTHON_*` const out of provision.rs and assert it equals the call in
devlaunch.tools that is supposed to produce it. No cargo and no Rust toolchain
-- it is a pytest, which is what puts the failure in front of whoever edits
either side.

Two tests rather than one, because the first alone would only check the goldens
it happens to have a mapping for: the second fails on any `PYTHON_*` const with
no rendering named for it, so a new golden cannot be added outside the check.
The mapping is written out rather than discovered, for the reason the module
already gives about tests that derive their expectations from the thing under
test.

Both failure modes were verified by breaking them on purpose: one character of
a content-hash mark in a golden fails with the name of the golden, and an
unmapped new golden fails with its name.
`Auto-publish` fires on every push to `main` and releases only when
`[workspace.package] version` in rust/Cargo.toml differs from its parent
commit's, so the bump is what turns this merge into a release; it then reads the
release body out of the `## [0.2.1]` section and fails the run if that section
is missing.

`rust/Cargo.lock` moves with it -- the workspace members inherit the version, and
every build in CI passes `--locked`, so a lock left at 0.2.0 is a red build
rather than a stale file.

`devlaunch-test-support`'s `versioned_with_the_workspace` pins the version as a
string and had to be edited too. Worth knowing that no CI step runs it: the
rust-parity job tests `devlaunch-runner`, `devlaunch-core`, `aid` and `dl` by
name and never that crate, so the one test standing guard over this was found by
grep rather than by the gate it exists to be part of.
@blooop
blooop enabled auto-merge August 20, 2026 18:02
blooop added 2 commits August 20, 2026 19:03
0.2.1 went out from main while this branch was open (#272, the --help and README
reordering): tag v0.2.1 is on main's tip and the GitHub release exists. So the
version bump here had to move, and it is not a cosmetic renumber -- Auto-publish
skips a version it finds already tagged, so merging this branch claiming 0.2.1
would have published nothing at all and said so only in a log nobody reads.

Cargo.toml, Cargo.lock and devlaunch-test-support's version pin go to 0.2.2, and
the changelog keeps both sections: their 0.2.1 as shipped, this branch's notes
under 0.2.2 above it.

The README conflict is main's reordering rather than a disagreement. That commit
moved the whole reference half of the document, including `## The shared pixi
package cache`, and left a short intro where it used to be -- so this takes
main's text at that position outright and re-places `### Where the tools
themselves land` where its neighbours went, still between the shared-cache
section and the recreate note. Same for the earlier conflict in this branch:
main's shared-cache uid paragraph and this branch's subsection were both
insertions at one point, and both are kept.
@blooop
blooop merged commit 1b01ac2 into main Aug 20, 2026
15 checks passed
@blooop
blooop deleted the fix/tools-own-pixi-home branch August 20, 2026 18:31
blooop added a commit that referenced this pull request Aug 20, 2026
Main moved 43 commits and four releases (0.2.0 through 0.3.0) while this branch
was being written, and two of those commits touch the same ground. Reconciled
rather than re-applied, since main thought about one of them more carefully than
this branch had.

**The Rust toolchain in the pixi environment (#284) was already done, better.**
This branch had added `rust` as a plain dependency; main added it as a *feature*
carried only by `default`, so the py31x solves and their CI caches do not each
drag in a 1.5GB toolchain, with the 521MB→2044MB disk trade written down. Main's
design is kept and the plain dependency dropped; the collapse to one environment
in this branch then removes the reason the feature had to be scoped at all, and
its comment says so.

One correction on top of it: the pin was `rust = "1.97.*"`, while AGENTS.md told a
reader the environment is "pinned to the 1.97.1 that rust-toolchain.toml names".
Those disagree the day 1.97.2 is published — silently, and only inside the
container, which is the worst place for a toolchain difference to surface.
Tightened to `1.97.1.*`, which is what the lockfile already held, so it costs no
re-solve and makes main's own sentence true. `test_agents_doc.py` now reads the
feature and holds the two to each other exactly.

**Nothing was lost to the deletion.** The two Python modules main edited in that
window were checked against their Rust counterparts before being removed:
`workspace_id.py`'s `TARGET_LENGTH = 47` (#283) is `TARGET_LENGTH: usize = 47` in
`domain/workspace_id.rs`, and `tools.py`'s `PIXI_HOME_TARGET = "$HOME/.devlaunch/pixi"`
(#277) is `PIXI_HOME_EXPORT`/`PIXI_BIN_LINE`/`CLAUDE_SHIM_BIN_LINE` in
`flows/provision.rs`, with byte-goldens pinning the rendered scripts. Both sides
moved together, which is what the parity discipline was for.

The rest was mechanical: main's `pixi.lock` is lock-file version 7, which needed
a newer pixi than the container had, and the manifest's dependency changes here
required a fresh solve (932 lines shorter, four environments gone). README keeps
main's new paragraph about cargo needing no toolchain of your own — that one is
about the crates, not about the Python build this branch ends. `test/unit/test_devcontainer_manifest.py`
gained 333 lines on main and imported `tomli`, which this branch removes; it reads
`tomllib` now, like its two siblings.

Also, while re-reading the goldens that survive: the `PYTHON_*` constants in
`flows/provision.rs` are named for where they were transcribed from, and that
module is now gone. Kept under their names — what they pin did not change when
their author left, and the name is the one thing still recording that nobody read
them off the implementation they judge — with a note saying exactly that, and
saying that there is nothing left to re-derive them from.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant