Follow the recorded devpod workspace id, and reconcile the orphans - #210
Conversation
devlaunch#88. The id dl handed devpod was derived from (owner, repo, ref) on every command and written down nowhere, so the derivation was the only copy of it in existence. When #81 moved that derivation, every workspace created under the old one stopped being addressable in the same instant -- 36 of 39 on the reporting host. Two changes, doing different jobs. `WorktreeInfo.devpod_workspace_id` has been declared since the worktree backend was written and nothing ever assigned it. It is now written when a clone is prepared, including on re-registration, so records from older builds acquire one with no migration. A launch still derives an id and asks devpod about it first, consulting the record only when devpod denies it -- which keeps #145's warm attach path clear of the metadata lock, the parse and the migration check. Every devpod-addressing subcommand reads the one resolution, so they are fixed together. `dl --reconcile` repairs what has already drifted. It joins devpod's records to dl's by path and never by id -- the id is what moved -- rewrites devpod's source.localFolder at the clone holding the checkout, and fills in the workspace id on dl's record. It deletes nothing and guesses at nothing: a clone a live workspace opens is never taken from it, a clone two dead records both match is claimed by neither, and an unmatched orphan is named and left. Not a mode of --prune, which promises never to touch a devpod workspace.
Reviewer's GuideThis PR makes devpod workspace IDs persistent and introduces Sequence diagram for resolving devpod workspace id on launchsequenceDiagram
actor User
participant dl
participant devpod
participant Metadata
User->>dl: dl <workspace> attach
dl->>devpod: get_workspace_state(derived_id)
alt devpod knows derived_id
devpod-->>dl: state
dl->>dl: resolve_known_workspace => KnownWorkspace(derived_id, state)
dl->>devpod: use derived_id for subsequent calls
else devpod denies derived_id
devpod-->>dl: None
dl->>Metadata: recorded_devpod_workspace_id(owner, repo, branch)
alt no recorded id or same as derived
Metadata-->>dl: None or derived_id
dl->>dl: KnownWorkspace(derived_id, None)
else recorded id differs
Metadata-->>dl: recorded_id
dl->>devpod: get_workspace_state(recorded_id)
alt devpod knows recorded_id
devpod-->>dl: state
dl->>dl: KnownWorkspace(recorded_id, state)
dl->>devpod: use recorded_id for subsequent calls
else devpod denies recorded_id
devpod-->>dl: None
dl->>dl: KnownWorkspace(derived_id, None)
end
end
end
File-Level Changes
Assessment against linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #210 +/- ##
==========================================
+ Coverage 92.38% 92.76% +0.37%
==========================================
Files 24 24
Lines 3467 3649 +182
==========================================
+ Hits 3203 3385 +182
Misses 264 264
🚀 New features to boost your workflow:
|
The half-made repair, a devpod record dl cannot read or write, a source no filesystem call will accept, a record dl cannot name a directory for, an unknown option, and a cache dl is not allowed to read. Each is the direction a failure must not go, and none of them had a test.
blooop
left a comment
There was a problem hiding this comment.
This was generated by AI during review.
Two-axis review of #210 against ticket #88 at merge-base e3d4639. Preflight: all checks green (ci ×5, e2e, prek, gate, codecov patch+project, GitGuardian), non-empty diff (7 files, +1384/−8), MERGEABLE.
Standards
- BLOCKING —
reconcile_plan's candidate index resolves collisions silently, contradicting its own docstring.candidates[(owner, repo, spelling)] = resolvedis last-write-wins acrosslist_worktrees(), and the legacy spelling is known non-injective: commit5db5c42records five verified preimages offeature-authunder the very regex_legacy_leafresurrects. So two records can claim one key and the winner is iteration order — a coin flip that re-points a workspace at the wrong branch's clone. The docstring says "Two candidates are refused rather than resolved, in both directions"; only the orphan side (wanted[clone] > 1) is actually refused. Either detect key collisions and route them toUnadoptable, or stop claiming both directions.devlaunch/dl.py:2506-2530. - BLOCKING —
claimed = set(locations.by_path)is a weaker guard than the sibling command's, and bypasses the accessor built for it.by_pathis keyed by the source path, so a live workspace opening<clone>/subprojectleaves<clone>unclaimed and adoptable — the exact mistakeWorkspaceLocations.holder()exists to prevent ("Equality answered no and deleted the parent",dl.py:1847-1867). Relatedly,reconcile_commandcomputeslocationsbut never checkslocations.unlocatable, where--prunerefuses to run for the same reason (an unfollowable live workspace could be holding any candidate). Both hazards land on the invariant the PR advertises: "a clone a live workspace already opens is never taken from it." - Non-blocking — duplication with #155's classification:
_orphaned_workspacesre-walks the samesource_places/_canonical/_site_ofchainworkspace_locationsjust walked, becausemisplacedis keyed(owner, repo)and collapses. WideningMisplacedto a tuple would remove the second traversal and the near-duplicateUnadoptable(same two fields). It also discardsInARepositoryOnly(owner, repo)and recomputes it viasource.relative_to(root).parts[:2]. - Non-blocking —
Reconciliation = Adoptable | Unadoptable(dl.py:2500) is defined, undocumented, and referenced nowhere: speculative generality in a file where every other alias is consumed. - Non-blocking —
KnownWorkspace(id, state: Optional[str])is the one new type here that isn't a sum type, andworkspace_idchanges meaning (resolved vs. derived) withstate is None. Every neighbour (SourcePlaces,SourceSite,Decision) models this as arms. - Non-blocking — unlike
prune_clones,apply_reconciliationre-checks nothing between consent and write; the deliberate second pass is the house pattern, so the omission deserves at least a sentence. - Nit — tests otherwise match
test_prune_orphaned_clones.pyclosely (World, FakeDevpod,needs_an_unprivileged_user, fidelity rationale). Divergences: three function-local imports with pylint disables, andimport subprocesstwice inside one method, where the sibling imports at module top. README/CHANGELOG/help conform; anchor resolves.
Spec
Ran the new/modified suites → 89 passed; also test_devpod_spawn_counts.py + test_prune_orphaned_clones.py → 85 passed, neither in the diff.
Nothing blocks on this axis. All four "Done when" bullets are met.
- Deviation 1 (derived-first) — sound, non-blocking. The ticket's §1 says "use the stored id, falling back to derivation only when there is no record", i.e. record-first;
resolve_known_workspace(dl.py:3691) is derived-first. But "Done when" only asks that paths "use the stored id in preference to a derived one", and the orders diverge only when devpod knows both ids for one triple — unreachable for a workspacedlcreated after this change. The warm-path claim holds: the record lookup is behindstate is not None, andtest_a_workspace_devpod_knows_builds_no_clone_managerasserts_CLONE_MANAGER_KEY not in dl._cache. One resolution feedsworkspace_idforstop,rm,up,restart,recreate,resetandattach_workspace(→ssh), covering the ticket's full list. - Deviation 2 (Docker volumes) — sound. The volume datum arrived as a comment; no "Done when" bullet covers it.
- Deviation 3 (#180 / no schema bump) — sound, verified.
SCHEMA_VERSION = 2unchanged,storage.pyuntouched;to_dictusesasdictso the field serializes, andfrom_dict_drop_unknownmeans older readers tolerate it. Written on both new clone and re-registration (workspace_clone.py:730), pinned by two tests. - Deviation 4 (no end-to-end recreate) — sound. The ticket itself: "That last step is the human's, on the host."
- Reconcile safety verified in code, not prose. No
devpod deleteand no deletion anywhere inreconcile_plan/apply_reconciliation; ties report both sides; the rebuild warning prints before the confirmation. (The claimed-clone guard's strength is the Standards axis's finding.) - Non-blocking: the ticket-thread breadcrumb promised "orphan + no clone → named, exit code says so";
reconcile_commandreturns 0 when there is nothing to adopt. The ticket body only requires "say so" — breadcrumb/implementation mismatch, not a spec miss. - Non-blocking: an orphan sourced deeper than a clone leaf joins on
source.nameand so can never match — it falls to the report, which is the safe arm. README version-skew section and CHANGELOG (### Added+### Fixed, both under[Unreleased]) match what is built.
Verdict
Request changes — two blocking findings, both on the reconcile safety invariants (Standards):
- Candidate-index collisions resolve last-write-wins; route key collisions to
Unadoptableso the docstring's "both directions" is true (dl.py:2506-2530). - Use
WorkspaceLocations.holder()(notset(locations.by_path)) for the claimed guard, and refuse to run whenlocations.unlocatableis non-empty, matching--prune's reasoning (dl.py:1847-1867).
Spec axis is fully clean. Non-blocking findings recorded above. GitHub refuses --request-changes from the PR author's account, so this is posted as a comment; this written verdict governs.
Two blocking findings from the two-axis review of #210, both on the safety invariant the command advertises: a clone a live workspace already opens is never taken from it. The candidate index was `candidates[key] = resolved` across `list_worktrees()`, and the legacy leaf spelling is known non-injective -- 5db5c42 records five verified preimages of `feature-auth` under the same regex `_legacy_leaf` uses. Two records claiming one key meant the winner was dict insertion order, so a workspace could be re-pointed at the wrong branch's clone with nothing said. The index now holds every clone a name answers to, and a name two clones answer to adopts neither and reports both, which is what the docstring's "in both directions" already claimed. The claimed-clone check keyed on the live workspace's *source* path, so `devpod up <clone>/subproject` left `<clone>` unclaimed and adoptable -- the mistake `WorkspaceLocations.holder()` was carved out to prevent ("Equality answered no and deleted the parent"). It uses `holder()` now. And `--reconcile` computed `locations` without ever looking at `unlocatable`, where `--prune` stops for the reason that applies here unchanged: a live workspace whose source cannot be followed could be holding any of the candidates. It stops too, with `--prune`'s report; `report_unlocatable` takes the command name and the outcome verb, since the sentence under them is the same sentence. Tests, each mutation-checked against its own fix reverted: two clones answering to one old name adopt neither and both are named; a live workspace sourced at `<clone>/subproject` keeps `<clone>`; an unfollowable live workspace stops the command with the report `--prune` gives. The two tests that asserted an unfollowable source was skipped now assert the refusal -- that premise was the finding.
Fix re-review (fresh context, commit ce08cce)Both blocking findings RESOLVED, verified by independent mutation runs: reverting the index to last-write-wins reddens exactly the collision test (and removing the same-record dedupe reddens 8); reverting holder() to equality reddens the subdirectory test; deleting the unlocatable guard reddens 3. The exit-0→1 inversion of the unfollowable-source tests is judged correct — an unfollowable live source could hold any candidate, symmetric with prune's reason for stopping. Verdict: clean — merging with a merge commit. |
Closes #88.
The id
dlhanded devpod was derived from(owner, repo, ref)on every command and written down nowhere, so the derivation was the only copy of it in existence. When #81 moved that derivation, every workspace created under the old one stopped being addressable in the same instant — 36 of 39 on the reporting host. Nothing was corrupted and nothing was deleted;dlbegan asking devpod about ids devpod had never been given, and devpod answered, correctly, that there were no such workspaces.Two changes, doing different jobs. One makes the failure impossible to repeat; the other repairs what already happened. Neither repairs the other's half.
1. The record gets written, and gets followed
WorktreeInfo.devpod_workspace_idhas been declared since the worktree backend was written and had zero writers. It is now written when a clone is prepared — on re-registration too, which is how a record from an older build acquires one with no migration step.Reading it is placed deliberately. A launch still derives an id and asks devpod about it first; the record is consulted only once devpod has denied the derived id. That keeps #145's warm attach path clear of the metadata lock, the parse and the id-scheme migration's version check — three things #145 removed from the path a user waits on — while paying for the lookup exactly in the case this ticket is about. The two orders differ only when devpod knows the derived id and a different recorded one for one triple, which after this change takes a workspace
dldid not create.A stored id devpod also denies is not used:
metadata.jsonis append-mostly, so a record naming a workspace deleted months ago is ordinary, and addressing it would substitute one absent workspace for another and lose the derived id a create needs.stop,rm,restart,recreate,resetand the attach all read the one resolution (resolve_known_workspace), so they were fixed together rather than one at a time.2.
dl --reconcileA new global command, and the three decisions the ticket asked to be made deliberately:
prune --repairand not automatic.--prunestates as its contract that it never touches a devpod workspace; repair is nothing but a devpod-workspace write, and folding it in would retract that promise for every existing--pruneuser to save a flag. Automatic-on-startup writes another tool's records unasked, which nothing else here does.workspace.jsondirectly. devpod v0.26.1 exposes no subcommand that changes an existing workspace's source — the surface isbuild,delete,list,logs,ssh,status,stop,upplus config subcommands, and the only one that sets a source is a create, which needs a container daemon and would destroy the record being repaired. Onlysource.localFolderis replaced; the file is rewritten from what devpod itself last wrote, souid, provider options and timestamps survive, and it goes through a temp file and a rename so a failure leaves the record whole.dlhas used for a clone directory (current hashed leaf, bare branch, pre-Derive workspace ids at one parse boundary #81 flattened branch). This is the same reasoning--prune's_site_ofalready runs on, reused rather than duplicated.It deletes nothing and guesses at nothing. A clone a live workspace already opens is never taken from it; a clone two dead records both match is claimed by neither; an orphan with nothing to adopt is named and left standing. Refusing costs a line in a report, guessing costs a workspace. The plan is printed and confirmed before anything is written,
-yskips the question, and the plan names thedl <workspace> recreatea repair costs before asking rather than after acting — the container was built with the dead path bind-mounted, and no record change moves a mount.Real output, from a scratch cache and a scratch devpod home:
How this lines up with #155
--prune'sDisputedarm — "#88's record-disagreement shape, never deletable" — is untouched and stays untouched.--prunekeeps refusing to guess which clone a misplaced workspace needs;--reconcileis the command that resolves the disagreement, after which those clones classify asReferencedin the ordinary way. The README's prune section now points at it.Tests
Persistence (
test/test_workspace_clone.py, 2 tests) — the record carries the iddlhands devpod, for a new clone and for a re-registered existing one.Addressing (
test/unit/test_stored_workspace_id.py, 8 tests) — the stored id wins over a derived id devpod denies; a record agreeing with the derivation changes nothing; a record with no stored id and a stored id devpod also denies both fall back to the derivation; and the warm path still builds no clone manager, which is #145's guard. The scheme change is simulated at the derivation — one syllable narrower, the change the format has genuinely undergone once — never written down as a literal id, and a guard test asserts the simulation really moves the id so the rest cannot pass vacuously.Reconciliation (
test/unit/test_reconcile_orphaned_workspaces.py, 11 tests) — adoption for a missing folder, for the config-only stub, and for a branch whose old directory name was flattened; the record learning the id; refusal to take a clone a live workspace opens, to break a tie between two orphans, or to touch a workspace outside the cache; the report-and-keep path with nodevpod deletereached; the confirmation gate; and a second run finding nothing to do. devpod's on-disk shape is written as devpod writes it, so the repair is asserted against devpod's format rather than against the test's idea of it.Mutation-checked, eight for eight. Each of these was applied and the named test went red, alone: dropping the id write (2 tests red), ignoring the record in resolution (2 red), letting a claimed clone be a candidate, resolving ambiguity by listing order, not writing the id back on adoption (2 red), forgetting the legacy flattened leaf, counting only missing folders and not the stub, leaving unadoptable orphans unnamed, and skipping the confirmation.
Boundaries declared rather than decided
docker rmwithout-v,DeleteVolumedefined with zero callers — not a record disagreement, and nothing in this ticket's "Done when" covers it. It wants its own ticket.dlsharing a reconciled cache derives the old directory name, misses it, clones a second directory, registers a second workspace and rewrites that branch's record with an empty id — undoing the repair for that one workspace. It is not destructive, the next--reconcilesorts it out, and the README says to upgrade or give the old build its ownXDG_CACHE_HOME.Not verifiable here, and the ticket says so
That a repaired workspace then recreates end to end needs a Docker daemon. Every claim above is from records on disk and from
devpod --helpon v0.26.1; the finaldl blooop/devlaunch recreateis the human's, on the host.pixi run cigreen — 1645 passed, pylint 10.00/10, exit 0.🤖 Generated with Claude Code
Summary by Sourcery
Persist the devpod workspace id in workspace records and add a CLI command to reconcile devpod workspace records with devlaunch clones after an id/naming scheme change.
New Features:
dl --reconcileto re-point orphaned devpod workspaces to the correct clone directories based on source paths and branch naming history.contextfield to workspace metadata to track the devpod context owning each workspace.Bug Fixes:
Enhancements:
devpod_home()helper and update config path resolution accordingly.resolve_known_workspaceso all workspace lifecycle commands use the same logic.Tests:
dl --reconcile, including adoption, refusal cases, idempotence, and user confirmation behaviour.