389 Commits
Author SHA1 Message Date
Maciej Lisiewski 7062229090 chore(js): switch the workspaces from npm to pnpm
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.
2026-11-06 04:08:24 +01:00
Teknium 792ed0d99a fix(update): reclaim the shallow.lock a killed update's shallow fetch left behind
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.
2026-11-02 04:24:00 -08:00
Teknium 57ebc3eb83 fix(update): reclaim the ref locks a killed update's fetch left behind 2026-11-02 04:06:07 -08:00
Teknium 001b0eba27 fix(update): never offer the delete answer when there is nothing to pull; scan ignores showUntrackedFiles
- 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.
2026-11-01 10:08:01 -08:00
Teknium 005fbe8058 fix(update): stash only what the update collides with, and ask before stashing
`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.
2026-11-01 10:08:01 -08:00
kshitijk4poor c9460ed495 ci(process): the detached-spawn ratchet also catches process_group= and kwargs["start_new_session"] = ...
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.
2026-10-29 16:58:09 +05:30
Teknium ca69d857c3 fix(update): a proxy the user set in .env beats the injected system proxy
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.
2026-10-27 13:23:31 -07:00
Teknium 565eee7014 fix(update): the release tag fetch is best-effort and never moves a local tag
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.
2026-10-27 13:23:31 -07:00
Teknium 0e3aefb8c1 fix(update): fetch a stable release's own tag with its commit
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.
2026-10-27 13:23:31 -07:00
Teknium 4b90de5022 fix(update): each update stage names itself where it starts, so a failed build still shows the restart
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).
2026-10-27 07:53:07 -07:00
unsupportedpastels 40bf5044a3 refactor(update): move the channel-release hold next to select_apply_target
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
2026-10-22 18:57:17 -04:00
arcabotai 0cc3cff790 fix(cli): say a held channel install is newer than the release and was held
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)
2026-10-22 18:57:17 -04:00
arcabotai 2594c44d6a fix(cli): hold a channel install whose release predates the gateway runtime
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)
2026-10-22 18:57:17 -04:00
Teknium 3a9d585e4f fix(update): diagnose a damaged checkout from the checkout, not the stderr
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.
2026-10-21 16:57:37 -07:00
Teknium cfaafec502 Merge origin/main (#35: 133 commits to f925b01791) into feat/unified-gateway-runtime
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.
2026-10-10 06:49:02 -07:00
Teknium 6a92a2aa49 feat(update): source installs default to the stable release channel
`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.
2026-10-09 19:46:19 -07:00
Teknium 4e7fbad8fe Merge origin/main (#34: 43 commits to 3eb7ed08d1) into feat/unified-gateway-runtime
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.
2026-10-09 18:41:50 -07:00
Teknium f549ce3fbd fix(update): drop config_unreadable: an unreadable config.yaml also hides consent, so no row is ever sent
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.
2026-10-09 14:36:08 -07:00
Teknium 2379b2e2eb fix(update): pre-apply exits that read exception/subprocess_failed name their reason
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.
2026-10-09 14:36:08 -07:00
Teknium 408cc0d12e Merge origin/main (#32: 36 commits to cf23a1a5cc) into feat/unified-gateway-runtime
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.
2026-10-09 10:24:04 -07:00
Maciej Lisiewski 2ff58849f4 ci: ruff UP037 fixes 2026-10-09 17:34:38 +02:00
Maciej Lisiewski 9eab688b42 ci: ruff UP017 fixes 2026-10-09 17:34:36 +02:00
Teknium 1427a3a38c fix(update): refuse to move an install back past the gateway runtime once a state.db depends on it
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)
2026-10-09 02:10:49 -07:00
Teknium 65bec86e37 Merge origin/main (#27: 19 commits to 517b5e10fe) into feat/unified-gateway-runtime
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).
2026-10-08 12:09:24 -07:00
Maciej Lisiewski d76cce4f11 ci: apply all automatic linter fixes available 2026-10-08 17:22:54 +02:00
Teknium b178d9098c Merge origin/main (#18: 76 commits to 3569fba6a6) into feat/unified-gateway-runtime
No conflicts. Overlapping auto-merges: agent/conversation_loop.py, agent/credential_pool.py,
cron/{executions,jobs,scheduler}.py, hermes_cli/{main,update_cmd}.py, Desktop i18n en/types,
locales/_keys.desktop.json, tests/fixtures/resolution_allowlist.json, cron docs.
2026-10-07 07:33:34 -07:00
Teknium a81d3408bc fix(update): a killed git's index.lock no longer blocks the next update
`.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)
2026-10-07 07:18:58 -07:00
kokhlo 9c6ff602d6 fix(update): sweep stale git locks at the start of the run, not only before the fetch
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.)
2026-10-07 07:18:58 -07:00
Teknium ca38632fc0 Merge origin/main (#17: 454 commits to e6757f2029) into feat/unified-gateway-runtime
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.
2026-10-07 06:48:09 -07:00
Teknium 476e0f1138 fix(telemetry): pre-receipt exits keep their initiator; index.lock permission errors; bare-interpreter updates park
- 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).
2026-10-07 06:15:58 -07:00
Teknium b49e2ff2b9 fix(telemetry): review findings on pre-apply stop reasons
- 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.
2026-10-07 06:15:58 -07:00
Teknium d56a48d626 feat(telemetry): updates that stop before applying say why
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.
2026-10-07 06:15:58 -07:00
Teknium 59b5808bc8 fix(update): pause POSIX gateways before the first checkout move, restart them after deps 2026-10-07 04:39:00 -07:00
Teknium 1e500ebce2 Merge origin/main (#16: 4,465 commits to 9dcab1e440) into feat/unified-gateway-runtime
99 conflicted files resolved in nine area lanes (Desktop composer, Desktop renderer/shared
client, Electron main, Ink, tui_gateway, gateway, state, CLI/config, cron/tools/CI). The
unified runtime's architecture wins where it deliberately replaced a mechanism (pool,
fallback lanes, claim poller, renderer queue); every orthogonal main change is kept, ported
into the <stem>_<topic> sibling that now owns relocated code.

Notable ports and repairs in this merge:
- tui_gateway/server.py relocated regions: atomic _attach_built_agent (#49852), slash ranking
  registry names, minted-key helpers methods_session.py calls, request_overrides, service tier.
- gateway/run_turn*.py: i18n accessors for the removed reply constants, first-contact
  internal/DM rule, rotated-compression persist, fast-mode tiers, prompt-pin rehydration.
- webhook: route_from_chat_id decodes main's opaque webhook:v2 chat ids (old ids still read).
- ws.py: main's per-connection dispatch queue with authority-first dispatch inside it.
- state: schema event for runtime revision renumbered to 31; worker mirrors updated for
  main's message uid/merge fields, on_contended lease arg, compression columns.
- config: branch's bot_chat_delivery_timeout removal renumbered v47 -> v50.
- Electron: primary-profile pin, redirect header scoping, remote fetchJsonForBackend, SSH
  teardown on profile delete, held-update dial gate restored (lost in earlier merges).
- Desktop: canonical clarify.request mapped to main's questions[] card; replay truncation vs
  snapshot_required; periodic secondary prune tick restored; DELETE already_absent restored.
- cron/tools: diagnostic category on cron failure notices restored; owner_holds_delivery
  ported onto the authority; admitted receipts can no longer be cancelled locally.
2026-10-06 14:24:24 -07:00
Teknium 0ba1f73ae7 fix(update): commit-point tree moves bind the paused gateways' tree gate
#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.
2026-10-06 06:27:57 -07:00
Teknium 2e1493f73e fix(update): a retarget or a later update keeps the host restart debt that stood before it
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.
2026-10-06 06:22:27 -07:00
Teknium a0c51c6dc8 fix(update): the fork sync judges its target before moving and owes a commit every landing holds (review G1, O4, F23)
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.
2026-10-06 06:22:27 -07:00
Teknium 803f83c7f1 fix(update): every tree move names the armed commit and verifies HEAD before its marker goes (review O2, O3)
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.
2026-10-06 06:21:58 -07:00
Teknium e92cec9dff fix(update): startup syntax is judged by the target's Python over the whole entry closure (review G3, N15)
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.
2026-10-06 06:21:58 -07:00
Teknium b9d85003f8 fix(update): a syntax rollback whose marker arm times out still rolls back
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".
2026-10-06 06:21:57 -07:00
Teknium bdb513c9f0 fix(update): a failed CP0 branch switch says whether it was refused, torn or a missing branch
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.
2026-10-06 06:21:57 -07:00
Teknium 56fa24ab6f fix(update): a failed later tree move owes the restart for the HEAD it settled on
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.
2026-10-06 06:21:56 -07:00
Teknium e6d03aa066 refactor(update): move the systemd unit timing budgets into update_cmd_fleet_unit_budget
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.
2026-10-06 06:21:10 -07:00
Teknium e557016d7f fix(update): a requires-python bump never admits a merge-conflict marker (review N15)
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.
2026-10-06 06:21:09 -07:00
Teknium d5618647a7 fix(update): CP0 owes the restart for the commit its checkout lands on (review N16)
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.
2026-10-06 06:21:09 -07:00
Teknium f3dd812df6 fix(update): no tree move starts without its recovery marker and a resolved target (review F17, F22, F23, F24, F25, F29)
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.
2026-10-06 06:21:09 -07:00
Teknium 2cfa57f70b fix(update): a killed syntax rollback keeps its recovery record until the tree is whole
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)
2026-10-06 06:20:30 -07:00
Teknium 98649fd74f fix(update): a historical updater's hand-off no longer dies on a missing ruamel
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)
2026-10-06 06:20:30 -07:00
Teknium f562145c70 fix(update): a syntax rollback resets HEAD and the index before any file
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)
2026-10-06 06:20:30 -07:00
Teknium 26e3e2d55d fix(update): keep the commit point's obligations and copies through failed later moves
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)
2026-10-06 06:20:30 -07:00