Commit Graph

49581 Commits

Author SHA1 Message Date
Yuan Li
8bd23cddee test(web): cover sidebar state replay 2026-10-06 09:37:03 -04:00
Yuan Li
d3c3a1ea2d fix(web): ignore replayed sidebar connection state 2026-10-06 09:37:03 -04:00
Teknium
0c77988889 test(update): the post-commit ZIP resume test's git-command fake accepts checkout_move
The same #132386 interaction as the ZIP completion tests: this test
(from main) replaces update_cmd._prepare_git_command with a no-argument
lambda inside its child process, and this branch calls it with
checkout_move=..., so the child died with TypeError and the test read it
as a failed update. The fake takes **_ like every other fake of it.
2026-10-06 06:18:39 -07:00
Teknium
2489694dae refactor(update): the startup half of a previous update runs from main_install_repair
On main, #132365 moved container-exec routing out of main.py, which
lowered main.py's code-health line cap to its new size (3643). This
branch's paused-gateway recovery added four lines to main() and pushed
the file to 3647, so the code-health check refused it.

The owed-fleet-restart warning and the paused-gateway recovery move
together into main_install_repair._recover_update_debts_on_startup, next
to the Windows quarantine sweep that already lives there; main() calls it
at the same point, so startup order and behaviour are unchanged. Both
steps stay late-imported, so tests patching
update_cmd_fleet._warn_pending_fleet_restart_on_startup or
update_pause_record.recover still intercept them. The fleet warning's
silent `except Exception: pass` now logs at debug with the traceback,
which is what the health check asks of a catch-all boundary in new code;
a failure there still never blocks the launch.
2026-10-06 06:18:39 -07:00
Teknium
e3acdd1c87 test(update): the post-swap ZIP tests' git-command fakes accept checkout_move
#132386 added three ZIP tests that replace update_cmd._prepare_git_command
with a no-argument lambda. This branch calls it as
_prepare_git_command(checkout_move=...), so after the rebase onto main each
fake died with "unexpected keyword argument 'checkout_move'". They now take
**_ like every other fake of that function in the tree; the assertions are
unchanged.
2026-10-06 06:18:39 -07:00
Teknium
e84fb96622 test: the cold-start lifecycle fixture answers the probe the relaunch verifier polls
The post-update relaunch verifier now polls every target in one loop through
`gateway_windows._live_gateway_pids` (d243ecf107f); these Windows-only tests
still stubbed only `_wait_for_gateway_ready`, so the relaunched `beta` was
never seen and resume raised "relaunch ... was not verified alive: beta".

The same probe also answers "is this profile already running?" before a cold
start, so the stub follows the lifecycle: a home answers (with a fresh PID,
through the verifier's own PID filter) only once its profile was relaunched via
`launch_detached_profile_gateway_restart`. A profile that has not started yet
still reads as down and is cold-started, and one profile never vouches for
another. Assertions are unchanged.
2026-10-06 06:18:39 -07:00
Teknium
f00b4a0339 fix: the planned-stop consumer takes the pause lock only for a pause on disk and drops settled receipts
consume_planned_stop_marker_for_self took .hermes-update-paused-gateways.lock
on every platform for every stop (up to 2 s when busy), creating the lock
file in the root home, and on a busy lock fell back to keeping the request
and writing a .accepted receipt that nothing ever removed. The lock only
protects the checkpoint into a Windows update pause record; with no record
or claim of this checkout on disk (every non-Windows host, or a pause
recovery/the updater already retired) there is nothing to checkpoint.

The consumer now holds the lock only when a pause is on disk. The
checkpoint hook re-checks after the request was read, so a pause that
appeared after the first look (an update writes its record before its
request) is still checkpointed under the lock. With no pause on disk, the
consumer removes the .accepted receipt and an expired stamped request an
earlier drain left: their debt is settled. While any pause is on disk both
stay (never earlier).

test_a_busy_pause_mutex_never_turns_a_planned_stop_into_an_unplanned_one
now puts a pause record on disk first: without one, keeping the request on
a busy lock was the leftover this fixes; with one, its assertions (planned,
request retained, no block) are unchanged.
2026-10-06 06:18:39 -07:00
Teknium
a73deeafc8 fix: a second stop signal never deletes a stamped accepted stop request
When the gateway's home refuses a new .accepted receipt, mark_stop_accepted
stamps the kept request itself with accepted=true; that stamp is then the
only durable trace that this draining incarnation accepted the update's
stop. _read_live_pid_marker unlinked every expired marker, so a second
signal past the request TTL (another Ctrl+C / SIGTERM, which calls the
consumer again) or a planned-stop watcher probe in the same home deleted
it, and recovery then retired the restart debt of a gateway that was still
draining.

An expired stamped request still matches nobody (no PID-reuse grief), but
the TTL cleanup no longer unlinks it.
2026-10-06 06:18:39 -07:00
Teknium
636fbbd7c4 fix: the stash and churn steps bind the paused gateways' tree gate before writing files
Round 6 bound the branch switch, the upstream syncs, the pull and the rollback.
Other steps that run while the Windows pause holds the gateways still rewrote
tracked files unbound:
- `git stash push` and its cleanup `reset --hard`;
- `git stash apply` and the reset after a conflict;
- the rejected-restore `reset --hard` + `clean`;
- the lockfile-churn `checkout -- <locks>` and the line-ending `checkout`.

If one of them was interrupted mid-write, the gate judged the torn file only
against whatever refs a fetch had left. A restore after the pull had no
baseline at the moved HEAD at all. Either way the paused set was admitted.

Each step now runs inside `_checkout_move`. `paths=` binds the files it
rewrites in place, and `revert=True` binds every tracked change it finds,
because it puts them back to HEAD. A restore binds its stash commit. The gate
counts those paths as writable. A restore that applied its tracked changes in
full reports `whole`, so the tree it changed by design retires its baseline
instead of holding the set.

Removed the "already up to date" switch-back `checkout <current_branch>`. It
only ran when no checkout had left current_branch: in-place and release
updates never switch, and a parked switch is excluded. It was a no-op that
nothing gated. Helpers take a `checkout_move` binder (`_moves_for(token)`);
test doubles of `_prepare_git_command` accept its new keyword.
2026-10-06 06:18:39 -07:00
Teknium
b3835f9656 fix: the paused gateways' tree gate judges a moved HEAD against the commit it reached
`git checkout <existing branch>` can exit 0 and move HEAD while leaving a file
unwritten ("unable to unlink old ..."): HEAD and the index name the new commit,
the file keeps the old bytes. `tree_is_whole` only judged baselines at an
unmoved HEAD, so that torn tree was admitted and the paused gateways started
on it.

`_checkout_move` now judges every move the moment git returns: the paths
between the commit it started from and the one HEAD reached that are changed
and were not already there byte for byte (`update_pause_record.torn_by_move`)
are recorded on the baseline as `landed[<head>]`. The gate holds while any of
those paths is still changed. Judging at return time, not at resume, keeps a
later dependency sync or stash restore that rewrites an upstream-changed file
(package-lock.json) from holding the set. A recorded move the updater died
before judging is judged at resume time against what the pause saw.

A step's own baseline is retired only when its move landed whole (it was
retired on any HEAD move before). The moved-HEAD gate reuses tracked_changes
and the dirty digests the record already carries.
2026-10-06 06:18:39 -07:00
Teknium
9f2e211156 fix: every checkout-writing update step binds the paused gateways' tree gate first
Only the principal pull recorded its target (mark_move) before git wrote the
tree. The first branch switch in _prepare_checkout_for_update (and its
`checkout -B` fallback), a fork's early and late upstream sync, and the
post-pull syntax rollback wrote the tree unrecorded: a torn switch or sync at
an unmoved HEAD was judged against whatever refs a later fetch left, and a
failed second move (upstream sync after a successful pull) had no baseline at
its starting HEAD at all, so the gate admitted the torn tree.

All of them now run inside _checkout_move(token, *targets). Before git runs it
persists the resolved target(s): on the pause's own baseline when the step
starts from the pause HEAD (it knows the edits already there), otherwise on a
new baseline taken at the step's starting HEAD with its dirty digests. A
record that cannot be written raises, so the step does not run. Once git
returns, a per-step baseline is retired if HEAD moved or the tree is exactly as
the step found it, so a clean refusal never holds the set. The upstream sync
takes the context as `checkout_move`; the pull's move is extracted to
_move_checkout_to so _pull_updates does not nest deeper.
2026-10-06 06:18:39 -07:00
Teknium
6483df614d fix: post-update relaunch verification polls every target in one loop
Retiring each paused runtime on its own readiness made the wait serial: every
profile and unmapped entry ran its own 2 s confirmation window (plus a 6 s
per-target floor past the deadline), so 3 profiles took 6 s and 5 took 10 s
where the fleet-wide wait had taken one window. All targets now share one poll:
one fleet process scan per tick, each target credited only on its own hits for
a whole confirmation window, and a PID one target vouched for is never credited
to another. Measured with readiness stubbed to succeed: N=1/3/5 went from
2.0/6.0/10.0 s to 2.0 s flat (N=10 also 2.0 s).

