Skip to content

Decide: workspace-id collision & sanitization strategy #55

Description

@blooop

Question

Branches feature/x and feature-x currently collide: sanitize_workspace_id (dl.py:392, /→-) and _sanitize_branch_dir (workspace_clone.py:41, non-word→-) both flatten separators, so two distinct branches map to the same workspace id and the same clone directory.

Decide the fix strategy:

  • What non-colliding scheme replaces it (e.g. encode / distinctly, or hash-suffix on collision)? Devpod workspace ids have a 48-char limit and their own charset rules — the scheme has to fit (note the related truncation bug at dl.py:447-449: repo names ≥ 47 chars skip truncation entirely).
  • Migration: existing workspaces and clone dirs were created under the old scheme — rename, leave-and-tolerate-both, or document-and-break?
  • Does ref validation (_validate_ref, applied on only one of three paths) fold into the same change?

Consult /constructive-modeling — this is a data-shape decision (the id derivation is a parse boundary).

Activity

  1. blooop commented on Aug 7, 2026

    @blooop
    OwnerAuthor

    Retargeted by #53 (Rust rewrite: go, with a final Python release first). Still a decision ticket, still open — but under a go it matters more, not less, and it now has two consumers instead of one.

    The id-derivation scheme this ticket picks is a parse boundary, and it is the first data-shape decision devlaunch-core will encode. /constructive-modeling was already on this ticket; it is now the load-bearing reason the ticket exists, because the type-safety motive is what justified consuming devlaunch as a linked crate at all — wf will hold Workspace as a shared struct, so a workspace id that can collide is a defect that propagates into a second codebase.

    Two additions to the question:

    • The scheme must be decided once and encoded once. Whatever replaces sanitize_workspace_id (dl.py:392) and _sanitize_branch_dir (workspace_clone.py:41) has to be a single derivation the Rust core owns, not two flatten-separators implementations that happen to agree.
    • Migration now has a natural boundary. The final Python release is the last version reading the old scheme, so "rename / tolerate-both / document-and-break" can be answered against a known cut-over point rather than an open-ended one — and if the answer is tolerate-both, the core inherits that tolerance, so it needs deciding here rather than discovered there.
  2. self-assigned this
    on Aug 7, 2026
  3. blooop commented on Aug 7, 2026

    @blooop
    OwnerAuthor

    breadcrumb: Six defects confirmed by running the real code (not read off the source): non-injective id (feature/auth and feature-auth both → devlaunch-feature-auth); _sanitize_branch_dir has five verified preimages of feature-auth (/, -, space, +, :); the 48-char guard at dl.py:447-449 disables truncation when len(repo_name) >= 47, yielding an 80-char id; truncation is itself collision-generating (two branches sharing a 30-char prefix → byte-identical id); _ → - at dl.py:445 vs _ → deleted at dl.py:469; and _validate_ref guards 2 of 3 paths, missing get_workspace_path:78, structurally because it returns a naked str carrying no evidence.

    breadcrumb: Decision-grade external evidence — kinisi-robotics/kinisi_ros#9766 is a real incident caused by devlaunch's layout. A downstream get_instance_id read path components; because devlaunch checks out as <cache>/<org>/<repo>/<branch>, the parent names the repo, so every branch collapsed onto one instance id → shared container name, compose project and image tag. Three consequences for this ticket: (1) the failure mode is silently destructive, not just wrong-attach — the sim reaper keyed on the shared id and force-removed the other checkout's running sidecar, before any visible error; (2) an earlier cut of their fix derived the id from the origin remote and broke under git remote rename and under a launcher with no git on PATH — empirical confirmation that impure id derivation has already bitten in this stack, which rejects the stateful hash-on-collision option; (3) KINISI_HOSTNAME had no length cap and the real id already sat at 62/64 bytes — devlaunch's setup_hostname (dl.py:396) sets the hostname to the workspace id, and downstream stacks prefixes/suffixes on it, so 48 chars is a ceiling others eat into, not a budget to fill.

    breadcrumb: Root cause generalised: devlaunch's clone-dir leaf is unique only within its parent, so identity is spread across two path levels and any downstream tool keying on one component loses it. Scheme decided — <repo-slug>-<branch-slug>-<syl6> with a pronounceable-syllable suffix derived algorithmically from a hash of the unsanitized (owner, repo, ref) triple (no wordlist, so nothing has to stay byte-identical across the Python→Rust cut-over). Layout decided — the same self-identifying id names both the devpod workspace and the clone dir, so no single-component heuristic can collapse two branches again. This dissolves the two-derivations defect as a side effect.

  4. blooop commented on Aug 7, 2026

    @blooop
    OwnerAuthor

    Answer

    The defect is that every distinction lives in prose: spec_to_workspace_id(str) -> str, _validate_ref(str) -> str, _sanitize_branch_dir(str) -> str. Spec vs id, validated vs raw, within-budget vs not — none of it is in a type. Six defects follow from that, all confirmed by running the code, not by reading it.

    The six defects

    # Defect Verified
    1 Derivation is not injective blooop/devlaunch@feature/auth and @feature-auth both → devlaunch-feature-auth
    2 48-char invariant not enforced len(repo_name) >= 47 makes max_branch <= 0, so the guard 0 < max_branch at dl.py:447-449 skips truncation entirely — a 47-char repo yields an 80-char id
    3 Truncation is itself collision-generating two branches sharing a 30-char prefix → byte-identical id
    4 One rule, two derivations, disagreeing _ → - at dl.py:445, _ → deleted at dl.py:469: my_repo → my-repo vs myrepo
    5 Validate-don't-parse, and it already bit _validate_ref guards workspace_clone.py:99 and :278 but not get_workspace_path:78; it raises while _sanitize_branch_dir coerces
    6 Two identities in one record models.py:44-47 — workspace_id: str and devpod_workspace_id: Optional[str] with an unanswerable None

    _sanitize_branch_dir has five verified preimages of feature-auth: feature/auth, feature-auth, feature auth, feature+auth, feature:auth.

    Severity is higher than "wrong workspace"

    kinisi-robotics/kinisi_ros#9766 is a real incident caused by this layout. A downstream get_instance_id read path components; because devlaunch checks out as <cache>/<org>/<repo>/<branch>, the parent names the repo, so every branch collapsed onto one instance id — shared container name, compose project and image tag. The first symptom was silent destruction: the sim reaper keyed on the shared id and force-removed the other checkout's running sidecar. The docker name conflict came second. So finding #1's failure mode is destroying another workspace's state, not merely attaching to it.

    That PR also supplies two facts that shaped this answer:

    • An earlier cut of its fix derived the id from the origin remote, and broke under git remote rename and under a launcher with no git on PATH. Impure id derivation has already bitten in this exact stack — which is why the stateful hash-on-collision option is rejected below.
    • KINISI_HOSTNAME had no length cap and the real id already sat at 62/64 bytes. setup_hostname (dl.py:396) sets the container hostname to the workspace id, and downstream stacks prefixes and suffixes on it. 48 chars is a ceiling other tools eat into, not a budget to fill.

    The scheme

    <repo-slug>-<branch-slug>-<syl3>        target cap 38; hard ceiling 48 (devpod)
    
      repo    never truncated below 20 chars — it is the context that makes the id readable
      branch  absorbs all truncation; segment-aware (drop middle /-segments before truncating)
      syl3    6 chars: 3 consonant-vowel syllables, 18 bits of sha256 over the
              UNSANITIZED (owner, repo, ref) triple. Never truncated.
    

    Identity lives in syl3; legibility lives in the slug. Because only the slug is ever truncated, truncation can no longer cause a collision — that dissolves defect #3 and turns the truncation policy into a pure readability choice with no correctness risk.

    Encoding: 16 consonants (bdfghjklmnprstvz) × 4 vowels (aeio) = 64 combinations = exactly 6 bits per syllable. Pronounceable and memorable (zovomo, kifumo, leneve) with no wordlist — the two tables are 20 characters total, so cross-language agreement across the Python→Rust cut-over is a one-line test assertion rather than a 2048-word file that must not drift.

    Why the cap is 38, not 48. Tested against the real names in this repo (branches to 50 chars, repos to 16), a cap of 41 lands the kinisi hostname at exactly 64/64 — zero headroom, so one extra character upstream stops the container booting. 38 keeps 3 chars of margin.

    Rejected alternatives: reversible escape encoding (/→_) is injective but still collapses under truncation, so defect #3 survives; hash-on-collision needs a registry read, making derivation stateful and racy between concurrent dl invocations — the exact impurity that failed in #9766; a full hash is injective but destroys the stated goal that ids be meaningful in devpod list.

    Verified against real names — 12/12 unique, every repo name intact, max length 41 at the old cap:

    devlaunch-main-zovomo                      wayfinder-main-hesiro
    devlaunch-feature-auth-polise              devlaunch-feature-auth-nesata   <- #1 fixed
    devlaunch-dependabot-codecov-actio-sifiva  devlaunch-dependabot-prek-action-2-balono
    python-template-dependabot-lib-dep-tonifi  lifetime-foc-rig-dependabot-codeco-razota
    kinisi-ros-ags-devcontainer-toolin-leneve  devlaunch-fix-gh-auth-in-devcontai-pedove
    

    Segment-awareness is what makes the dependabot cases legible: naive truncation gives devlaunch-dependabot-github-actions-, which says nothing about which action; dropping the middle taxonomy segments gives devlaunch-dependabot-codecov-actio-.

    The layout: one self-identifying id, both places

    The same id names the devpod workspace and the clone-dir leaf. Today the leaf is a bare branch name, unique only within its parent — identity spread across two path levels, so any consumer keying on one component loses it. That is the trap that produced #9766, and it is not limited to custom scripts: <cache>/blooop/devlaunch/main and <cache>/blooop/wayfinder/main present the same folder name to the standard devcontainer CLI, VS Code, and anything else deriving names from the path — a cross-repo collision needing no slugification quirk at all.

    A self-identifying leaf makes every path-keyed consumer correct by construction. This also dissolves defect #4: one derivation, used twice, instead of two that disagree.

    The repo name stays in the leaf — it is the context that makes the id readable, and the syllables (hashed over the triple) already make the leaf globally unique without it. The cost is that a downstream rule which re-prefixes the repo name produces it twice; that is fixed on the consumer side, not by stripping context here. Recorded as a coordination note, not work on this map: #9766's "promote the clone dir, keep the repo name in front" rule should skip the prefix when the leaf already carries it.

    Ref validation folds in (defect #5)

    The new derivation is the parse boundary. _validate_ref(name: str) -> str returns a naked str carrying no evidence, indistinguishable from unvalidated input — which is structurally why get_workspace_path:78 was missed. Validate once on the way in and return a type that proves it, and the missing-guard class becomes unrepresentable. The raise (validate) vs coerce (sanitize) split in one module collapses to one response to bad input.

    Splitting this into its own ticket was considered and rejected: it touches the same functions and would conflict.

    Migration: rename the clones, orphan the containers

    Workspaces are git clones (workspace_clone.py:234), not worktrees — .git is a directory and the remote points at the .bare path, which does not move. So renaming a clone dir is a lossless mv, and any uncommitted work survives. That is the one thing here that is not cheaply recreatable, so it decides the strategy.

    • Clone dirs: the final Python release renames old-scheme dirs on sight, skipping .bare. (This repo's cache has 15+ such dirs under blooop/bencher alone: main, w1, asdf1, test-dotfix, …)
    • Devpod containers: take new ids; old ones are orphaned, with a one-line notice and a cleanup command — the precedent #9766 set for exactly this situation.
    • Tolerate-both was rejected: it requires carrying the colliding flatten forever, including into the Rust core, keeping the collision permanently representable — the opposite of the point. It would also leave old clone dirs non-self-identifying, so the #9766 trap stays open for them.

    Detection can be structural — a new-scheme leaf ends in - plus three consonant-vowel pairs — with the caveat that a branch literally named foo-bexoza false-positives. Whether metadata.json instead gains an explicit version field stays fog on the map, now sharpened by #56.

    Not fixed here

    Defect #6 (WorktreeInfo carrying both workspace_id and devpod_workspace_id: Optional[str]) is real but out of this ticket's scope — it is a record-shape question, and with one derivation there is no longer a reason for two fields. Left to the storage work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions