pnpm 12.15.0 (native executable) installs every JS workspace from one
pnpm-lock.yaml, imported from package-lock.json with the same resolved
versions. website/, the WhatsApp bridge and the photon sidecar stay separate
npm projects.
Configuration moves from .npmrc and the root manifest into
pnpm-workspace.yaml: the 14-day minimumReleaseAge and its excludes,
overrides, allowBuilds (the old allowScripts set, with strictDepBuilds
failing any unlisted build script), engineStrict, sideEffectsCache: false
(in-place rebuilds must not write into the store) and
verifyDepsBeforeRun: false (a `pnpm run` never installs).
The pnpm version is pinned in pm/lock.json and in root devEngines.
pmOnFail: ignore keeps that pin out of pnpm-lock.yaml: a leading pin
document makes osv-scanner read zero packages. scripts/build/node-deps.mjs
enforces the exact version instead, and every lock reader refuses a
multi-document lock. A root preinstall refuses `npm install`; devEngines
onFail is warn so npx (MCP servers, the tsc linter) keeps working inside
the checkout.
User-machine installs run --frozen-lockfile --trust-lockfile (release age is
checked when locking, in CI, not per user). node-deps removes npm-era
node_modules trees before the first pnpm install. Nix builds the store with
fetchPnpmDeps and a hash file that CI checks.
A `git fetch --depth 1` (installer checkouts are shallow) killed during its pack
transfer strands .git/shallow.lock, and every later fetch fails "Unable to create
'.../shallow.lock': File exists" until the 10-minute age sweep. The killed-update
reclaim (same custody + no-live-git proofs as the ref locks) now covers it;
release_dead_ref_locks becomes release_dead_fetch_locks.
Hermes's own _ShallowLock holder runs no git between its git calls, so it now keeps
the lock fd open while held: the /proc / lsof fd probe (and Windows' unlink refusal)
sees a live Hermes holder and the lock stays.
- A main checkout reports in_place_update=False, so the keep/delete prompt fired even with HEAD
already on origin/main; a "no" deleted edits on a no-op run. Ask only for a real branch switch
or when the target is not HEAD or an ancestor of it.
- status.showUntrackedFiles=no hid every untracked entry from the scan, so the stash fell back
to whole-tree --include-untracked. The scan now passes --untracked-files=normal.
`hermes update` ran `git stash push --include-untracked` on every dirty tree,
so any untracked folder in the checkout was hashed into git objects and
deleted from disk, then written back on restore. A 55 GB leftover worktree
copy (2.8M files) made the "stashing before update" step take minutes, and
declining the restore left the whole folder parked in git stash.
- Untracked entries are stashed only when a ref the update can land on
(target, origin/<branch>, local <branch>) has a file at that path, or a
parent/child of it; the rest stay on disk via exclude pathspecs, because a
pull or checkout never refuses on them. Comparison is casefolded so a
case-insensitive filesystem still counts Config.py vs config.py. Forks and
unreadable target trees keep the old stash-everything behaviour (the
upstream sync lands on a ref fetched later).
- An interactive terminal now asks "Keep these changes after updating?"
BEFORE anything is stashed. Yes stashes and restores without a second
prompt; no deletes exactly what would have been stashed, so no stash
entry is left behind. A run with nothing to pull does not ask. Gateway
/update, --yes and non-interactive (Desktop) paths are unchanged apart
from the scoped stash.
The check flagged start_new_session (keyword or dict key) and os.setsid/setpgid/setpgrp, but not
Popen's process_group= keyword, which also puts the child in a group of its own, nor the
item-assignment shape kwargs["start_new_session"] = True. Seven production sites used those shapes
unmarked, so "a new detached spawn can't bypass it" was not true. All three shapes now count for
both keywords (process_group=None, the default, does not).
The six sites it now finds are all awaited with a group-killing timeout or run a gateway restart
that must not share the updater's group; each gets a `detached-spawn: ok` marker with that reason
(shell hooks, the Windows-safe probe runner, update steps, git repack, the two update recovery
restarts). The seventh is in update_custody's embedded watchdog script, a string the AST never sees.
Tests: the four new shapes are flagged and process_group=None / start_new_session=False items are
not; the new cases fail on the prior checker.
The Desktop hands `hermes update` the system proxy in both spellings
(HTTPS_PROXY and https_proxy). The .env load replaces only the spelling
the file uses, and urllib and curl prefer the lower-case one, so the
update still went to the system proxy and the .env NO_PROXY stopped
applying. After the .env load, `hermes update` now undoes everything the
Desktop added when .env sets a proxy (the Desktop injects nothing when it
sees one). A .env NO_PROXY alone replaces the user own entries in every
injected NO_PROXY variant and keeps the bypass the Desktop added; a `*`
there drops the injection.
Review S1 (kshitijk4poor) on #144894.
The stable fetch added the release tag to the commit fetch as a forced
refspec (+refs/tags/vX:refs/tags/vX). Two main contracts went red in CI:
- a remote without that tag (a fork or mirror, every channel-manifest
fixture) failed the whole fetch with "couldn't find remote ref", so the
update stopped (test_update_channel_release_hold,
test_source_channel_integration);
- the forced refspec moved a stale local tag the stable pin promises never
to move (test_stable_git_uses_remote_identity_without_moving_local_tags).
The commit fetch is main's again. After it succeeds, fetch_release_tag
fetches that one tag only when the checkout lacks it, without +, and
ignores a failure, so #143656's label fix stays for installs missing the
tag and every other install updates exactly as on main.
A stable update fetched the release commit with --no-tags. version_info names a
checkout by the newest release tag merged into HEAD, so the checkout moved to
v0.22.3 while /health, the install stamp, the receipt and About all named
v0.22.2(+1) until something fetched tags (#143656). The resolver already
verified the tag on origin; fetch exactly that one ref with the commit.
The Desktop hand-off window's stage text was published from the END mark of the
previous stage (`record_stage` guessed the next one from a table). A failed build
published nothing, but the code is committed so the update still restarts the
gateways, and the window kept the build/packaging message through the whole
restart (Greptile review on #144922).
Each message is now published where its stage begins: "Downloading updates"
after the backup, "Updating dependencies" before the completion's dependency
sync, "Building the apps" at the product build, and "Restarting Hermes services"
inside the fleet restart phase itself, so every caller (current completion and
historical takeover) gets it after a failed build too. `record_stage` is back
to origin/main's shape: no next-stage table, no failed-snapshot special case.
The Windows stage-relay test waits with tests.waiting.wait_until instead of two
hand-rolled sleep loops (the STAGE_CLI child imports it from the repo it already
puts on sys.path).
update_cmd.py sat at ~2000 lines after the hold landed. Move
_hold_channel_release_behind_runtime unchanged into update_cmd_check as
hold_channel_release_behind_runtime, beside the channel target it acts on.
Facade helpers (_m, _capture_head_sha) are read through _uc() at call
time like the rest of that module, and downgrade_refusal is still
imported per call, so no import cycle and existing patches stay
effective. No behaviour change.
Refs #134889 (GW-19)
Refs #140762
Refs #140729
Review follow-up on #140741, carried onto #140778: the release can sit
beside HEAD's line rather than behind it, so "ahead of" claimed more
than the guard proves; use "newer than". The run no longer closes with
"Already up to date!" under the hold notice: _finish_already_up_to_date
takes a held message. Drop the redundant expected_sha pin (the
up-to-date path recaptures HEAD). _assert_held_at_head now checks the
new wording and the held closing line.
(cherry picked from commit 0dc55e84c3a1128f7d708e62ce49bae6a68ecc00)
A stable-channel install on a post-cutover main commit could never update:
the channel's release predates the gateway runtime its state.db already
uses, so every `hermes update` hit the downgrade refusal and exited 1,
though the user never asked to go back (#140729).
When the channel (not an explicit --branch) picked that release, keep the
install on HEAD, say it is ahead of the release and how to follow main,
and finish like an up-to-date run. An explicit --branch to a pre-runtime
commit is still refused.
(cherry picked from commit 6127cdc338c63197e1be5aa6dbbeb879821d304b)
git prints the same "fatal: not a git repository" line when the REMOTE
has a dangling .git file, so the line alone sent users with a healthy
checkout to reinstall. The diagnosis now also requires the checkout to
fail to resolve as its own top level.
The repair command names the failing checkout (--dir / -InstallDir):
a bare one-liner rebuilt the default directory and left a custom-dir
install broken.
Three conflicts:
- apps/desktop/electron/updater/checkout-source.test.ts: main's 354fe11d7e is the
same releases/latest fixture fix this branch carried as c300c295c1 (#135944); main's
version (it also drops the unused release-candidates route) is taken.
- agent/context_compressor.py: both sides added one import line; both kept.
- gateway/run_turn.py: main's 4c7ab8b2a0 added trigger="gateway_hygiene" to the hygiene
_compress_context call this branch moved to gateway/run_turn_hygiene.py; the branch side
of run_turn.py is kept and the trigger is carried onto the moved call.
`hermes update` and the Desktop update button on an official source
checkout now move between published vX.Y.Z releases instead of tracking
main commit by commit, and `hermes update --set-channel stable` works.
- stable resolves through R2 when a record exists; until one is published
it falls back to the latest published GitHub release (non-draft,
non-prerelease, strict vX.Y.Z, origin tag verified). It used to fail
with "Channel object not found: releases/channels/stable.json".
- default_channel(): an official-origin source checkout defaults to
stable; forks, mirrors and checkout-less trees keep main. Package
installs (MSIX, Store, bundled, electron-updater) are untouched.
- An install that never chose a channel only moves forward: a checkout
already newer than the latest release (every install that tracked main)
stays put until a newer release ships. --set-channel stable is the
explicit consent to land exactly on the release commit.
- Same rule in --check, the apply path and the passive source_check the
Desktop and banner use.
Two conflicts:
- tui_gateway/server.py: main's a274d3388f (#135781) quoted the two _TurnScopes
annotations in code this branch already moved and quoted; the branch side is kept.
- website/docs/developer-guide/relay-shared-metrics.md: main added channel_unresolved's
config note, gateway_pause_failed and the blobless-preflight sentence to the
hermes.update.run failure-class row; the branch's downgrade_refused entry is spliced
into main's row after commit_point_refused.
The exit is real (RuntimeError from require_readable_config_before_write), but the consent gate
reads the same config.yaml and reads it as collection off: a live run on a scratch install with
an unparseable config.yaml wrote no metrics store at all. A closed value nothing can emit only
widens the contract.
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.
17 conflicts, all from main's lint sweep (ruff UP007/UP017/UP022/UP028/UP031/UP037/UP041,
FURB188/FURB192); no other main commit touches a conflicted file. Resolution: the branch side
of each file plus main's exact autofix (ruff 0.15.10, main's ruff.strict.toml, --fix
--unsafe-fixes). ruff.strict.toml takes main's copy. The pool-retirement live fixture stays
deleted with the pooled backend this branch retires.
jquesnelle item 6 asks that older code refuse a downgrade. Already-released code cannot be
changed, but this release's updater is the one that performs the move. `hermes update
--branch <old>`, a channel switch to an older release, and a pinned release commit all
pass through _refuse_before_commit_point before the tree changes.
update_downgrade_guard.downgrade_refusal refuses only when both of these hold:
* the target does not descend from HEAD (forward updates never match) and none of its
hermes_state*.py files defines the session_admissions ledger. This is one
`git grep` against the object store, and the pathspec keeps a blobless clone from
fetching the tree. Any git failure proves nothing and does not refuse;
* the state.db of this profile or a sibling profile holds session_admissions or
worker_executions rows. A read-only open; an unreadable store or one with no runtime
tables does not refuse.
The refusal names each affected store and says downgrading is unsupported. It gives the
way back: `hermes gateway stop`, check out the older release, then put back each store's
pre-upgrade copy. It names the newest pre-update quick snapshot whose state.db predates
the ledger (`/snapshot restore <id>` on the older release). When no snapshot predates the
cutover it points at `hermes import <backup.zip>`. Nothing moves, and the receipt records
the closed stop token `downgrade_refused` (contract, v4 schema enum and metrics doc
updated together).
Verified against the real objects: with an r7-written store holding ledger rows, targets
v0.21.6 and origin/main are refused and HEAD passes.
Review: https://github.com/NousResearch/hermes-agent/pull/106742#issuecomment-6073624820
(cherry picked from commit 9688fff768cc595752b7f7bd9f4074274ef6b49f)
45 conflicts, all from #135177 (lefthook + `ruff check --fix` over 2,366 files). No other main commit
touches a conflicted file. Resolution: the branch side of each file, then main's exact autofix
(ruff 0.15.10, `--config ./ruff.strict.toml --fix`) on it — rewrites only (typing generics, noqa
drops, import forms), no import removed. tests/cron/test_bot_chat_cli_home.py stays deleted
(1cc9cb9199 retired its lane).
`.git/index.lock` left by a killed git refused every later merge with
"File exists": the stale-lock sweep keeps any lock younger than 10 minutes,
so a `hermes update` killed mid-run (Windows crash cell `mid_fetch`, run
37573293632) or a version probe killed by its own 3 s timeout (#132089)
failed the next update at "Pulling updates...".
- `gitlock.release_dead_index_lock`: drop the lock as soon as the launch
repair's ownership proof (`_early_recovery._release_dead_index_lock`:
/proc fds + lock-keeping gits on Linux, lsof on macOS, the unlink itself on
Windows) shows no live git holds it. An interrupted tree move keeps its own
lock judgement. `_cmd_update_impl` calls it first, under the update lock.
- `version_info._git_version_info`: the read-only status probe runs as
`git --no-optional-locks status`, so a timed-out probe strands no lock.
Part of #132089 (the stale index.lock half; naming why a pre-apply exit fired is separate work)
The stale-lock / aborted-pack sweep ran only in the apply path, after the
snapshot. A run that died in that window left `.git/index.lock` behind with
nothing in the next run to remove it, so the following update failed with
"File exists" until an operator cleaned it up by hand. The sweep moves to the
top of `_cmd_update_impl`, ahead of the plan and the backup;
`clear_git_debris` already wraps locks and aborted-transfer pack temps (and
the partial-clone maintenance keys), and `_sweep_stale` skips everything
while a git process holds it, so the earlier position is safe.
(Salvaged from #132089 work; the receipt stop-reason half of the original
commit is now owned by the overhaul's receipt API and is not carried here.)
12 conflicted files, resolved in three lanes:
- gateway/run.py: take the branch split; main's tirith removal and spool-replay
ordering ported into run_runtime_init.py / run_bootstrap.py.
- config migrations: main keeps v50 (tirith); the branch's bot-chat timeout removal
becomes v51 (_config_version 51, template + test updated).
- hermes_state_usage.py: main's voice_chat task route threaded through the branch's
owner transaction; worker.persist usage allowlist accepts task.
- tui_gateway/server.py: branch side; main's edits applied where the code now lives
(agent_factory, session_registry). Desktop sessions get the catalog toolset on the
authority path; daemon-run finite CLI turns are not watched turns; lazy session info
reports nous/welcome on the free tier.
- Desktop/Ink: one branch-child spinner fix (main's route-token check); main's
uninstall i18n keys kept, pool keys stay removed; voice_turn forwarded and accepted
by the authority submit (surface_v1.voice_turn).
- chat_ws / PTY keepalive test: main's stricter test kept.
- record_stop_without_receipt reads the update marker raw and tags initiator=desktop only when the
marker names this run's hand-off (delegate line = us, or HERMES_UPDATE_HANDOFF_PID / our parent),
so a Desktop-started early refusal reads kind=desktop and a CLI run refused by someone else's
update stays cli.
- _move_checkout_to classifies its git error with the shared classifier (git_output_stop_class):
index.lock 'File exists' -> git_index_locked, 'Permission denied'/EACCES -> permission_denied
(now a stop class), else checkout_move_failed.
- _publish_shared_metrics: an interpreter that cannot import the config reader (the -I -S bootstrap
that finalizes a dependency-preparation failure) never emits; it parks the bounded receipt in
pending_updates unless it can tell collection is off (loaded config says off, or no config.yaml),
and the next normal start applies the existing gate (report_pending_updates / begin_process purge).
- The parked copy keeps a stop reason's label only when it is an exception type name (plus its
errno token) or one of Hermes' two fixed phrases; any other text before a colon reads '-'
(a free phrase like 'my project name:' used to survive).
- _exit_after_failed_branch_switch's branch_missing/checkout_move_failed probe cannot raise, so
the exit it names is unchanged even when git refuses.
- hermes update --plan/--check/--list-venv-holders on a managed install records no update run.
- Docs/contract say 'before the apply stage mark' and name the tokens that fire after git moved
and restored the tree; tests drive git_error_stop_class and _zip_stop_class directly.
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.
#132338 (on main) binds every checkout-writing update step to the paused
gateways' tree gate before git writes a file; this branch rewrote those
steps around its commit point, so the replay kept the commit-point code and
this commit carries the gate into it:
- the principal move (target_sha) runs under _checkout_move via
_move_checkout_to, main's extraction, on the commit-point form
- the branch switch at its commit point binds both resolved commits
- the broken-update rollback binds pre_pull_sha around every write,
its in-process restore included, from all three callers
- stash, restore (all four), fork sync (both) and churn cleanup pass
checkout_move; a failed switch's stash restore too
- "already up to date" no longer switches back to current_branch
(main's behaviour)
The gate helpers live in update_cmd_pause_gate.py (re-exported by
update_cmd) so update_cmd.py stays under its line cap.
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: CP1 merges/checks out/reconciles to the resolved target OID instead of origin/<branch>
or the tag name, and refuses (settled as a failed move, debt re-owed for the HEAD it settled
on) when HEAD is neither that commit nor a merge containing it. CP0's `checkout -B` names the
resolved origin commit; its `checkout --no-guess <branch>` must name the branch to attach HEAD,
so a landing on any commit but the armed one is settled the same way and reported as a moved
branch instead of a success.
Why: a ref that moved between resolve/arm and the git argv landed a commit the marker and the
restart debt did not name, and the marker was dropped on git's exit 0 (O2 for CP0, O3 for CP1).
Tests (RED at fd5c37f17bf): the pull lands the armed B after origin/main moves to C; a CP0
branch that moves to B after A was armed returns failure with HEAD and debt both on B.
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: _rollback_if_pulled_syntax_error catches (OSError, subprocess.SubprocessError) around
arm_tree_move, so a TimeoutExpired from its `symbolic-ref` call takes the existing
"rolling back without the marker" path.
Why: only OSError was caught; a timeout aborted the rollback before any reset and left the
broken release checked out (review C10).
Test (RED at 5dfc384669e: TimeoutExpired escapes): arm_tree_move timing out still lands HEAD
and the file on the pre-pull commit with "Rollback complete".
What: _prepare_checkout_for_update hands a failed switch to _exit_after_failed_branch_switch,
which tells three cases apart: the commit point refused (the switch returns args
_ARM_REFUSED; its reason is printed alone), a switch torn under its kept marker (the autostash
stays parked and is named, the next launch restores the checkout), and a branch git could not
check out (the old "does not exist locally or on origin" message).
Why: a CP0 refusal printed "Branch ... does not exist locally or on origin." above the real
reason, and a settle-failed switch (torn tree, marker kept) still re-applied the autostash onto
the half-written tree, mixing the user's edits into git's partial writes (review C8).
Tests (both RED at 5dfc384669e): a refused switch prints the refusal without the missing-branch
line; a torn switch never calls _restore_stashed_changes and names the parked stash.
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.
Health ratchet: update_cmd_fleet.py may only shrink vs main (2166 > 2165).
_systemd_restart_timeout (+ _SYSTEMCTL_RESTART_TIMEOUT_MAX) and
_service_restart_sec (+ _RESTART_SEC_UNITS) move verbatim to the new
sibling as systemd_restart_timeout / service_restart_sec; both late-import
update_cmd_fleet._systemctl, so the seam tests patch is unchanged. Callers
late-import the sibling; the unused update_cmd re-export of
_service_restart_sec is dropped (no shim). 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: _switch_branch_at_commit_point arms the obligation for the attempt's own target, not
resolve(target_ref).
Why: with local main (A) behind origin/main (B) the --no-guess checkout lands on A while B was
owed; a stop before CP1 left debt no checkout at A could discharge. CP1 retargets to B before its
own move.
Red (pre-fix line): host expected_sha == B after landing on A.
Green: expected_sha == A.
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.
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)