Three ways the installation could still end up with its source, its binary and
the operator's content describing different things.
Rollback copied aside only *tracked* protected files, so a role upstream had
deleted and the operator had recreated at the same path was untracked - and
`git reset --hard` wrote the old commit's copy over it. The stash now also
covers untracked protected paths the target commit writes to, and names them in
keptContent like any other file it saved.
The pending-build marker said only "a compile is owed". An update that found
new commits therefore merged them and built the *new* tree, leaving `.prev`
holding a binary for a commit the state file no longer named - a rollback then
paired old source with the original binary. And a marker left behind after a
successful swap rebuilt and overwrote that same `.prev`. The marker now names
its target commit and the hashes on both sides of the swap, the state records
which commit the live binary came from, an owed build is finished before the
tree moves again, and a retry that finds the swap already done clears the
marker instead of repeating it. Rollback verifies that pairing before touching
anything and refuses (binary_changed) rather than reset the source beside a
binary it cannot account for; an update whose build failed never swapped one,
so its rollback moves the source only and no longer demands a `.prev`.
The version pair is recorded before the config is written, so a crash in
between still leaves a rollback that can put the old number back.
The review found two failure paths that reported success for work the engine had
not done.
Rollback: it refused only when *non-protected* source files were modified, and
then ran `git reset --hard` to the previous commit - which discards every local
change to tracked files, not just the update's. Edits to roles/skills/tools (the
update itself keeps those via its own stash) and edits made after the update
were silently wiped, while Result said nothing: deployment.md's promise that
"work done afterwards is not swept away" did not hold. Rollback now copies every
protected file that differs right now into `.update-backup/rollback_<ts>/`
before the reset, sets the copies back after it, re-applies hand deletions, and
names what it kept in Result.KeptContent like an update does.
Retry: Apply merges and writes the state file before it builds, so a failed (or
toolchain-less) build leaves HEAD on the new commit with the old binary still in
place. The retry then took the `!snap.UpdateAvailable` no-op branch, aligned the
version and returned err=nil - the job was recorded as succeeded, the page said
"update finished", the CLI printed that source and binary were in place, and the
old executable kept running. The tree now carries a `.update-build-pending`
marker, written the moment the merge lands (and when an adoption commits to its
work tree) and cleared only after the new binary is in place. The no-op branch
reads it: with a toolchain it compiles and reports the build (commits=0,
binaryBuilt=true); without one it keeps refusing exactly like the first attempt,
so "install go and click again" is now a real instruction. The console stops
treating "nothing to pull" as "nothing to do" (the retry button stays live, the
confirm dialog and the done toast say the build is what will happen), and the
CLI's printResult no longer swallows the build-only result.
Both paths are covered by regression tests that fail on the pre-fix tree: two
rollback scenarios driven through real git (edit before and after the update, a
hand deletion), and four retry scenarios driven through a scripted go on PATH
(failed build, state-write failure, an adoption's failed build retried by the
next update, and no toolchain then toolchain).