36 Commits
Author SHA1 Message Date
unsupportedpastels e57149e4ad fix(gateway): read no profile config for a generic multiplexer refusal
_served_by_multiplexer() consulted host_serves_named_profile() for every
PermissionError from the multiplexer's control socket, so a plain refusal
(on POSIX too, where no RefusedOwner can occur) parsed the default and the
secondary's config.yaml. An unparseable default config then made
discover_gateway_endpoint(secondary) raise ParserError where the base
returned 'absent'.

Check for the pipe-proven RefusedOwner first; any other PermissionError is
"not served" before a config is read, as before #140706's change. The
standalone-secondary opt-out is unchanged for the named refusal.

Refs #134889 (W9)
Refs #140762
Refs #140706
2026-10-18 01:12:29 -04:00
unsupportedpastels 6b6a4640b9 fix(gateway): keep a standalone profile out of its elevated default gateway's refusal
A named profile with no gateway of its own asks the default multiplexer's
control pipe. The previous commit returned that pipe's named refusal
(gateway_elevated / gateway_other_session) for every profile under the
root, but multiplexer_root_for() only checks where the home lives. A
profile that set gateway.standalone: true is never served by the
multiplexer, so the refusal made its discovery report inaccessible and
ensure_gateway_runtime() returned before starting the profile's own
gateway.

The refusal is now passed on only when the existing boot policy
(host_serves_named_profile, the predicate behind implied_host_root and
multiplexer_serves_home) does not rule the home out. A standalone profile
falls through to its own lock and start path, as on the base.

Refs #134889 (W9)
Refs #140762
Refs #140706
2026-10-18 01:12:29 -04:00
unsupportedpastels 0b5505c0a4 fix(gateway): name an elevated gateway for a multiplexed profile too
A named profile served by the default profile's multiplexer has no
control pipe of its own. discover_gateway_endpoint() takes the
FileNotFoundError path and asks the multiplexer's pipe through
_served_by_multiplexer(), which caught every PermissionError as "not
served". The RefusedOwner raised when the client proves the multiplexer
runs elevated was swallowed there: discovery reported the secondary's
own lock state and ensure waited until starting/deadline instead of
naming the boundary.

The multiplexer lookup now returns the same inaccessible verdict
(reason code + remedy) direct discovery returns, built by one shared
_refused_owner_verdict() helper. Every other lookup failure, including
a generic PermissionError, still means "not served".

Regressions drive discover_gateway_endpoint() and
ensure_gateway_runtime() for a secondary whose own pipe is absent while
the multiplexer's pipe raises the refusal: both return the named
verdict at once and ensure attempts no service start or spawn.

Refs #134889 (W9)
Refs #140762
Refs #140706
2026-10-18 01:12:29 -04:00
unsupportedpastelsandCasey dc38e0bd8e fix(gateway): name an elevated gateway from its control pipe instead of OpenProcess
A non-elevated Desktop or CLI could never reach an elevated gateway on
Windows. The client half of the control-pipe identity check opened the
server PID with OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION), which a
filtered token cannot do on a High-integrity process, so every attach
failed with "cannot verify bootstrap peer process" and discovery
reported the generic `authorization`.

The client now also reads the pipe object itself through its own handle:
the owner and DACL (GetSecurityInfo) and the server's session
(GetNamedPipeServerSessionId), next to GetNamedPipeServerProcessId.
judge_pipe_server() decides from those facts and is pure, so it is unit
tested with the Win32 calls injected:

- server process readable: its token user must be this user, as before;
  a pipe owned by BUILTIN\Administrators (an elevated creator) seen from
  a non-elevated client is refused as `gateway_elevated`.
- server process unreadable: never attached. A pipe owned by this user
  or Administrators whose DACL grants exactly this user (the gateway's
  own private shape) is this user's gateway across the boundary and is
  refused with the existing classify_refused_owner() reason and
  refused_owner_remedy() wording; anything else stays the generic
  refusal.

The refusal is a PermissionError subclass carrying the reason code, and
discover_gateway_endpoint() reports it directly, so the name no longer
depends on a provable gateway.pid/gateway.lock record.

Refs #134889 (W9)
Refs #140762
Refs #140706

Co-authored-by: Casey <caseyrussell1976@gmail.com>
2026-10-18 01:12:29 -04:00
unsupportedpastels b250fced6c fix(gateway): name the unsafe control node in the client's inaccessible verdict
The salvaged commit before this one gives DiscoveryError a path, uid or kind
and a chown hint for unsafe_control_path, but discover_gateway_endpoint kept
only exc.reason, so a client still printed the bare "Gateway inaccessible:
unsafe_control_path". unsafe_control_permissions (a group/other-accessible
socket, lock or control directory, or a home another user can write) also
still carried nothing.

