Decided on #400, which established there is no live race — read the resolution comment there before starting, because it is what keeps this slice from being mis-sold. This is a maintainability and modelling change. Nothing here closes a bug, and the PR must not claim to.
Why
MetadataStorage offers get_* and add_* and no update (domain/metadata.rs:498, :461), so a caller with one field to change has no move but read-modify-write across the seam: the record is cloned before the lock exists, and add_* inserts it wholesale over the map exclusive freshly reloaded (:562). Two production sites do that today.
That it costs nothing today is an accident of which fields have readers, and the argument runs through six files. #400 §4 has it in full; the short version is that the only concurrently-writable field of BaseRepository is worktrees, which nothing reads, and the only concurrently-writable fields of WorktreeInfo are either unread or derived byte-identically by the other writer. Add a reader for worktrees, or a field to BaseRepository written off the repo-lock path, and it goes live with nothing going red.
The module already knows the right shape. commit_migration (metadata.rs:674) reloads under the lock and hands the reloaded IndexMap to an edit closure; migrate_cache mutates records through values_mut() (flows/migration.rs:192). This generalises that rather than adding a second mechanism.
Slice
update_repository(owner, repo, |r| …) and update_worktree(owner, repo, branch, |w| …) on MetadataStorage, running the closure on the record loaded under the lock, saving only if the key was there. Built on exclusive, shaped after commit_migration.
- Convert the two genuine call sites:
flows/repo_manager.rs:1429-1436 — last_fetched.
flows/lifecycle.rs:3068-3070 — devpod_workspace_id.
- Leave the other two alone, and say why in the PR body:
flows/migration.rs:306 already runs inside commit_migration's closure on reloaded records. It is not a cross-seam RMW.
flows/workspace_clone.rs:960-961 reads nothing — WorktreeInfo::new (domain/model.rs:283) builds the record from launch inputs. Wholesale registration is a different intent from updating a field, so add_worktree stays and this site keeps calling it.
- With no flows-layer site constructing a record it did not read, narrow the fields on
BaseRepository/WorktreeInfo (domain/model.rs:199-223) to module-private wherever the borrow checker now allows. This is the constructive-modelling half: "a record whose unseen fields the caller invented" stops being representable.
Net lines should be negative in flows/.
Tests, red first
Not the two-process test #400 originally proposed — that passes today, for the reason in §4, and would mislead. Both of these live at the metadata seam and need no subprocess: flock is per open file description (domain/locks.rs:14-17), so a second MetadataStorage on the same path inside one test is a genuine second writer.
- A concurrent field update survives. Store A loads. Store B writes a different field of the same record and saves. Store A
update_*s its field. Assert both fields on disk. Red today with add_*: the pre-lock clone overwrites B.
- An update does not resurrect a deleted record. Store A loads. Store B removes the worktree. Store A
update_worktrees that key. Assert the key is still absent, and that the call reports it did nothing. Red today at metadata.rs:506, whose insert is unconditional.
Out of scope
Making the repo lock cover apply_reconciliation and remove_clone. Those are real asymmetries (#400 §1) but a separate question, and the closure fix does not depend on the answer.
Decided on #400, which established there is no live race — read the resolution comment there before starting, because it is what keeps this slice from being mis-sold. This is a maintainability and modelling change. Nothing here closes a bug, and the PR must not claim to.
Why
MetadataStorageoffersget_*andadd_*and no update (domain/metadata.rs:498,:461), so a caller with one field to change has no move but read-modify-write across the seam: the record is cloned before the lock exists, andadd_*inserts it wholesale over the mapexclusivefreshly reloaded (:562). Two production sites do that today.That it costs nothing today is an accident of which fields have readers, and the argument runs through six files. #400 §4 has it in full; the short version is that the only concurrently-writable field of
BaseRepositoryisworktrees, which nothing reads, and the only concurrently-writable fields ofWorktreeInfoare either unread or derived byte-identically by the other writer. Add a reader forworktrees, or a field toBaseRepositorywritten off the repo-lock path, and it goes live with nothing going red.The module already knows the right shape.
commit_migration(metadata.rs:674) reloads under the lock and hands the reloadedIndexMapto an edit closure;migrate_cachemutates records throughvalues_mut()(flows/migration.rs:192). This generalises that rather than adding a second mechanism.Slice
update_repository(owner, repo, |r| …)andupdate_worktree(owner, repo, branch, |w| …)onMetadataStorage, running the closure on the record loaded under the lock, saving only if the key was there. Built onexclusive, shaped aftercommit_migration.flows/repo_manager.rs:1429-1436—last_fetched.flows/lifecycle.rs:3068-3070—devpod_workspace_id.flows/migration.rs:306already runs insidecommit_migration's closure on reloaded records. It is not a cross-seam RMW.flows/workspace_clone.rs:960-961reads nothing —WorktreeInfo::new(domain/model.rs:283) builds the record from launch inputs. Wholesale registration is a different intent from updating a field, soadd_worktreestays and this site keeps calling it.BaseRepository/WorktreeInfo(domain/model.rs:199-223) to module-private wherever the borrow checker now allows. This is the constructive-modelling half: "a record whose unseen fields the caller invented" stops being representable.Net lines should be negative in
flows/.Tests, red first
Not the two-process test #400 originally proposed — that passes today, for the reason in §4, and would mislead. Both of these live at the metadata seam and need no subprocess: flock is per open file description (
domain/locks.rs:14-17), so a secondMetadataStorageon the same path inside one test is a genuine second writer.update_*s its field. Assert both fields on disk. Red today withadd_*: the pre-lock clone overwrites B.update_worktrees that key. Assert the key is still absent, and that the call reports it did nothing. Red today atmetadata.rs:506, whoseinsertis unconditional.Out of scope
Making the repo lock cover
apply_reconciliationandremove_clone. Those are real asymmetries (#400 §1) but a separate question, and the closure fix does not depend on the answer.