runtime: cut the fleet to v0.20.5 (2026.8.19) with all fleet patches carried - #20
Merged
Merged
Conversation
…ther gateways, and the app itself Remote-mode installs had every update affordance (About panel Update now, ⌘K Update Hermes, the update-ready toast) pointed at the BACKEND only, so users updated their VPS forever while the desktop app itself sat weeks stale — with no signal it was behind (the skew warning only fired the other way). Reported by Santiago Sarceda: mac app on v0.20.0 kept repro'ing UI bugs fixed on main because 'update' never touched the app. - store/updates.ts: applyEverythingUpdate() orchestrates all targets — active backend first (detailed progress), every other eligible registered gateway via the existing Electron fan-out (cloud rows skip), the client LAST (its apply relaunches the app). startActiveUpdate/ requestActiveUpdate route through it whenever more than one update target exists; single-machine installs keep the one-button flow. - After ANY successful backend update, the client version is re-checked and a one-click 'Update desktop app' warning fires if the GUI is still behind — the reverse-skew signal that didn't exist. - electron: hermes:connections:update-all accepts optional excludeIds so the flow doesn't double-dispatch the active backend / local runtime. - i18n: 7 new updates.* keys across en/zh/zh-hant/ja/ar. - docs: desktop.md Updating section + multi-connection guide. - tests: 10 new cases (gating, ordering, exclusions, failure isolation, memoization, nudge on/off).
…ever sticky When the chosen/keyed backend fails a web_search or web_extract call (bad key, upstream outage, 5xx, raised exception), that single call retries on the keyless free-tier ring instead of erroring. The next call attempts the chosen backend again — no sticky failover, no state. Resolves the keyed half of NousResearch#78984/NousResearch#32159 (keyless half landed in the ring PR). - tools/web_tools.py: _rescue_eligible (keyed ring vendors + non-ring backends eligible; keyless-mode calls excluded — they already walked the ring), _rescue_search/_rescue_extract (search annotates rescued_from + backend_error naming the original failure and the retry-next-call semantics; extract rescues only whole-batch failures, partial failures pass through untouched; rescue failure preserves the ORIGINAL backend error with the rescue note appended) - both dispatchers wrap the provider call: failure-results AND raised exceptions rescue; ineligible paths re-raise unchanged - web.keyless_rescue config key (default true; implicitly off when keyless_fallback is off); docs updated Live E2E: keyed Tavily with an invalid key 401'd and the call was served by the real ring with the rescue annotation; a second call re-attempted Tavily first (statelessness proven); whole-batch extract rescue returned real page content. 13 new tests; 67 green across the keyless suites.
…roster survives outages, spawn failures log, host-key change stops the retry wall Three fixes from one remote-gateway (VPS) debug bundle, all live-reproduced and re-verified on a headed Electron seat via CDP: - Bots roster no longer shrinks during a gateway outage: source enumeration is bounded (10s/source instead of wedging the roster IPC >30s behind a dead dial) and a bounced remote source keeps painting its last-known profile list (was SSH-only), so 4 bots never show as 2 mid-outage. - Pool backend spawns that die before the child exists (forced-local spawn of a profile that only exists on the remote) now log the failure to desktop.log, and the profile-exists guard runs BEFORE the Starting line — no more orphaned no-READY/no-exit spawn bursts in bundles. - An SSH host-key change (VPS reinstall) is classified terminal like a reauth rejection: it latches, the boot-failure overlay shows the ssh-keygen -R guidance, and the renderer stops the infinite boot-retry loop (one bundle had 157 consecutive failures over 2.5h). Reset/repair/ apply-config clear the latch; live-verified Retry-after-fix boots clean.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…d of a generic failure A GitHub-side HTTP 429 during 'hermes update' printed only 'Failed to fetch updates from origin.' — and the curl 'unable to access ... returned error: 429' shape even matched the network-error branch, blaming the user's connection for a GitHub outage. - new _classify_fetch_failure(): 429/rate-limit -> 'GitHub is rate limiting requests or having an outage — try again in 5 minutes'; 5xx -> outage message with githubstatus.com; ordered BEFORE the generic 'unable to access' network check - both fetch-failure sites (update apply + --check) now share the classifier via _print_fetch_failure(), and both always print the first raw stderr line so the wire error stays diagnosable - tests: classifier matrix + E2E against a live local HTTP server returning 429 through real git Fixes NousResearch#89287
build_session_key embeds the workspace segment (scope_id) in every Slack dm/group/thread key, but both cron seed helpers built their SessionSource without it: the seeded row keyed agent:main:slack:dm:<chat>:<thread> while a real scoped reply keys agent:main:slack:dm:<team>:<chat>:<thread> — a row no reply ever resolves to. DMs were rescued only incidentally by the legacy-key claim-once migration; scoped channels/threads got continuation amnesia, and identical channel ids in two workspaces could collide. Capture HERMES_SESSION_SCOPE_ID into the cron origin (_origin_from_env — the session-context var async_delegation already snapshots), add scope_id to _seed_cron_thread_session/_seed_cron_channel_session, and pass the origin's scope at all three seed call sites. Tests: scoped dm-thread / channel-thread / flat-channel seed-vs-reply key equality through the real build_session_key, plus a two-workspace non-collision guard.
…ilure-one-shot-rescue feat: failing keyed web backends rescue onto the keyless ring for one call, never sticky
…e subcommand 'hermes --version' (and -V) now prints the full version report — banner version line with upstream SHA, install directory, authoritative install method, Python and OpenAI SDK versions, and update status — making the separate 'hermes version' subcommand redundant. The subcommand is removed. - _startup_fast.print_fast_version_info() is now THE canonical version printer: static lines print instantly from stdlib probes, then the banner label, install-method resolver, and update check lazy-import after the first line is on screen (each degrades gracefully). - main.py _print_version_info() delegates to it (used by /version in the CLI chat surface and the --version flag path); the old duplicate implementation is deleted. - hermes_cli/subcommands/version.py removed; parser wiring, subcommand sets, console-engine extraction entry, and tests updated. Hermes Console keeps a 'version' command wired to the shared printer. - Termux fast paths now include update status too (previously check_updates=False). - Docs/i18n, CONTRIBUTING, SECURITY, and nix checks updated to 'hermes --version'.
…get) The seed was decoupled from the mirror opt-in (in_channel is the continuation surface regardless of attach_to_session), but the thread-id-clearing gate above it still read mirror_this_target. With the advertised default config (attach_to_session=false, cron.mirror_delivery unset) and an origin carrying a real thread_id, the brief kept delivering INTO the origin thread while the flat (thread_id=None) session got seeded — brief and continuation surface in different places, so a plain reply never saw it. Flatten on the same gate as the seed: origin_target (with the existing live_adapter_ready guard). Fan-out/broadcast targets are unaffected. Test drives _deliver_result with a thread-carrying origin and default knobs, asserting on the routed DeliveryTarget.thread_id — RED on the old gate, GREEN now.
…on send_for_platform Two gaps in the block-formatting hint stamping: 1. Wrong descriptor: _format_hints gated on self.descriptor — the PRIMARY identity's scalar — while one RelayAdapter fronts N platforms. A Slack-primary adapter stamped Slack hints onto known Discord chats; a Discord-primary adapter suppressed hints for Slack chats whose own negotiated descriptor advertised the bit. Resolve per destination: send/edit use _descriptor_for_chat (the same seam max_message_length already uses) plus the chat's logical platform for the config sub-block; the knob lookup is now per-logical-platform (platforms.relay.extra.<platform>.*) instead of hardwired to slack. 2. Missing lane: send_for_platform — the scheduled/persisted-home lane (gateway/delivery.py), i.e. the CRON delivery path, the flagship consumer of the in_channel brief — never stamped hints at all. Stamp there too, resolving descriptor_for_platform(logical) off the transport; the scalar descriptor is used only when it belongs to that exact platform (fail closed). Tests: Slack-primary/Discord-chat no-leak, Discord-primary/Slack-chat still-stamps, send_for_platform stamps for capable platform and stays clean for incapable — all against a two-platform negotiated-descriptor transport. Existing single-platform suite unchanged and green.
… its main tab (NousResearch#89788 follow-up) The NousResearch#89788 gate read main-tab ownership from a plain module Map — invisible to React — and openGroupChat set the selection atom before recording the tab. Every open therefore rendered BotsPane in a selected-but-unowned window, painting the in-pane room beside the main tab, and the duplicate stuck because the later Map write repaints nothing. - $groupMainTabsRev atom shadows tab-map membership; all mutations go through recordGroupMainTab/dropGroupMainTab; BotsPane subscribes, so the in-pane gate re-evaluates on tab open/close. - openGroupChat records the tab BEFORE setting the selection atom; older desktops without the main-window door (and a throwing door) still get the in-pane fallback. - Regression tests: gate is false at the instant the selection atom flips (fails on the old ordering — sabotage-verified), and rev bumps on tab open/close.
…atform's descriptor RelayAdapter.supports_inchannel_continuable is a scalar adopted from the PRIMARY identity's handshake descriptor, but one RelayAdapter fronts N platforms and the connector advertises the bit per platform. Reading the scalar for every logical platform both leaked a Slack-primary True onto other fronted platforms (activating the flat surface their descriptor never advertised) and suppressed a non-primary platform's advertised True (forcing thread mode on capable Slack behind a Discord primary). Add supports_inchannel_continuable_for_platform(platform): resolves the platform's own negotiated descriptor via descriptor_for_platform (the same Phase 1.5 seam max_message_length uses), scalar fallback only when the per-platform descriptor is unavailable. The scheduler's D6 gate prefers the query when the adapter provides it; native adapters keep the class-attribute path byte-identically. Tests: two-platform descriptor matrix (primary-True no-leak, non-primary-True honored, unknown-platform scalar fallback).
_cron_mirror_delivery_enabled still promised 'cron deliveries live only in the cron job's own session' as the unconditional default, but the in_channel continuable surface now seeds the target session regardless of attach_to_session/cron.mirror_delivery (the seed IS the continuation feature, and in_channel is itself opt-in). State the carve-out where the guarantee is documented.
…allback nuance test_flat_key_wins_over_subblock asserted the OPPOSITE of its name (the sub-block wins, matching _relay_slack_extra). Rename to what it proves. Also note in _resolve_cron_surface_mode why its fallback differs from _relay_slack_extra's all-or-nothing sub-dict: the flat key is the legacy staging shape, and a flat knob applies to every fronted platform, gated only by the per-platform D6 capability check.
…nent supervised watchers (NousResearch#84558) NousResearch#84327 excluded _spawn_supervised's permanent watchers (session-expiry, kanban, reconnect, the scale-to-zero watcher itself, ...) from _scale_to_zero_has_live_background_work() via a _hermes_supervised_watcher tag, because counting them made an armed gateway consider itself busy forever and never go dormant. Two more permanent, infinite-loop tasks are added to _background_tasks OUTSIDE _spawn_supervised and were untagged: - _loop_heartbeat_task (loop_heartbeat_forever, NousResearch#66892): a `while True` loop started unconditionally in start() on every gateway boot. Extracted the inline spawn block into _start_loop_heartbeat_task() so it's independently testable, matching the existing _start_heartbeat_poller() pattern. - _heartbeat_poll_task (_poll_loop in _start_heartbeat_poller): also a `while True` loop, started the first time a session registers a heartbeat watch, and then permanent for the rest of the process. Because _loop_heartbeat_task starts on every boot, it alone made _scale_to_zero_has_live_background_work() return True forever on every armed instance, regardless of the NousResearch#84327 fix -- confirmed empirically against the real method with the exact untagged-task shape this task has. Two new regression tests spawn each task through its real production entry point and assert the busy check returns False; both fail against the unfixed code (missing method / real assertion failure). Co-authored-by: pierrenode <298902573+pierrenode@users.noreply.github.com> Co-authored-by: Ben Barclay <ben@nousresearch.com>
…he page it opened
The in-app browser was a one-way mirror. open_preview put a page in the pane
and read_preview read its text back, but nothing could touch it. A click meant
falling back to the browser_* tools, which drive a separate Chromium the user
cannot see — so "log into this and pull my invoices" happened in a different
browser from the one on screen, with none of the sessions the user is already
signed into.
Four pieces, and they only make sense together:
· an in-page engine that inventories what is interactable and performs the
verb, injected as source because it has to run inside the guest page;
· the preview.act.request bridge from the gateway into the pane;
· drive_preview, for acting: elements, click, type, scroll, press, and the
pane's own back/forward/reload;
· annotate_preview, for marking without acting.
Those last two started as one tool doing two unrelated jobs. Leaving a mark is
not an action — it outlives the turn that drew it — so it gets its own verb,
and the interaction verb gets a name that says what it does.
Gating is the existing surface rule: desktop_ui folds in on session
source: 'desktop', and the bridge refuses to act for a background session, so a
turn running behind the user's back cannot reach into the page they are working
in.
Two details worth a reviewer's attention. Typing assigns through the
prototype's value setter, because React shadows value with its own accessor and
ignores an input event whose value it believes it already wrote — a plain
el.value = … types into a field that snaps back on the next render. And
clicking replays the pointer/mouse pair before activation, because frameworks
bind to mousedown as often as to click.
A dispatched MouseEvent is untrusted, so hover menus never opened and any control that gates on isTrusted ignored it. The pane now sends input through the webview itself: the pointer travels to its target and the page cannot tell it from a hand.
Driving someone's browser invisibly is unnerving, and this browser is the one they are signed into. The pane now draws the field the agent can reach, a box round what it is touching, a cursor that goes there, and a wipe over text it just read — one cursor primitive and one mark primitive, in a closed shadow root so the agent's own inventory cannot see them. Marks carry the same handle the agent addresses them by, so the word on screen and the word in the transcript are the same string. The point is supervision rather than decoration: a person glancing at the pane can tell what is about to happen to their live session, and stop it. It also has to cover the waiting. The agent flashes through a click in under a second and then sits idle for the twenty to a hundred seconds the model spends deciding what to do next, which is most of the wall clock of any task — so the surface used to look broken during the part where it was working hardest. A think stage runs off the $busy edge, sparsely flashing elements from the field the last action left behind, and rest stops it. It guards itself: started before there is an overlay or a field, it idles until there is one, so it can be raised on the turn boundary without knowing whether anything has been inventoried yet. read_preview had the same hole from the other side. Reading is the cheapest thing the agent does — hundredths of a second between two model round trips — so paging through a document left the pane dark for twenty seconds immediately after the one moment that showed anything. It draws a top-to-bottom wipe over the text it took, and that is the one stage allowed to be a wipe: reading is the only thing the agent does to a page in an order a person could follow. Both go through preview-nudge, which says a single stage to an overlay the page already has rather than re-shipping the engine to narrate. On a page the agent never acted on it is a no-op, which is the honest answer — chrome there would be a lie about what it did. Everything respects prefers-reduced-motion.
…ole page Every drive_preview action answered with the entire inventory — around 120 elements of ref, role, label, and an up-to-eight-rung `:nth-child` selector chain. On a real app shell that was ~24.5k characters, re-sent after every click, so a ten-step task paid for ten copies of a page that had barely moved. Handles are now durable and legible. An element is named after what it is and what it says — `btn-sign-in`, `inp-email`, `srch-search-projects` — minted once per page and never reused, with duplicates disambiguated as `btn-edit`, `btn-edit-1`. Each one remembers a stable attribute, its role, its accessible name, and the nearest landmark it sits in, so when a framework destroys the node and builds a new one the handle moves across and the agent is told `rebound` rather than being handed a removal it has to react to and an addition it has to re-read. The re-bind ladder is anchortree's (Apache-2.0), minus its geometry rung, which can never clear the threshold on its own. Because the handles hold, the first look at a page returns the inventory and every look after it returns only what moved. `changed` carries the ref and whichever of label/value/disabled actually shifted — role and selector are absent by construction, since a change in either would mean the re-bind ladder was looking at a different element. A delta gives way to a full re-read when half the page is new, where there is nothing left to reuse. The selector column is gone with it. It was 74% of the inventory on an 85-element page, nothing downstream ever read it, and a positional chain is wrong the moment a sibling appears. An `#id` or `[data-testid]` survives when the page offers one; everything else is addressed by handle. Legibility is what makes the delta work rather than a nicety. `+ btn-sign-in` on turn nine reads on its own, where `+ @e42` sends the model back to an inventory twenty thousand tokens ago. Measured on an 85-element app shell: 18,693 -> 4,930 characters for a baseline, and a steady turn that moved two things costs ~200.
test_auth_add_xai_oauth_persists_under_external_owner exercises the device-login persistence path via auth_add_command with no_browser=True, but under pytest stdin is not a tty, so the unattended xAI device-login guard (0c6fc49) raised SystemExit before the behavior under test ran. Monkeypatch sys.stdin.isatty to True so the test models a deliberate terminal-driven login; the guard's own rejection test is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 338531b)
…ocking Salvaged from the pre-quicksilver stash (stash0-pre-quicksilver-20260720, preserved patch in backups/runtime-upgrade-20260805), re-applied onto runtime/fleet-20260805 and adapted to the v0.20.0 encrypted-cache drift. * _cache.py: file_lock() advisory cross-process lock (flock/msvcrt, 30s timeout, best-effort on lock-file creation failure) + DiskCache .lock_path()/.lock(). * bitwarden.py: _shared_cache_home() (~/.hermes) as an L2b plaintext disk cache shared by all profile homes; cold fetches take the shared lock and double-check the cache before hitting the API, so 13 concurrent profile startups coalesce into one `bws secret list`. Encrypted cache stays profile-local (keyed off the bootstrap token) and opts out of sharing. Stale network-outage fallback and clear_caches() cover the shared copy. * tests: cross-process coalescing test (two subprocess profiles, one bws call) + fixture keeps the shared cache out of the developer's ~/.hermes. Dropped from the stash: auth.py/credential_pool.py/config hunks (superseded designs) and the _apply_fleet_grants env-alias hunk — the fleet-grants subsystem was not carried onto the v0.20.0 fleet branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 88805bf)
Extend the oauth.refresh_owner=external contract from xai-oauth to openai-codex. Under external ownership Hermes adopts scheduler-owned Codex pool tokens but never POSTs the refresh token: resolve gates force/expiring refresh, _refresh_codex_auth_tokens (and its Codex-CLI self-heal import) fail closed, pool proactive/reactive rotation adopts from disk instead, and load_pool stops seeding the pool from the singleton. Fleet's oauth_tokens.py scheduler remains the sole rotating writer via oauth_token_write_authority=external-scheduler. Interactive `hermes auth add openai-codex` passes interactive-login write authority (single-site principle — no other add path is granted), and an interactive re-auth under external ownership appends a device_code pool row when none exists so Fleet recovery can restore an empty pool. External ownership with no usable scheduler-owned pool row raises typed codex_external_pool_unavailable instead of silently rotating. Absent config keeps runtime-owned behavior; malformed ownership fails closed. Same-user accidental-writer fence only — not cryptographic. Re-derived from stash0-pre-quicksilver-20260720 onto the committed xAI ownership design (c0c0527); the stash's blanket interactive-login grants across nous/minimax/qwen/dashboard add paths remain rejected. Refs: NousResearch#77553 (cherry picked from commit e46cbd1)
(cherry picked from commit eff88d7a802b3b2e0c1e5e9f2c1e9d3c2b1a0f9e) Rebased onto v0.20.5: upstream added an _orphaned_desktop_backend_pids patch to this test and bound it to the import-time cli_main alias. That is the exact binding this commit exists to remove, so it is retargeted onto main_mod alongside PROJECT_ROOT and the subprocess tripwire. tests/hermes_cli/test_update_venv_health.py: 5 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Telegram /new already started the next turn on the config primary. A local APIConnectionError (errno 24) then jumped Sol to Luna and wrote Luna into sessions.model, so /new felt like it did not restore primary. Classify EMFILE as local_resource, block provider fallback, and stamp the config default onto the new session row at reset time. (cherry picked from commit 2f380ba)
(cherry picked from commit 6b21ea9) Rebased onto v0.20.5. Upstream rewrote the voice dispatch block (typed callables, multi-file actual_paths loop), so this keeps upstream's structure and reapplies only what this commit is for: send_voice targets voice_chat_id from _resolve_auto_tts_delivery rather than event.source.chat_id. Upstream's re-derivation of reply_anchor/thread_meta from the event is dropped. It would have overwritten the resolved delivery anchors and pointed the reply back at the webhook source, which is the bug this commit fixes. tests/gateway/test_webhook_voice_deliver.py: 6 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`migrate()` documents `discover_plugins` as "when True (default), query `plugin/list` against the live codex CLI ... Set False to skip the subprocess spawn (for tests or restricted environments)". Two tests never opt out. They assert config.toml RENDERING, not plugin discovery, but leave the parameter at its default, so each run spawns the real codex CLI, which shells out to `git ls-remote https://github.com/openai/plugins.git HEAD` -- a live network call to a third-party repository from a unit test. Every other test in the file already gets this right: the ones that pass `discover_plugins=True` monkeypatch `_query_codex_plugins` first. These two just missed the switch. Both tests PASS either way, which is why it went unnoticed -- the same silent shape as the update-flow leak fixed in eff88d7. A suite that reaches the network is non-hermetic (fails offline, depends on a third party's repo staying reachable) and slower for no benefit. Verified with a logging `git` shim that records every git invocation including those from grandchild processes: before: 1 call -- `ls-remote https://github.com/openai/plugins.git HEAD` after: 0 calls 20 passed in both cases. (cherry picked from commit 3cc99d7)
(cherry picked from commit 10cda15)
* fix(computer-use): add background-only policy gate * perf(computer-use): avoid config deepcopy in policy gate (cherry picked from commit 386ad82)
Prevent pathological redactor backtracking on mixed secret-keyword and opaque payload output. Preserve existing redaction semantics and add a mutation-sensitive performance regression. (cherry picked from commit e06bd01)
) #16 made config scanning linear and covered the case where a large opaque payload sits on its own line, away from the secret keyword. It did not cover the shape that actually took a gateway down: the unbroken run being the VALUE the secret key is assigned to. Adds four regression tests: - test_long_undotted_value_completes_fast — `token=<32 KB>`, the production shape. ~95s before the linear rewrite. - test_undotted_value_scaling_is_not_quadratic — asserts on the growth RATE rather than a budget at one fixed size, so a reintroduced quadratic is caught while still cheap instead of only once catastrophic. - test_long_undotted_secret_still_redacted — linear scanning must not stop a real long secret being masked. - test_midtoken_keyword_still_not_matched — a key that merely embeds a keyword must stay untouched, guarding the candidate-scanning rewrite against widening what counts as a config key. Tests only. The pattern fix this branch originally carried is dropped as redundant: #16 already makes both shapes linear (`token=<64 KB>` went from ~6 minutes to 3ms), so re-adding a key-start lookbehind on top would change a security-sensitive regex for no measured gain. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit d29eafb)
v0.20.5 added a lowercase env-name pass to redact_sensitive_text:
[a-z0-9_]+(?:_|^)(?:key|pass|pw|token|...)(?=[^a-z0-9_]|$)
The greedy run scan restarts at every offset of a long [a-z0-9_] payload, so
an opaque base64/hex blob costs O(n^2). Measured on the pattern alone with
`token=` + 16K of filler: 1364 ms, against 0.2-0.3 ms for every other
pattern in the function. `redact_sensitive_text` runs on the gateway's hot
path, and a CPU-bound regex holds the GIL, so this is the same shape that
wedged the whole gateway on compaction payloads.
This is the defect PRs #15/#16 fixed in _CFG_DOTTED_RE, reintroduced upstream
in a different pattern. The three ReDoS regression tests from #15 fail on
stock v0.20.5.
Fix mirrors #16: anchor candidate discovery at a left boundary, scan the key
possessively, and move the <prefix>_<secret word> rule into a string-only
validator that cannot backtrack.
Verification:
- pattern alone, same input: 1364 ms -> 0.0 ms
- differential test of old vs new over 2066 generated cases covering secret
words, prose lookalikes, separators, quoting and os.getenv values: zero
divergence in match spans and groups
- tests/agent -k redact: 131 passed (was 128 passed, 3 failed), suite wall
time 81 s -> 3.8 s
Worth sending upstream.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…custom Delegation children frequently log `provider=custom` while still pointed at the official OpenCode Go host. `recover_with_credential_pool` treated that as a pool/agent mismatch, skipped the Go credential pool, and hopped model (ox-alpha -> deepseek) instead of rotating CodeWalnut -> personal. Match on the official Go base URL so the rotation path is taken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 37837de)
…t act on Rotating onto a free OpenCode Zen model (x-preview-f-free, mimo-v2.5-free), and the hop back off it to the configured primary, is routine capacity shuffling rather than a durable state change. Buffering a status line and a one-shot notice for it trained operators to ignore the notice entirely. Suppress both for that pair of transitions. Every other provider/model switch still buffers the line and records the notice; the logger.info record is kept unconditionally so the transition stays in the log. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit ff77569)
(cherry picked from commit df6521b) Rebased onto v0.20.5, which reduces this to tests only. The original commit added an accept-and-ignore `source` parameter so live gateways calling transcribe_audio(path, None, "gateway") would not TypeError. v0.20.5 already has `source` as a real parameter, forwarded to the pre_transcription plugin hook, so the shim is dropped and upstream's signature is kept. The tests are still worth carrying. test_live_gateway_caller_stays_arity_safe is rewritten: it grepped gateway/run.py for the two-argument call spelling and asserted the three-argument form was absent, which upstream now legitimately uses. It binds the real call shapes against the real signature instead, which is the invariant that actually failed in production. tests/tools/test_transcription_tools.py: 55 passed, 1 failed. The failure (test_config_device_and_compute_type_passed_to_whisper, expects compute_type=float32 and gets int8) reproduces on stock v0.20.5 with none of this branch applied. Pre-existing upstream, tracked separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 22, 2026
main carries nothing newer than v0.20.5; its fleet patches are all carried on this branch already, under new SHAs from the rebase. `git cherry` confirms the only commits whose patch-id differs are the ones deliberately adapted during the rebase (telegram receipt, live-git test guard, webhook voice, redact linear scan, STT arity) plus the one deliberately skipped (npm advisories, #13 — upstream v0.20.5 already covers 4 of its 6 pins and adds more). Conflicts therefore resolve to this branch's side in every case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fleet patches carried onto v0.20.5 encoded their rules as dense inline expressions inside long functions. Same behaviour, extracted and tested. agent/chat_completion_helpers.py The free-Zen quieting rule was eight underscore-prefixed locals inline in try_activate_fallback, with no test at all — the one change in #18 flagged as unverified. Extracted as _is_routine_free_zen_hop with named endpoints. agent/redact.py The <prefix>_<secret word> rule already lived in _is_lower_env_secret_key but nothing exercised it directly; only the end-to-end redaction path did. gateway/status.py The process-rollover check was an inline four-way comparison with a comment longer than the code. Extracted as _is_process_rollover, which lets the clear_profile_platforms boundary be stated as a test rather than prose. agent/agent_runtime_helpers.py The Go-host check was a bare substring against a lowercased base_url. Extracted as _is_official_opencode_go_url with the marker as a constant, and it no longer assumes base_url is a str. New coverage (+60 tests): - free-Zen quieting: both quiet transitions, six shapes that must stay announced, case/padding handling, and AST assertions that the notice and buffered line sit inside the guard while the log line stays outside - lowercase env-assign: key rule against the pattern it replaced, validator linearity, pattern scaling, and the underscore-run adversarial input - process rollover: matching/differing pid, recycled pid via start_time, and the no-identity payload that clear_profile_platforms depends on - OpenCode Go URL: official hosts, near-miss endpoints, empty and non-str Touched test files plus the new one: 1039 passed, 3 failed. The three are pre-existing and reproduce on stock v0.20.5. ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`check-attribution` scans every author between the merge base and HEAD. On a pin cutover that is the whole upstream range, so 548 distinct author emails are in scope and 15 had no mapping file. Handles come from the GitHub commit API for each author's own commit, not guessed from display names. Three had no login on the API: - `tranquilflow@users.noreply.github.com` — handle taken from the noreply address and commit author name; it lacks the `id+login` form the check auto-resolves. - `agent@Agents-Mac-mini.local`, `agents@Jarviss-Mac-mini.local` — machine identities from local git config, no GitHub account behind either. Re-running the workflow's own logic locally now reports 0 unmapped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Single consolidated PR for the fleet runtime upgrade v0.20.0 → v0.20.5. Supersedes and closes #4, #10, #11 and #19.
Base is upstream release commit
fcbd1076a9, tagv2026.8.19. There is nov0.20.5git tag; upstream tags CalVer and the semver lives only inpyproject.tomlandhermes_cli/__init__.py. Delta from v0.20.0 is 3,820 upstream commits across 4,040 files.This is a pin cutover, not a casual merge. It requires a venv rebuild and named-interpreter reprovisioning on every host. See the deployment notes at the bottom.
What is in it
22 commits on top of v0.20.5.
main: OAuth ownership enforcement (xAI + Codex), Bitwarden shared disk cache, sanitized Telegram runtime receipt, webhook auto-TTS deliver routing,/newEMFILE handling, cron lifecycle guard, computer-use background-only policy, the redact ReDoS fixes, and the CI/test guards.state: pool SessionDB read connectionsandstate: bound PEAK read connections with a permit.v0.20.5 reintroduces the gateway wedge
Upstream added a lowercase env-assign pass to
redact_sensitive_textwhose pattern restarts a greedy run scan at every offset of an opaque payload:Measured on the pattern alone against
token=plus 16K of filler:_ENV_ASSIGN_RE_CFG_DOTTED_RE_CFG_ANCHORED_RE_ENV_ASSIGN_LOWER_REredact_sensitive_textis on the gateway hot path and a CPU-bound regex holds the GIL, so this is the same shape that previously locked up a whole gateway on compaction payloads. The three ReDoS regression tests from #15 fail on stock v0.20.5.Fixed here with the same technique as #16: anchor candidate discovery at a left boundary, scan the key possessively, and move the
<prefix>_<secret word>rule into a string-only validator that cannot backtrack.os.getenvvalues: zero divergence in match spans and groupstests/agent -k redact: 131 passed, was 128 passed and 3 failed; suite wall time 81 s → 3.8 sThis one is worth sending upstream.
Rebase resolutions worth reviewing
Four picks needed judgement rather than mechanical merging. Each is explained in its own commit message.
clear_profile_platformscontract, whose fixture writes a payload with no pid. The reset is now gated on the file actually recording a prior process identity, so a real rollover still discards stale receipts and upstream's contract holds.reply_anchor/thread_metafrom the event is dropped, since it would point the reply back at the webhook source, which is the bug being fixed.test_update_venv_health: upstream bound a new patch to the import-timecli_mainalias, which is the exact binding that test exists to remove. Retargeted ontomain_mod.sourceas a real parameter forwarded to thepre_transcriptionhook, so the shim is dropped and upstream's signature kept.Deferred decisions
The npm advisories patch was skipped because upstream v0.20.5 already covers 4 of its 6 pins and adds more besides (lodash, yauzl, protobufjs, ip-address, postcss, tar). Keeping
package.jsonand the lockfile coherent was worth more than the remaining delta. Two gaps are given up deliberately:3.3.17against our3.3.1840.10.2against our42.9.1Electron is a major desktop-framework bump against 3,820 commits of upstream desktop changes, which is the wrong thing to force through a runtime upgrade. It remains an unresolved security delta and should be handled on its own.
Verification
The 20 test files this branch touches: 979 passed, 3 failed. All three are pre-existing and were each checked rather than assumed:
test_config_device_and_compute_type_passed_to_whisperfails identically on stock v0.20.5 with none of this branch applied. It appears to read real local config rather than a fixture.test_run_agentopenrouter failures pass standalone and pass with their whole file (276 passed). On stock v0.20.5 those same two error out in a combined run, alongside 305 other problems that this branch's conftest fixes resolve.Against a freshly built v0.20.5 venv (
mcp 2.0.0,httpx2 2.7.0,cryptography 50.0.0):Deployment notes
A venv rebuild is unavoidable:
mcp1.28.1 → 2.0.0 (MCP revision 2026-07-28, moves its HTTP stack tohttpx2, whichtools/mcp_tool.pyandtools/mcp_oauth_manager.pyimport by name),cryptography48.0.1 → 50.0.0,nemo-relay0.6 → 0.7.1.uv pip install -e '.[all]'. A bare-e .yields only 61 packages.[all]deliberately excludes messaging (telegram/discord/slack), which is lazy-installed at first use. A from-scratch venv therefore has no Telegram stack until those extras are installed.What this closes
_resolve_auto_tts_deliveryhelper that feat(webhook): route voice through the deliver-target platform adapter #4 lacks.#85125Phases 1 and 2a,bound sequential tool calls,dump wedged worker stacks). The fifth exists only to compensate for a tree lacking upstream's executor refactor, which v0.20.5 has.🤖 Generated with Claude Code