DiscoveryError now keeps its detail on .detail, discovery passes it to
GatewayDiscovery.detail, and connect_gateway prints it as "Gateway
inaccessible: <code> (<detail>)". unsafe_control_permissions names the node,
its mode and the chmod that fixes it. The detail stays within 500 characters
like the other details, but never by slicing: a long profile home appears
twice (node and command), so a repair command that does not fit is dropped
with an explicit notice rather than printed cut off mid-path or without its
closing quote, and an overlong description is cut with a visible marker. The
paths are the canonical home discovery resolves (on macOS /var is
/private/var). The codes are unchanged, so _private_lock's narrowing branch
and every caller matching on reason still work.

Also marks the salvaged commit's os.getuid() line POSIX-only for the Windows
footgun check (the suppression stayed on the line it was split from).

Refs #134889 (GW-11)
Refs #140208
Refs #139842
2026-10-17 13:30:32 -04:00
Teknium 31870dda2c test(windows): host facts are arguments, so no test pretends to be on Windows
The ACL, update-repair and refusal-naming tests patched sys.platform to
"win32" (and runtime._NATIVE_WINDOWS to True), which tests/AGENTS.md
rules out: a test that needs the interpreter to believe it is on another
OS belongs on that OS. The lock-dir file used a bare skipif(os.name ==
"nt") the OS-lane selector does not see.

- make_user_dir / grant_user_access / grant_tree_user_access and
  _repair_windows_home_acl take elevated= (default: the probe, which is
  False on every host but an elevated Windows one). The sys.platform gate
  was redundant with that probe and is gone.
- unusable_dir_remedy takes windows=, _refused takes windows= (default:
  this host).
- Tests pass those facts in; only the native writer (_grant_with_pywin32)
  is still replaced, to record what a grant would have touched. The real
  ACL effect stays in tests/test_hermes_dir_acl_windows.py and
  tests/gateway/test_refused_owner_windows.py (platforms("windows")).
- test_scoped_lock_dir_unusable.py: platforms("posix") plus a separate
  geteuid skipif; test_off_windows_it_is_plain_mkdir is platforms("posix");
  a new posix test pins that a refusal off Windows never probes.

Review finding by @alt-glitch on #140490 (warning 1) and by
@unsupportedpastels on #140490 (minor 3)
Refs #134889 (W8)
Refs #134889 (W2)
Refs #140337
2026-10-17 08:03:53 -07:00
Teknium b8ea5196e4 fix(windows): a refusal names only a gateway proved by its process identity
_owner_pid took the first PID in gateway.pid/gateway.lock without checking it,
so the "started as administrator" / "another logon session" guidance could name
a recycled PID or an unrelated process, and a stale gateway.pid masked a live
gateway.lock owner. Now each record must pass the shared identity chain from
gateway.status: alive (_live_pid_from_record), recorded start time known and
equal to the live one, recorded home not another profile's, and a gateway's
live command line (or record when the cmdline is unreadable across the
elevation boundary). A record that fails falls through to the next; none
proved keeps the generic "authorization". Linux tests use real stub owner
processes (spawns_gateway_lookalike) instead of a fake PID.

Review-9 finding 5 on #140490.

Refs #134889 (W8)
Refs #140337
2026-10-17 08:03:53 -07:00
Teknium eecaa7af9a fix(windows): name an elevated or other-session gateway instead of "authorization"
A same-user gateway whose control pipe or process refuses the client was
reported as `Gateway inaccessible (authorization)`, which reads as a
credential failure. Two real causes hide behind it:

- #139845: the gateway runs elevated; the non-elevated Desktop's OpenProcess
  on its PID fails (err 5).
- #140252: a session-0 S4U boot-task gateway won the control pipe; the
  interactive Desktop can never reach it.

discover/ensure now probe the owner PID (gateway.pid / gateway.lock) on a
refusal: ProcessIdToSessionId for both sides, TokenElevation for both. A
different session is `gateway_other_session`; a same-session owner that is
elevated (or whose token a filtered client cannot open) is `gateway_elevated`.
Each carries a remedy in `detail`, which Desktop already shows in its boot
error. Anything else keeps `authorization`. Exit code stays 4.

