Skip to content

🤖 fix: give each Xum installation its own SSH plan namespace (#5174) - #5611

Merged
ThomasK33 merged 7 commits into
mainfrom
fix/plan-installation-scoped-migration
Oct 4, 2026
Merged

ThomasK33 merged 7 commits into
mainfrom
fix/plan-installation-scoped-migration

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

Summary

Each Xum installation now keeps its SSH and Coder plans in its own folder on the remote host. A clear or delete in one installation can no longer remove another installation's plan. This PR replaces #5469 and closes #5174.

Workspaces created before this change move their plan once, on first access:

  • Xum copies the workspace's own old plan file into the new folder.
  • Xum never imports the old shared plan file on its own (Option B, decided by the maintainer). The user imports it with an explicit action.

Background

Before this change, every installation that used one SSH host wrote plans to the same path, ~/.mux/plans/<project>/<name>.md. Two installations with the same project and workspace name used one file. A clear in one deleted the other's live plan (#5174).

#5469 fixed the path. It kept the old shared file as a fallback that every plan reader could adopt. That fallback needed special handling in each plan consumer, and nine Codex review rounds kept finding new cases. The last finding: after a reset of the installation ID, a read adopted another installation's stale shared plan. This PR removes the fallback. Migration happens once, in one function.

Persisted-state contract

State Where Contract
Installation ID installation_id file in the data root (~/.xum by default) A random UUID, created once (exclusive create, fsync, then read back). If the file is not a valid UUID, Xum refuses SSH plan reads, writes and deletes until the user restores or deletes the file. One data root per ID: a moved root keeps it, but a copy that stays usable must get a new ID before it opens an SSH or Coder workspace (docs give the steps). Xum does not detect copies.
New plan path SSH host: ~/.mux/plans/installation-<uuid>/<project-id>/<name>.md The only path that Xum writes or deletes as an SSH workspace's plan.
remotePlanMigrated One flag per workspace row in config.json Set once and never cleared. Every new or forked row starts with it set. A migration sets it after the copied plan is on disk durably. A full clear or a removal sets it before Xum deletes the plan. Once it is set, Xum reads only the new path, also when that file is missing and after an ID reset. Older builds keep the field. An invalid value counts as set.
Old plan files SSH host: plans/<id>.md (this workspace's own) and plans/<project>/<name>.md (shared) Xum never moves, writes or deletes them.

How the migration works

resolvePlanFileLocation (planLocation.ts) is the single resolver that every plan consumer uses (reads, attachments, snapshots, rename, fork, clear, removal, turns). For a row without the flag, it migrates once, under a lock for that row, and checks the flag again inside the lock:

  1. If a plan already exists at the new path, Xum keeps it.
  2. Otherwise, if plans/<id>.md exists, Xum copies it in this order: temp file, fsync, hard link, fsync the folders. Then Xum sets the flag.
  3. Otherwise, if only the shared file exists, Xum copies nothing and leaves the flag unset (Option B). The workspace then offers the import.
  4. If there is no old plan, Xum sets the flag.

Explicit import (Option B): workspace.getImportableLegacyPlan returns the shared file's path, but only for an older row whose only old plan is that file. workspace.importLegacyPlan runs the same migration, with the shared file allowed as the source. It never replaces an existing plan, so a second import returns already_present. It never deletes the shared file. After a full clear there is nothing to import.

UI: a self-gating notice above the chat input (after an import, the Context tab fetches again, so it shows the plan), LegacyPlanImportBanner. It shows the file's path, warns that other installations can own that file, and has an Import plan button. The command palette has the same action, Import plan from an older Xum, for keyboard use. I chose the notice over a Context tab row because the right sidebar is hidden at phone widths (768 px and below).

Validation

  • Formal model: formal/plan-storage/PlanMigration.tla, checked by check.sh.
    • MC_mig_reported reproduces 🤖 fix: give each Xum installation its own SSH plan namespace #5469's last finding. Its fixed twin passes.
    • The full scenario and the downgrade scenario pass all 9 invariants. The full scenario covers concurrent reads, a user import twice, a clear, a write by another installation, an ID reset, a crash between any two writes and a host power cut. The downgrade scenario adds an older build that edits the shared file.
    • The model states the unique-identity assumption. The full scenario includes a copy of an older root with a new ID. MC_mig_boundary_shared_uuid documents the boundary: a copy that keeps the ID restores a cleared plan (NoResurrection fails there, as expected).
    • The model catches 10 deliberate bugs. Two of them are Option A (import the shared file automatically) and an import that overwrites an existing plan. check.sh exits 0.
  • Tests:
    • The F6 (🤖 fix: two Xum installations on one SSH host can share or delete each other's plan files #5174) test in workspaceService.planStorageFormalRepro.test.ts is an ordinary passing test now. It was an expected-failure test before.
    • workspaceService.remotePlanNamespace.test.ts has 23 tests on a real shell (a local runtime stands in for the SSH host). They cover the reported sequence, the ID-file-first rule, recovery after a failed copy or flag write, a clear racing a migration, removal, downgrade edits, every consumer, and import, repeat import, overwrite refusal and import after a clear.
    • LegacyPlanImportBanner.test.tsx: one import at a time (a button click and the palette action), errors, and the palette action is removed when the notice goes.
  • Dogfood: I ran a dev-server against a Docker sshd. I created an SSH workspace, cleared its flag in config.json to simulate an older row, and put a plan at the shared path on the host. The notice appeared. Importing through the command palette copied the plan into installation-<uuid>/…. The shared file stayed in place, the Context tab showed "Plan file", and a second import returned already_present.

Notice, desktop
Notice at 375 px
After import, Context tab shows the plan file

Risks

  • An ID reset hides plans. Deleting the installation_id file starts an empty folder. Plans under the old ID stay on the host but unused. The plan-mode docs explain how to recover them.
  • Downgrade. An older build reads only the old paths and ignores the newer copy. The docs explain how to copy the plan back before you downgrade. Edits that an older build makes are not imported when you upgrade again.
  • Old shared-only workspaces have no plan until imported. This is intended (Option B). The notice explains it.
  • Rename of a shared-only row is refused. The shared file is named after the workspace, so Xum refuses the rename until the user imports that plan or writes a new one. The error message names the file.
  • A rename made while downgraded. An older build renames the workspace but cannot move the newer copy. After the upgrade, Xum looks under the new name. The plan stays on the host, and the docs explain how to rename the file (known limitation, review round 2).
  • Child workspaces and unmigrated parents. A parent workspace from before 🤖 fix: two Xum installations on one SSH host can share or delete each other's plan files #5174 that has not migrated yet is left out of a child's list of parent plan paths until its own next plan access migrates it. A turn never lists a legacy path.
  • A copied root that keeps its ID. This is outside the contract. Both roots share one tree, so one can restore a plan that the other cleared, or delete a plan that the other still shows. Xum does not detect copies, because path or host fingerprints would misclassify moves. The docs require a new ID for a copy and give verified steps (review round 4, contract reduction).
  • Removal is best effort, as before. If the flag write times out during a removal, the removal still goes on (🤖 plan storage: removal goes on when its legacy-plan fence times out (#5469 follow-up) #5492, still open).

Follow-ups

Supersedes #5469.


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $77.03

@mintlify

mintlify Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
Mux 🟢 Ready View Preview Oct 4, 2026, 11:44 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-04T11:45:37.636599Z 2a31d4f Manual request
🔒 Security Review ✅ Completed 2026-10-04T11:48:24.836908Z 2a31d4f Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6dc67b0ac4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/node/services/workspaceService.ts Outdated
Comment thread .github/workflows/formal.yml

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8e292d160a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/common/utils/planStorage.ts
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

Round 2 is resolved: the downgrade-rename case is now documented as a known limitation (reply on the thread). Please review the current head.

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 807023c1d4

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 807023c1d4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/node/services/turnContextAssembler.ts
Comment thread src/node/utils/runtime/planLocation.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3f1f186d01

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/node/utils/runtime/planLocation.ts
Comment thread src/node/utils/runtime/planLocation.ts
Comment thread src/node/config/installationIdentity.ts
@ThomasK33

Copy link
Copy Markdown
Member Author

Review loop stopped. This PR is not ready to merge.

Head: 3f1f186d01. Stage: round 4 of the review budget (blockers only).

Blocker: #5611 (comment)
A data root copied before the upgrade keeps the installation UUID, and both copies start with the migration flag unset. If copy B clears a workspace, only B's config records it. Copy A can later migrate and copy the preserved legacy ID plan into the shared scoped path. The plan comes back after the clear. No concurrent backends are needed. The formal model has one flag and one lock, so it does not cover two configs that share one remote namespace.

The fix needs a decision:

  1. Option A (recommended if copying a root must keep its UUID and plans): retirement state shared on the remote host. This needs a design pass first: write order, interrupted migration, clear versus migration, and a model with two local configs on one namespace.
  2. Option B: reduce the contract. Independent copies become separate installations (reset the UUID on the copy). UUID preservation stays only for a move where the source stops running. This is not a fix for copies that already exist.

Required CI fails only because the unresolved thread fails Codex Comments. I will not request more reviews, push fixes, or merge until a new explicit go-ahead arrives.

… SSH plans (#5174)

Design pass for a reduced replacement of #5469. PlanMigration.tla models a single
migration point (location resolution): under the row's lock, re-check the flag, copy
the id plan (else the shared basename plan) into the scoped namespace unless a scoped
plan exists (temp, fsync, link, fsync dir), then persist the row flag whatever the
source was. Reads after that see the scoped namespace only.

check.sh runs the new MC_mig_* configs against PlanMigration.tla. The #5469 r9 finding
(MC_mig_reported) violates NoReactivation, NoForeignAfterId and NoLegacyTouch; the
reduced design holds under concurrent reads, clear, foreign writes, identity reset,
a restart between any two writes, a power cut, and downgrade/upgrade. Eight mutants
stay caught. Full check.sh: 174 runs, all as expected.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_
SSH and Coder plans live under ~/.mux/plans/installation-<uuid>/<project id>/<name>.md;
the UUID is created once in the data root (fail closed when unusable). Older rows migrate
once in resolvePlanFileLocation: their own plans/<id>.md plan is copied (temp, fsync, link,
fsync dir), then the monotone row flag remotePlanMigrated is set; afterwards only the scoped
path is read. Option B: the shared pre-#5174 plan is never imported automatically; the user
imports it with workspace.importLegacyPlan (notice above the chat input, or the command
palette), which never replaces an existing plan and never moves or deletes the legacy file.

Supersedes #5469.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_
…t tab after an import; run the model on config changes
…hared plan alone; list only migrated SSH ancestors
…; model the shared-ID boundary

A copy of a data root that stays usable must get a fresh installation ID before it
accesses remote plans: the migration flag and lock are local to a root, so two roots
with one ID can restore each other's cleared plans. Xum does not detect copies.

- docs: the contract, and verified steps that give a copy its own ID and copy the
  old plan tree on the host without replacing files (cp -Rn src/. dst/ copies nothing
  on BusyBox, so the step copies only into a tree that does not exist yet).
- formal: state the unique-identity assumption; actor k (a copy of a pre-upgrade root)
  with CopyIdentity fresh in MC_mig_full; MC_mig_boundary_shared_uuid documents the
  shared-ID counterexample (NoResurrection fails).
@ThomasK33
ThomasK33 force-pushed the fix/plan-installation-scoped-migration branch from 3f1f186 to 8dc11f9 Compare October 4, 2026 11:05
@ThomasK33

Copy link
Copy Markdown
Member Author

Dogfood: two data roots with their own IDs (contract reduction for the copied-root finding)

Setup: Alpine sshd in Docker (BusyBox cp), make dev-server on this branch, SSH workspaces in one project. Installation A is the original root. Installation B is a copy of A made after the copy contract was applied. Both roots were stopped while the copy was made.

  • feat-mig was created by this build, so its row is migrated. Its plan is in A's tree. I wrote it on the host, because no model ran in this sandbox.
  • feat-old simulates a row from before the upgrade: I removed remotePlanMigrated from A's config.json and put its plan at ~/.mux/plans/<workspace-id>.md.

Steps:

  1. Copy rootA to rootB. On B, run steps 2 to 4 of the new docs section "Give a copied data root its own ID" verbatim (the step-4 snippet was extracted from docs/agents/plan-mode.mdx and run with sh -s on the host).
  2. Start A. /plan shows both plans (A's feat-old migrates from the old file). /clear on feat-mig deletes only A's copy.
  3. Start B. /plan on feat-mig still shows the plan: A's clear did not touch B's tree. feat-old migrates into B's tree from the old file. /clear on feat-old deletes only B's copy.
  4. Start A again. feat-old still shows its plan: B's clear did not touch A's tree. feat-mig still has no plan: nothing came back.

The old file ~/.mux/plans/<workspace-id>.md is still on the host and unchanged at the end.

I verified the recovery procedure and found a bug in my first draft. The first draft used cp -Rn old/. new/. BusyBox cp copied nothing and exited 0. The docs now use cp -Rp old new only when the new tree does not exist yet. That form never writes into an existing tree. I also tested it with GNU cp: a second run left an edited file unchanged, and a missing old tree created nothing.

A: feat-mig before A: feat-mig after /clear B: feat-mig after A's clear
A feat-mig before A feat-mig after clear B feat-mig unchanged
B: feat-old migrated B: feat-old after /clear A: feat-old after B's clear A: feat-mig still cleared
B feat-old migrated B feat-old after clear A feat-old unchanged A feat-mig still cleared
Terminal evidence (host file listings and API reads)
== Copy B: recovery steps 2-4 from the docs, run verbatim (Xum stopped on both roots) ==
$ cat rootB/installation_id
68576783-9ff4-4b49-9cac-dc801d586d9a
$ uuidgen > rootB/installation_id && cat rootB/installation_id
6e60d35e-828c-4af5-83b1-dd467bbb07ca
$ ssh host sh -s <<step 4 (Alpine, BusyBox cp)
  old=68576783-9ff4-4b49-9cac-dc801d586d9a; new=6e60d35e-828c-4af5-83b1-dd467bbb07ca; plans=~/.mux/plans
  if [ -d "$plans/installation-$old" ] && [ ! -e "$plans/installation-$new" ]; then
    cp -Rp "$plans/installation-$old" "$plans/installation-$new"
  fi
rc=0
$ ssh host find ~/.mux/plans -type f | sort
/home/testuser/.mux/plans/b25d31d3a4.md
/home/testuser/.mux/plans/installation-68576783-9ff4-4b49-9cac-dc801d586d9a/repo-556e6f7b1363/feat-mig.md
/home/testuser/.mux/plans/installation-6e60d35e-828c-4af5-83b1-dd467bbb07ca/repo-556e6f7b1363/feat-mig.md

== Installation A: /clear on feat-mig (UI), then the host ==
$ ssh host find ~/.mux/plans -type f | sort
/home/testuser/.mux/plans/b25d31d3a4.md
/home/testuser/.mux/plans/installation-68576783-9ff4-4b49-9cac-dc801d586d9a/repo-556e6f7b1363/feat-old.md
/home/testuser/.mux/plans/installation-6e60d35e-828c-4af5-83b1-dd467bbb07ca/repo-556e6f7b1363/feat-mig.md

== Installation B (fresh ID) started: plan reads (API) ==
{"success":true,"data":{"content":"# feat-mig plan\n\nWritten by installation A after the upgrade.\n","path":"/home/testuser/.mux/plans/installation-6e60d35e-828c-4af5-83b1-dd467bbb07ca/repo-556e6f7b1363/feat-mig.md"}}
{"success":true,"data":{"content":"# feat-old plan\n\nWritten by an older Xum before the upgrade.\n","path":"/home/testuser/.mux/plans/installation-6e60d35e-828c-4af5-83b1-dd467bbb07ca/repo-556e6f7b1363/feat-old.md"}}
$ ssh host find ~/.mux/plans -type f | sort
/home/testuser/.mux/plans/b25d31d3a4.md
/home/testuser/.mux/plans/installation-68576783-9ff4-4b49-9cac-dc801d586d9a/repo-556e6f7b1363/feat-old.md
/home/testuser/.mux/plans/installation-6e60d35e-828c-4af5-83b1-dd467bbb07ca/repo-556e6f7b1363/feat-mig.md
/home/testuser/.mux/plans/installation-6e60d35e-828c-4af5-83b1-dd467bbb07ca/repo-556e6f7b1363/feat-old.md

== Installation B: /clear on feat-old (UI), then the host ==
{"success":false,"error":"Plan file not found at /home/testuser/.mux/plans/installation-6e60d35e-828c-4af5-83b1-dd467bbb07ca/repo-556e6f7b1363/feat-old.md"}
$ ssh host find ~/.mux/plans -type f | sort
/home/testuser/.mux/plans/b25d31d3a4.md
/home/testuser/.mux/plans/installation-68576783-9ff4-4b49-9cac-dc801d586d9a/repo-556e6f7b1363/feat-old.md
/home/testuser/.mux/plans/installation-6e60d35e-828c-4af5-83b1-dd467bbb07ca/repo-556e6f7b1363/feat-mig.md

== Installation A started again: plan reads (API) ==
{"success":false,"error":"Plan file not found at /home/testuser/.mux/plans/installation-68576783-9ff4-4b49-9cac-dc801d586d9a/repo-556e6f7b1363/feat-mig.md"}
{"success":true,"data":{"content":"# feat-old plan\n\nWritten by an older Xum before the upgrade.\n","path":"/home/testuser/.mux/plans/installation-68576783-9ff4-4b49-9cac-dc801d586d9a/repo-556e6f7b1363/feat-old.md"}}
$ ssh host sh -c "find ~/.mux/plans -type f | sort; cat each"
--- /home/testuser/.mux/plans/b25d31d3a4.md
# feat-old plan

Written by an older Xum before the upgrade.
--- /home/testuser/.mux/plans/installation-68576783-9ff4-4b49-9cac-dc801d586d9a/repo-556e6f7b1363/feat-old.md
# feat-old plan

Written by an older Xum before the upgrade.
--- /home/testuser/.mux/plans/installation-6e60d35e-828c-4af5-83b1-dd467bbb07ca/repo-556e6f7b1363/feat-mig.md
# feat-mig plan

Written by installation A after the upgrade.

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

Round 5 (blockers only). The copied-root finding is addressed as a contract reduction on head 8dc11f9: one data root per installation ID, verified recovery steps in the docs, and the shared-ID case kept as a documented model boundary. Please review the current head.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 8dc11f91a8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 8dc11f91a8

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

macOS uuidgen prints uppercase, and Xum uses the lowercase form in remote plan
paths, so step 4 copied the tree to a name Xum never reads. Step 3 now writes
the ID in lowercase, and step 4 lowercases both pasted IDs.
@ThomasK33

Copy link
Copy Markdown
Member Author

Docs fix on 2a31d4f: lowercase the new ID in the copied-root steps

An independent pre-merge review found a defect in the round-5 docs. On macOS, uuidgen prints the ID in uppercase. Xum lowercases the ID before it builds remote plan paths. If a user pasted the uppercase ID into step 4, the plan tree was copied to installation-ABCD…, but Xum reads installation-abcd…. Workspaces that had already migrated then showed no plan in the copy. No file was lost.

The fix:

  • Step 3 now writes the ID in lowercase: uuidgen | tr 'A-Z' 'a-z'.
  • Step 4 lowercases both pasted IDs, so a pasted uppercase ID also works.

I verified the fix on the Docker sshd host:

  1. I made copy C of root A and produced an uppercase ID to stand in for macOS uuidgen output.
  2. I ran steps 3 and 4 with the uppercase ID pasted into step 4.
  3. Xum on C read feat-old from installation-<lowercase id>.
  4. A's tree and the old ID file stayed unchanged.

The first attempt failed for an unrelated reason. Restarting the container wiped the host, so the workspace checkout directory was missing and the read failed. I recreated the host state and ran it again. The log below shows both attempts.

Copy C reads the copied plan

Terminal evidence
== Copy C: macOS-style uppercase uuidgen output, docs steps 2-4 ==
$ cat rootC/installation_id
68576783-9ff4-4b49-9cac-dc801d586d9a
simulated macOS uuidgen output: 52BA2B95-3F72-46D0-B9C9-4B59D508296F
$ echo 52BA2B95-3F72-46D0-B9C9-4B59D508296F | tr 'A-Z' 'a-z' > rootC/installation_id
52ba2b95-3f72-46d0-b9c9-4b59d508296f
$ ssh host sh -s <<step 4 with the uppercase ID pasted
  old=$(printf %s '68576783-9ff4-4b49-9cac-dc801d586d9a' | tr 'A-Z' 'a-z'); new=$(printf %s '52BA2B95-3F72-46D0-B9C9-4B59D508296F' | tr 'A-Z' 'a-z')
  plans=~/.mux/plans
  if [ -d "$plans/installation-$old" ] && [ ! -e "$plans/installation-$new" ]; then
    cp -Rp "$plans/installation-$old" "$plans/installation-$new"
  fi
rc=0
$ ssh host find ~/.mux/plans -type f | sort
/home/testuser/.mux/plans/b25d31d3a4.md
/home/testuser/.mux/plans/installation-52ba2b95-3f72-46d0-b9c9-4b59d508296f/repo-556e6f7b1363/feat-old.md
/home/testuser/.mux/plans/installation-68576783-9ff4-4b49-9cac-dc801d586d9a/repo-556e6f7b1363/feat-old.md

== Installation C started: plan read (API) ==
{"success":false,"error":"Plan file not found at /home/testuser/.mux/plans/installation-52ba2b95-3f72-46d0-b9c9-4b59d508296f/repo-556e6f7b1363/feat-old.md"}
$ ssh host: cat every plan file
--- /home/testuser/.mux/plans/b25d31d3a4.md
# feat-old plan

Written by an older Xum before the upgrade.
--- /home/testuser/.mux/plans/installation-52ba2b95-3f72-46d0-b9c9-4b59d508296f/repo-556e6f7b1363/feat-old.md
# feat-old plan

Written by an older Xum before the upgrade.
--- /home/testuser/.mux/plans/installation-68576783-9ff4-4b49-9cac-dc801d586d9a/repo-556e6f7b1363/feat-old.md
# feat-old plan

Written by an older Xum before the upgrade.

== Rerun after recreating the host (the container restart wiped it; the first C read failed with cd: /home/testuser/xum: No such file or directory) ==
rc=0
/home/testuser/.mux/plans/b25d31d3a4.md
/home/testuser/.mux/plans/installation-52ba2b95-3f72-46d0-b9c9-4b59d508296f/repo-556e6f7b1363/feat-old.md
/home/testuser/.mux/plans/installation-68576783-9ff4-4b49-9cac-dc801d586d9a/repo-556e6f7b1363/feat-old.md
== Installation C started: plan read (API) ==
{"success":true,"data":{"content":"# feat-old plan\n\nWritten by an older Xum before the upgrade.\n","path":"/home/testuser/.mux/plans/installation-52ba2b95-3f72-46d0-b9c9-4b59d508296f/repo-556e6f7b1363/feat-old.md"}}
$ ssh host: cat every plan file
--- /home/testuser/.mux/plans/b25d31d3a4.md
# feat-old plan

Written by an older Xum before the upgrade.
--- /home/testuser/.mux/plans/installation-52ba2b95-3f72-46d0-b9c9-4b59d508296f/repo-556e6f7b1363/feat-old.md
# feat-old plan

Written by an older Xum before the upgrade.
--- /home/testuser/.mux/plans/installation-68576783-9ff4-4b49-9cac-dc801d586d9a/repo-556e6f7b1363/feat-old.md
# feat-old plan

Written by an older Xum before the upgrade.

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

Round 6 (the last; blockers only). Head 2a31d4f changes only the docs since the approved 8dc11f9: the copied-root steps now lowercase the new installation ID (macOS uuidgen prints uppercase). Please review the current head.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: 2a31d4f7ae

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 2a31d4f7ae

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@ThomasK33
ThomasK33 added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 235980f Oct 4, 2026
53 checks passed
@ThomasK33
ThomasK33 deleted the fix/plan-installation-scoped-migration branch October 4, 2026 12:29
yermakoffivan pushed a commit to yermakoffivan/mux that referenced this pull request Oct 4, 2026
coder#5621)

## Summary

Three fixes from the coder#5611 review (round 4), tracked in coder#5620:

1. **A legacy plan path that cannot be checked no longer counts as
missing.** The one-time SSH plan migration now treats a legacy plan as
absent only when Xum can confirm it. If a folder on the way cannot be
searched, the migration fails and leaves the row unmigrated, and the
next access retries. Before, Xum marked the row migrated and never
offered that plan again.
2. **The import notice settles after `nothing_to_import`.** When the
shared file vanished after the offer, the notice kept its **Import
plan** button and palette action, which could only fail again. Now the
notice keeps the reason and drops both.
3. **The installation-identity temp file is removed when its write
fails.** An outer `finally` removes `tempPath` (for example after
ENOSPC).

Refs coder#5620. Item 4 (a rename made while downgraded) needs a new remote
layout keyed by workspace ID. It stays open in backlog.

## Implementation

- `migrateRemotePlanScript` (`planLocation.ts`): the new shell function
`known` accepts a path that exists, or a path whose nearest existing
ancestor is a searchable directory. Only then did the lookup really find
nothing. Anything else exits with the new code `unverified` (7).
`migrateRemotePlan` turns that code into a `RemotePlanMigrationError`,
so plan reads, the import offer and clears fail closed, as they already
did for a failed copy. The check runs only where the answer matters: on
the id plan when Xum would fall back to the shared path, and on the
shared path when Xum would record "no legacy plan".
- `LegacyPlanImportNotice`: a `vanished` state hides the status text and
the button, and it unregisters the palette source. The alert stays
visible. The notice goes away on the next mount, when the backend no
longer offers the path.
- `loadOrCreateInstallationId`: the temp file's open, write, sync and
link run inside one `try`, and its `finally` removes the temp file.

I checked `known` with dash and bash: an unsearchable folder exits 7, a
missing file or a missing ancestor is accepted, and an existing path is
accepted.

## Validation

Pre-fix failures (the new tests on main at 235980f):

```
Expected: "refused"
Received: "offered null"
(fail) SSH plans are installation-scoped (coder#5174) > a legacy path that cannot be checked fails closed, and its plan is offered once it can be
- []
+ [
+   ".installation_id.62d4a7a0-cc3d-48bc-94a4-c341d548c107.tmp",
+ ]
(fail) installation identity creation failures (coder#5620) > a failed write of the new identity leaves no temp file in the data root
Received: HTMLButtonElement {
(fail) LegacyPlanImportNotice (coder#5174) > an import that finds nothing settles the offer: the reason stays, the actions go
 0 pass
 3 fail
```

After the fix, all three pass, together with the rest of
`workspaceService.remotePlanNamespace.test.ts`,
`installationIdentity.test.ts`, `LegacyPlanImportBanner.test.tsx` and
`src/node/utils/runtime/`. `formal/plan-storage/check.sh` passes, and
this PR does not change `formal/`. The new Storybook story
`NothingToImport` has a play that clicks **Import plan** and checks the
alert and the missing button.

Storybook, before the click (the offer) and after an import that found
nothing:

![Offer,
desktop](https://github.com/user-attachments/assets/a36fcd90-8a0a-422c-b888-f035d158de63)
![After nothing_to_import,
desktop](https://github.com/user-attachments/assets/87881cce-a807-43bb-8a19-cc1004e88cc4)
![Offer, 390
px](https://github.com/user-attachments/assets/33835b18-266d-4329-95f6-f080e1b59d12)
![After nothing_to_import, 390
px](https://github.com/user-attachments/assets/5eb93e25-4aa2-452f-9bbd-d823d92c2bbe)

## Risks

Low. Item 1 can turn a host with odd permissions on `~/.mux/plans` from
"no plan" into a plan read error until the permissions are fixed. That
is the fail-closed direction the issue asks for, and the error says what
Xum could not check.

---

_Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking:
`high`_

<!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high -->
yermakoffivan pushed a commit to yermakoffivan/mux that referenced this pull request Oct 4, 2026
…oder#5479) (coder#5622)

## Summary

A full clear and a rename of the same workspace no longer leave a
cleared workspace with its plan. In one backend they now exclude each
other. A clear refuses while that workspace is being renamed. A rename
refuses while a clear deletes that workspace's plan. Both refusals
commit nothing and keep the plan, and the user can retry.

Refs coder#5479 (item 2). Items 1 (ssh_config `HostName` aliases) and 3 (a
one-time test failure) stay parked.

## Background

`deletePlanFilesForWorkspace` reads the workspace's name with `getInfo`
and deletes the plan path derived from that name several awaits later.
coder#5611 added three awaits there: the installation identity, the SSH
migration flag and the plan location. A rename in that window moves the
plan to the new name. The clear then deletes the old, missing path and
commits, and the plan stays with the cleared workspace. A clear that
starts after the rename's config write but before its plan move has the
same result: it deletes the new, still missing path, and the move then
brings the plan back.

## Implementation

- `deletePlanFilesForWorkspace` refuses while `renamingWorkspaces` holds
the workspace. Otherwise it counts itself in `planDeletionsInFlight` (a
count, because two clears can overlap) for the whole deletion. The body
moved unchanged to `deletePlanFilesForWorkspaceWhileNotRenaming`.
- `rename()` refuses while `planDeletionsInFlight` holds the workspace.
It checks this right before it sets its renaming flag, in the same
synchronous step.
- Only the rename that set the renaming flag clears it (review round 1).
Before, an overlapping second rename that exited early deleted the first
rename's flag. That ended the exclusion of streams and clears while the
first rename still ran. A rename that finds the flag already set now
refuses at once.
- I updated the stale comment that called `getInfo` the last await
before the delete.
- Not covered: a rename in another backend on the same data root. Both
sets are in-process.

## Validation

Three repros in `workspaceService.planStorageFormalRepro.test.ts`, with
real local workspaces and real plan files:

1. A rename runs inside the clear's `getInfo`, after it read the old
name.
2. A clear runs inside the rename's `movePlanFile`, after the rename
registered the new name.
3. Review round 1: inside the first rename's `movePlanFile`, a second
rename is refused, and then a clear runs. Before that fix the clear
committed (`Received: "cleared"`).

Pre-fix (main at 235980f):

```
-   "new": undefined,
+   "new":
+ "# The plan
(fail) ... > a rename that lands while the clear deletes the plan does not carry the plan past it
Expected to contain: "renamed"
Received: "cleared"
(fail) ... > a clear while a rename moves the plan does not commit without deleting it
 0 pass
 2 fail
```

After the fix, both pass, with the rename, truncateHistory, history,
removePlanFiles, planNamespace, remotePlanNamespace and workspaceService
suites (230 tests). `formal/plan-storage/check.sh` passes. The model
does not cover a rename racing a clear of the same workspace, so
`formal/` is unchanged.

## Risks

Low. The only new behavior is a refusal when a clear and a rename of one
workspace overlap in one backend. Both are user actions that take
seconds at most.

---

_Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking:
`high`_

<!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high -->
yermakoffivan pushed a commit to yermakoffivan/mux that referenced this pull request Oct 4, 2026
…oder#5462) (coder#5623)

## Summary

`PlanStorage.tla` now models the removal guard and the clear guard as
separate steps from their deletes, and it adds a create that races each
of them. The model now shows the window that coder#5462 item 2 describes, and
it confirms that the create re-check from coder#5468 closes it. This PR
changes only `formal/plan-storage`.

Fixes coder#5462. Item 1 was done by coder#5468. Item 3 is done on main by coder#5611,
as shown in the triage comment on the issue.

## Background

The code reads the workspace registry (the sharing guard) and deletes
the plan in separate awaits, both in removal
(`deletePlanFilesOfRemovedWorkspace`) and in a full clear
(`deletePlanFilesForWorkspace`). The model treated guard and delete as
one atomic step, so it could not show a row that joins between them. It
also deregistered before the delete, which is the order before coder#5019.
The code deletes before it deregisters, so the name stays taken during
the delete.

## Model changes

- **Removal:** three sub-steps in the code's order: `guard` (records
whether a visible row shares the path), `del` (keeps the path when the
guard saw one), `dereg`. A new mutant, `removeDeregFirst`, uses the
order before coder#5019.
- **Clear:** with `ClearGuard`, two sub-steps, `guard` and `do`. Without
the guard it stays one step, as the code was before coder#5467.
- **New variable `keep`:** the guard's result, which the delete uses
later.
- **New scenarios:** `remove_race` (b creates the name and removes it
while a creates the same name and writes a plan) and `clear_race` (the
same with a guarded clear).
- The header cites the current code (`workspaceService.ts` at
235980f).

| Config | Fixes / mutant | Expected violations |
|---|---|---|
| `MC_remove_race` | none | UniqueOwner, NoForeignClobber |
| `MC_remove_race_fixed` | createRecheck (the code since coder#5468) | none |
| `MC_mut_remove_deregfirst` | createRecheck + removeDeregFirst |
NoForeignClobber |
| `MC_clear_race` | clearGuard | UniqueOwner, NoForeignClobber |
| `MC_clear_race_fixed` | clearGuard + createRecheck (the code since
coder#5467/coder#5468) | none |

The shortest counterexample for `MC_remove_race` NoForeignClobber is the
window from the issue: b and a pass their name preflights, b registers,
b's guard sees no other row, a registers (no re-check), a writes its
plan, and b's delete removes it.

## Validation

- `formal/plan-storage/check.sh` (all configs, PlanMigration included)
exits 0 on this head. Every existing config keeps its EXPECT verdict,
including the pre-fix finding configs.
- **The split is what exposes the window.** In a scratch copy, I gave
each delete the guard result from the same step, which is the old atomic
model. Then `NoForeignClobber` holds in both `MC_remove_race` and
`MC_clear_race`, and only the create race's `UniqueOwner` remains.

---

_Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking:
`high`_

<!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high -->

This branch was successfully deployed

1 active deployment
staging - docs — 2a31d4f7 Deployed Oct 4, 2026 by mintlify[bot]
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.

🤖 fix: two Xum installations on one SSH host can share or delete each other's plan files

1 participant