Skip to content

On Bun's isolated linker, rollback/remove of a superseded agent record (#934) drops the record and its blobs while the orphaned node_modules/.bun/<pkg>@<ver> copy still holds the agent patch, and the advised bun install relinks it #1084

Description

[agent] Found by the scheduled Bun bug-hunt routine (ledger #306).

Summary

#934 (fix for #933) added rollback_record_superseded: when a hosted pin replaces agent record A with patch B, rollback and remove skip the copy, drop record A and exit 0. #934 says "a copy that still holds A's patched bytes is restored as before."

Bun's isolated linker breaks that. After the hosted pin and a bun install, Bun links node_modules/plainpkg to a new store entry (node_modules/.bun/plainpkg@http+++…bbbbbbbb…) with B's bytes. It never removes the old node_modules/.bun/plainpkg@1.0.0 entry (see #599), and that entry still holds A's agent-patched bytes. Rollback sees B's bytes at the top-level copy and skips the whole target as superseded. It never restores the orphan.

Rollback then:

  • restores bun.lock to the registry tuple,
  • drops record A from .socket/manifest.json,
  • garbage-collects A's before/after blobs,
  • exits 0 with success.

The advised bun install then prints "no changes" and relinks node_modules/plainpkg to the orphaned plainpkg@1.0.0 entry. The project now runs agent patch A with no manifest record and no blobs. A second rollback is a success no-op, so socket-patch can't undo it any more. Only bun install --force (or deleting node_modules) puts the original back.

Impact

This is the v4 → v5 upgrade path (#933: v4 defaulted to agent mode, v5 defaults to hosted) plus an ordinary superseding patch, on Bun's isolated linker. Isolated is the default for new workspaces from Bun 1.3. After a rollback that reports success, the local tree silently keeps patched code that nothing records or can roll back. A fresh bun install --frozen-lockfile from a clean checkout is correct, so CI isn't affected.

v4.0.0 and main before #934 fail loudly instead: exit 1 "modified after patching", and the record is kept.

Repro (Linux, Bun 1.4.2; local mock patch API serving plainpkg@1.0.0 as uuid A, later superseded by uuid B)

mkdir p && cd p
printf '[install]\nregistry = "http://127.0.0.1:4873/"\nlinker = "isolated"\n' > bunfig.toml
echo '{"name":"app","version":"1.0.0","dependencies":{"plainpkg":"1.0.0","otherpkg":"1.0.0"}}' > package.json
bun install
socket-patch scan --mode agent --json         # API offers A → node_modules/plainpkg = PATCHED-A
# API now offers B (superseding A)
socket-patch scan --mode hosted --json        # bun.lock pins B; manifest still records A
bun install                                   # node_modules/plainpkg -> .bun/plainpkg@http+++…bbbbbbbb… (PATCHED-B)
                                              # node_modules/.bun/plainpkg@1.0.0 still holds PATCHED-A
socket-patch rollback --json                  # exit 0, success; warnings: rollback_record_superseded, reinstall_required
                                              # manifest.removedEntries: [plainpkg@1.0.0]; gc.removedBlobs: 2
bun install                                   # "Checked 3 packages (no changes)"
readlink node_modules/plainpkg                # .bun/plainpkg@1.0.0/node_modules/plainpkg
cat node_modules/plainpkg/index.js            # module.exports='PATCHED-A plainpkg';   <-- agent patch, unrecorded
socket-patch rollback --json                  # exit 0, success, results [] (nothing left to roll back)
bun install --force && cat node_modules/plainpkg/index.js   # ORIGINAL

remove pkg:npm/plainpkg@1.0.0 instead of rollback gives the same end state: exit 0, then the advised bun install relinks PATCHED-A.

Control (no supersede, so agent A → hosted A → rollback): the rollback result lists the orphan as a second verified file (./node_modules/.bun/plainpkg@1.0.0/node_modules/plainpkg/index.js), restores it, and the following bun install links ORIGINAL bytes. So the crawler does see the orphan. The superseded skip is what drops it.

Expected vs actual

  • Expected: per Fix rollback/remove of an agent record superseded by a hosted pin (#933) #934 and the CLI_CONTRACT rollback contract (rollback moves the project "toward fully unpatched"; exit 1 for anything that leaves the system still patched), a copy that still holds record A's patched bytes is restored, or the run fails and keeps the record. Record A and its blobs shouldn't be dropped while a copy at A's patched bytes exists.
  • Actual: exit 0 success, record and blobs dropped, A-patched orphan left in place and relinked by the next bun install.

Matrix (Linux, real Bun installs, mock patch API)

Bun linker agent A → hosted B → bun install → rollback → bun install remove agent A → hosted A control
1.4.2 isolated PATCHED-A relinked, record + blobs dropped, exit 0 (×2) same pass (orphan restored)
1.3.9 isolated same — —
1.4.2 hoisted (text bun.lock) pass: rollback exit 0 and a fresh frozen install is ORIGINAL (the in-place reinstall is #764) pass —
1.4.2 hoisted (bun.lockb) hosted leg refuses with the documented git checkout -- bun.lockb remedy — —
socket-patch 4.0.0, 1.4.2 isolated — exit 1 "modified after patching", record kept (#933 behaviour, no silent loss) — —

macOS/Windows: untested. Probe branches are on hold for this routine (stale-branch deletion is refused from the sandbox).

First bad commit

04885c3 (#934). Main before it (db83f01) and release 4.0.0 fail loudly and keep the record. Reproduces on main 05ecc6e.

Suspect code

  • crates/socket-patch-cli/src/commands/rollback.rs:2859, in superseded_record_skip: mismatched is true if any verified file is a hash mismatch, so a target whose top-level file holds B's bytes and whose store-orphan file is still at A's patched bytes (ready) is skipped as a whole. The gradle branch already guards this with holds_patched_bytes. The plain mismatch branch needs the same guard, or a per-file split.
  • crates/socket-patch-cli/src/commands/rollback.rs:2662: the skip continues past the restore for the whole target, so the orphan's ready file is never rolled back. The purl then goes into superseded, which drops the manifest entry and lets GC delete its blobs.

Related: #599 (Bun never prunes node_modules/.bun orphans) and #764 (the hoisted in-place reinstall keeps patched bytes). #1009's --force advisory for #764 would hide the symptom but not the dropped record.

Activity

  1. mikolalysenko commented on Oct 7, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triage: priority:p1 (Bun). Confirmed on main 05ecc6e: in crates/socket-patch-cli/src/commands/rollback.rs superseded_record_skip sets mismatched when any verified file is a hash mismatch, and only the Gradle branch checks holds_patched_bytes, so a target whose store-orphan file is still at the record's patched bytes is skipped as superseded and the record and blobs are dropped. Not a duplicate. Related to #599 / #764 (in #1009), but #1009 and #1035 don't change this function, so this is an independent root cause.


    Generated by Claude Code

  2. mikolalysenko commented on Oct 7, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Claiming this issue (shared root cause: superseded_record_skip skips a whole target as superseded even when one of its copies still holds the record's patched bytes). Branch: agent/fix-rollback-superseded-patched-copy. Claim-ID: 2026-10-07T20:20:55Z-609efe


    Generated by Claude Code

  3. mikolalysenko commented on Oct 7, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Draft PR: #1085


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions