TestGetHermesHome.test_default_path asserted ~/.hermes unconditionally,
but the native Windows default is %LOCALAPPDATA%\hermes (see
hermes_constants._get_platform_default_hermes_home). Branch the
assertion by platform so the test passes everywhere.
Salvaged from PR #96003 by @Aoshi-Dev (the parse-guard half of that PR
was superseded by #96169); authorship preserved.
Follow-ups to the salvaged #71385 guard (which raises RuntimeError from
require_readable_config_before_write on unparseable / non-mapping YAML):
- config_command: catch RuntimeError for set/unset and print a clean
one-line error + exit(1) instead of a raw traceback on the primary
'hermes config set/unset' CLI path.
- console_engine._capture_output: convert escaping RuntimeError into a
ConsoleCommandError so 'hermes console' and the dashboard console
report the refusal instead of crashing the REPL/websocket session.
- _warn_config_parse_failure: add a dedicated 'refuse-write' wording
branch — the old fallthrough claimed 'falling back to default config'
even though the write was refused and the file preserved.
- approval_mode: update the stale SystemExit-only comment.
- Regression tests for the console path and both config_command paths.
Fail closed when config.yaml is unparseable or non-mapping before set/unset writes, reuse the readable-config guard to return the parsed mapping, and cover refuse/empty-mapping paths with regression tests.
- Reuse the existing _commit_status variable for the terminal-edge gate
instead of the parallel _compaction_succeeded boolean (derived state).
- Give the commit_fence_cancelled abort the same force_terminal=True
terminal edge as the lock-contended abort, and reword the closure
comment that overstated the lock contender as 'the one exception'.
- Inline the codex app-server path's lifecycle closure: after gating on
success it reduced to a single success-site emit, so the scaffolding
(done-flag + closure + two no-op failure-path calls) was dead.
Eighth review round (the first against the atomic-renewal fix) verdict:
the production code holds - CAS exclusivity across real processes,
lease-extension schedules, clock skew both directions, renew-per-attempt
under 5xx backoff, defer accounting, and the author's mutants all
verified - but one shipped regression test could not fail against the
property it is named for.
test_renewal_extends_the_lease_across_the_post asserted
next_attempt_at >= lease_before under a frozen clock. A renewal that
matches the row but never extends the lease (M4: SET next_attempt_at =
next_attempt_at) satisfies >= trivially, and that mutant double-POSTs:
the un-extended lease expires mid-POST and a second process reclaims.
The reviewer demonstrated M4 surviving the whole suite while producing
a real duplicate send in a two-process schedule.
The test now renews 100s into the lease from an advanced clock and
requires the deadline to move strictly forward to exactly
renewal-clock + 300s. Verified: M4 now fails this test (61 others
unaffected); clean HEAD passes all 62.
No production code change. 277 tests; ruff + footguns clean.
The send_media lane (08b95c3) had no committed regression test: cover
explicit-bool stamping on media frames, the descriptor-platform
fallback when _platform_by_chat is empty (post-restart proactive
sends), and the omitted-key absence case.
When either unfurl key is set, media captions post as a separate
message before the file (the upload API cannot carry unfurl controls)
and native draft streaming falls back to edit-based delivery. Surface
both side effects in the config reference table.
The send and send_media lanes resolved the platform only from
_platform_by_chat, which is empty until an inbound frame arrives (e.g.
after a gateway restart). A proactive send to a Slack chat then missed
the unfurl stamp. Mirror the streaming gate and delivery resolver:
fall back to the negotiated descriptor's platform.
Relay-plane parity: hermes config set / Railway persist YAML booleans
as strings, and _slack_unfurl_kwargs silently dropped them — so
'unfurl_links: "false"' was a no-op on native while working on relay.
Coerce recognized string booleans exactly as _slack_unfurl_hints does;
unrecognized values still drop so junk config keeps Slack's default
instead of accidentally suppressing previews.
Replaces test_send_ignores_non_boolean_unfurl_options (which froze the
dropped-string behavior) with coercion + junk-drop tests.
Live staging (Coatue Slack):
- hermes config set / Railway knobs persist "true" as a string; bots that
omit unfurl_links do NOT inherit the human default, so dropping the
string looked like suppression.
- chat.startStream cannot carry unfurl_*. Native SlackAdapter already
falls back to chat.postMessage; the relay now matches.
Relay-fronted Slack reads platforms.relay.extra.slack.unfurl_links/unfurl_media
and stamps explicit booleans onto the frame metadata; the connector forwards
them to chat.postMessage with no config of its own (mirrors reply_in_thread).
Covers send, send_for_platform (cron/scheduled), and send_media lanes.
Seventh review found the claim-token fix incomplete, and its
reproduction is exact: the pre-POST check was READ-ONLY. A claimant
whose lease expired while suspended still passes it when it wakes
BEFORE anyone reclaims - its token is still in the row - and then a
second process legitimately reclaims while the first one's POST is in
flight. Both send. Reproduced at 60addb16e2: posts ['B', 'A'], both
reporting 'sent'. This is the check-to-POST expiry race, not the
documented mid-POST residual: A's lease was already dead before its
authority check passed.
The check is now an atomic RENEWAL (single CAS UPDATE): it requires the
token to match, the row to be pending, AND the current lease to be
unexpired, and only then extends next_attempt_at a fresh lease into the
future. rowcount == 1 is the only grant. A claimant that wakes past its
own lease fails the unexpired condition and yields even though its
token was never replaced - expiry alone means another process may
claim at any moment, so waking stale is disqualifying regardless of
whether anyone has taken the row yet. The renewed lease (300s) covers
the POST (30s timeout) with margin, and renewal runs before every
retry, not just the first attempt.
Regressions: the reviewer's exact ordering (expired wake before any
reclaim -> zero POSTs, row stays claimable), plus a healthy-claimant
renewal test. Mutation-checked: dropping the lease-unexpired condition
or the token condition each fails the suite.
The at-least-once scope note on _send_one stands: a suspension landing
mid-POST remains client-unfixable; the fixable window is now closed on
both sides (before the check, and between check and POST).
277 tests pass; ruff + footguns clean; staging E2E 202.
* fix(relay): map wire media[] → event.media_types; accept message_type voice
A relayed voice note arrived as MessageType.AUDIO with media_types=[] —
the STT gate (_event_media_is_stt_input) excludes AUDIO unconditionally
and its per-attachment MIME rescue was unreachable, so STT never fired
and the agent fell back to the "user sent an audio file attachment"
context note (live-verified on staging 2026-08-26, Discord + Telegram).
Two wire-boundary fixes, both additive within contract_version 1:
- "voice" parses to MessageType.VOICE: the enum already had it — pinned
by test so a future refactor can't collapse the two.
- media[] is now mapped into event.media_types (positional alignment
with media_urls; mime-less entries keep their slot as ""). This is
what run.py's per-attachment classifiers key off, so EVERY relayed
attachment — image vs document, audio vs voice — now routes like its
native-adapter equivalent, not just voice notes.
Behaviour pinned: new-connector voice → STT-eligible; legacy
audio-typed events unchanged (no STT); music uploads never STT-eligible
(direct _event_media_is_stt_input assertions on real wire-parsed
events, not mocks).
Pairs with the gateway-gateway PR that puts "voice" on the wire.
* review: pin the STT gate by test; fail safe on media/media_urls mismatch
Addresses independent review of #95274.
1. The PR's acceptance criterion is STT ROUTING, but no committed test
called _event_media_is_stt_input — it was only asserted ad-hoc. Adds
TestSttGate: voice→eligible, voice-without-media_types→eligible
(the new-connector/old-gateway shape), legacy audio-typed voice
note→not eligible, music→not eligible. Mutation-verified: removing
the VOICE branch from the gate turns these RED.
2. media_urls and media[] are INDEPENDENT wire fields that consumers
index by the same i. Mapping MIMEs positionally without checking
agreement means a disagreeing producer misassociates a MIME with the
wrong URL and mis-routes that attachment — strictly worse than no
MIME, which degrades safely to message-level classification.
_media_types_from_wire() now maps only when the lengths agree, warns
and returns [] otherwise.
Note for the record: MessageType.VOICE predates this PR and the gate's
VOICE branch ignores media_types, so a NEW connector against an OLD
gateway ALREADY fires STT. That is desirable, but it is not "unchanged"
— the PR body's rollout matrix said otherwise and is corrected.
* fix(relay): send a User-Agent on relay media requests (Discord CDN 403)
Discord's CDN rejects urllib's default "Python-urllib/x.y" User-Agent
with HTTP 403, and RelayMediaClient never set one. Every Discord CDN
pass-through download therefore failed; _localize_inbound_media then
kept the raw URL (its "a public URL still has value" branch), and the
consumer tried to open a URL as a FILE PATH:
WARNING gateway.relay.media: relay media download failed for
https://cdn.discordapp.com/...voice-message.ogg: HTTP Error 403
INFO gateway.run: Voice transcription failed for https://cdn.discord...
: Audio file not found: https://cdn.discordapp.com/...
This killed ALL Discord relay media inbound — voice notes, images and
documents alike — not just the voice lane. Telegram/WhatsApp were
unaffected because their media is connector-re-hosted (/relay/media/{id},
fetched from our own host) and localizes to real /tmp paths.
Reproduced from a clean shell against a live CDN URL:
curl (own UA) -> 200
urllib, no UA -> 403 Forbidden
urllib + descriptive UA -> 200, 14583 bytes, OggS magic
Fix: a module-level _MEDIA_USER_AGENT sent on both download() and
upload(). upload() only ever targets our own connector so it was not
broken, but a single client should identify itself consistently.
Validated on staging: hot-patched hermes-agent-stg-test-6698, restarted
the gateway service, and Ben's Discord voice note transcribed
successfully — zero new 403s and zero new transcription failures after
the patch (last 403 predates it).
Test is mutation-verified: removing the UA from download() turns it RED
while the other five media tests stay green.
* fix(relay): keep url↔mime pairing through media localization
Addresses a blocking review finding on my own change: mapping media[]
into media_types created a POSITIONAL contract that the rest of the
inbound path then broke.
1. _localize_inbound_media (adapter.py) filtered media_urls without
filtering media_types. Dropping a dead connector re-host is a NORMAL
best-effort path, so every surviving attachment inherited its
neighbour's mime. Reproduced through the real functions:
before urls [.../relay/media/dead, .../kept.png]
types [application/pdf, image/png]
after urls [.../kept.png]
types [application/pdf, image/png] <-- PNG reads as PDF
_event_media_is_image(ev, 0) -> False
The loop now carries (url, mime) as PAIRS, so a dropped URL drops its
mime with it.
2. _media_types_from_wire compared LENGTHS only, which is not alignment:
equal-length-but-reordered wire fields were accepted and paired
wrongly, and an absent media_urls skipped the check entirely while
still emitting types. Resolution is now BY URL (url -> mime lookup
over media_urls); an unmatched URL degrades to "" and falls back to
message-level classification.
Tests: 4 new cases driving the real chain (wire parse -> localization ->
run.py classifier), incl. the dropped-first-attachment case the existing
localization test could not catch (it builds events without
media_types). The obsolete length-mismatch test now asserts the stronger
by-url guarantee. Both fixes mutation-verified: reinstating the URL-only
filter fails 1 test, reverting to positional resolution fails 3.
Relay suite 258 passed; media/voice/stt selection 685 passed; ruff clean;
cross-repo integration payload re-verified.
* fix(relay): media_types is always one slot per media_url
Self-review after two review rounds flagged this bug class in adjacent
seams: I checked the function I edited, not every consumer of the
parallel arrays I created. Grepping ALL writers found a third instance.
merge_pending_message_event (gateway/platforms/base.py:2725-2735)
EXTENDS media_urls and media_types together when a second media message
merges into a pending one. My mapping could emit a POPULATED media_urls
with an EMPTY media_types (an older connector sends media_urls but no
media[]), so extend() concatenated lists of different lengths:
A urls [old1.png, old2.png] types []
B urls [new.pdf] types [application/pdf]
merged urls [old1.png, old2.png, new.pdf]
types [application/pdf]
-> old1.png reads as application/pdf; the real PDF gets ''
Fix: media_types is now ALWAYS len(media_urls), padded with '' — the
url-keyed lookup runs even when media[] is absent, and the localizer
rewrites the list unconditionally (no short-circuit that
could leave a stale/short list behind).
Tests: 4 new cases — padding with no media[], the merge shift above
driven through the real merge_pending_message_event, localization
preserving the invariant while dropping an entry, and normalization of
a short/empty media_types arriving from a non-wire source. All
mutation-verified: removing the padding fails 4; restoring the
guard fails 1.
Relay 262 passed; media/voice/stt selection 689 passed; ruff clean;
cross-repo integration payload re-verified.
Responds to the independent PR review (andrexibiza). Both P1s were
checked against current HEAD rather than taken on authority - the
review was written against 613849c190, before the interval-model
consent replacement landed.
P1-1 (same-UTC-day revoke/re-enable releases refused data): already
fixed by the interval model. The reviewer's exact reproduction - opt in
06:00, revoke 12:00, package collected 18:00, re-enable 20:00 same day
- was re-run at HEAD: the off-window package stays local, and a full-day
aggregate straddling the revocation boundary also stays local (period
containment, timestamp precision). The consent-windows harness already
pins both. The reviewer's related ask that consent-ledger persistence
failures fail closed also holds structurally now: reconciliation derives
state rather than recording transitions, so a lost write means a shorter
confirmed horizon - less is released, never more.
P1-2 (lease has no owner) was VALID at head. Reproduced exactly as
described: A claims, is suspended past the 300s lease, B reclaims and
POSTs, A resumes and POSTs again - and the ingest key is minute-
prefixed, so the duplicate lands as a DISTINCT stored object, making
this worse than a benign idempotent overwrite.
Fix: every claim now mints a claim_token (additive nullable column,
schema version unchanged). Ownership is revalidated immediately before
every external POST, and every settlement, rejection, and backoff write
is compare-and-set on (package_id, claim_token, pending). A lapsed
claimant that resumes yields without transmitting, and its stale
backoff cannot move next_attempt_at under the live claim's lease.
Two deterministic regressions ship with it: expiry -> reclaim -> resume
(the reviewer's schedule), and the subtler stale-backoff-clobber case.
Honest scope, documented on _send_one: delivery remains at-least-once.
The token closes the claim->POST gap; a suspension landing mid-POST
(bytes already on the wire) is not client-revocable. The residual
duplicate is byte-identical content; collapsing it fully needs
package_id-keyed dedupe at the ingest service.
275 tests pass; ruff + footguns clean; staging E2E 202.
387 commits from main; no conflicts (verified with merge-tree before
merging). Overlap limited to hermes_cli/config_defaults.py and
hermes_cli/setup.py, both auto-merged; all shared-metrics surfaces
untouched by main.
The reconcile-to-guarded-model interaction test's requestGateway mock
must only answer config.set with the confirm handshake — the panel's
model.options read rides the same dispatcher and was eating the
first mocked response.
The Bots editor's model write (profiles.configure) was the one switch
surface that bypassed the data-policy / expensive-model selection guard:
a guarded pick (e.g. muse-spark contributor tier) was applied silently,
with no confirm flow anywhere — the #95293 remainder after the core
picker's confirm handshake landed in use-model-controls.
Gateway: profiles.configure now answers confirm_required +
confirm_message for a guarded model (same handshake as config.set
model) and writes NOTHING until the client resends with
confirm_expensive_model: true. Other sections still apply; the pending
model section is not reported as failed.
Desktop: the confirm flow is extracted out of use-model-controls into
one shared applier (lib/guarded-model-switch.ts, exported through the
plugin SDK) — warning toast, staleness-guarded Confirm, single
confirmed resend, never a retry loop. The core picker and the Bots
editor now consume the SAME handler; the Bots editor's Confirm resends
only the model section with confirm_expensive_model: true.
Fixes#95293 (Bots surface remainder).
Refresh Models only updated the catalog cache, so the composer kept showing a model that was no longer in the new group list.
Co-authored-by: Cursor <cursoragent@cursor.com>
The Bots model picker's catalog read rode the bot's own socket with no
deadline: a wedged dial left the query pending forever, so the picker
spun indefinitely. On top of that every fetch forced refresh:true,
bypassing the staleTime cache, so each Bots view remount (tab re-front,
dialog reopen, pane visibility flip) knocked the picker back into its
loading state and wiped the staged provider/model pick mid-edit.
Bound every attempt to 20s (rejection falls through to the picker's
existing free-text fallback), drop the forced refresh so the read
participates in the cache like every other surface's catalog, and pin
the contract with a red->green regression suite.
Slim renderer UI for the managed SSH remote update engine (#95942),
adapted from #93042's renderer unit with the deferred canary/rollout
scope stripped. Adds a per-connection store (idle/updating/terminal
states, receipt, managed-update-in-progress busy envelope) and a
'Managed updates' section on the Gateways settings page with an Update
button, progress line, and correlated receipt per registered
Desktop-managed SSH connection. Fails closed when the Electron main
lacks connections.updateManaged.
The renderer pings each pool backend every 60s (`hermes:backend:touch` →
`touchPoolBackend` → updates `lastActiveAt`). The LRU eviction cap used a
keepalive-fresh window of 90s — only 1.5× the ping cadence — to decide
whether a backend was "plausibly still alive". One missed or delayed ping
pushed a live backend past the threshold and the cap-driven eviction killed
the active profile's backend mid-session, restarting the gateway and
re-minting runtime ids. On WSL2, where the renderer→Electron IPC roundtrips
through 9p, brief 9p hiccups commonly stretch a ping to seconds of observed
silence, producing the ~80–90s exit / ~2 min cycle reported in #95189
(122 gateway starts on 2026-08-26 alone, driving renderer OOM via reconnect
churn at ~5GB/day).
Widen POOL_KEEPALIVE_FRESH_MS to 4 minutes (3× ping cadence + IPC stall
headroom, still bounded well below POOL_IDLE_MS=10min). Backends with one or
even two missed pings are now spared; truly idle backends (multiple lapses,
minutes idle) remain eligible for eviction by the cap and the idle reaper.
The constant is also overridable via HERMES_DESKTOP_POOL_KEEPALIVE_FRESH_MS
to make this tunable without a rebuild.
An interrupted hermes update after git pull advanced HEAD never
restarted running gateways, and the next update said "Already up to
date" and skipped the fleet. Persist a HERMES_HOME fleet_restart_pending
marker after HEAD moves, clear it only when restart completes (or
nothing was running), and catch up on the next hermes update even when
git is current — also when latest.json records a stale runtime SHA.
Co-authored-by: GokayAI <gokay-ai@users.noreply.github.com>
Kanban/background completion wakes persist as role=user rows typed with
display_kind="internal_notification" (the synthetic-wake path in run.py).
The model-payload builder already strips display_kind before the request
and is_user_originated_turn already ignores it, but two compaction scans
still treated those rows as real user turns:
- _is_actionable_user_turn (tail anchor) only checked role/content, so a
notification became the protected 'last user turn' the compressor keeps.
- _derive_auto_focus_topic only skipped synthetic compression turns, so
operational notices leaked into the compact focus hint.
Both now exclude display_kind-typed rows, mirroring the existing
is_user_originated_turn exclusion. No schema change; cache- and
role-alternation-safe.
Behavior-contract tests feed 1,000 operational notifications around one
human turn and assert they never anchor the tail, become the auto-focus
source, or count as actionable user turns.
Fixes#92703
Group rooms persist source-qualified members. After Desktop switches to
the built-in This-device source, a dead loopback row still listed next
to the live profile and looked like a second agent. Collapse only the
sidebar tiles; $lastRoster, group seats, and mentions keep every
(connectionId, profile) identity.
main's hermes:connections:update-all handler grew (renderer-side exclusions +
the managed-SSH dispatch branch) after #95606 branched, pushing the claim-
guarded dial past the 2,000-char slice.
(connectionId, profile) scope, but it was only wired into hermes:connection,
hermes:connection:for, and the power-resume pool rebuild. Five other real
call sites still invoke ensureRegistryBackend()/ensureBackend() directly:
the media-protocol connection resolver, the terminal-pane backend resolver,
the ~5s roster-enumeration probe, the connections update-all dispatch, and
dispatchRegistryApiRequest (every registry-scoped hermes:api REST call).
ensureRegistryBackend() has a genuine await-then-check race
(reuseMatchingPrimarySshBackend before the pool entry check-and-set), so any
of these five racing a guarded dial for the same scope can each bootstrap
their own SSH tunnel / remote dashboard for the same connection — exactly
the symptom class #90812 was written to prevent, just reached through an
unguarded path instead of two renderer windows.
Left the ensureRegistryBackend() self-call inside its own dispatch-time
health-probe reconnect branch (registryDispatchRevalidation) unguarded —
that recursive path has its own coordination semantics and touching it
without modeling reentrancy against the newly-guarded entry points here is
a separate, riskier change better done on its own.
A wake-path liveness-probe timeout force-closed the primary renderer
socket even when the backend was merely busy mid-tool-call; the gateway
then saw its client vanish, ws_orphan_reap expired, and the running turn
died as a bare "Operation interrupted." placeholder.
While any session still reports working, the first inconclusive probe
timeout now defers the teardown behind one bounded re-probe; only an
exhausted consecutive-failure streak (or no in-flight work at all)
rebuilds the transport. The streak resets on a successful ping, a
healthy -32601 answer, a clean open, a gateway switch, and unmount.
The registry's active-connection-invalidated fallback re-dial was the one
getConnection() await in this file the #93454 bound-every-IPC-round-trip
sweep never reached. A wedged main-process round-trip during an eviction
fallback (idle reap, connection removal, profile delete) left $connection
latched on a promise that never settles instead of rejecting into the
existing catch/publish(null) path.