The reconciliation tests' liveness stub moves to the probe the verifier now
polls (_live_gateway_pids); their assertions are unchanged.
2026-10-06 06:18:39 -07:00
Teknium
e7704fb7a4 fix: startup gateway recovery prints to stderr, never the command's stdout
update_pause_record.recover() resumes paused gateways ahead of every hermes
command and prints its own notices to stderr, but the Windows resume path it
calls printed deferral/restart notices to stdout, corrupting the output of
unrelated commands (--json ones included). A recovering resume now routes its
stdout to stderr.
2026-10-06 06:18:39 -07:00
Teknium
4c77e8954b fix: a failed service pause keeps its record on typed rollback failures
_pause_windows_gateways_for_update decided whether to abandon the durable pause
record by searching the error text for "rollback failures"; rewording that
message would delete the record while gateways are still stopped. The service
pause now raises ServicePauseFailed carrying rollback_failures, and the record
is abandoned only when that list is empty. Any other exception keeps the record.
2026-10-06 06:18:39 -07:00
Teknium
9e367a9859 fix: a paused unmapped gateway keeps the home discovery proved
Discovery owns an unmapped gateway whose environment is unreadable when the
exact-birth spawn ledger names its home, but the pause snapshot re-read only
the environment and wrote `home: null`. The readiness predicate read that as a
pre-home record and let another home's gateway with the same selectorless argv
retire the debt.

The snapshot, discovery and readiness now share one resolver
(update_fleet_scope.gateway_pid_home: environment, else ledger witness). A home
that no longer proves refuses the pause before any stop, and only an entry with
no `home` key (an older updater's token) keeps argv-only matching; a recorded
empty home is never matched.
2026-10-06 06:18:39 -07:00
Teknium
54c8f9ff23 fix: the psutil-less identity test judges with one creation-time probe
update_lock caches this process's creation time (_OWN_CT). Warmed by an earlier
test through psutil, it differed from the stdlib probe identity() uses with
psutil hidden by that probe's resolution (macOS `ps -o lstart`: 1 s), so the
test failed in a full-file run and passed alone. It now starts from an empty
cache, so both sides use the same probe.
2026-10-06 06:18:39 -07:00
Teknium
759341e3a1 fix: drop update_pause_record.orphaned_record, which only tests called
Its callers (the record test helper and the hostile-host pause cell) now read
orphans() directly, the same survey recovery and adoption use.
2026-10-06 06:18:39 -07:00
Teknium
a04e1a942b fix: startup recovery keeps the Windows resume's progress lines off the command's stdout
recover() runs before unrelated commands (anything printing --json, the
serve/dashboard processes Desktop reads over a pipe). Its own notices already go
to stderr, but the Windows resume helpers it calls print with bare print(),
which landed in that command's stdout. The resume now runs with sys.stdout
pointed at stderr. The record tests' resume stand-ins report on the real stdout
(sys.__stdout__) accordingly; no assertion changed.
2026-10-06 06:18:39 -07:00
Teknium
dd23f9c7b2 fix: a recovering launch pays an unready gateway relaunch once per backoff window
update_pause_record.recover() runs before every command but `update` and
`gateway run` (chat, doctor, config, the serve/dashboard processes Desktop
spawns). Its Windows resume waits out the relaunch readiness budget for each
profile/unmapped gateway; one that never became ready was handed back unowned
"so the next launch retries at once", so every following command stalled
~30 s again (measured: 31 s on each of three consecutive launches).

Profile and unmapped entries now get the stored backoff SCM services already
have (service_retry): an attempted entry the resume left owed gets
relaunch_retry {attempts, next_at} (60 s doubling, capped at 1 h); a launch
inside the window holds it owed beside the draining entries without relaunching
it. The debt itself is never dropped, a tree-gate deferral does not count as
an attempt, and `hermes update` (merge_into does not carry the backoff) still
retries at once.
2026-10-06 06:18:39 -07:00
Teknium
7b68039222 fix: unreadable or unwritable accepted-stop evidence keeps a draining gateway owed
The pause record's second evidence reader (_names_request) turned a refused or
malformed planned-stop marker / accepted receipt into False, i.e. "this update
never asked it to stop". Past the request's TTL recovery then dropped the live,
still-draining gateway (drop_never_stopped) and durably retired its debt.
Now only an absent file or a valid one naming something else is "not asked";
refused/malformed is unknown: the entry stays owed (held as draining while its
process lives), and unknown is never resolved into stop_sent, so the next pass
judges it again.

The consumer's receipt (mark_stop_accepted) swallowed its own write failure, so
an accepted stop could leave no durable trace at all. When the home refuses the
new receipt file it now stamps the kept request in place ("accepted", opened r+,
never created); a torn stamp reads malformed, hence unknown, hence owed. An
expired request nobody accepted still expires.
2026-10-06 06:18:39 -07:00
Teknium
c853032a79 fix: same-HEAD gateway admission is judged on the move the update recorded
The unmoved-HEAD tree gate decided which paths the failed move could have
written from the CURRENT FETCH_HEAD and remote refs, so a later fetch whose
target no longer touched a half-written file turned the gate from hold to
resume on unchanged partial bytes. And a path dirty at pause was exempt by name
alone, so an autostashed edit let different half-written bytes through.

_pull_updates now records the resolved move target on the pause's own baseline
before git writes (mark_move; a failure aborts the move), and the gate diffs
HEAD against that recorded target. The pause also stores each dirty path's
digest, so only the same bytes count as "seen at pause". A pause with no
recorded move, or a record from before these fields, keeps the fetched-refs
and pathname behaviour.
2026-10-06 06:18:39 -07:00
Teknium
cff9e7a0f5 fix: an accepted planned stop stays owed for the whole drain
When both the producer's mark_stop_sent and the consumer's checkpoint into the
pause record were refused (busy mutex, Windows replace refusal), the consumer
still accepted the stop and left only the 60-second request marker. An updater
that died meanwhile left recovery to drop the still-draining gateway as never
asked once that TTL passed, losing its restart.

The consumer's fallback now writes a receipt beside the marker (the gateway's
own home, not the record) naming the accepting incarnation; recovery treats it
as stop evidence without a TTL. Unconsumed requests still expire, and a user's
stop (another stopper) is still no evidence.
2026-10-06 06:18:39 -07:00
Teknium
7bf241ee08 fix: an unreadable saved pause or claim is unknown, never absent
read() returned None for ENOENT, a refused read and malformed bytes alike, so
the carrier survey dropped an unreadable newer claim (its older alpha+beta copy
then executed again, replaying completed alpha) and write() replaced an
unreadable orphaned record (losing its restart obligation).

