Fix rollback/remove of an agent record superseded by a hosted pin (#933) - #934
Mikola Lysenko (mikolalysenko) wants to merge 7 commits into
Conversation
Assisted-by: Claude Code:claude-opus-5-5
Regression tests for #933: an agent-mode record A left in the manifest after a hosted scan pinned the superseding patch B. Rollback and remove must restore the lock, drop record A and exit 0, both after a reinstall (tree holds B's bytes) and before one (tree still holds A's bytes). Assisted-by: Claude Code:claude-opus-5-5
After an agent-to-hosted migration where the patch was replaced, the manifest still recorded agent patch A while the lockfile pinned hosted patch B. Rollback tried to restore A in place over B's installed bytes and exited 1 with "modified after patching" on every run; remove aborted the same way before restoring the lock, leaving B live. A manifest record whose package release a live hosted pin wires to a different patch is now superseded. When its installed copy holds neither side of the record, rollback and remove leave the copy to the hosted lock restore and the next install, report rollback_record_superseded and drop the record instead of failing. A copy that still holds A's patched bytes is restored as before. Fixes #933 Assisted-by: Claude Code:claude-opus-5-5
main has failed socket-patch-core's lib tests since Gradle support (#646) and the digest helpers (#865) both landed. The guard test production_digests_go_through_the_helpers flags three files #646 added that still hash inline: crawlers/gradle_cache.rs, patch/jvm_jar.rs and patch/sidecars/maven.rs. That breaks test, test-release and coverage on every open PR. Each inline sha1/sha256 call now goes through sha1_hex_of or sha256_hex_of, which compute the same lowercase hex. Behaviour is unchanged. Assisted-by: Claude Code:claude-opus-5-5 (cherry picked from commit 659ac2c)
|
[agent] Generated by Claude Code |
|
BugBot review Generated by Claude Code |
A Gradle or Maven copy holding the superseding hosted patch's jar fails rollback before file verification: its hash directory is not the download the old record patched (gradle_rollback_hash_mismatch), or its swapped jar has no backup here (jvm_jar_backup_missing). Those copies now get the same rollback_record_superseded handling as a plain hash mismatch, so remove no longer aborts on them before restoring the lock. Neither refusal writes anything. Assisted-by: Claude Code:claude-opus-5-5
|
BugBot review Generated by Claude Code |
jvm_jar_backup_missing is only raised once every jar member verifies as the old record's patched bytes, so treating it as superseded would drop the record while the patched jar stays in the cache. It now fails as before. A gradle_rollback_hash_mismatch copy is left to the hosted leg only when none of the record's files there still hash to its patched bytes; a corrupt before-blob for the directory the record did patch fails as before. Assisted-by: Claude Code:claude-opus-5-5
|
BugBot review Generated by Claude Code |
The superseded-copy probe read installed files with a bare read, so a FIFO or device planted in a Gradle cache directory would block rollback and remove forever. It now uses read_regular_to_bytes like the neighbouring Gradle checks; a non-regular file is refused and counts as possibly patched, so the copy fails as before. Assisted-by: Claude Code:claude-opus-5-5
|
BugBot review Generated by Claude Code |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit ec6ca85. Configure here.
|
[agent] Ready for review at head
Generated by Claude Code |
LLM Description written by Claude Code:claude-opus-5-5
Fixes #933
Root cause
After an agent→hosted migration where the patch is superseded (same
name@version, new UUID B),.socket/manifest.jsonstill records agent patch A while the lockfile pins hosted patch B.vexalready recognises this as a superseded record (vex_record_superseded).rollbackandremovedon't: their agent leg (rollback_patches_inner) runs the in-place restore of record A against files that, after a reinstall, hold B's bytes. The before/after hash check then fails with "File has been modified after patching", so rollback reportspartial_failureand exits 1 on every run.removeaborts withrollback_failedbefore its hosted leg runs, so the hosted pin stays live.Fix
rollback::superseded_by_hosted(manifest, hosted_pins)maps each manifest record to the hosted uuid when a live hosted pin wires the same package release (canonical base purl, with composer version equivalence) and no pin for that release carries the record's own uuid.rollback_patches_innertakes that map. If a superseded record's in-place restore fails before writing anything, because the copy holds neither side of the record (a hash mismatch or a missing file), the copy is left to the hosted leg's lock restore and the next install. The run gets arollback_record_supersededwarning and the purl lands in the newRollbackOutcome::superseded, not as a failure. A copy that still holds A's patched bytes (no reinstall yet) is restored in place as before. A Gradle hash directory this record never patched, such as the superseding jar's own download (refused withgradle_rollback_hash_mismatch), is treated the same way, but only when none of the record's files there still hash to itsafterHash(66ec386 and 1494f42, from Bugbot). That probe reads through the FIFO-saferead_regular_to_bytes(ec6ca85, from Bugbot). A swapped jar with no backup (jvm_jar_backup_missing, raised only when the jar holds this record's patched members), an unreadable file and a missing before-blob all still fail as before.rollback's manifest cleanup drops superseded records.removepasses the same map, so it proceeds to its hosted leg and restores the lock.CLI_CONTRACT.mddocuments the new warning code and the manifest-cleanup rule.The hosted scan itself is unchanged. It could also drop or flag record A when it re-pins, but a scan without a reinstall would then lose the record of A's bytes still in
node_modules. Fixing this at rollback/remove covers both orders.Ported fix: 9c0a0c3 cherry-picks #878 ("Route Gradle digests through utils::digest").
mainfailssocket-patch-core --lib(production_digests_go_through_the_helpers), which turnstestandcoveragered on every PR. The commit becomes a no-op once #878 merges.Tests (red → green)
New in
crates/socket-patch-cli/tests/in_process_rollback_hosted.rs. Each runs a real in-processscan --mode hostedthat pins superseding patch B over a manifest with agent record A:rollback_drops_an_agent_record_superseded_by_a_hosted_pin: the tree holds B's bytes. Before the fix: exit 1,partial_failure,failed: 1, record kept. After: exit 0,rollback_record_superseded, lock byte-identical to pristine, record dropped, B's bytes untouched, and a re-run exits 0.rollback_restores_a_superseded_agent_record_still_installed: the tree still holds A's bytes. This is a control and passed before and after: A is restored in place and there's no warning.remove_unhosts_a_package_whose_agent_record_is_superseded: before the fix, exit 1 withrollback_failedand the pin kept live. After: exit 0, lock restored, record dropped, warning reported.Unit tests in
commands::rollback::tests:superseded_skip_covers_a_copy_holding_neither_side,superseded_skip_covers_a_gradle_dir_the_record_never_patched,superseded_skip_keeps_jvm_copies_holding_the_patched_bytes(red on 66ec386),superseded_skip_never_blocks_on_a_fifo,superseded_skip_leaves_other_failures_and_records_aloneandsuperseded_by_hosted_needs_a_pin_with_another_uuid.Local results:
cargo test -p socket-patch-cli --all-featuresfor--test in_process_rollback_hosted(26),rollback(38),remove(91),in_process_remove_repair_lifecycle(23),in_process_rollback_all_ecosystems(8) andgradle_agent_cli(33): all pass. The--lib supersededunit tests (8) pass too.cargo clippy --workspace --all-features -- -D warnings: clean.cargo fmt: only the changed hunks were formatted, becausemainitself isn'tcargo fmt --checkclean and CI doesn't run fmt.cargo test --workspace --all-features --no-fail-fast(on b43e50c+9c0a0c3): 226 test binaries green. The 11 failures are all write-failure or unremovable-file tests that rely onchmoddenying writes, and they can't pass in this root sandbox:covgap_commands_vendor×3,in_process_redirect×3,repair×2, and corecopy_tree/vlt_heal/pypi_poetry/pypi_requirements. None of them touch rollback, remove or hosted pin code. CI runs as a non-root user.npm/,pypi/andgem/wrappers only dispatch to the binary.Per-issue checklist
rollback_drops_an_agent_record_superseded_by_a_hosted_pinremove_unhosts_a_package_whose_agent_record_is_superseded🤖 Generated with Claude Code
https://claude.ai/code/session_01YbFRgXi519YoPqEJgyU5pS
Generated by Claude Code