The timed-out cat-file read is caught in target_syntax_error (the preflight) instead of
read_target_files, so the preflight prints one line saying the post-update check still runs;
publish_recovery_closure keeps suppressing the timeout as before.
hermes.update.run rows with apply_mode=unknown still had three exits with no stop_class on main:
- The startup-syntax preflight (update_cmd_commit.read_target_files) runs `git cat-file --batch`
under a 120 s limit. On a blobless install (install.sh clones --filter=blob:none) every changed
blob lazily fetches in its own round trip, so a slow link times out and the uncaught
TimeoutExpired ended the whole update as `subprocess_failed` after 2+ minutes (31 CLI + 23
Desktop install-days). An unread batch now counts as unread files, which is the preflight's
documented contract; the post-move syntax check stays the backstop.
- An unreadable/unparseable config.yaml (require_readable_config_before_write RuntimeError) or an
invalid configured channel name (ChannelError) while picking the channel read `exception`. They
now record `config_unreadable` (new) / `channel_unresolved`.
- A failed Windows gateway pause (_abort_on_error / ServicePauseFailed RuntimeError) read
`exception`; it now records `gateway_pause_failed` (new).
Two closed UPDATE_STOP_CLASSES values, the v4 schema enum and the relay-shared-metrics doc row
change together; every legacy value stays valid.
Every pre-apply exit of `hermes update` records one closed token on the
receipt at the exit itself (update_receipt.record_stop_reason ->
stop_class), and shared_metrics_update.update_failure_class maps it to
the run's failure_class. aborted_before_apply stays only as the fallback
for an exit with no recorded reason.
Exits that fire before the receipt opens (update-lock refusal, Git
operation in progress, managed install) write no receipt, as before, but
now record a metrics-only row with the same closed class.
The parked receipt copy keeps stop_class, exit_code, the stop reason's
leading label and the restart/user-action flags, so a parked run
classifies the same as the in-process one. A run that committed and is
owed only an interrupt or the user's parked changes reads outcome
partial (failure_class interrupted / local_changes_parked), never failed.
`publish_recovery_closure` spawned `git cat-file blob` once per closure
file, while `read_target_files` already reads any set of committed paths
in one `git cat-file --batch` (review K132361 P3, efficiency). The closure
now uses that reader with the same `--no-replace-objects` git, and keeps
every check: each file must be a blob `ls-tree` lists at `pre` and its
bytes must hash to that blob id, else the same OSError as before.
A probe against 14 real commits of this repository (12 that carry the
closure, 2 that miss a closure file) published byte-identical closures
and raised identical errors with the old and new code; git spawns per
publish went from 4 to 2.
`commit_obligations_armed()` had only test callers and `reset_for_tests()`
was a bare alias of `begin_update_attempt()` (review K132361 P3). Both are
gone, with no compat alias. The tests now reset through the real
`begin_update_attempt()` and assert the obligations a run owes where they
live: the completion-pending record and the host restart record on disk,
not the module's in-memory snapshot.
- update_cmd_commit._admitted_python no longer asks a PATH `uv` for interpreters: Hermes never
resolves the user's uv (tests/test_managed_runtime_resolution.py ratchet, b63c138d78). The
python3.N scan goes through hermes_platform.resolver.locate_command; with none admitted the
running interpreter judges, as before.
- test_desktop_exe_integrity: the desktop build now holds the checkout lock, so npm reaches
subprocess.run wrapped in the Windows custody launcher; the fake packer finds the npm argv
inside the wrapper instead of missing it and staging nothing.
- test_update_lock_windows_live C3 case: the launcher's argv carries "hermes: update custody",
which the live-system guard reads as `hermes update`; the test runs it against a tmp_path
checkout, so it takes the guard's documented bypass marker.
Disarm put a non-host record (the completion tail, the legacy per-home marker) back through a
fixed `.<name>.<pid>.restore` temp, unlinking whatever stood there first. That kept a planted
symlink/hardlink from being written through (N05) but deleted an ordinary user file of that name.
It now uses the host module's `replace_bytes`: a `mkstemp` temp (unpredictable, O_EXCL, 0600) in
the same directory, fsynced, then `os.replace`d over the record; a collision just picks another
name, so nothing at any guessable sibling is touched (review O1, kshitijk4poor F22/N05 fixed-path
part). `_replace_bytes` is renamed `replace_bytes` now that two modules use it.
Test: ordinary file, symlink and hardlink planted at the old name plus a no-collision control; all
unrelated bytes and the planted entry survive and the record is restored. The three collision cells
fail on fd5c37f17bf.
The host restart record's `found` baseline is what an owner's release puts back. A second arm by
the same owner (CP0 branch, then the pull, then the upstream ff) saw a non-empty `owners` list,
inferred "nothing was here" and wrote `found: null` on the different-SHA path; the disarm after a
rollback to the start commit then unlinked the restart debt that predated the run. A new owner
arming another SHA over another run's record did the same to that run's debt.
`_baseline_for` keeps one rule: a new owner saves the bytes there now (other owners' stakes
included); an owner already in the record carries the baseline it saved, or, when it shared the
record, the shared record minus its own stake. So release always restores exactly the state before
this owner's first stake (review R1).
The snapshot, owner and run start were process-global and set only once, so a second
`hermes update` in one interpreter reused the first update's snapshot and owner and handed back
the first update's committed tail and restart as its own undo when its move was refused.
`begin_update_attempt()` starts each attempt with no state; `_cmd_update_impl` calls it first
(one additive line in update_cmd.py) (review O5).
Tests: same-run B->C retarget with a real git rollback check, a later owner's failed retarget, a
shared record left for another target, and a second in-process update entered through
`_cmd_update_impl`; all four fail on fd5c37f17bf.
What: _sync_with_upstream_if_needed compiles the upstream commit's startup inventory before its
fast-forward (the origin target's preflight, applied to the second move) and raises
UpstreamTargetBroken without moving; both callers roll the update back the way the post-pull
guard did (the late one checks the branch first, so an unexpected branch's ref is never reset).
A later move of the run arms its obligations for its pre commit (debt_sha_for_move): every
landing of a fast-forward contains it and the fleet discharge holds the gateways to the actual
HEAD, so a failed merge needs no retarget write. The merge verifies HEAD is the counted commit
before reporting success.
Why: the fork ff dropped its marker on git's exit 0 and only the caller's later guard judged the
code, so a kill in between left a broken commit with no recovery record (G1); a failed merge's
retarget to the pulled commit could itself fail and leave the debt naming the unmerged commit
with HEAD on the pulled one (O4, the residual of F23).
Changed test: test_update_syntax_failure_restores_pre_update_head pinned the post-pull guard
catching the upstream target ("Pulled code has a syntax error") and the broken commit landing on
the unexpected branch; the target is now refused before its move, so it asserts the refusal
text and that the broken commit never landed. Outcomes (exit 1, HEAD back at base, stash) hold.
Tests (RED at fd5c37f17bf): a refused retarget leaves the debt on the pulled commit; a broken
upstream target is refused with HEAD unmoved and no marker.
What: the critical inventory now holds every module the launcher and hermes_bootstrap import
before application code (hermes_bootstrap.py, _parser, _launchers, the recovery closure,
update_handoff, venv_sync, pm/environments), pinned by a test that derives that set from the
real launcher text and bootstrap imports. One startup_syntax_error() serves the preflight, the
post-pull guard and the ZIP gate: a target whose requires-python excludes this interpreter is
compiled by an installed interpreter it admits (uv python find, then python3.N on PATH); with
none installed this interpreter still compiles it, and a refusal names the Python to install.
The preflight reads pyproject + the inventory in one `git cat-file --batch`.
Why: a broken bootstrap passed both guards and failed at the first import (G3); a Python bump
reduced the check to a conflict-marker scan, so `def broken(:` landed (N15, the class the
00f0e40b6ce marker scan left open); preflight cost one git spawn per file (kshitijk4poor).
Changed test: test_syntax_guards_skip_a_target_that_requires_a_newer_python pinned the N15 bug
(`def newer_syntax(:` admitted under >=3.99); it is replaced by the no-interpreter refusal and
the admitted-interpreter verdict tests.
ZIP gate (update_cmd_zip.py, czip's file): its compile loop is replaced by the shared helper.
What: arm_tree_move records `index_lock=<inode:mtime_ns>` (empty: none) of .git/index.lock in the
marker before the move's git runs. The in-process settle after a failed git (after_failure)
still never touches a lock, but remembers it as foreign (`foreign_lock=`) only when it is that
pre-move generation (_lock_predates_move); a marker without the record (older updater,
hand-written) keeps the old rule (every lock foreign).
Why (review C15, P1; e2e test_git_killed_inside_the_rollback_reset_is_finished_by_the_next_launch):
the syntax rollback's `reset -q` SIGKILLed holding index.lock left that lock; F05's
`foreign_lock = after_failure and lock.exists()` recorded it as foreign, and F06's known_foreign
then skipped _release_dead_index_lock on every later launch: "cannot resume yet (a running git may
still hold .git/index.lock)" forever, HEAD on the broken release. A lock is foreign only if it
existed before our git ran (or a live process holds it, which _release_dead_index_lock still
refuses); one that appeared during the move is reclaimed once proven dead. F05/F06 hold for the
pre-move generation: a user's `git commit` waiting in the editor is never deleted. Residual
window: a foreign git that creates the lock between the marker write and our git's first lock
attempt (milliseconds) is treated like our own.
Tests: the killed-rollback lock is not remembered and the next launch reclaims it and lands on
pre (RED at 5dfc384669e: foreign_lock= recorded); a lock that predates the move stays, with its
marker, across the settle and a launch.
What: arm_tree_move writes the marker through write_durable_text, the zip swap journal's
pattern lifted into one _early_recovery helper (fresh O_EXCL|O_NOFOLLOW temp, fsync, os.replace;
the temp unlinked on failure). write_zip_swap_journal now calls it with its own fixed temp name
(unchanged bytes and name); every other caller gets a pid-suffixed temp so two writers never
unlink each other's.
Why: the marker is the restore's only record and its docstring promises it is durable before
git writes, yet it was written in place with write_text: a power cut could leave an empty or
half-written marker over the torn tree it exists to identify (review C3).
Test (RED at 5dfc384669e: no os.replace): the marker's bytes are fsynced and renamed over its
name, the old marker is never rewritten in place, and no temp is left.
What: the host obligation record carries `owners` (one commit-point token per arming run:
install root, pid, uuid) and `found` (base64 of what its first owner found there).
write_host_obligation(owner=) adds the run to a same-SHA record instead of keeping it
byte-identical; disarm_commit_obligations releases the host record through
release_host_obligation, which removes only this run's token and, when no owner is left,
puts back what the first owner found (absent -> unlinked). A record whose owners do not name
the run (rewritten by another install's newer pull, or unparseable) stays untouched. The
non-host restores in disarm now fsync before os.replace (review C12, partial).
Why: the F22 disarm compared bytes, but a same-SHA arm kept the record unchanged, so install
A and install B arming the same SHA shared byte-identical bytes and A's failed run deleted the
debt B still owed through (review C2, probe review/commit/w2/probe_cas.py).
Test (RED at 5dfc384669e: record deleted): two installs arm the same SHA; A's disarm keeps the
record, the last owner's disarm puts back what the first owner found (nothing).
What: disarm_commit_obligations, refused off the run's start commit, now re-arms the
obligations for the HEAD the checkout is actually on instead of leaving them on the failed
move's target; owe_restore_of arms them for the move's pre when the tree is torn under its
marker (the commit the next launch's restore lands on). Every failed-move site settles through
these: _pull_updates (any settle, not only one back at the baseline), the CP0 switch (exception,
torn and settled paths, and an attempt-1 arm left behind when attempt 2 is unresolvable), the
syntax rollback's failure path, and the upstream fork sync, whose merge catch now also takes
OSError/SubprocessError (CustodyRefused is an OSError) instead of letting it escape armed.
arm_commit_point no longer says "the checkout was not changed" when an earlier step of the run
(CP0) already moved it. The fork sync refuses a HEAD that is not an object id instead of
arming pre=, and a failed re-arm is logged and printed, not suppressed.
Why: a second move in one run re-armed the host obligation for its own target; when it failed
it settled back to a commit that is not the run start, disarm refused (head != start_head) and
nothing re-armed for the actual HEAD, so the record named a commit the checkout never holds
(undischargeable on a gateway-less host, wrong for the catch-up restart).
Review C1, C11. Tests (RED at 5dfc384669e): CP1 refused after CP0 owes A, not B, and the refusal
names the moved HEAD; a CustodyRefused upstream merge after the pull owes c1.
check-windows-footguns misread os.fdopen(os.open(...), "wb") as a text-mode
fdopen without encoding= (the nested call hides the mode argument). The
write was already binary; binding the descriptor first makes that visible.
Behaviour is unchanged.
preflight_refusal: any error from the foreign-owned-venv probe (pm's
RuntimeError refusal, an unreadable facts record or venv dir) must refuse
the update before the first tree move; narrowing would let an unforeseen
error escape as a traceback instead of a refusal.
_download_and_swap_zip: the all-or-nothing boundary; any failure must
settle the swap journal, disarm the obligations and exit 1 with the
retry advice. Both catch-alls stay, now carrying `# health: allow BLE001`
waivers; behaviour is unchanged.
What: when the target requires another Python, the preflight and the post-move guard still
refuse a startup-critical file holding git's <<<<<<< / >>>>>>> lines (conflict_marker) instead
of skipping the check outright.
Why: the skip waved through breakage no Python parses whenever the same release raised
requires-python past the running interpreter, and the update succeeded at the broken HEAD.
Scope: other syntax under a bumped requires-python stays unjudged; judging it needs the
target's interpreter, which PM provisions only with the new code.
Red (b6502fd93c9 update_cmd_commit.py: target admitted; update_cmd.py: guard does not roll back).
Green: preflight refuses; the guard rolls back to pre and exits 1.
What: the rollback marker records `ref=` (git symbolic-ref -q HEAD, empty when detached);
the launch repair redoes the HEAD step only while HEAD still names that ref, else it keeps the
marker and prints the manual reset.
Why: the redo ran `git reset pre` against whatever HEAD named: a branch the user checked out at
the broken target before relaunching was rewound while main stayed broken and the marker went.
Test data: the two hand-written rollback markers in test_update_interrupted_pull.py gain the
ref= field the writer now records (no assertion changed).
Red (6c4a167d467 update_cmd_commit.py + _early_recovery.py): feature rewound to pre.
Green: feature stays at target, marker kept.
What: arm_commit_point arms the obligations then the interrupted-pull marker as one hard
precondition (failure hands the obligations back and the caller stops before git): the origin
pull exits, CP0 returns a failed result (the caller restores the autostash), the upstream ff
skips. The pull resolves its target with a checked rev-parse --verify and refuses a non-OID.
The pull's refusals run inside the try whose finally names the autostash. The syntax rollback
(cannot refuse: the tree is broken) rolls back unmarked with a warning, and
settle_failed_tree_move no longer counts a missing marker as proof the tree is whole, so only a
tree verified at pre reports "Rollback complete".
Why: _best_effort/suppress(OSError) let git move with no marker (a kill left a torn tree no
launch could identify), an unresolved target armed 'target=' and still merged, and an arm
failure after the autostash exited with no stash guidance.
Red (91270e84a26 update_cmd.py/update_cmd_git.py/update_cmd_commit.py): IsADirectoryError escapes after the unmarked move (pull, CP0, upstream, rollback); unresolved target not refused; stdout '' (no stash ref).
Green: SystemExit/failed result/False with HEAD and tree unchanged; stash ref printed; rollback prints the manual recipe.
What: the restore temp is a per-pid name, unlinked first and created O_CREAT|O_EXCL (0600).
Why: tmp.write_bytes followed a symlink/hardlink left at the fixed '<name>.restore' name, so a
same-user link redirected the write and could replace the obligation with the link.
Red (pre-fix disarm): sentinel 'PRECIOUS' overwritten with 'ORIGINAL'.
Green: sentinel untouched; obligation is a regular file holding ORIGINAL.
What: _write_fleet_restart_pending_marker reports whether a store took the restart and
arm_commit_obligations raises (pre-move refusal) when neither did; the arming snapshot treats
only FileNotFoundError as absence (any other read error refuses); disarm restores/unlinks a
record only while its bytes are still exactly what this run's latest arm left there.
Why: the tree could move with no restart owed; disarm replayed a stale snapshot over another
install's newer host record (shared per OS user) and deleted a record it merely failed to read.
Red (7847374318c update_cmd_commit.py + update_cmd_fleet.py): arm returned with both stores unwritable; disarm -> 'OLD' over 'OTHER-INSTALL'; read error -> record deleted.
Green: arm raises OSError; newer record kept; unreadable record survives; plain arm/disarm still restores.
A published recovery closure was trusted on presence alone: a power loss after the rename
could leave a torn _early_recovery.py that failed every launch forever, and foreign bytes
ran in place of pre's code.
- publish_recovery_closure writes a MANIFEST of blob ids (ls-tree at pre, each cat-file
output re-hashed against its id), fsyncs files and directories before the rename,
re-verifies an existing closure instead of trusting it, and refuses a pre that is not a
full commit id (no HEAD, no path under the git dir).
- The minted launcher hashes every file against the manifest before importing; a missing,
torn or foreign closure is discarded and rebuilt from git's objects (--no-replace-objects)
and verified again.
- Its git is the recorded absolute one or an absolute PATH entry, never a bare 'git' that
CreateProcess (or an empty POSIX PATH entry) resolves from the current directory (m4).
- A repair refused by a live writer exits 1 with the repair's own reason: no "repairing it"
banner, no import traceback (m3).
(cherry picked from commit 94e436325b40b5b99962c3c646d3760ecad9dc4f)
The launch-time repair (hermes_bootstrap -> _early_recovery under the checkout lock) lives in
the tree a killed git rewrites. A merge SIGKILLed while writing hermes_bootstrap.py,
_early_recovery.py, a prefix of hermes_cli/__init__.py or a custody module left every launch
exiting 1 with the marker kept and the tree never restored.
arm_tree_move now publishes the recovery/lock/custody modules, as committed at the move's
pre commit (git cat-file, never the working tree), to <git dir>/hermes-update-recovery/<pre>/
by an atomic directory rename before it writes the marker. The minted launcher catches a
failing checkout import; with the marker present and its pre's closure published it purges
checkout modules, runs restore_interrupted_pull from that copy (same claim and checkout lock:
a live writer still refuses) and relaunches from the restored tree.
(cherry picked from commit 2bc12d9283d90a54f970a354a52f0a95d41bbfb0)
(cherry picked from commit 924b2a9a2d4dde6bc2a9074ff3ee0cd8f87c8684)
A merge killed while writing hermes_constants.py now reaches the repair (the launcher
imports hermes_bootstrap first), but on a Windows install whose only git is PM's store
copy the repair looked it up through pm, which imports hermes_constants: the lookup
failed, a bare 'git' died with WinError 2 and the tree stayed torn (native mid_git,
run 37198020181). arm_tree_move records the absolute git this run resolved (PATH or
expose_pm_git) and _git_executable prefers it.
(cherry picked from commit 59a744696d3805fdc9fb3f0bf5d5f3b20161cbce)
(cherry picked from commit 8bc8502e336d847a88fc4fd7c408f18d6950377f)
update_cmd_commit's diff/show/rev-parse/status probes run through
update_custody.run_git like every other updater git call: -c gc.autoDetach=false
-c maintenance.auto=false, no lock fd for readers, the owner's kill-on-close job
on Windows (R2).
(cherry picked from commit ee8726ef171b9dd355aed80014c5feb3f0ac3256)
(cherry picked from commit cd2ca1f31296f9de2b3f14f2196e8fe67653bd5a)
R1. The launch-time replay of a syntax rollback ran `git reset -q pre` before any
dead-lock reclaim and ignored its result; HEAD still on the broken release then
read as "git finished" and the marker was deleted, leaving the broken tree and a
stale index.lock with nothing left to restore it. In-process, the same path made
settle_failed_tree_move report the rollback complete.
Now: the killed step's index.lock is reclaimed first and only once its git is
proven gone (/proc fds + live git with cwd in the checkout on Linux, lsof on
macOS/BSD, the unlink itself on Windows; unknowable = keep); update-ref, reset
and the HEAD readback are each checked; a rollback marker is removed only when
HEAD == pre, no index.lock and no tracked change. The in-process rollback checks
drop_added_files and the same whole-tree oracle before removing the marker, and
says the next launch finishes it when it could not.
Was (reviewer probe, real git SIGKILLed at index.lock): restore returned False,
HEAD broken, lock left, marker gone. Now: restore returns True, HEAD pre, clean,
lock and marker gone; a live lock holder keeps the marker and the lock.
(cherry picked from commit 087ae72d38be89892a70e97a935db532a9e8284f)
(cherry picked from commit 5984c98726cb29b1123552f875f99d5020f01b71)
The takeover's new fleet-obligation arm imported `update_cmd_fleet`'s writer,
which imports `update_cmd` -> `hermes_cli.config` -> ruamel. `prepare()` runs
in the HISTORICAL interpreter before PM syncs the new dependencies, so every
update from a release whose venv has no ruamel (the installer-script and
e2e-upgrade core/handoff/pm legs) exited 1 with
`Update preparation failed: No module named 'ruamel'`. The arm now writes the
host obligation through the stdlib-only `update_host_obligation`, with the same
per-home breadcrumb fallback.
Also:
- The syntax rollback's mixed reset un-tracked the files the broken commit
ADDED, and `reset --hard` never touches untracked files, so they stayed in
the tree (`?? hermes_cli/`). They are now removed by name after the reset.
- A git that EXITED on another git's `index.lock` no longer has that lock
deleted by the in-process settle: the lock is foreign by construction, the
scan only reads, and a tree with writes to put back keeps its marker.
- `_early_recovery`'s git runs carry `cwd` as well as `-C`.
- `_early_recovery._git_executable` gets a reasoned resolution-allowlist row
(stdlib-only launch repair; cannot import `hermes_platform`).
Tests rewritten to the new contract:
- test_source_release_channels zip fallback: the download stub takes the
target SHA the ZIP swap now journals, and asserts it is passed through.
- test_update_target_identity: the fault injection keys the upstream sync by
its remote (`upstream`/`upstream/main`), since the sync now merges the
fetched ref instead of a second `pull`; the origin-target syntax case
expects the pre-move preflight refusal.
- test_hostile_commit ZIP cell kills right after a tracked file landed, not
at the N-th swap (listing order made it land on unchanged entries only).
(cherry picked from commit b6adf96ca6988262fe7c408ea652d3b390c7be8d)
(cherry picked from commit 028cb933271626d21003c255bb397deb8998b7da)
The syntax rollback's first step was `reset --soft` (or `update-ref` for a
parked/detached checkout), which leaves the index on the broken commit. A
kill during the following `reset --hard`, after it had written a good file,
left that file staged as the broken version, so the next launch's restore
landed on the pre-update commit with a dirty index (`MM`).
The first step is now a mixed reset (`reset -q <pre>`), after `update-ref
--no-deref HEAD` for the detach mode. HEAD and the index are back on the
pre-update commit before any file is written. The marker's `rollback=` field
is `branch`/`detach`, and recovery redoes the same steps when the kill came
before them. A failed rollback step now goes to the in-process restore every
time. Before this, a failure in the first step dropped the marker, even
after `update-ref` had already moved HEAD.
Also: the old branch-switch hostile cell triggers on `checkout [--no-guess]
main`, since CP0 now passes `--no-guess`. The fork cell makes origin read as
a fork through the git shim, because the sandbox otherwise reports the
official URL and the upstream sync never runs.
(cherry picked from commit 23f9658868b9c1f639ed972e59251fe154372929)
(cherry picked from commit 5fd6de2e62152076d6703e5ff70250ec1ece71b9)
Review round 2 on the LP-COMMIT commit point:
- A failed upstream fork fast-forward after a committed origin pull no
longer hands back the obligations the origin pull armed.
`disarm_commit_obligations` now refuses unless HEAD is the commit the
run STARTED from (`record_run_start`), so every caller is covered; the
restore of the snapshot is an atomic replace.
- The ZIP swap journal counts a leftover `<entry>.hermes-update-old` as
"existed" (`_stage_replacement` puts it back before the swap), and the
`swapping` recovery treats any backup as proof the entry existed, so a
kill mid-swap can no longer delete both copies. A pre-run journal the
start-of-run settle could not clear refuses the swap instead of being
overwritten.
- CP0 runs `git checkout --no-guess <branch>` only when the local branch
exists; otherwise only the marked `checkout -B <branch> origin/<branch>`
runs. git's checkout guess used to create the branch and rewrite the
tree while the code believed the checkout could not move.
- The syntax rollback moves HEAD back to the pre-update commit first
(`reset --soft`, or `update-ref --no-deref` for a parked/detached
checkout), then `reset --hard`. The marker names pre = the good commit
and `rollback=<mode>`, so a kill anywhere lands the next launch on the
pre-update commit; recovery redoes the HEAD step if the kill came first.
- The syntax guards (preflight `git show`, ZIP compile gate, post-pull
backstop) skip a target whose `requires-python` excludes the running
interpreter: its syntax may be a newer Python's. Unknown specs keep
checking.
- The no-op exit in `_verify_head_after_pull` disarms (self-verified).
(cherry picked from commit c787a8c3f9823be8639f6f38efbbdff2b78a97cd)
(cherry picked from commit d785ad1c7e919bd7d64d17f2e9d664b84a7e8982)
Arm the source-completion tail and the fleet obligation in the parent before
any tree move; cover the CP0 branch switch, the upstream fork ff and the syntax
rollback with the interrupted-pull marker; settle (never drop) the marker on a
non-kill git failure; refuse foreign-owned venvs and uncompilable targets before
HEAD moves; push the fork only after a validated update; ZIP fallback only from
an untouched checkout; journal the ZIP swap so start-of-run recovery finishes or
rolls it back and no staging/backup sibling wedges the next run.
(cherry picked from commit 584acad44f3d6c552b7c2767d12c612de0436498)
(cherry picked from commit c6567ac2d28c421f384d95e1a6fc28912cb30f86)