Repository navigation
Build: persist the devpod workspace id instead of re-deriving it, and reconcile the orphans #88
Description
Work
Stop re-deriving the devpod workspace id. Persist the id dl actually created and address devpod by that, then reconcile the workspaces already orphaned.
Re-scoped. This ticket previously proposed giving
run_devpodan explicitcwd. That is retracted — see "Retracted premises" at the bottom. The cause below is observed on the host, not hypothesised.
Root cause
PR #81 (fix/64-workspace-id-parse-boundary — commits 5db5c42, 3875246) changed both the workspace id and the clone-directory leaf from <branch> to <repo-slug>-<branch-slug>-<8-char syllable suffix>. It migrated dl's own metadata and left devpod's records pointing at the old paths.
For devlaunch-main on the reporting host:
| side | records the workspace at | actual state |
|---|---|---|
~/.cache/devlaunch/metadata.json |
…/repos/blooop/devlaunch/devlaunch-main-zovomobo |
35 entries, real checkout, init-host.sh present |
~/.devpod/contexts/default/workspaces/devlaunch-main/workspace.json |
…/repos/blooop/devlaunch/main |
1 file, no .git |
The metadata record kept its original created_at (2026-03-01T18:39:40, matching devpod's creationTimestamp) while carrying the new id and path — the same record, rewritten in place. devpod's record was never touched.
Why the failure looks like a missing hook
The old devlaunch/main checkout is gone. On recreate, devpod finds its recorded source folder absent and reconstitutes it from cached workspace_result.json, which contains exactly:
{"name":"devlaunch","initializeCommand":{"":[".devcontainer/claude-code/init-host.sh"]}}That is byte-for-byte the shape of the lone devlaunch/main/.devcontainer/devcontainer.json left on disk. devpod then chdirs into that folder and runs the hook — but init-host.sh is repo content, not devcontainer config, so it was never restored. Absent → the reported error. Fresh create works because it clones to the new path and registers a workspace whose source matches.
Scale
36 of 39 devpod workspaces on the reporting host have a recorded source folder that is missing (35) or a config-only stub (1). Affects bencher, wayfinder, python_template, rockerc, lifetime-foc-rig, scratch_env, devlaunch. Only three are healthy — all created after the scheme change.
Diagnostic, for anyone reproducing:
devpod list --output json | jq -r '.[] | "\(.id)|\(.source.localFolder)"' |
while IFS='|' read -r id p; do
[ -d "$p/.git" ] || echo "BROKEN $id -> $p"
doneThe change
1. Follow the record, don't re-derive (the durable fix)
WorktreeInfo.devpod_workspace_id already exists (worktree/models.py:71) and is never assigned anywhere — grep it: one declaration, zero writers. Had dl stored the devpod workspace id it created and used that to address devpod, changing the derivation would have been harmless.
This is the same lesson the codebase already learned for the path. From remove_workspace_by_id's own docstring:
Following the record matters because the derivation has changed: every workspace created before the current id scheme has a bare branch name as its clone-directory leaf. Re-deriving the leaf here looked for a directory that never existed […] The stored path makes old and new workspaces both removable with no migration.
Exactly that, for the id. Persist devpod_workspace_id when a workspace is created, and have every devpod-addressing path (up, ssh, stop, rm, recreate/restart/reset) use the stored id, falling back to derivation only when there is no record.
2. Reconcile what is already orphaned
A derivation fix does not repair existing state — the 36 broken records predate it. Needs a one-time reconciliation that, per devpod workspace whose recorded source is missing or has no .git:
- if a metadata record for the same
(owner, repo, branch)points at a healthy clone, adopt it: rewrite devpod'ssource.localFolderand populatedevpod_workspace_id; - if no healthy clone exists, say so and let the user drop the workspace — do not silently delete.
Decide deliberately whether this is dl migrate, dl prune --repair, or automatic on startup, and whether writing ~/.devpod/.../workspace.json directly is acceptable versus driving it through devpod CLI. Containers bind-mount the dead source path, so any repaired workspace needs a rebuild regardless — the reconciliation should say that rather than implying the container is reusable.
Must be idempotent, and must cope with metadata that has already been migrated to the new scheme while devpod's has not (the current host state).
3. Version skew
Installed dl on the reporting host is 0.0.9, which still computes branch-name paths, while a main-based run already rewrote metadata.json to the new scheme. Note explicitly what happens when an older dl meets migrated metadata. PR #86 (Release 0.0.10) is what ships PR #81 — the migration should land before or with it, not after.
Done when
- A test asserts
devpod_workspace_idis persisted on create and that devpod-addressing paths use the stored id in preference to a derived one. - A test asserts a workspace created under the old id/path scheme is still addressable after the derivation changes — i.e. the regression that caused this cannot recur. Simulate the scheme change; don't hardcode today's suffix.
- A test covers reconciliation: orphaned record + healthy clone → adopted; orphaned record + no clone → reported, not deleted; running it twice changes nothing.
pixi run cigreen.
Verification honesty
The root cause above is observed from on-host state (devpod list, metadata.json, workspace.json, workspace_result.json) — not inferred. What cannot be verified in a session without a Docker daemon is that a repaired workspace then recreates successfully end to end. That last step is the human's, on the host: dl blooop/devlaunch recreate.
Retracted premises
Recorded so nobody re-treads them:
cwdon the devpod spawn is not the fix. devpod chdirs into the workspace's recorded source folder before runninginitializeCommand, identically whether the workspace is addressed by path or by id.dl's working directory never reaches the hook. Verified by running devpod v0.26.1 for real against a throwaway project with the same bare relative hook — no Docker needed, sinceinitializeCommandfires before any container exists.- The original ticket's supporting facts were read off a stale branch. At
dl.py:1367-1370onmain, when the workspace already exists,workspace_specis a bare workspace id, not a clone path, andcustom_idisNone. The proposedcwd=workspace_specwould have handed subprocess a nonexistent directory named after a workspace id — on exactly the three subcommands it targeted. - "The fast path never calls
ensure_workspace" is also not the cause. It is true thatif workspace_id in existing_idsskipsclone_mgr.ensure_workspace(), but adding a clone check there would inspect the new path and find it healthy, while devpod keeps using the old one. No check about the clone catches a disagreement over where the workspace lives. - recreate/restart fail on a relative initializeCommand #87's "not a stale clone" was right for the wrong reason. It ruled this out on repo-content grounds — correct, there is no commit where the
initializeCommandreference exists without the script. But the folder devpod runs in is not a checkout at all; it is a config-only stub devpod itself wrote from cache. Content-level reasoning could not see it.
Activity
blooop commentedon Aug 7, 2026
breadcrumb: claimed; single-ticket map #87. Ruled out missing file, stale clone, and
-- <cmd>mis-parsing before filing. Cause is a labelled hypothesis (relative hook path vs devpod's inherited cwd) — the build stage confirms it before implementing. No repro possible in-session: devpod v0.26.1 present, no Docker daemon. Launching /tdd in fresh context.blooop commentedon Aug 7, 2026
breadcrumb: Stopped at the "Confirm first" gate. The hypothesis is disproven, so no code was written. Nothing was branched, committed or pushed.
What I did
Ran devpod v0.26.1 for real, in an isolated devpod home, against a throwaway project whose
devcontainer.jsoncarries the same bare relativeinitializeCommandshape (.devcontainer/claude-code/init-host.sh). The hook wrote its own working directory to a log. No Docker daemon is needed to reach the hook — devpod runsinitializeCommandbefore it touches a container, so the run dies after the hook, which is exactly the window this question lives in.Observed (not reasoned)
.devcontainerin it: hook ran, and its cwd was the workspace's source folder, not the launcher's.up <id> --recreate— the exact shapedl … recreateproduces — launched from a different directory with no.devcontainer: hook ran, cwd again the workspace's source folder.devpod chdirs into the workspace's recorded local source before running the hook, and it does so identically whether the workspace is addressed by path or by id. The directory dl is launched from does not reach
initializeCommandat all. Giving the devpod spawn an explicit working directory would change nothing about this failure.devcontainer.jsonintact, but remove the hook file itself. The failure is "the folder devpod resolved does not contain that file", not "devpod was standing in the wrong folder".Two things the ticket records as established that are not
workspace_upis the bare workspace id and the custom id is dropped — the clone-path branch is theelse, taken only when the workspace does not yet exist. So the proposed change, implemented literally, would handsubprocessa working directory named after a workspace id, which never exists as a directory — on precisely the three subcommands the ticket targets. The "missing directory" case would not be an edge case, it would be the only case.Cheapest next move, on the host
Ask devpod which folder it is actually using for the existing workspace, then look in it:
blooop/devlaunchdefault-branch workspace out ofdevpod list --output json.devcontainer/claude-code/init-host.sh, and whether it is the same folder a fresh workspace getsIf it is a stale or foreign folder, this is a "dl never re-verifies the clone on the existing-workspace path" bug and needs a different ticket. If the folder is correct and the file is there, then the cause is something neither this ticket nor #87 has named yet and the map should reopen.
handoff
blooop commentedon Aug 7, 2026
breadcrumb: build stage stopped at the confirm gate — hypothesis disproven, no code written. Correcting my own ticket: I wrote it from a checkout of the stale
slow_installbranch, notorigin/main. Two 'established' facts in the body are wrong onmain:dl.py:1367-1370: when the workspace already exists,workspace_spec = workspace_id(a bare id) andcustom_id = None. recreate/restart/reset therefore pass no clone path.cwd=workspace_specwould have handedsubprocessa directory named after a workspace id — nonexistent on exactly the three subcommands the ticket targeted.clone_mgr.ensure_workspace(), so nothing on the existing-workspace path verifies the clone is present or populated before devpod is asked to rebuild from it.The proposed fix is also inert, shown by experiment rather than argument: devpod v0.26.1 was run for real against a throwaway project with a bare relative
initializeCommand(no Docker needed — the hook runs before any container). devpod chdirs into the workspace's recorded local source before running the hook, identically whether addressed by path or by id. dl's cwd never reachesinitializeCommand. The human's exact three-line error was reproduced by a different mechanism: source folder present,devcontainer.jsonintact, hook file absent from that folder.Parked pending one host check that settles it —
devpod list --output jsonfor the recordedsource.localFolderof thedevlaunch-mainworkspace, then whether that folder actually holds.devcontainer/claude-code/init-host.sh. If stale or foreign, this is a 'dl never re-verifies the clone on the fast path' bug and needs a re-scoped ticket.[-]Build: run the devpod spawn from the workspace directory[/-][+]Build: persist the devpod workspace id instead of re-deriving it, and reconcile the orphans[/+]blooop commentedon Aug 8, 2026
Datum from #101 — there is a second class of orphan, and this ticket's reconciler is the natural owner.
Alongside the orphaned devpod workspace records this ticket already covers, the same
lifecycle leaks Docker named volumes, and it leaks them by design rather than by a
migration accident.
Source.
devpod delete→pkg/driver/docker/docker.go:137DeleteDevContainer→d.Docker.Remove(ctx, container.ID)→pkg/docker/helper.go:137:Plain
docker rm, notdocker rm -v. ADeleteVolumehelper exists (helper.go:98) andGitHub code search over
loft-sh/devpodreturns exactly one hit for it — its own definition,zero callers. So devpod never removes a volume. Docker never garbage-collects a named
volume regardless.
dladds nothing:purge_all_data(dl.py:321) isdevpod delete --forceplus
shutil.rmtreeof the cache.Every devcontainer here carries at least one named volume —
source=${localWorkspaceFolderBasename}-pixiin.devcontainer/devcontainer.json, plusdind-var-lib-docker-<devcontainerId>once the docker-in-docker feature is on. Both outlivetheir workspace permanently.
On this host today, whose
devpod listis a clean three workspaces:Only
devlaunch-main-zovomobo-pixianddevlaunch-t1-vebilote-pixishowLINKS 1. Twelve showLINKS 0:main-pixi2.705 GB,w1-pixi1.947 GB,w2-pixi1.877 GB,vscode821 MB,bugfix1-pixi524 MB,aid-pixi519 MB,slow_install-pixi519 MB,aid_auto_2-pixi518 MB,t1-pixi429 MB,dind-var-lib-docker-default-wf-9ef1b279 kB (+2 unrelated compose volumes).That is ~9.9 GB of dead volumes against 1.0 GB of live ones — 10:1 — and some date to July.
Suggested scope, matching this ticket's existing contract. The reconciler already promises
"if no healthy clone exists, say so and let the user drop the workspace — do not silently
delete". Same rule for volumes: for each
*-pixi/dind-var-lib-docker-*volume with no livecontainer and no matching workspace, report it with its size and let the user run
docker volume rm. Reporting is cheap and safe; deleting Docker volumes on the user's behalf isnot something
dlshould start doing — especially since-pixinames derive from a directorybasename (
${localWorkspaceFolderBasename}), so two repos with amainworktree share onevolume, which is already flagged as an open question on #93.
Note this leak is independent of the nested-daemon work — every one of the twelve orphans is
a plain
-pixivolume from the existing non-dind devcontainer. dind adds a second named volumeper workspace to a leak that was already there.
Close the four ways a protected clone reached the removal set
blooop commentedon Aug 14, 2026
breadcrumb: re-entering the build stage on the re-scoped ticket. Restating what I will build, and the seams I will test at.
What I am building
--id, instead of leaving that field declared and never written.(owner, repo, branch)is consulted before the launch is treated as cold; a stored id devpod does recognise wins, and the launch attaches to it instead of cloning a second workspace. Every devpod-addressing subcommand (up,stop,rm,restart,recreate,reset, attach) is downstream of that one resolution, so they are fixed together.Three decisions the ticket asked me to make deliberately
--reconcile, notprune --repairand not automatic.--prunestates as its contract that it never touches a devpod workspace; repair is exactly a devpod-workspace write, so folding it in would retract that promise for every existing--pruneuser. Automatic-on-startup mutates another tool's records without asking, which this codebase does not do anywhere else.build,delete,list,logs,ssh,status,stop,up, plus config subcommands — none of them re-points a source). The only CLI-shaped route is a create, which needs a container daemon and would destroy the very record being repaired. The write is one field of one JSON file devpod itself keeps per workspace.--prune's placement rules already run on.Order of preference when the derivation moves
Derived-first-then-record rather than record-first, and it is a trade rather than a shortcut. Reading the record on every launch means loading metadata under its lock on the warm attach path, which #145 deliberately removed. Asking devpod about the derived id is already paid for by the existing status round trip, so the record is read only when that misses — the exact case this ticket is about. The two orders differ in observable behaviour only when devpod knows the derived id and a different stored one for one triple, which after this change requires a workspace dl did not create.
Seams I will test at
WorkspaceCloneManager→MetadataStorage. Pins: the stored devpod workspace id equals the id dl hands devpod, for a new clone and for a re-prepared existing one.dl.main([...])with devpod's spawn recorded. Pins: derived when they agree; the stored one once the derivation has moved under the record. The scheme change is simulated at the derivation, not written down as a literal suffix, so this test cannot rot into asserting today's ids.Boundaries I am declaring rather than deciding
blooop commentedon Aug 14, 2026
breadcrumb: build stage complete — PR #210 open, all checks green (5-version CI matrix, e2e, prek, gate, codecov patch and project). Not merged, not self-reviewed; a fresh-context review stage follows.
The slice, in one line:
dlnow writes down the devpod workspace id it creates and follows it when the derivation misses, anddl --reconcilere-points the workspaces the last derivation change already orphaned.The three decisions the ticket left open, settled
dl --reconcile, notprune --repairand not automatic.--prunepromises never to touch a devpod workspace; repair is nothing but a devpod-workspace write, so folding it in would retract that promise for everyone already relying on it.dlhas named a clone directory. Where the match is not unique it is refused, not guessed.Ordering, and the cost it avoids
The record is read only after devpod has denied the derived id. Reading it first would put the metadata lock, the parse and the migration's version check back on the warm attach path that #145 deliberately cleared — the path a person waits on. The two orders differ only when devpod knows the derived id and a different recorded one for one triple, which now takes a workspace
dldid not create.How this sits with #155
--prune'sDisputedarm is untouched and stays untouched.--prunestill refuses to guess which clone a misplaced workspace needs;--reconcileresolves the disagreement, after which those clones classify as referenced in the ordinary way. The prune docs now point at it.Boundaries declared rather than decided
What a session here still cannot verify
That a repaired workspace recreates end to end needs Docker. Everything claimed is from records on disk and devpod's own CLI surface; the final
dl blooop/devlaunch recreateremains the human's, on the host — and it is worth doing, because that host is the one the ticket was written from.blooop commentedon Aug 15, 2026
wf-review verdict on #210: Request changes — two blocking findings, both on reconcile safety invariants (candidate-index collisions resolve last-write-wins; claimed guard uses by_path keys instead of holder() and skips the unlocatable check). Spec axis fully clean — all four Done-when bullets met. Fix round dispatched on the same branch.
blooop commentedon Aug 15, 2026
Fix round ce08cce re-reviewed by a fresh-context agent: both blocking findings resolved (collision → contested/Unadoptable both directions; holder() containment + unlocatable refusal matching --prune). #210 merged. Outstanding manual step: end-to-end recreate of a repaired workspace needs Docker on the host with the real orphans — run
dl --reconcilethere and recreate one repaired workspace to close the loop.blooop commentedon Aug 15, 2026
Manual e2e step closed (run on the reporting host, real Docker,
dl-nextat main 67e2acf).The real orphans first.
dl --reconcileagainst the real cache named all 4 (devlaunch-main,kinisi-ros-main,kinisi-ros-ags-fix-branch-clone-instance-id,kinisi-ros-ags-devcontainer-tooling-support) and adopted none — correctly:~/.cache/devlaunch/repos/blooop/devlaunch/and.../kinisi-robotics/kinisi_ros/are now empty, so there is no clone to re-point at. The 36-of-39 state the ticket was written from has since decayed further — the new-name checkouts are gone too. Nothing was deleted; a second run finds the same 4; exit 0 per the "nothing to consent to" arm. Disposition of those 4 records is a human call (dl <ws> rm), exactly as designed.The repair→recreate loop, end to end. Since no real orphan is repairable, the loop was exercised on a workspace created for the purpose (scratch
XDG_CACHE_HOME, real devpod, real Docker): colddl blooop/devlaunch up→ record written withdevpod_workspace_id: devlaunch-main-zovomobo(the automatic half, confirmed live) → clone moved so devpod's source went dead while the record knew the new location → plan printedRe-pointing 1, pipednaborted cleanly →-yrewrote devpod'ssource.localFolder(verified indevpod list --output json) →dl blooop/devlaunch recreaterebuilt the container against the repaired record → docker inspect shows the bind mount.../repos/blooop/devlaunch/main -> /workspaces/devlaunch-main-zovomoboand a one-shotgit rev-parseinside it answers.rmthen refused on the container's ownpixi.lockchurn (the unsaved-work guard, working) and--forcedeleted it. Real state untouched throughout.One finding filed as #224: running
--reconcilefrom inside<root>/<owner>/<repo>misreports every git-URL-sourced workspace as that repo's orphan —Path(url).resolve()resolves scp-like URLs against cwd. All failure modes point toward refusal, never adoption or deletion.