Only a missing file is absent now; a refused or malformed one raises
RecordUnknown (an OSError), so selection, claiming and the new pause's write
fail closed and leave every file for a later, readable retry.
2026-10-06 06:18:39 -07:00
Teknium
74ea8010c0 fix: an unmapped gateway's restart debt is retired only by its own runtime
The pause saved an unmapped Windows gateway (no profile PID file) as pid + argv
only, and readiness accepted any owned-fleet gateway with the same argv tail.
Two homes can run identical selectorless argv, so home B's gateway (already
restored, or service-ready in the same pass) retired home A's debt while A
stayed down; the replay also ran A under the updater's HERMES_HOME.

Capture the runtime's home (dashboard_procs._hermes_home_for_pid) and birth at
pause, replay on that home, and require readiness from a gateway process on the
same home born after the one it replaces. Entries from before home capture keep
the argv-only match.
2026-10-06 06:18:39 -07:00
Teknium
fc0434d111 test: the pause record's git resolver is a reviewed resolution site
update_pause_record._git_executable reads the paused gateways' checkout outside the updater, where
install.ps1's PM-staged git is not on PATH, so it falls back to that staged git after PATH. Same
shape and reason as hermes_cli._early_recovery._git_executable (#132361), which it becomes once
both land; the managed-runtime ratchet needs the row until then.
2026-10-06 06:18:39 -07:00
Teknium
d723fabcbe fix(update): an unmoved HEAD holds paused gateways only for paths the update's move could write
Decision for review 5411136378's "same HEAD" item. At the pre-update HEAD the
gate counted every tracked change the pause had not seen as git's half-written
checkout. A no-op update (already at the target) still runs its dependency and
web builds; one that rewrites a tracked file (a lockfile, a generated asset)
made the end-of-update resume defer, and every later launch and every later
no-op `hermes update` (adoption keeps the old baseline) deferred again, so the
gateways stayed stopped until someone cleaned the file. A user editing a tracked
file had the same effect.

Rule chosen (a committed update's gateways never wait on a follow-up that cannot
succeed, and never start on a torn tree): a fast-forward, reset or merge only
writes paths that differ between HEAD and its target, and the update fetches
that target first. An unseen change now counts only on a path that differs
between HEAD and a commit in FETCH_HEAD or refs/remotes. When git cannot say or
nothing was fetched, every unseen change still counts (the previous behaviour),
so the torn-tree cells keep holding.
2026-10-06 06:18:39 -07:00
Teknium
e9803548f5 fix(update): a moved HEAD holds paused gateways for dependencies only where a launch syncs them
Decision for review 5411136378's "moved HEAD" item. tree_is_whole required
pm.venv_is_current at a moved HEAD and counted any exception as "not current".
The updater's early exits after the pull (wrong branch, channel sha mismatch)
reach the resume before the dependency sync, so they deferred; on a checkout
whose launches never sync dependencies (developer checkout, no install stamp,
lazy installs disabled, ZIP) or whose probe raises, the deferral never ended
and gateways stayed stopped where main restarted them.

Rule chosen (gateways never stay stopped after a committed update without an
owed follow-up that can succeed): defer only when the install is self-managed
(updateMechanism "self", the conditions venv_sync.prepare_launch syncs under),
because every later launch syncs dependencies before startup recovery runs, so
the owed record resumes on the next launch. Everywhere else, and when currency
cannot be established, resume now; the restarted gateway's own launch syncs or
prints the remedy.

Changed assertions: test_resume_waits_for_a_whole_tree pinned "dependencies"
deferral on a stamp-less checkout, which is the stranding this item reports; it
now expects a resume. test_an_adopted_baseline_never_replaces_this_runs_own
patches the renamed helper (_venv_is_current -> _deps_hold_resume).
2026-10-06 06:18:39 -07:00
Teknium
1f6a9ba106 fix(update): the paused-gateway tree gate finds PM's git and runs it in the update's custody
update_pause_record._git ran a bare ["git", "-C", root] through subprocess.run.
On a Windows install whose only git is the copy install.ps1 staged in PM's store
(expose_pm_git puts it on PATH only inside the updater), every gate read died
with WinError 2: head_sha returned None and tree_is_whole refused on every
launch, so paused gateways stayed stopped. Reads now resolve git like the
updater does without installing anything (PATH, then PM's recorded entry, then
the lockfile's staged entry) and run through update_custody.run_git, so inside
an update they are job-bound on Windows and carry the custody config.

The resolver mirrors _early_recovery._git_executable from #132361 minus the
marker-recorded path; this lane does not carry that module's version.

Review 5411136378 (#132338) fix 3.
2026-10-06 06:18:39 -07:00
Teknium
ccca876c94 fix(update): the pause record's owner identity uses the reader's creation-time probe
identity() took its creation time from process_identity._process_create_time
(psutil only), while identity_is_live judges with update_lock.incarnation_live,
whose producer update_lock.process_create_time falls back to the stdlib probe.
Without psutil every record was written with ct=None, which the reader can only
call unprovable (live), so a killed updater's pause was never orphaned on such a
host. identity() now calls update_lock.process_create_time, the same producer
the marker uses.

Review 5411136378 (#132338) fix 2.
2026-10-06 06:18:39 -07:00
Teknium
183eff2f5d fix(update): pause-record temp cleanup, per-thread mutex, corrupt history quarantine
Three hygiene gaps in the Windows paused-gateway record:

- _atomic_write left `<name>.<pid>.tmp` beside the record when os.replace
  was refused (a Windows reader without FILE_SHARE_DELETE) or the write
  failed: the temp is now unlinked and the error still propagates.
- _mutex counted re-entry process-wide, so a second thread of the same
  process (gateway consumer, watcher) entered while the first held the
  kernel lock: the depth is now threading.local and each thread's first
  entry takes its own descriptor + flock/msvcrt lock, so threads exclude
  each other like processes.
- A `.retired` list that reads but does not parse raised RetiredUnknown
  from adopt_orphans -> every Windows update aborted, forever. With no
  record or claim of this checkout left the list guards nothing: it is
  renamed to `.corrupt` (warning on stderr) and the update goes on.
  While a record/claim exists the list may be the only proof it was
  completed, so it stays unknown (R9 fail-closed is unchanged); a list
  that cannot be read at all (sharing violation) is unknown as before.

Review W3.
2026-10-06 06:18:39 -07:00
Teknium
5c6de278be fix(update): narrow the service-readiness parent walk to psutil/OS errors
_service_gateway_ready.under_service wrapped psutil.Process(pid).parents()
in an unwaived suppress(Exception): any bug in the walk read as "this
gateway is not under the service", i.e. a silent not-ready verdict that
leaves a resumed service owed forever. Only a vanished gateway or an
unreadable process tree (psutil.Error, OSError) means "not the
service's"; anything else now reaches the resume boundary, which
reports it.

Review W2.
2026-10-06 06:18:39 -07:00
Teknium
d83906e058 fix(update): keep every pause's tree baseline in the paused-gateway gate
merge_into carried an adopted orphan's pre_sha/dirty_at_pause with
setdefault, so record_pause's later stamp_tree kept the ORPHAN's
baseline and dropped this run's own. When the killed run had moved
HEAD X->Y, this run's torn same-HEAD checkout at Y was judged against
X: HEAD "moved", so only the venv was checked and the gate passed
(probe: composite (True, '') vs own stamp (False, 'half-written')).

The token now carries `baselines`: one {pause_id, pre_sha,
dirty_at_pause} per pause (stamp_tree is idempotent per pause_id;
merge_into, finish_pause and abandon_pause carry the list). The gate
fails when ANY baseline taken at the current HEAD sees a tracked change
its own pause did not, so the oldest evidence still blocks a torn tree
the orphan left and this run's evidence blocks the tree it tore itself.
The record format is new on this stack (not on main): no compat read.

Review W1.
2026-10-06 06:18:39 -07:00
Teknium
bf1918fcf7 chore(update): waive the pause-record recovery boundaries' catch-alls
Code-health BLE001 in hermes_cli/update_pause_record.py. All three are
deliberate boundaries, now waived on the except line with the reason:
- _venv_is_current: fail closed -- any error reading venv state means "not
  current", so paused gateways stay stopped.
- _resume_fenced: any resume error is reported and the claim handed back in
  the finally path, never raised into the launch.
- recover: start-of-run recovery never bricks a launch.
Behaviour unchanged.
2026-10-06 06:18:39 -07:00
Teknium
32b7d817b6 refactor(update): narrow Windows resume probes' blind excepts
Code-health BLE001 in hermes_cli/update_cmd_windows.py:
- _service_gateway_ready.under_service: service.pid() raises psutil.Error /
  OSError and int() ValueError/TypeError; those still mean "owns nothing".
- _service_running: psutil missing (ImportError), missing service / SCM
  access (psutil.Error, OSError), non-Windows psutil without
  win_service_get (AttributeError) still mean "not running".
- _unmapped_ready_filter.matches: Process().cmdline() raises psutil.Error
  (exited/denied/zombie), OSError, ValueError (invalid pid): still no match.
- _resume_paused_set.attempt keeps its catch-all (per-step isolation: a
  failed step is recorded and re-raised as a summary so it never keeps the
  other paused runtimes stopped) and is waived on the except line.
The narrowed sets are exhaustive for the calls they guard; behaviour
unchanged.
2026-10-06 06:18:39 -07:00
Teknium
f9f8962fbb refactor(gateway): move inline-source argv parsing out of gateway/status.py
gateway/status.py grew past main's size (code-health FILE_LINES). Move the
cohesive `python -c <src>` parsing group -- inline_source_flag_index,
command_line_runs_inline_source, the Hermes bootstrap patterns,
_bootstrap_entry and inline_bootstrap_argv -- verbatim into the new sibling
gateway/status_inline_source.py (no imports of the facade, so no cycle).
status.py imports the three names it uses; the one external caller
(hermes_cli/update_cmd_windows.py) now imports from the sibling. No shim,
no docs named the symbols. Behaviour unchanged.
2026-10-06 06:18:39 -07:00
Teknium
5ba4a05ad2 fix(update): an argv value equal to "update" no longer suppresses paused-gateway recovery (review R5405535944-4-raw-update-argv)
What: main() calls update_pause_record.recover() unconditionally instead of inside the raw
`"update" not in sys.argv[1:]` block; recover() already skips the parsed `update`/`gateway run`.
Why: `hermes --resume update ...` (a session named "update") left gateways a killed update paused
stopped, though no update was running.
Red: pre-fix main.py -> [--resume update --version] leaves the orphaned record on disk.
Green: test_startup_recovery_follows_the_parsed_command_not_raw_argv passes both cases (`update --help` still defers).
2026-10-06 06:18:39 -07:00
Teknium
e3275db9ae fix(update): startup recovery resumes gateways only while holding the checkout lock (review F5-recovery-start-vs-checkout-mutation)
What: _resume_claimed takes the checkout lock (bounded contention wait) around the tree gate and
the verified gateway start; when an update already took the checkout after our claim, the claim
is handed back unowned and untouched, so that update or the next launch owns it.
Why: recovery judged the tree and launched gateways with no checkout custody, so a concurrent
`hermes update` could mutate the checkout under freshly started gateways (the updater skips a
live claim and may discover no running gateway to pause).
Red: pre-fix update_pause_record.py -> "gateways restarted while an update owned the checkout: resume ['default']".
Green: test_recovery_never_starts_gateways_under_an_update_that_took_the_checkout passes (set resumed by the next launch).
2026-10-06 06:18:39 -07:00
Teknium
0f3fedd9a6 fix(update): adopting an orphaned pause keeps its pre-update tree evidence (review F2-adoption-restamps-baseline)
What: merge_into carries the adopted pause's pre_sha/dirty_at_pause (setdefault), so
record_pause's stamp_tree keeps the oldest baseline instead of stamping the current tree.
Why: re-stamping certified a tree the killed update left half-written as "dirty before the
pause", letting the adopting update's resume start gateways on a torn checkout.
Red: pre-fix update_pause_record.py -> test fails "adoption certified the torn tree as the pre-update baseline".
Green: test_an_update_adopting_an_orphan_never_certifies_the_tree_it_left_torn passes.
2026-10-06 06:18:39 -07:00
Teknium
329287a70b fix(gateway): the pause bookkeeping never turns a planned stop into an unplanned one
R8 re-review m5 (first bullet). `consume_planned_stop_marker_for_self` waited up to 10 s on the
pause mutex inside the shutdown path and raised when the consumer checkpoint could not be written,
so a planned stop was classified as an unexpected signal (on Windows during a pause a supervisor
could then respawn the gateway while the update holds the venv).

- The mutex wait here is 2 s.
- A busy mutex or a failed checkpoint classifies the stop without consuming it: the request stays
  on disk, which is the evidence recovery reads in place of the checkpoint (it expires by TTL).

Tests: test_a_busy_pause_mutex_never_turns_a_planned_stop_into_an_unplanned_one (new) and
test_failed_consumer_checkpoint_keeps_request_and_user_stop_is_not_update_debt (now asserts the
stop is planned while the request is kept) — both RED on 13017670871, GREEN here.

(cherry picked from commit 7145a469d5916bc2cddb974401a509ebb656f4ba)
2026-10-06 06:18:39 -07:00
Teknium
76afc136f3 fix(update): a launch recovering an unready SCM service no longer stalls on it
R8 re-review M3. Since D4b a running service stays owed until its gateway is ready, and
`update_pause_record.recover()` runs before every non-update command: each launch re-claimed the
debt and waited the full 60 s readiness budget (+2 s confirm), with no backoff, for as long as the
service's gateway stayed unhealthy.

- A recovering launch (`token["recovery"]`) probes readiness for 5 s; the 60 s wait stays with
  `hermes update`.
- A service found running but unready records `service_retry` {attempts, next_at} on the token
  (60 s, doubling, capped at 1 h). A recovering launch inside that window skips a still-running
  service at once and keeps it owed; a stopped one is still started. Success clears the entry.

Test (native Windows, real SCM service whose gateway never comes up, two real recovering
launches): test_a_launch_recovering_an_unready_service_keeps_the_debt_without_stalling.

(cherry picked from commit 823c214534e5f8d301097c063cfc79ebbfddc13c)
2026-10-06 06:18:39 -07:00
Teknium
b29247ef8b fix(update): an unreadable retired list never makes a completed pause owed again
R8 re-review M2. `_retired()` read any OSError/ValueError as "nothing retired": while the list
was unreadable (a Windows sharing violation, an AV scanner) a completed obligation came back into
`orphans()`, `claim()` took it, and a retirement made in that window rewrote the list with only
its own ids, erasing the history for good.

- Only an absent list is empty. One that exists but cannot be read (or is malformed) raises
  `RetiredUnknown`: `orphans()` reports it (recovery prints the skip), `claim()` takes nothing,
  `_retire()` raises before rewriting the list or deleting a carrier.
- `orphans()` surveys under the pause mutex, so it no longer reads the list while a mutator
  replaces it (Windows opens without FILE_SHARE_DELETE).
- `hermes update` aborts before its first stop with a clear message when it cannot read what an
  earlier update paused, instead of adopting from an unknown list.
- `_mutex()` takes an optional wait (used by the planned-stop consumer in a later commit).

Test: test_an_unreadable_retired_list_claims_nothing_and_forgets_nothing (real file, mode 000 as
the stand-in for a Windows read refusal) — RED on 13017670871, GREEN here.

(cherry picked from commit 08ce58ae672457def25deb5e7f775f5166fa7eaf)
2026-10-06 06:18:39 -07:00
Teknium
2dc8d7799c fix(update): the pause force-kills only the processes it discovered
The force-kill after the drain passed get_process_start_time(pid) read at kill
time as the expected identity, so a PID that exited during the drain and was
reused validated against its replacement's own birth. Births are now captured
at discovery (a mapped gateway's strict PID-file identity first, the rest
right after discovery, launchers before the first request) and the kill is
authorized by those; a PID with no discovered birth is still refused.

Native windows test drives _stop_windows_gateways against a real survivor
whose discovered birth is its own (killed) or an earlier process's (spared).

(cherry picked from commit 9e01b731511996da4f8300d0c147a1ce4859742e)
(cherry picked from commit 1301767087185ca81bde9cec2eea7684c1c03479)
2026-10-06 06:18:39 -07:00
Teknium
5a0f7f250e fix(update): an SCM service retires its restart debt only once its gateway is ready
SCM 'running' proves the service wrapper started, not that the gateway it
supervises came up: a service-only pause token was discharged (and its durable
record deleted) while the gateway was absent or crashed, losing the retry. Each
restarted service now leaves the token only after a stable gateway in its
profile home, descended from the service's current process, passes the same
readiness poll relaunched profiles use; otherwise it stays owed.

Native windows test: a real compiled service wrapper under the SCM, absent
gateway (stays owed) and a real 'hermes gateway run' (retired).

(cherry picked from commit d3d8349c82122773017a0fdb645057831557feaa)
(cherry picked from commit ea0758b6ba58b489843e33f2201393efb917535e)
2026-10-06 06:18:39 -07:00
Teknium
3832f36a33 fix(update): a completed pause obligation never runs again from a copy Windows kept
Completion (a recovering launch's finished claim, an update's discharged record)
now lists the obligation's ids (and every id it absorbed) on a durable retired
list before unlinking its files. A reader holding a file without
FILE_SHARE_DELETE makes the unlink fail; that leftover copy is now redundant by
id instead of an orphan whose stale set the next launch resumes again. Same-id
copies keep the furthest-progressed one (a claim's rev), so a partly resumed
claim is not shadowed by the stale source. The list is pruned once no readable
file carries an id.

Native windows tests hold the file open from a real second process.

(cherry picked from commit 7f5b98a0e10ce2f578ae576481b51dc8029f4e99)
(cherry picked from commit 4fba720f1e9879f93b9d17d4a5c6bb4980ffe2ae)
2026-10-06 06:18:39 -07:00
Teknium
6e655be477 fix(update): preserve pause debt across marker consumption and adoption
Checkpoint validated stop consumption before unlinking requests and reread
claimed tokens under custody. Reject stale and mismatched marker evidence,
and retain cold-start generations when orphan pauses are adopted.

Real-process regressions cover producer death, claim/consume ordering,
failed checkpoint writes, marker validation, and successive orphan adoption.

(cherry picked from commit 2f4fd37ebd61a2a6676763e62fa812acddb98a1b)
(cherry picked from commit 4e16ee29bbe0e681f6692006f2b44b95c4e08dc7)
2026-10-06 06:18:39 -07:00
Teknium
3ea11ff045 fix(update): planned-stop marker paths are best effort (a process without a profile home has none)
(cherry picked from commit 6b44b5ee73379e2959c09080f34eb5f8c2e97e9d)
(cherry picked from commit cdf40f8a8ebef1a33488ecc3c6f19d95c14a6f63)
2026-10-06 06:18:39 -07:00
Teknium
19b33c9d67 test(e2e): native cells for the real pause killed before and right after its stop request (N1)
(cherry picked from commit b1d8cadaee3744cba52d334d04b53ef81aef625a)
(cherry picked from commit 7c637e180f438101b2a0d1b87bddd1732c4b1add)
2026-10-06 06:18:39 -07:00
Teknium
b00c919cf7 fix(update): a pause owes a live gateway only once its stop request was issued (N1)
mark_stop_requested recorded intent for every running gateway before the first
request; an updater killed in between left a never-asked, still-serving gateway
held as 'draining' forever, and restarted it after a later user stop.

Now the intent also names where each request goes (planned-stop marker) and the
stopper; mark_stop_sent records each request once issued (marker on disk, else a
positive socket ACK), before the socket call. A live entry is owed only when it
was recorded sent or this update's own marker still names it (a user's stop
marker is not evidence); the evidence is resolved once into stop_sent so a merge
or a hand-back carries it. Force-killed pids need no record (dead = owed).

(cherry picked from commit e199442e5690614065e3013f06ad54b18584811d)
(cherry picked from commit f2af22c6e8a45a7712046107b10c17fc9ad9d690)
2026-10-06 06:18:39 -07:00
Teknium
1ad685a59b test(e2e): custody journey turns the respawn-storm breaker off (cells restart the gateway on purpose)
(cherry picked from commit 93bbd366f3da731b75326a086e4cf62946d8fd22)
(cherry picked from commit fd24b22b1b8877f41f472db3c850049228558839)
2026-10-06 06:18:39 -07:00
Teknium
151c4ab40c test(e2e): custody cells pause the gateway with a clean stop, as an update does
(cherry picked from commit 78842c6b59cc35e0e7825612e6bdddf362ad8017)
(cherry picked from commit ce7b7d362016896d09bc384ecb41907b7904228c)
2026-10-06 06:18:39 -07:00