Skip to content

Fix: honor remote deletions in metadata.json merge so unpinning sticks (#1105) - #1120

Open
KaladinX wants to merge 5 commits into
mainfrom
1105-bug-unpinning-doesnt-stick
Open

Fix: honor remote deletions in metadata.json merge so unpinning sticks (#1105)#1120
KaladinX wants to merge 5 commits into
mainfrom
1105-bug-unpinning-doesnt-stick

Conversation

@KaladinX

@KaladinX KaladinX commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Draft on purpose: major changes are landing on main on Monday; this branch will ingest those before it's ready for review.

Fixes #1105.

Problem

Unpinning a project deletes meta.pinnedExtensions from metadata.json, and the deletion reaches the server. But when a teammate with any unsynced local work then syncs, the 3-way merge in mergeObjects (src/projectManager/utils/merge/resolvers.ts) reads "field absent from theirs" as "theirs has no opinion" and keeps the local copy of the pin, which gets pushed back to the server. The project ends up pinned again for everyone, admin included, with no error anywhere. The same applies to deleting any ordinary metadata.json field.

Fix

One guard in mergeObjects, before both the leaf merge and the object recursion: a field present in base, absent from theirs, and unchanged locally since base is a deliberate remote deletion, so the key is left out of the merged result. Placing it before the recursion matters — fixing only the leaf line would still rebuild a deleted object as {} (or entry by entry) through the recursive path, which the ticket's AC explicitly forbids.

Everything else keeps its existing behavior:

  • Local changed the field, theirs deleted it → conflict, local wins (unchanged rule).
  • Field added locally (absent from base and theirs) → kept, not mistaken for a deletion.
  • Local side deleted, theirs unchanged → stays deleted (the admin-side path that already worked).
  • The specialized merges (edits, initiateRemoteUpdatingFor, projectSwap, users, originalFilesHashes) are skipped or overridden after the generic merge, exactly as before.

resolveMetadataJsonConflict is now exported so tests can run the real resolver end to end, per the ticket's checklist.

Test checklist (from #1105)

All automated items are covered by 10 new cases in src/test/suite/resolvers/resolveMetadataJsonConflict.test.ts:

  • Delete all of meta.pinnedExtensions, local unchanged → no pinnedExtensions in result
  • Delete one entry inside pinnedExtensions → only that entry gone, sibling survives
  • Local deletes, server unchanged → stays deleted
  • Server changes a pin's version/url, local unchanged → server values win
  • Both sides changed the same field differently → local wins
  • Delete an ordinary non-pin field → deletion sticks
  • Specialized fields still governed by their own merges (covered for users; the others are skipped before the guard can see them)
  • Full resolveMetadataJsonConflict run of the unpin scenario → no pinnedExtensions, unrelated local change still merges
  • Manual check on two real machines with builds that include the fix — still to do, needs two humans/machines

Riskiest part

The guard changes merge behavior for every field of metadata.json that lacks specialized handling, not just pins: remote deletions now stick when the local side hasn't touched the field. That is the ticket's intent, but it's the place to stare at in review. The comparison uses the same JSON.stringify equality the leaf merge already used, so "unchanged locally" is judged exactly as before.

Gates

npm run lint clean; full VS Code suite: 1286 passing, 7 pending, 0 failing.

KaladinX and others added 3 commits August 7, 2026 12:15
Problem: when an admin unpinned a project (deleting
meta.pinnedExtensions from metadata.json) and a teammate synced with any
unsynced local work, the teammate's merge silently restored the pin and
pushed it back to the server. mergeObjects() read "field absent from
theirs" as "theirs has no opinion" and kept the local value; it could
not tell a deliberate deletion from a field that never existed. Any
ordinary metadata.json field deletion arriving from the server was
undone the same way.

Change: in mergeObjects(), a field present in base, absent from theirs,
and unchanged locally since base is now left out of the merged result.
The guard sits before the object recursion so a deleted object is
dropped whole instead of being rebuilt {} or entry by entry. All other
rules stay as they were: local changes still win over remote deletions
(conflict), locally-added fields are kept, the local-side-deletes case
still works, and the specialized merges (edits,
initiateRemoteUpdatingFor, projectSwap, users, originalFilesHashes) are
untouched because they run before or override the generic merge.

Also exports resolveMetadataJsonConflict so the new unit tests can run
the real end-to-end resolver, per the ticket's test checklist.

Tests: 10 new cases in resolveMetadataJsonConflict.test.ts covering
every automated item in the #1105 checklist. Full suite: 1286 passing,
7 pending, 0 failing.
@KaladinX
KaladinX marked this pull request as ready for review August 11, 2026 22:59
@KaladinX

KaladinX commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Ran an adversarial pass over this fix, tracing the pin lifecycle across frontier-authentication, the Codex shell's Conductor, and frontier-server. One thing worth a small guard before merge, one rollout caveat, and two all-clears.

1. An empty theirs now wipes metadata.json (new failure mode, cheap guard available)

Update: the guard landed in c45671ctheirsIsTrustworthy (non-blank theirs that parses to an object with at least one key) now gates the deletion rule, each honored deletion is logged via debugLog, and two tests cover the empty-string and empty-object cases. The paragraphs below are kept for context.

The conflict contents come from frontier-authentication's GitService.ts (the conflict-building block around lines 2770-2892). remoteContent starts as "", and a failed readBlobAtRef on an existing file is swallowed by the surrounding try/catch. An incomplete fetch can therefore produce a conflict with theirs: "" and isDeleted: false. codex-editor's empty-content guard (the BLOB_READ_FAILED sentinel) only covers isNew files, so an existing metadata.json flows straight into the resolver.

Before this fix an empty theirs was harmless: every field fell through to "keep ours". With the deletion rule, theirs parses to {}, so every field the local user hasn't changed since base (projectName, languages, source/target language, and the rest) is treated as deliberately deleted, and the stripped file is committed and pushed to the whole team.

Suggested guard: disarm the deletion rule when conflict.theirs is blank or parses to an object with no keys. A real metadata.json always has keys, so an empty one means "the remote blob could not be read", not "the remote deleted everything". That restores the old keep-ours behavior in exactly the cases where the old behavior was the safe one. Roughly three lines in resolveMetadataJsonConflict plus a test.

2. Rollout: one stale client re-pins everyone

A teammate on an older extension still resurrects the pin and pushes it. Clients with the fix then see the pin as a remote addition (absent from base), which the merge correctly takes. So unpins only stick once every collaborator has updated, and a single stale client re-pins the project for everyone. Nothing to change in this PR; worth a line in the release notes so "the bug is back" reports during the rollout window get read correctly.

3. Conductor cache: verified, no change needed

I checked whether the shell's cached remote pins could keep a project pinned after metadata.json converges. They cannot: frontier-authentication clears the cache twice per sync. Pre-merge it reads pins fresh from the fetched remote head and calls setRemotePins with the result (SCMManager.ts:597), which is undefined after an unpin, and the Conductor removes the stored key. Post-merge it refreshes again from the merged local file (SCMManager.ts:756), where readLocalPinnedExtensions returns undefined when the key is absent. The planned two-machine test remains the end-to-end confirmation.

4. Server side: all clear

frontier-server never writes metadata.json. Its only touches are read-only GitLab raw-file fetches (versionPins.ts for the admin pin inventory, metricsScheduler.ts for validation thresholds), and no unpin endpoint exists server-side. A side benefit of this fix: the version-pins dashboard stops showing resurrected pins for projects an admin already unpinned.

Problem: the #1105 fix treats "field in base, absent from theirs, ours
unchanged" as a deliberate remote deletion. But the conflict builder in
frontier-authentication (GitService.ts) initializes remoteContent to ""
and swallows readBlobAtRef failures, so an incomplete fetch can deliver
a conflict with theirs: "" and isDeleted: false. The deletion rule would
then read every locally-unchanged field as remotely deleted, strip
metadata.json down to its specialized-merge fields, and push the result
to the whole team. Before #1105 the same input was harmless (every
absence fell back to keeping ours).

Change: compute theirsIsTrustworthy once per merge — theirs must be
non-blank and parse to an object with at least one key — and require it
before honoring any remote deletion. A real metadata.json always has
keys, so an empty theirs means "the remote blob could not be read", not
"the remote deleted everything". Also log each honored deletion via
debugLog, since the original bug shipped with no trace anywhere.

Why this shape: the guard restores the old keep-ours behavior in exactly
the cases where the old behavior was the safe one, and changes nothing
when theirs is a real file. Tests cover both the empty-string and
empty-object forms.
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.

Bug: Unpinning a project doesn't stick

2 participants