Relationships
#2495 managedConfig: extension lockfile writes lose updates, and auto-resolved installs never reach the shared lockfile
Opened by hammz · 9/24/2026
Description
Split from swamp-club#2483 during triage on 2026-09-24. #2483 fixes how the managed config base is resolved at startup, and how extension list reads the managed lockfile. This issue fixes how the managed extension lockfile is written. It is related to swamp-club#2429 and should land before it.
Three problems exist today in managedConfig repos on extension-backed datastores (S3/GCS).
1. Lost updates to the shared lockfile
extension pull,updateandrmuserequireRepoMarker, which does not pull. They read-modify-write the cache copy ofconfig/upstream_extensions.json.- They then push with
pushManagedConfigChangesDeferred. That push is also done without a pull (managed_config_sync.ts~62-80), and the S3 push is last-writer-wins per file. - On a fresh machine, the first
swamp extension pull Xwrites a lockfile that contains only X, and that overwrites the team's remote lockfile. - On a stale cache,
rmreverts additions made by peers.
2. Auto-resolved installs never reach the shared lockfile
- The auto-resolver records installs in the instance-local in-repo
.swamp/config/upstream_extensions.json. - #2483 keeps this for now. Its loaders read that file read-only alongside the shared lockfile, as a transitional measure, so auto-resolved types stay visible.
- Auto-resolved sets therefore differ per instance.
- Pins and dependencies are read from the local file only.
3. The "files already exist" dead end
- When extension files sit on disk but the lockfile has no entry, the auto-resolver installs with
force: false, hitsConflictErrorand gives up (auto_resolver_adapters.ts~236-262). - That leaves the type unresolvable until someone runs
swamp extension pull X --force.
Target design (decided on #2483)
- A managed repo has a single shared managed lockfile at the resolved config base.
- Every writer hydrates it from the datastore before writing, and publishes it after. The writers are: explicit extension commands, the auto-resolver,
doctor --repair, search install, and the repo upgrade install pass. - The auto-resolver writes and publishes the shared lockfile. Auto-resolved extensions become team-wide.
- The auto-resolver adopts extension files that are already on disk instead of dead-ending. It verifies them against the legacy entry's
filesChecksumor a checksum-verified registry archive, and never touches the files. The swamp-club#121 local-edit guard is kept. - The legacy in-repo lockfile is merged once and retired. This removes #2483's transitional read.
Constraints found by the #2483 adversarial reviews
The full findings are recorded on the #2483 lifecycle as ADV-93, ADV-119 to ADV-130 and ADV-132.
Legacy path can be the live lockfile (critical).
<repo>/.swamp/config/upstream_extensions.jsonis the live managed lockfile for a filesystem datastore at its default path ({repo}/.swamp), and wheneverconfigis not a datastore subdir.- Merge and retire only when the realpaths differ, and only for extension-backed datastores.
Hydrate primitive.
- S3
hydrateFile→pullFilenever binds the namespace. The correct relPath depends on the instance's history, and a bound serve singleton writes to<cache>/<ns>/<ns>/…. pullFileis not atomic, and does not update the index.- Use
pullChangedscoped toconfigwith the namespace, or a non-persisting fetch followed by an atomic local write. - The two bugs above should be filed against swamp-extensions (the S3 and GCS cache sync).
Locking.
- An unlocked hydrate reverts other processes' unpushed writes: a scoped
pullChangedslow path re-downloads any file that differs. - The advisory lock (10×100ms) cannot be held across network I/O.
- Stage download, verify and extract outside any lock. Then, under the datastore global lock: hydrate, write the entry,
markDirtythat path, run a scoped push, and release. - Filesystem datastores skip all of this.
Publish.
pushManagedConfigChangescalls a baremarkDirty(), which pushes the whole cache, and does it without the global lock, on a second sync instance.- A failed publish only warns, so the next hydrate drops the entry.
upstream_extensions.json.locksits in the synced dir and can be uploaded;isInternalCacheFileonly excludes a bare.lock.- Publish must be path-scoped. A failed publish must fail the command, or persist a marker that makes the next hydrate merge. Move the transaction lock out of the cache.
Held locks (architecture).
- The auto-resolver runs inside CLI commands that hold the global lock (model validate/evaluate, workflow evaluate).
- In serve, it runs inside exclusively gated handlers (
model.create,vault.create,vault.migrate), and ungated inside runs. The sync gate is not re-entrant. - Needed: a coordinator-level sync-session accessor. Inside a locked command, write,
markDirtythe path, and let the flush publish. - Serve needs its own auto-resolver port, bound to its singleton sync service and gate: a re-entrant gate, or a post-unit publish queue. Update
SYNC_GATED_REQUESTS,UNGATED_PUSH_HANDLERSandintegration/serve_deps_rules_test.ts.
Recovery pull and datastore bootstrap.
- Extension commands must resolve installed-only (#2483 adds that option).
- Install into a scratch
LockfileRepository, then replay the entries into the hydrated shared lockfile after resolution. - Deferred records are flushed on the unresolved-to-resolved transition, and never overwrite an existing shared pin (swamp-club#465). Warn about the version skew instead.
- Make any exemption content-based: the staged archive's
datastores/provides the marker's type. Limit it toextension pulland the auto-resolver's datastore path.
Adoption.
- Needs a new
InstallationInspectionstate (unrecorded), anadopt()port, and an output event and renderer. - Refactor extraction into a shared
stageExtension/commitStaged: the raw archive layout differs from the installed layout (manifest header, bundles and skills live outside the root). - Fix pull computing
filesChecksumbefore orphan pruning (pull.ts~1177 versus ~1187). - Adoption reaches only trusted collectives.
Legacy merge on a name clash.
- Prune the union of both entries' file lists.
- Record the on-disk datastore extension version when it matches the legacy entry.
- Retire to a timestamped
.migrated.
Serve install.
createExtensionInstallDepsshould takelockfilePathplus a sync port.- Serve passes
resolveManagedPathsFromContext, andctx.syncServicewithout re-gating.
Tests.
- A
ManagedLockfileSyncPort {hydrate, publish}seam with a temp-dir fake remote. - A legacy path equal to the live path.
- A JSON-mode exempt pull with the auto-resolver configured.
- A pin not overwritten by a deferred record.
- The
.lockfile never uploaded. - A failed publish followed by a hydrate.
- Two instances sharing a MinIO bucket (manual e2e).
Docs.
design/enablers/datastores.md: the "where mutations write" table (auto-resolve row, hydrate step); serve's auto-resolver lockfile write; gate re-entrancy.- The manual's Known limitations entry on last-writer-wins.
- The swamp skill's
repo/references/structure.md~124-128 andshare/references/joiner-instructions.md~80-84 ("the repo's lockfile pins"). The swamp skill is bundled, so tessl must stay at or above 90%.
Expected
- No extension write command, and no auto-resolve, ever overwrites entries it did not change in the shared lockfile, including from a fresh or stale cache.
- Auto-resolved extensions are recorded in the shared lockfile and published.
- Files already on disk are adopted instead of dead-ending.
- The legacy in-repo lockfile is retired safely, and #2483's transitional read is removed.
Open
No activity in this phase yet.
hammz commented 9/24/2026, 5:35:45 PM
Another lost-update path found during the #2483 v5 review. On a fresh pod, swamp datastore setup extension migrates the repo-local .swamp/config tree into the cache, pushes it, then deletes it (libswamp/datastores/setup.ts ~454-497, ~550-560, ~709-713). That tree includes the in-repo upstream_extensions.json (the transitional auto-resolve lockfile), so the push probably overwrites the remote config/upstream_extensions.json with the instance-local copy. Setup also rewrites the whole datastore block of .swamp.yaml, which drops managedConfig (setup.ts ~570-581, ~718-728). Both should be covered by the hydrate and scoped-publish design here.
hammz commented 9/30/2026, 8:11:54 PM
Split progress
Two parts of the split proposed on swamp-club#2612 are now filed as their own issues. Each ships on its own lifecycle and PR:
| Part | Issue | Scope |
|---|---|---|
| (a) | swamp-club#2838 | Fetch the shared lockfile under the datastore global lock before writing it, then publish only that path |
| (e) | swamp-club#2837 | datastore setup extension overwrites the config tier with the repo-local copy and drops managedConfig from .swamp.yaml |
Checked against main (fe52cda6): #2429 and #2709 already fixed part of (a). Publishing now pushes only the changed path, a failed publish throws, and the pulled-extensions lock with refresh() exists. What's left is fetching the lockfile before the write, under the lock.
Still to file: (b) the auto-resolver writing the shared lockfile, which needs a design for serve's gate re-entrancy first; (c) adopting extension files already on disk; (d) retiring the legacy lockfile. (a) and (b) must land before #2612's CLI convergence (unit I).
Sign in to post a ripple.