No Windows host was available: the classifier and mapping are unit-tested,
the native probes are covered by tests/gateway/test_refused_owner_windows.py
(Windows CI); the elevated-owner case needs a live retest.

Refs #134889 (W8)
Refs #139845
Refs #140252
Refs #140337
2026-10-17 08:03:52 -07:00
Teknium fc49050f94 fix(acp): an overlay HERMES_HOME attaches with the mirrored profile's identity
On top of the salvaged discovery fix (#138951): discovery now finds the
owner a per-task overlay home's symlinked gateway.lock names, but the
client still minted its session ticket at the overlay's own control socket
(which does not exist: "No such file ... task-home/gateway.sock.path"), and
GatewayACPAgent compared the owner's profile_id with the overlay path, so
it would refuse profile_mismatch next.

gateway_profile_home() names the profile home that serves a home (the home
itself, or the profile an overlay's lock names, under the same private
checks as discovery). connect_gateway ensures, mints the ticket at and
GatewayACPAgent checks profile identity against that home. One owner
stays the writer; nothing local starts for the overlay.

Red on main: tests/acp_adapter/test_gateway_overlay_home.py (real
acp_adapter.entry with HERMES_HOME = a private task dir whose gateway.lock,
state.db and config.yaml link to a real gateway.run daemon's home) failed
session/new with -32603 "Gateway inaccessible: not_ready"; with #138951
alone it failed on the missing overlay control socket. Green: session/new,
a turn and session/load run, and the daemon's own viewer reads the turn.

Refs #138949
Refs #134889 (ACP-18)
Refs #140138
2026-10-16 21:13:32 -07:00
KoNit-K 90ed2494c0 fix(acp): resolve private overlay gateway homes
(cherry picked from commit 6ac1479703afef7a306ef3df03640dbe211e5eb3)
2026-10-16 21:13:20 -07:00
Teknium da76391fdc fix(gateway): hermes update drain stays reachable for a parked approval/clarify (#139041)
`hermes update --gateway` drains each gateway with the control socket's
`pause-for-update`, which runs `request_restart` and therefore already
publishes `drain_reason: restart_wait` with its endpoint (#139661). But
`ensure_gateway_runtime` checked the update fence first and answered
`draining/update_paused` with no endpoint, so no client could attach and a
turn parked on approval/clarify held the update for the whole cap.

While fenced, ensure now discovers the existing owner (read-only) and hands
it out only when it is a draining owner in that restart wait; every other
state stays `update_paused`, and nothing is ever started or replaced while
an update owns the install. The updater's drain report names the way to
answer a parked chat turn.

Refs #134889 (GW-6)
2026-10-16 03:56:32 -07:00
Teknium 3a99ad6b67 fix(gateway): read a standalone host's refusal like gateway status does, once per ensure pass
ensure_gateway_runtime's new reason reader duplicated
recorded_standalone_warning_lines but skipped its gateway_state check: a
host that recorded `stopped` (or `startup_failed`) with a reused live PID and
an old multiplex_standalone_reason parked the named profile instead of
waiting for the multiplexer that holds the root lock. Both now go through
gateway_multiplex_mode.recorded_standalone_reason().

_standalone_multiplexer_reason and _multiplexer_starting each called
implied_host_root (an uncached YAML parse, ~24 ms) and missing_owner_state;
the loop now computes the pending host root once per iteration
(_starting_multiplexer_root) and reads the reason from it.

The RT-16 test mocked the status read away, so a wrong path or key still
passed; it now writes the root's real gateway_state.json, and a negative
case covers no state / no reason / stopped / dead PID (each still waits).

Review: kshitijk4poor on #139661, non-blocking cleanup.
Refs #134889 (RT-16)
Refs #139574
2026-10-16 01:54:58 -07:00
Teknium 6a0cb5e648 refactor(gateway): name the restart_wait reason instead of repeating it
hermes_cli.gateway_runtime spelled the restart-wait drain reason as a
literal in three places although gateway.drain_control.RESTART_WAIT_REASON
exists; they now read the constant. Desktop's local-gateway.ts repeated the
attachable-restart-wait predicate in reensureDuringRestart and
ensureLocalGateway; one local isRestartWait() now mirrors Python's
attachable(). No behaviour change.

Refs #134889 (GW-6)
Refs #139574
2026-10-16 01:54:58 -07:00
Teknium 6b083b51d4 fix(tui): let Ink attach to a restart_wait owner on start and recover
connect_gateway already accepted a draining/restart_wait owner, but the
Ink TUI bootstraps through ui-tui/scripts/gateway_bootstrap.py, which
still refused every state but ready on both its start and its recovery
path. Reopening the TUI to answer an approval/clarify parked across a
restart therefore kept failing until the restart finished. Both
bootstraps now share one predicate, gateway_runtime.attachable(), and a
recovery no longer re-ensures an owner it can attach to.

Addresses Greptile review 4291205551.

Refs #134889 (GW-6)
Refs #139574
2026-10-16 01:54:58 -07:00
Teknium e1e6f30486 fix(gateway): let clients reach a restart-wait owner to answer a parked prompt
A graceful restart (Desktop's code-SHA restart, `hermes update --gateway`) waits for in-flight
turns, but a turn parked on approval/clarify can only finish when a human answers, and the
draining owner refused every client: identify dropped its capabilities, `gateway ensure`
returned a bare `draining` (exit 6), the WS boundary closed interactive tickets 1013 and every
submit raised `runtime_draining`. The wait therefore ran the full 1800 s cap.

The draining owner still owns the turn (#106742: one owner per session), so it stays reachable
during the restart's after-turn wait:

- request_restart publishes `drain_reason: restart_wait` and identify keeps the endpoint while it
  is set; drain_gateway_runtime (stop()) withdraws it, so the window closes before teardown.
- `gateway ensure` reports `draining`/`restart_wait` (still exit 6, never a fresh-start target)
  WITH the endpoint; Desktop's ensureLocalGateway and the Python client attach to it.
- The WS boundary admits interactive tickets while the window is open, so attach + respond()
  reach the parked prompt.
- New submits are committed queued (durable FIFO, next owner runs them exactly once); the claim
  path stays refused, so the draining owner never starts them.
- A turn nobody answers still ends interrupted at the cap and is never replayed (unchanged).

Refs #134889 (GW-6)
Refs #139574
2026-10-16 01:54:58 -07:00
KoNit-K 04e06cb92a fix(gateway): fail fast for standalone parked profiles
(cherry picked from commit 64c5b2251d2f533aea93b592b320576ec03315d5)
2026-10-16 01:54:58 -07:00
Teknium 3d68651cdc refactor(gateway): keep the stale-owner replacement within code-health caps
ensure_gateway_runtime's replacement branch moves into _observe_replacing_stale (CC back
under target; the stopped owner reads as draining/"replaced", the same retiring path an idle
exit takes). gateway status notes (the incompatible-owner report, standalone-by-config) move
to hermes_cli/gateway_status_notes.py so gateway.py shrinks instead of growing; blind excepts
narrowed; the TUI verdict-echo test moves to the notice test file.

(cherry picked from commit 547d7da12556d2d0ed26b3644bc4a7e62c446574)
(cherry picked from commit 0ddf64ebd2fb856afc9cc029aa4f22e7b08eddc0)
2026-10-13 00:31:15 -07:00
Teknium 861afd67b2 fix(tui): say why the gateway refused instead of retrying silently
The bootstrap's verdict (`gateway incompatible: runtime_protocol`) never matched the TUI's
stderr-problem filter, so the reason was dropped and the status bar only cycled "retrying in
30s". The bootstrap now prints the verdict and its remedy (ensure's `detail`) as verdict lines,
and STDERR_PROBLEM_RE echoes them in the activity feed. Remedy strings are kept to one line that
fits the 120-char activity row.

(cherry picked from commit 66a6f0093e5c15e9f571c11e42b6732844dcd7c4)
(cherry picked from commit bcc5d020c45be9a3dcd434aa500e8d25c81c162b)
2026-10-13 00:30:57 -07:00
Teknium 14e3689a62 fix(gateway): ensure replaces a gateway an older Hermes left running on this home
After an upgrade the old daemon keeps answering the home's control socket without a
runtime_protocol, so every ensure (TUI bootstrap, Desktop's `hermes gateway ensure`,
`hermes chat`) answered incompatible/runtime_protocol forever: the TUI retried every 30 s and
Desktop's Retry looped on "Hermes couldn't start".

ensure_gateway_runtime now stops that owner once, the way `hermes gateway stop` does (a
planned-stop marker in the owner's home, SIGTERM, bounded wait, start-time-guarded force
kill), then starts the current owner like any absent one. Refused with a reason in `detail`:
a newer-protocol owner, another install's (home outside this install's root, or the process
serves another home), and anything whose pid/start time cannot be pinned.

(cherry picked from commit f807caa7a989d82bf577ad38eb12f413ee626a1e)
(cherry picked from commit 294639f272154723ceb1c485038942437bf927d9)
2026-10-13 00:30:50 -07:00
Teknium 4fd3300d58 fix(gateway): refuse an unprovable foreign gateway.lock with the recovery command
A gateway run once as root leaves a 0600 root-owned gateway.lock. On Linux
_unlink_stale_lock proves it unheld through /proc/locks and replaces it; on
macOS/BSD _inode_locked_per_proc returns None, the `is False` test fails and
_open_lock re-raised the bare PermissionError, so `gateway run` logged an
opaque "Permission denied" and discovery/ensure reported a reason-less
`inaccessible` forever.

Auto-replacing there is unsafe (nothing can prove the inode unheld), so the
refusal stays, but it now carries the terminal reason foreign_stale_lock and
the exact recovery (`sudo lsof <lock>`, then `sudo rm <lock>`): reserve raises
it (logged by gateway run's "Cannot reserve gateway profiles"), and discovery
returns reason_code=foreign_stale_lock with the same detail when the lock
cannot be opened read-only, which ensure and every client print.

Tests: tests/gateway/test_foreign_stale_gateway_lock.py::test_reserve_without_proc_locks_refuses_with_recovery_command
       tests/gateway/test_foreign_stale_gateway_lock.py::test_discovery_names_the_unreadable_lock_and_its_recovery

Review: dokterdok dokterdok-N40
(cherry picked from commit 40e6e0f709e4bfe7559b87956db29a44669027e8)
2026-10-10 22:49:17 -07:00
Teknium b82a4f1df2 perf(gateway): overlap the cold-start toolchain probe and notice READY sooner
Two small items from the cold `chat -q` profile (lane life, review 7):
- The pre-READY turn warm-up ran the host toolchain probe (five subprocess
  calls, ~0.33 s) serially. start_gateway now background-starts the same
  idempotent probe right after the config load, so it runs beside the boot
  imports; the warm-up and first prompt read its cached result
  (`agent.environment_probe: false` still skips it).
- ensure's READY poll backed off to 250 ms, so a client noticed READY a
  median 0.22 s late. The cap is now 50 ms (a lock read or one local
  identify per probe).

Cold chat -q, fresh home, no MCP, same worktree with only these two files
swapped, 6 interleaved reps: median 6.71 s before, 6.46 s after.

Review: https://github.com/NousResearch/hermes-agent/pull/106742#issuecomment-6073624820
(cherry picked from commit dbeb2a7a156cc1f20791f14d6a03b977e0a1fd08)
2026-10-09 02:10:48 -07:00
Teknium 2107ea5f90 fix(gateway): an auto-started gateway exits once nothing needs it
Every `hermes chat` / `chat -q` / TUI client that found no gateway for its
home started a detached `gateway run` that nothing ever stopped: ~270-350 MB
each, so throwaway homes (scripts, CI, test suites, five homes in the review)
piled up daemons indefinitely.

A client's own start now launches `gateway run --idle-exit` (hidden flag).
That gateway arms a watcher (gateway/run_idle_exit.py) only when no
supervisor owns it (systemd/launchd/Desktop/external stay excluded, as do
installed services, Desktop's `gateway ensure` start, which keeps its
attached gateway for the app's lifetime, and a hand-run `hermes gateway run`).
It exits after `gateway.unmanaged_idle_exit_seconds` (default 600, 0
disables) when all of these hold on two consecutive checks:
- no live ticketed WebSocket, no unredeemed ticket, no HTTP/WS activity
  inside the window;
- no messaging adapter configured, connected or reconnecting (any profile);
- no runnable cron job, no running cron job, no kanban task in
  scheduled/ready/running/review;
- no queued/started admission, live managed worker, hosted-room task,
  in-flight agent/cron/API work, background process/delegation, active
  heartbeat/loop or pending handoff.
Every probe fails closed (an unreadable source keeps it up). The exit
withdraws admission synchronously and publishes draining with
`drain_reason: idle_exit`, so a client that races it waits for the old
process to go and starts a fresh one transparently.

Live (fresh fake homes, loopback provider, window 6 s): chat -q -> gateway
gone at +8.6 s -> second chat -q ok on a new gateway -> gone again; a client
arriving mid-drain succeeds on a fresh gateway; a configured webhook adapter,
a scheduled cron job, a running kanban task, `unmanaged_idle_exit_seconds: 0`,
a Desktop-style `gateway ensure` start and a hand-run `gateway run` all stay
up (watched 24 s).

Review: https://github.com/NousResearch/hermes-agent/pull/106742#issuecomment-6073624820
(cherry picked from commit 7cdfa2364afb89ac85430d10542cc46cecea0bd4)
2026-10-09 02:10:48 -07:00
Teknium ac6545eeaa fix: a gateway that dies while starting shows its own error in the client
When the unmanaged gateway a client started exits before it serves (an
import error, an unsupported interpreter, a config refusal), ensure already
returned `runtime_exited` promptly (35e5aaeef9c), but the client only said
"see logs/gateway-stdio.log of the started profile". The traceback was
written to that file before logging is up, so the user had to find and open
it by hand.

ensure now records where this launch's output starts in the append-only
stdio log, and on exit returns the last lines of that launch only (banner
dropped, earlier runs excluded), redacted with force=True, plus the exact
command to read the whole log. `chat -q`, the classic CLI view, Kanban
workers and the Ink bootstrap print it as-is.

Live: a startup exception in a fresh home now fails in 3.1 s with exit 1 and
the RuntimeError line (secret masked), where the reviewed head waited out
`Gateway starting: deadline`. No fallback path is added (maintainer ruling).

Review: https://github.com/NousResearch/hermes-agent/pull/106742#issuecomment-6073624820
(cherry picked from commit 4d0e968ef93ee8978b1eef42f247e846cb7fe6d0)
2026-10-09 02:10:48 -07:00
Teknium 5fe50c239d fix: a dead unmanaged gateway start ends ensure promptly
ensure requests at most one start per call and then waits for a live
owner. When the spawned `gateway run` exited (config refusal, import
error, crash) without ever publishing an owner, the loop kept seeing
`absent`, never started another (by design), and reported
`starting: deadline` only after the full timeout (60 s by default).

Keep the Popen handle and, when it has exited before a discovery that
still sees no owner (poll first, so a child that published and then
exited cannot be misread), return `inaccessible: runtime_exited` with
the exit status and where its stdio log is. A racing winner still
surfaces as starting/ready.

Review: https://github.com/NousResearch/hermes-agent/pull/106742#pullrequestreview-5462648005
2026-10-08 19:08:58 -07:00
Teknium 927e1f5c6c fix: a reserved root no longer suppresses a standalone profile's start
a257be0b made the offline cold-start target follow the boot policy, but
_multiplexer_starting still asked only "is the root reserved?" and then
waited for it. A `gateway.standalone: true` secondary is never served by
the root (profiles_to_serve skips it), so while the root owner held its
reservation, ensure for that profile spawned nothing and returned
`starting: deadline` (probe: 3.0 s, spawned=[]).

Use implied_host_root, the same boot-policy verdict the cold-start
target uses, so only a profile the root would serve waits for it.

Review: https://github.com/NousResearch/hermes-agent/pull/106742#pullrequestreview-5462648005
2026-10-08 19:08:58 -07:00
Teknium ed8973bbb7 fix(gateway): a killed update's dead marker no longer blocks every launch
ensure_gateway_runtime refused with `draining: update_paused` whenever
.hermes-update-in-progress merely existed. A killed `hermes update` leaves
a marker naming a dead process, so after main's crash-recovery work landed
every `hermes chat` on Windows printed `Error: Gateway draining:
update_paused` and exited 1 until a manual update (crash cells mid_fetch and
desktop_handoff on heads a6ae4eb586 and 326aacfbcf).

Judge the marker the way main's launchers and the Desktop do
(update_lock.judge_marker + checkout_lock_held): a live claim, an empty
claim still being published, an unreadable marker, or any marker over a
held checkout lock (a killed update's tree still runs) fences; a dead or
malformed marker over a free lock does not. The client stays read-only:
reclaim remains the launcher's / next update's job. The JSON-boundary test
now plants a live claim instead of garbage, which main's launch path would
admit.

(cherry picked from commit 17603c7c7bdafc4a7125ae7c02de4ee681355d83)
2026-10-07 15:19:43 -07:00
Teknium 785f1c86a6 fix(desktop): restart a stale attached gateway instead of re-attaching it (review M7)
What: `gateway ensure --json` now publishes the owner's boot commit
(`endpoint.code_sha`, from its control-socket `identify`) and the ensure
client's own commit (`client_code_sha`, same `get_code_identity` resolver).
Desktop compares them in ensureLocalGateway: a ready owner that provably runs
other code is restarted once with a supervised `hermes --profile P gateway
restart` (P = the profile owning the process: `default` for a served
secondary), then re-ensured. The Models-page "Restart Hermes"
(hermes:backend:recycle) runs the same supervised restart for a local
descriptor before dropping it. Both reuse runGatewayEnsure (resolver, spawn
env scrub, Windows cmd.exe quoting) with a 120 s bounded client.

Why: main's 55b51fa8ad8..61b69853eec made Desktop attach only to a backend
booted from its checkout. The attach model dropped that, so after
`hermes update` the running gateway answered model calls 503 "Restart
required", and Restart only tore down processes Desktop owns (none) — the next
ensure re-attached the same stale gateway forever.

Guards: unknown identity on either side (non-git install, older gateway, older
CLI) keeps the plain attach. One restart per (gateway, client) commit pair per
process, shared by concurrent dials; an owner a restart cannot move (a unit
pinned to another checkout) is then attached as-is rather than restarted on
every dial. A failed restart is logged; the re-ensure reports what answers.

Red: local-gateway.test.ts 'a ready owner running other code than this Hermes
is restarted once, then re-ensured' (pre-fix: 1 failed | 13 passed);
backend-recycle.test.ts 'kills the owned SSH serve before the primary child…'
and 'recycles a pooled profile…' (2 failed | 3 passed);
test_gateway_runtime_home_identity.py::test_ready_endpoint_carries_the_owner_boot_commit
(AttributeError: no code_sha). Green: vitest 20/20 (local-gateway,
backend-recycle, local-gateway-python); pytest gateway_runtime* 16 passed,
3 skipped. Live (fake HOME): ensure payload carries matching code_sha /
client_code_sha; `gateway restart` on an unsupervised owner drained it and
the next ensure attached a fresh instance_id.

(cherry picked from commit d8c5356625168e582c5c2ebc64f4950ecf7e0972)
2026-10-06 21:58:29 -07:00
teknium1 f0638e4487 fix(gateway): named profiles attach through the host multiplexer
The dashboard's profile-scoped PTY never connected (e2e/core/dashboard
test_pty_chat_turn_reconnect_and_profile_scoped_keepalive):

- gateway ensure for profiles/<name> with no multiplexer evidence yet spawned
  a per-profile daemon, which main's guard refuses ("does not get a gateway of
  its own"). ensure now starts the host root in that case, matching the guard:
  standalone profiles, an installed per-profile service, and an explicit
  multiplex_profiles: false keep their own daemon.
- the Ink bootstrap found the host endpoint but asked the profile's own socket
  for the session ticket; it now uses control_home_for like the CLI and Desktop.
- the attached TUI shows main's 'Session: <id>' banner and its profile-labelled
  composer (session.info carries profile_name).

The dashboard harness stops a sandbox gateway before reaping: the TUI's daemon
outlives its viewer by design, outside the dashboard's process group.
2026-09-27 12:37:32 -07:00
teknium1 9c7dbc6e6f fix(gateway): a cold daemon boot fits inside the client ensure deadline
Every attaching entrypoint (-z, chat -q, acp, cron, kanban workers) waits on
ensure_gateway_runtime for a cold `gateway run`. Under the parallel e2e shard on
the 32-core runner that boot took 10-20 s and the 30 s default tipped ~130 turns
into `Gateway starting: deadline`. Two changes: `cmd_gateway` no longer seeds the
skill library before dispatch (run_bootstrap seeds it inside the daemon home; the
CLI pass doubled a fresh-home boot), and the default deadline is 60 s across
ensure_gateway_runtime, `hermes gateway ensure --timeout` and the docs.
2026-09-24 08:11:42 -07:00
Teknium bad0d932b6 fix(desktop): post-merge seams — completion finds its reserved stream row; ticket bridge stays stdlib-only
- use-message-stream: a redirect reserves its correction row below the live
  stream without sealing it, so the completion reducer must accept a still-
  pending stream row above the last user message as its own reply.
- completion-boundaries.test: main's new tests return the canonical admission
  receipt (admission_id echo) that submit.ts validates.
- hermes_cli/gateway_runtime: RuntimeState is type-only; the module-level
  gateway.runtime_contract import loaded gateway/__init__ -> agent -> httpx into
  the Desktop's stdlib-only Python ticket bridge (system python3 in JS CI).
2026-09-23 04:19:38 -07:00
teknium1 b36858fd9c fix(gateway): publish parked profiles so their clients fail fast with profile_parked
A secondary the multiplexer could not serve at boot (state.db is a directory, home owned by a
stray daemon) was parked in a private descriptor list only: identify and gateway_state.json
said nothing about it, so `hermes -p broken gateway ensure` waited the whole timeout for a
served_profiles entry that would never appear (rc 5 starting/deadline), `chat -q` sat 38 s on
"Gateway starting: deadline" and `gateway status` reported not running, with no hint that the
profile's store was the problem.

The park now goes through one seam (`run_runtime.park_profile` / `unpark_profile`) that keeps
`parked_profiles` as a name -> reason map in the runtime descriptor, publishes it in the
`identify` payload and writes it to the root's `gateway_state.json` (boot publishes its verdict
even when empty, clearing a previous run's). The hot-serve reconcile parks and unparks through
the same seam, carrying the real reason from `_serve_profile_runtime`. On the client,
`_served_by_multiplexer` maps a parked name to a terminal `inaccessible` /
`profile_parked` verdict (exit 7 via ensure_exit_code) with the reason in the new
`GatewayDiscovery.detail`, which `connect_gateway` appends to the error so `chat` and Desktop
name the cause immediately.
2026-09-16 16:35:34 -07:00
teknium1 b3aa491ffe fix(gateway_runtime): a named-profile client starts the multiplexer, never a per-profile daemon
`hermes -p p1 gateway ensure` / `chat` while the default multiplexer is down (restart,
update, crash) saw p1 as `absent` and spawned `gateway run` for p1's own home. That
standalone daemon answered the client, then blocked the multiplexer's next
all-or-nothing reserve ("Cannot reserve gateway profiles: Gateway runtime already owns
profile .../profiles/p1"), leaving `-p p1` on starting/deadline and the default on
profile_mismatch until someone found and killed the stray. Desktop reaches the same
path through redialLocalGateway during every `gateway restart`.

The start target is now decided by `gateway_runtime_multiplex.multiplexer_serves_home`:
an explicit `gateway.multiplex_profiles: true` on the default profile, or the served set
the last multiplexer recorded in the root's `gateway_state.json` (the boot-time verdict of
an unset flag), makes the ROOT the home whose service is discovered and whose daemon is
spawned. Every ensure caller (CLI ensure, chat, cron run, Desktop, session_a2a) inherits
it because they all go through `ensure_gateway_runtime`.

Live (disposable root, real daemons): mux serving default+p1 -> SIGINT -> `-p p1 gateway
ensure --json` -> before: rc 0 with control_home null and p1/gateway.pid,sock (a stray
owner), mux restart rc 1 "Cannot reserve"; after: rc 0 with control_home == root, one
`--profile default gateway run` process, no p1/gateway.pid, mux restart clean.
2026-09-16 16:23:54 -07:00
Teknium e62ba4671e fix(cli): discover a served secondary through the default multiplexer's control socket
R3: `hermes -p <sec> chat`, `gateway ensure --json`, Desktop ensure, ACP attach and
WorkerRPC probed only `profiles/<sec>/gateway.sock*`. A served secondary has no socket
of its own — the multiplexer holds its gateway.lock — so discovery reported
`starting/deadline` forever and `ensure` tried to spawn a competing per-profile daemon.

`discover_gateway_endpoint` now falls through to the default root's control socket
when the profile's own is absent: the multiplexer's `identify` lists the home under
`served_profiles`, so `_endpoint(payload, home)` yields a ready endpoint whose
`profile_id` is the secondary's home and whose new `control_home` is the root.
`_session_ticket` mints against `control_home`, so the ticket is bound to the
secondary and redeemed by the multiplexer against its served set; `WorkerRPC` follows
the same discovery. `ensure_gateway_runtime` treats a starting multiplexer as the
pending owner of its named profiles instead of spawning a second gateway.

Only the live multiplexer's descriptor proves service (never a held lock alone); a
root that does not list the profile keeps the previous absent/starting verdict.
2026-09-11 21:33:59 -07:00
Teknium 7ee15a24dc fix: endpoint discovery canonicalizes the caller's home before matching served profiles
_endpoint compared _canonical_home(served) (resolved + normcase, i.e. lower-cased
on Windows) against the caller's raw str(home). A mixed-case or symlinked HERMES_HOME
therefore never matched: WorkerRPC in the managed worker got profile_mismatch and
reported owner_unavailable although the daemon was ready (windows-latest run
34366868404). Canonicalize both sides; a different directory is still refused.
2026-09-09 08:38:32 -07:00
Teknium 6f29108035 feat: ensure a gateway without installing or replacing services 2026-09-08 11:44:06 -07:00
Teknium e903841cb8 feat: distinguish unavailable gateway owners from absent runtimes 2026-09-08 11:44:05 -07:00