sync: defer 51 commits (agent-loop-core fix + new gateway relay family + 1 unported tui_gateway) - #4
Merged
Merged
Conversation
OpenVikingMemoryProvider only overrides on_session_end and inherits the base-class no-op for on_session_switch. When the agent rotates session_id (via /new, /branch, /reset, /resume, or context compression), the provider's cached _session_id stays at the value initialize() captured. All subsequent sync_turn writes then land in the already-closed old session, and on_session_end tries to commit it a second time — the new session never accumulates messages and never triggers memory extraction. The fix mirrors the pattern Hindsight uses (NousResearch#17508): 1. Wait for any in-flight sync thread to drain under the OLD _session_id before we mutate it, otherwise the commit below races the last message write. 2. Commit the old session if it accumulated turns — same extraction semantics as on_session_end. Skip if empty (nothing to extract). 3. Drain in-flight prefetch from the old session and clear its cached result so the new session doesn't see stale recall. 4. Rotate _session_id to the new value and reset _turn_count. Commit failures are swallowed (logged at WARN) so a flaky server can't strand the provider on the old session forever — same posture as the existing on_session_end commit. (cherry picked from commit a1e7185)
…sion_end Two hardening fixes prompted by review on NousResearch#28296: 1. sync_turn() now snapshots the target session id before spawning the worker. The previous code read self._session_id inside the worker, so a worker delayed past on_session_switch's bounded join could read the rotated-in NEW id and write the OLD turn's messages into the wrong session. 2. on_session_end() resets _turn_count to 0 after a successful commit, making the old-session commit path idempotent with the new switch hook. /new and compression call commit_memory_session() (which fires on_session_end) immediately before on_session_switch; without this, the old session would be committed twice. On commit failure we leave _turn_count > 0 so on_session_switch retries. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 2ea8d5c)
Three follow-ups from review on NousResearch#28296: 1. Sync worker outliving the bounded join. Each sync_turn POST has _TIMEOUT=30s and there are two per turn, but on_session_end and on_session_switch only join for 10s. If the worker is still alive after the join, committing the old session orphans the worker's late writes past the commit boundary — they land in an already- committed session and never get extracted. Both hooks now re-check is_alive() after the join and skip the commit when the worker hasn't drained. 2. on_memory_write late session_id capture. Same shape as the pre-fix sync_turn: f-string for the post path read self._session_id inside the worker, so a switch between thread spawn and post call landed the memory note in the new session. Snapshot sid at call time, same pattern as sync_turn. 3. Stale prefetch repopulating the new session. The pre-switch drain+clear only protects against workers that finish before the join completes; one finishing after the clear would write its result into the new generation's slot. Added a monotonic _prefetch_generation; workers capture it at spawn and refuse to write if it has advanced. Tests: existing in-flight-sync test updated to drain (it tested the join-before-commit happy path); four new tests cover hung-writer skip on end + switch, on_memory_write sid capture, and prefetch generation gating. 177/177 memory tests pass. (cherry picked from commit 3791a87)
sync_turn's bounded join could drop a still-alive previous worker by replacing the single _sync_thread slot. The dropped worker kept POSTing under the old sid but was no longer visible to on_session_end / on_session_switch, so the commit could fire while orphaned writes were still in flight — those writes landed past the commit boundary and were never extracted. Replace the single _sync_thread slot with _inflight_writers: Dict[sid, Set[Thread]]. Writers self-register on spawn (sync_turn, on_memory_write) and self-deregister on exit. The commit path drains _drain_writers(sid, 10.0) and skips the commit if any writer for that sid is still alive after the bounded budget. Also trim inline review-rationale comments to short invariants per reviewer style ask: "commit only after session writes drain" and "drop prefetch results from older switch generations." Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 7537ee6)
…-scaffolding fix(tests): type-correct OpenViking skill-scaffolding test sentinels
# Conflicts: # tests/openviking_plugin/test_openviking.py
…dows (NousResearch#47919) * fix(desktop): keep streaming painting in unfocused secondary chat windows The chat transcript streams to screen through a requestAnimationFrame-gated flush, which Chromium pauses for blurred/occluded windows. The primary window opted out with `backgroundThrottling: false`, but the secondary "session windows" (cmd-click pop-out, new-session, subagent-watch) hand-copied their webPreferences and silently lost that flag — so a streamed answer in one of them stalled until the window regained focus (reported on Windows 11). The primary window's own comment even claimed it was "matching the secondary windows," which was no longer true. Hoist the chat-window webPreferences into a single shared factory (`chatWindowWebPreferences`) in session-windows.cjs and use it for BOTH windows, so they can never drift on this flag again. * test(desktop): assert chat windows disable background throttling Cover chatWindowWebPreferences: it must set backgroundThrottling=false (so the streaming transcript paints while the window is blurred) and pass the preload path through while keeping the hardened defaults (contextIsolation, sandbox, nodeIntegration=false).
NousResearch#47792) * fix(desktop): pin Electron below the broken native extract-zip install The Windows desktop install fails at "Building desktop app": Electron's postinstall aborts with `ERR_DLOPEN_FAILED loading index.win32-x64-msvc.node` / "Cannot find native binding" from `@electron-internal/extract-zip`. Root cause is a dependency drift, not the user's machine. Electron changed its install mechanism mid-patch-series: electron 40.9.3 .. 40.10.2 -> @electron/get@^2 + extract-zip@^2 (pure JS) electron 40.10.3 / 40.10.4 -> @electron/get@^5 + @electron-internal/extract-zip@^1 (native napi) apps/desktop declares `electronVersion: 40.9.3` (the tested, JS-extract build) but pinned the dependency as `electron: ^40.9.3`, so `npm ci`/`npm install` silently resolved 40.10.3/40.10.4 — onto the brand-new native extract-zip whose win32-x64 binding fails to dlopen on some Windows hosts. The committed lockfile already carried 40.10.3, and the installer's mirror fallback can't help (it re-runs Electron's own `install.js`, which uses the same broken native module). Fix: - Pin `electron` to an exact `40.10.2` — the newest build before the native extract-zip switch — and align `build.electronVersion` to match (Electron Builder needs electronVersion/electronDist to match the installed binary). - Add a root `yauzl: ^3.3.1` override so the (re-introduced) JS extract-zip path also works on Node >= 24.16 / >= 26.1, where the old yauzl hangs. This is the same workaround the wider Electron ecosystem adopted. - Regenerate package-lock.json: drops @electron-internal/extract-zip and @electron/get@5, restores @electron/get@2 + extract-zip@2 + yauzl@3.4.0. * test(desktop): lock the Electron pin/version/lockfile consistency contract Guards against the dependency drift that broke the Windows desktop install: the Electron dependency must be an exact version, must equal build.electronVersion, and the lockfile must resolve to that same version so `npm ci` installs exactly what electron-builder packages. Asserts the relationships, not a specific version number.
…ck turn state Follow-up hardening on @ehz0ah / @harshitAgr's session-switch work (NousResearch#28296): - on_session_switch no longer runs the old-session writer-drain + pending-token GET + commit POST inline on the caller's command thread. /new, /branch, /resume, /undo call it synchronously, so a slow drain (up to 10s) or wedged commit blocked the user-facing command — the same hazard NousResearch#41945 fixed for end-of-turn sync. State now rotates synchronously (cheap) and the old-session commit is offloaded to a daemon finalizer (generalized _finalize_session_async). - Guard the (_session_id, _turn_count) pair with _session_state_lock: sync_turn runs on the memory-manager executor thread while the session hooks run on the command thread, so the snapshot+reset vs increment was a cross-thread race. - _session_needs_commit checks the committed-session guard BEFORE the turn_count>0 shortcut, closing a double-commit window when a racing sync_turn re-increments after commit+reset. - Add a _shutting_down flag so deferred finalizers stop POSTing against a torn-down client; track all prefetch threads in a set so invalidate/shutdown join every one, not just the latest slot. Tests: regression for the non-blocking switch (asserts the caller returns while a slow drain is parked off-thread) and the committed-guard ordering; updated the deferred-commit test to the unified finalizer contract.
…lvage NousResearch#44921) (NousResearch#46794) * fix(logging): alias RotatingFileHandler to concurrent-log-handler On Windows, stdlib RotatingFileHandler.doRollover() uses os.rename(), which fails with PermissionError [WinError 32] whenever another process holds an append-mode handle on agent.log — essentially always in Hermes (TUI, gateway, hy_memory server, MCP servers, and on-demand CLI commands all log from separate processes). This pinned agent.log at the 5 MiB threshold and spammed stderr with a traceback on every emit (NousResearch#44873). Add concurrent-log-handler==0.9.29 as a core dep and alias its ConcurrentRotatingFileHandler as RotatingFileHandler in hermes_logging.py. It wraps the rename in a cross-process file lock (via portalocker: pywin32 on Windows, fcntl on POSIX) so only one process rotates at a time. Aliasing keeps every existing isinstance/class-declaration reference working unchanged. Co-authored-by: tuancookiez-hub <tuancookiez@gmail.com> * fix(logging): gate concurrent-log-handler swap to Windows only The initial salvage aliased RotatingFileHandler -> ConcurrentRotatingFileHandler unconditionally, which regressed POSIX: CLH opens lazily and rotates via its own lock path, breaking managed-mode (NixOS) group-writable perms and eager file creation that _ManagedRotatingFileHandler depends on. CI caught it as 2 failures in test_managed_mode_*_group_writable on Linux. The WinError 32 bug (NousResearch#44873) is Windows-specific — POSIX renames an open file fine, so stdlib already works on Linux/macOS. Gate the swap behind sys.platform == 'win32': Windows uses CLH, POSIX keeps stdlib RotatingFileHandler. - hermes_logging.py: platform-conditional import. - tests/test_hermes_logging.py: import RotatingFileHandler from hermes_logging (single source of truth) so the autouse fixture's isinstance checks match the real handler class on both platforms. - pyproject.toml/uv.lock: mark the dep 'sys_platform == "win32"' so portalocker /pywin32 only ship where used. --------- Co-authored-by: tuancookiez-hub <tuancookiez@gmail.com> Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
… densification (NousResearch#47866) * feat(search_files): path-grouped lossless densification of content matches Content-mode search_files results repeat the {path,line,content} JSON keys and the full path string for every match. Group consecutive same-path matches under one path header with indented '<line>: <content>' rows — lossless (every path/line/content byte preserved), self-describing (matches_format key), and readable by the model with no decode step. 57.8% mean token reduction on real search_files content outputs (422-output corpus), fires on 97% of them. Gated at >=5 matches; below that the verbose array is left untouched. Default to_dict(densify=False) is unchanged, so no other caller is affected. ripgrep emits matches path-ordered, so consecutive grouping never reorders results. * test: accept densify kwarg in _FakeSearchResult.to_dict The search loop-detection tests stub SearchResult with a fake whose to_dict() must mirror the real signature now that it takes densify=. * test(search_files): edge-case losslessness battery for densification Adversarial single-line content (colons, indentation, unicode/emoji, empty, trailing whitespace, quotes+commas), paths with spaces, and an explicit one-line-per-match invariant documenting the ripgrep contract the format relies on (0/6775 real match contents contained a newline).
…queuing (NousResearch#47987) A /title typed before any message in a fresh desktop chat could be silently lost: the session DB row is deferred to the first prompt, so session.title found no row, only stashed pending_title, and returned pending:true. It then relied on a post-turn apply block to write the title. When that turn never landed under the same session_key (or the apply path didn't fire), the title was dropped and the sidebar fell back to the first-message preview — e.g. "/title my-custom-name" then "hello" left the session titled "hello". Mirror the messaging gateway's _handle_title_command: an explicit /title is clear user intent, not an abandoned draft, so create the row up front (_ensure_session_db_row) and set the title immediately via the profile-aware _session_db handle, returning pending:false. This also fixes the frontend symptom for free — the desktop handler's immediate refreshSessions() now pulls the correct persisted title instead of clobbering the optimistic value with a still-NULL row. If row creation can't take (DB unavailable / racing writer), fall back to the existing pending_title queue so the post-turn apply block remains a recovery path. The sidebar's min-messages filter keeps a titled 0-message row hidden, so a /title'd-but-never-used draft still doesn't clutter the list. Updates the test that asserted the old queue-on-missing-row behavior and adds a fallback-to-queue regression test. Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
…h#47985) A failed turn leaves a red error banner inline in the transcript. These errors are renderer-local state (never persisted) and stay pinned to the message until the session is reloaded, so a stale, no-longer-relevant error (e.g. a transient provider/inference error) lingers with no way to clear it. Add an 'x' dismiss button inside the existing MessagePrimitive.Error block. Clicking it clears the error from BOTH the live $messages view and the per-runtime session cache — the view first, because preserveLocalAssistantErrors re-grafts any still-errored message it finds in the view onto the next session.info flush, so clearing only the cache would let the heartbeat resurrect the banner. A bare error placeholder (no streamed content) is dropped entirely; a turn that streamed partial output before failing keeps its text and just sheds the error. The control only renders when an onDismissError handler is wired, so secondary/embedded Thread usages are unaffected. Adds the dismissError string to all four locales (en/ja/zh/zh-hant) and two behavior tests. Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
fix(openviking): implement on_session_switch hook + harden session writes (salvage NousResearch#47662)
…sResearch#46513) (NousResearch#46818) * fix(photon): preserve text in mixed iMessage attachments When an iMessage bubble carried both text and an attachment, spectrum-ts' inbound mapper returned only buildAttachmentMessage(...), dropping the user's typed text before Hermes could see it. The Photon adapter then had no 'group' content path, so the text was lost entirely. - adapter.py: handle a new 'group' content type that flattens text + attachment items, preserving the typed text alongside cached media (extracted shared _normalize_binary_payload helper). - sidecar: emit 'group' content in normalizeContent, and ship patch-spectrum-mixed-attachments.mjs which patches spectrum-ts' pinned mapper (at npm postinstall AND at sidecar startup, so existing installs self-heal). Windows robustness fixes on top of the original PR: - The patcher's CLI guard used 'import.meta.url === file://${argv[1]}', which never matches on Windows (file:/// + drive letter) — it silently no-opped. Switched to pathToFileURL(argv[1]).href. - The patcher matched \n-joined strings, so a CRLF checkout (Windows git autocrlf) defeated every replacement. It now normalizes CRLF->LF for matching and restores the original EOL style on write. Co-authored-by: Yuhang Lin <yuhanglin@YuhangdeMac-mini.local> * chore: map YuhangLin contributor email for attribution (NousResearch#46513) --------- Co-authored-by: Yuhang Lin <yuhanglin@YuhangdeMac-mini.local> Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
…sResearch#47655) * fix(desktop): recover stranded session windows when resume fails Opening a session in a new window (or any routed resume) could latch the thread loader on "session" forever — the reported "stays stuck loading, even after a nap" bug. Two compounding causes: 1. use-session-actions.resumeSession's catch ran the REST transcript fallback OUTSIDE its own try. When session.resume rejected AND the fallback also threw (the common case on a wedged/unreachable backend), the throw skipped setMessages and left activeSessionId null with an empty transcript — exactly the state the loader gates on (messagesEmpty && !activeSessionId), with no terminal/error state. 2. use-route-resume's self-heal could never re-fire: resumeSession sets selectedStoredSessionIdRef synchronously at entry (before failing), so stuckOnRoutedSession stays false, and on an already-open idle window neither pathnameChanged nor gatewayBecameOpen fire again. The window never retried — naps, focus, nothing recovered it. Fix: - Wrap the REST fallback in its own try so a fallback failure can't strand the loader. - Add $resumeFailedSessionId: armed on terminal resume failure, cleared at the next resume's entry (and left clear on success). - use-route-resume gains a bounded backoff auto-retry (4 attempts, 1s→8s) that re-resumes while the routed session matches the failure flag, with a fire-time liveness recheck so a recovered session isn't double-resumed. Regression tests cover: fallback-wrap arming the flag without throwing, flag cleared on success, retry fires on backoff, no retry for a non-routed/recovered session, and the retry cap. * feat(desktop): show error + manual Retry when resume retries exhaust When a stranded session window's bounded auto-retry gives up (gateway resume RPC + REST fallback fail through all MAX_RESUME_RETRIES attempts), the loader latched forever. Add a $resumeExhaustedSessionId atom armed at the give-up point so the chat view swaps the perpetual spinner for an explicit error state + manual Retry button. Retry / reconnect / reselect clears the latch and resets the auto-retry counter for a fresh cycle; a route-change away from the stranded session also clears it. Distinct from $resumeFailedSessionId (armed during the backoff window) so the error UI only appears once auto-recovery has actually given up, not mid-retry. Adds i18n strings across en/ja/zh/zh-hant and 3 tests covering latch-arms-on-exhaustion, stays-clear-while-retries-remain, and clears-on-route-change. * fix(desktop): address review on stranded-resume recovery layer Follow-up to review on NousResearch#47655 (PR head 253bfc0). Four issues on the recovery layer: 1. (blocking) Arm $resumeFailedSessionId only when the transcript is still empty after the REST fallback ($messages.get().length === 0), matching the atom's documented contract and the loader's messagesEmpty gate. Previously armed on any resume-RPC reject regardless of fallback outcome, so a window that recovered its history via REST still auto-retried and, on exhaustion, blanked the visible transcript behind the error overlay. 2. Reset the bounded-retry attempt counter on the $resumeExhaustedSessionId armed->cleared edge so a manual Retry / reconnect / reselect on the SAME stranded session gets a fresh backoff cycle, not a single one-shot attempt that immediately re-arms the error. (Keyed on the exhausted latch rather than the resumeFailedSessionId null->value transition the review suggested: the auto-retry loop itself toggles resumeFailedSessionId every cycle, so keying the reset there would defeat the MAX_RESUME_RETRIES cap. Only resumeSession clears the exhausted latch, making its clear edge the unambiguous manual-retry signal.) 3. Advance retryAttemptRef only when the timer actually dispatches a resume, not at schedule time. Prevents unrelated dep changes during the 1s-8s backoff window (transient gatewayState flip, non-stable resumeSession) from burning attempts and hitting MAX with fewer than 4 real resume attempts. 4. Drop unrelated blank-line-only insertions in store/session.ts and use-session-actions.ts to keep the diff tight. Tests: +3 (RPC-fails-REST-succeeds-no-arm; manual-retry-fresh-cycle; no-attempts-burned-on-dep-churn). All 19 resume tests + full session-hook suite (65) pass; tsc --noEmit clean. --------- Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
…ation (NousResearch#48081) After the June lockfile regeneration (NousResearch#46652) floated electron and reshuffled npm workspace hoisting, the desktop pack fails with "The specified electronDist does not exist". apps/desktop/package.json pointed electronDist at the repo root (../../node_modules/electron/dist) while npm now installs electron nested under apps/desktop/node_modules/electron. The two contradict, so a clean install can never package the app (Windows + macOS). - electronDist -> node_modules/electron/dist (resolved relative to apps/desktop, i.e. the workspace-local install npm actually produces). - hermes_cli/main.py, scripts/install.sh, scripts/install.ps1: add a runtime electron-dir resolver that prefers apps/desktop/node_modules/electron and falls back to the root hoist, so dist checks + the mirror re-download work under either npm layout. - patch-electron-builder-mac-binary.cjs: try the workspace-local Electron.app before the root hoist in the macOS binary-restore fallback (sibling site no PR touched). - test: assert build.electronDist resolves to where the lockfile installs electron, so a future hoist change (root <-> nested) can't silently break it. Salvages the overlapping work in NousResearch#48003 (sitkarev), NousResearch#48012 (omegazheng), and NousResearch#48033 (james47kjv). Co-authored-by: sitkarev <59806492+sitkarev@users.noreply.github.com> Co-authored-by: omegazheng <zheng@omegasys.eu> Co-authored-by: james47kjv <220877172+james47kjv@users.noreply.github.com>
…arch#47490) * Harden hosted Docker install tree * Document hosted Docker immutable install tree
Behavioral regression harness locking the capability surface that the future RelayAdapter must reproduce: the abstract-method set (connect/disconnect/send/ get_chat_info), message_len_fn default, supports_draft_streaming default, and the stream_consumer MAX_MESSAGE_LENGTH attribute read. Passes on main before any RelayAdapter exists. Phase 0, Task 0.1 of the gateway-relay plan.
Frozen, JSON-serializable handshake payload the connector hands the future RelayAdapter: char limit, draft-streaming/edit/threading flags, markdown dialect, len_unit. Mostly a wire projection of PlatformEntry + the adapter capability methods. contract_version gates additive-only evolution; declared EXPERIMENTAL until >=2 Class-1 platforms validate it. from_json ignores unknown keys (forward-compat) and fills optional defaults. Phase 0, Task 0.2 of the gateway-relay plan.
CapabilityDescriptor.from_platform_entry() projects an existing PlatformEntry
(label, max_message_length, emoji, platform_hint, pii_safe, name) into a
descriptor, proving the descriptor is a projection of existing config rather
than a parallel concept. Runtime-only capabilities (len_unit, draft/edit/
thread/markdown) are caller-supplied. max_message_length==0 ('no limit') maps
to the stream_consumer 4096 default.
Phase 0 complete. Task 0.3 of the gateway-relay plan.
One BasePlatformAdapter subclass that reads its capability profile from a CapabilityDescriptor: MAX_MESSAGE_LENGTH attribute, message_len_fn (table-driven by len_unit: chars=len, utf16=Telegram-style code units), supports_draft_streaming. Implements the four abstract methods (connect/disconnect/send/get_chat_info) by delegating to an injected RelayTransport (full protocol lands in Task 1.2). Adds Platform.RELAY enum member. No per-platform gateway code. Phase 1, Task 1.1 of the gateway-relay plan.
Defines RelayTransport (lifecycle/handshake/inbound/outbound/interrupt) as the gateway<->connector wire contract; RelayAdapter.connect now registers an inbound handler that bridges connector-delivered MessageEvents into handle_message. Adds an in-memory StubConnector under tests/ and an E2E round-trip proving: connect registers the handler, inbound events reach the adapter, guild_id drives build_session_key isolation (two guilds -> two keys; same guild/channel/user -> one), outbound send round-trips, get_chat_info is proxied. Phase 1, Task 1.2 of the gateway-relay plan.
… off by default) register_relay_adapter() registers the generic 'relay' platform via the same PlatformRegistry path as plugin adapters — no core dispatch changes. OFF by default (dark-launch): only registers when HERMES_GATEWAY_RELAY is truthy (or force=True for tests), so existing single-tenant/direct deployments are unaffected. Factory builds a transport-less RelayAdapter with a placeholder descriptor; the real descriptor is negotiated at handshake. Phase 1, Task 1.3 of the gateway-relay plan.
RelayAdapter.on_interrupt(session_key, chat_id) bridges a connector-delivered mid-turn /stop into the existing interrupt_session_activity path, setting the per-session _active_sessions Event and clearing typing — cancelling exactly the targeted session's turn without touching siblings (mirrors test_stop_thread_ sibling isolation). Transport.send_interrupt carries the gateway-side egress to the connector for socket-owner routing. Phase 1, Task 1.4 of the gateway-relay plan.
Formal interface between the Hermes gateway (RelayAdapter) and the Node connector repo: handshake, CapabilityDescriptor field table, MessageEvent inbound envelope with per-platform SessionSource discriminators (Discord guild_id is REQUIRED for server isolation), outbound action set, /stop interrupt routing, signed-body verify-at-edge/byte-preserving rule, and the additive-only contract_version policy. Documents bot-identity-vs-tenant separation so single-bot consolidation (Phase 6) stays open. Read-first artifact for the connector implementer. Phase 1, Task 1.5 of the gateway-relay plan.
CI guard: fails if gateway/ or plugins/ ever imports the test-only stub connector or defines StubConnector. Matches code leaks (imports / class defs), not prose mentions, so the transport.py docstring reference to the stub's path is allowed. Phase 1 complete. Task 1.6 of the gateway-relay plan.
The platform-connected-checker invariant test requires every built-in Platform enum member to have either a generic token path or a bespoke entry in _PLATFORM_CONNECTED_CHECKERS. Platform.RELAY was added without one, so test_all_builtins_have_checker_or_generic_token_path failed. Relay dials OUT to a connector and is 'connected' once an endpoint URL is configured (extra['relay_url'] or extra['url']); the capability descriptor is negotiated at handshake time, so the URL is the only config-level signal in the experimental phase. Add the checker plus a synthetic-config case exercising its True path.
Add an invariant test pinning docs/relay-connector-contract.md to the Python source of truth so the doc (which the connector repo mirrors by hand) cannot silently drift: - CapabilityDescriptor §2 table ⟷ dataclass fields + required/optional - SessionSource wire keys (to_dict output) ⟷ §3 documented fields - per-platform discriminator columns exist as real SessionSource fields - guard that is_bot stays off the wire until deliberately promoted Writing the test surfaced a real gap: §3 only enumerated 5 discriminators in its per-platform table while to_dict() emits 12 keys. Seven wire keys the connector must populate (chat_name, chat_topic, user_id_alt, chat_id_alt, parent_chat_id, message_id, user_name) were undocumented — a connector author reading the doc would never know to set them. Added a complete SessionSource wire-field table to §3. The connector's existing contract.ts already carries all 12, so no connector change is needed; the doc was the lagging artifact.
The Phase 1 exit gate requires BOTH Discord and Telegram to round-trip through the relay stub, but test_relay_roundtrip.py only covered Discord. Add the Telegram companion exercising its distinct discriminator profile: - no guild_id — two chats isolate on chat_id alone - forum topics share one chat_id and isolate by thread_id (the Telegram analog of Discord per-guild isolation), shared across participants by default (thread_sessions_per_user=False) - DM isolation by chat_id - utf16 len_unit + markdown_v2 dialect round-trip and configure the adapter - outbound send round-trips through the stub Proves the CapabilityDescriptor + build_session_key generalize beyond Discord, not just the struct (which the descriptor unit tests already covered).
Under the A2 trust model the connector is the SOLE crypto/identity boundary: it verifies/decrypts every inbound platform payload at the edge (it holds the tenant secrets), normalizes to a tenant-scoped MessageEvent, and forwards only the sanitized event. The gateway re-validates nothing — it cannot without being handed the shared signing secret, which on a shared bot is itself the cross-tenant leak. The relay path already imports no platform-crypto today; this locks that in as an enforced invariant so nobody bolts re-validation (Discord ed25519, Twilio HMAC, WeCom BizMsgCrypt, generic webhook signature checks) onto the relay later and silently re-couples the gateway to platform secrets it must never hold. Verification stays in the direct platform adapters (gateway/platforms/*) which serve non-relay deployments. - test_relay_package_imports_no_platform_crypto: AST-walks gateway/relay/* and fails on any import of a platform-crypto/verification module. - test_relay_package_calls_no_signature_verification: fails on any verification-symbol reference (ed25519/hmac/bizmsg/verify_*). Invariants (assert the relation 'relay re-validates nothing'), not frozen snapshots. Verified the guard bites: injecting a wecom_crypto import makes it fail, removing it goes green. docs §6 rewrite follows in a later commit.
The relay outbound surface had send/edit/typing but no way to act on a SHARED-identity capability (e.g. a Discord interaction follow-up token, ~15min) that the connector captured + stripped at the edge. Under A2 that credential never reaches the gateway, so the gateway can't just 'send with the token' — it needs a semantic op naming the session it's already in. Adds the follow_up op end to end on the gateway side: - RelayTransport.send_follow_up(action): protocol method. Action carries op='follow_up' + session_key + kind + content (+ metadata) and NO token. - RelayAdapter.send_follow_up(session_key, kind, content, metadata): builds that action and returns a SendResult. The connector resolves the real capability (its resolveOutboundCapability), enforces the tenant match so tenant B can't wield tenant A's capability, and egresses; success=False when the capability is absent/expired/mismatched (nothing to retry — a leaked gateway holds zero capability material). - StubConnector records follow_ups + a canned next_follow_up_result. Tests: round-trips without a token; the wire action carries only session refs (no credential value field — the 'kind' string is a type ref, not the secret); failure surfaces when the connector can't resolve; no-transport fails cleanly. 55 passed. §4 doc entry follows in the contract-rewrite commit.
The contract's §6 still said the connector 'forwards the signed body byte-for-byte so the gateway's existing crypto validates against unmodified bytes.' That model is incoherent under an untrusted, disposable tenant gateway on a shared bot: - re-validating Twilio HMAC / WeCom crypto needs the shared signing secret (handing it over IS the cross-tenant leak), - WeCom payloads are encrypted with that secret (the connector must decrypt at the edge just to route), - a Discord interaction token lives inside the signed body — you can't both preserve the bytes and strip the credential. Rewrites §6 to the actual model: the connector is the SOLE crypto/identity boundary — verifies/decrypts at the edge, normalizes to a tenant-scoped MessageEvent, strips shared-identity capabilities into its vault, and forwards only the sanitized event. The gateway re-validates nothing (the invariant test from the crypto-shed commit enforces this). Notes that this unifies the passthrough + relay planes and points to the connector repo's capability-trust-boundary.md. Also documents the follow_up op in §4 (token-less capability action added in the previous commit). The conformance test (§2/§3 tables) stays green; contract is unpublished/EXPERIMENTAL so no version-bump ceremony. 55 passed.
…ation
Adds the concrete transport behind the RelayTransport Protocol — the missing
'later-phase work' the relay scaffold deferred. The gateway dials OUT to the
connector over a WebSocket and speaks the newline-delimited JSON frame protocol
(docs/relay-connector-contract.md; connector src/relay/protocol.ts):
- connect(): opens the ws, sends hello{platform,botId}, starts a background
read loop, and resolves handshake() when the connector's descriptor frame
arrives.
- inbound frames -> the registered InboundHandler (rebuilt into a MessageEvent
via _event_from_wire, mapping the snake_case SessionSource wire form back
onto the gateway dataclasses).
- send_outbound / send_follow_up / get_chat_info: request/response correlated
by a uuid requestId against a per-request future, with a timeout so a caller
never hangs; send_interrupt is fire-and-forget.
- disconnect(): cancels the reader, closes the ws, and fails any in-flight
outbound waiters with a structured error.
RelayAdapter.connect() now negotiates the real CapabilityDescriptor from the
transport and adopts it (_apply_descriptor updates MAX_MESSAGE_LENGTH +
markdown surface), replacing the construction-time placeholder. Lazy
'import websockets' mirrors gateway/platforms/feishu.py; WEBSOCKETS_AVAILABLE
gates construction.
…Y_RELAY gate Wire the relay adapter into gateway startup and make activation config-driven instead of a dark-launch flag. - gateway/relay/__init__.py: replace relay_enabled()/HERMES_GATEWAY_RELAY with relay_url() (GATEWAY_RELAY_URL env or gateway.relay_url in config.yaml) — the same shape as gateway.proxy_url. register_relay_adapter() registers when a URL is configured and builds a live WebSocketRelayTransport; with no URL it's a no-op (direct/single-tenant deployments unaffected). force=True keeps the transport-less adapter for unit tests. relay_platform_identity() reads the hello platform/botId from GATEWAY_RELAY_PLATFORM/GATEWAY_RELAY_BOT_ID. - gateway/run.py: call register_relay_adapter() during GatewayRunner.start(), right after plugin discovery, so a configured connector relay is registered on every boot. Failures are logged, never block startup. This removes the dark-launch posture: the relay is on whenever it's configured, shipping the production end state rather than hiding it behind a flag.
- test_ws_transport.py: drives WebSocketRelayTransport against a REAL in-process websockets server (not a mock socket): handshake (hello->descriptor), inbound frame -> handler, outbound request/response correlation, follow_up routing, and clean disconnect failing pending waiters. Skips if websockets is absent. - test_relay_registration.py: rewritten for the config-driven gate — registers when GATEWAY_RELAY_URL is set / an explicit url is passed / force=True; no-op without a URL; trailing slash stripped; adapter constructs through the registry. Full relay suite: 57 passed.
…stalls (supersedes NousResearch#48081/NousResearch#48082) (NousResearch#48091) * fix(desktop): resolve electronDist dynamically + self-heal blocked installs Supersedes the static-path approach (NousResearch#48081) and the install-step self-heal (NousResearch#48082) with a fix that removes the whole failure class instead of chasing each symptom. Three distinct faults converged into the June desktop-build outage; this closes all three. Root cause (the part NousResearch#48081 left open — "Gap B"): build.electronDist was a static relative path in apps/desktop/package.json, but npm workspace hoisting is NOT deterministic — depending on the npm version and what else is installed, npm nests the workspace-only electron devDep under apps/desktop/node_modules/electron OR hoists it to the repo root. A static path matches only one layout, so a clean install intermittently fails with "The specified electronDist does not exist". NousResearch#48081 re-pointed the path at the nested layout (correct today) but electron-builder reads electronDist STATICALLY, so any future hoist change silently breaks it again — only caught by a CI invariant, never self-corrected. Fix: - scripts/run-electron-builder.cjs: resolve electron the way Node's runtime does — require.resolve("electron/package.json") walks node_modules from the desktop project upward and finds electron wherever npm actually put it. The path can never drift out of sync with the install layout again, on any OS/npm version. * dist present -> pass -c.electronDist=<abs>/dist so electron-builder reuses the unpacked runtime (keeps the NousResearch#38673 fast path that dodges the 26.8.x missing-binary re-unpack bug). * dist absent -> omit electronDist; electron-builder fetches Electron itself via @electron/get honoring electronVersion + ELECTRON_MIRROR. package.json: builder script now runs the wrapper; the static build.electronDist is removed (the resolver owns it). - main.py / install.sh / install.ps1: on a dependency-install failure where the electron package staged but its dist is missing (electron's install.js process.exit(1) on a blocked/throttled binary download — NousResearch#47266/NousResearch#47917/NousResearch#48021), repopulate the dist via electron's downloader (canonical, then npmmirror.com) and CONTINUE to the build instead of aborting. npm runs postinstall LAST, so the only casualty is electron/dist; bailing here is what made the pack-time mirror self-heal unreachable on a blocked network. Hard-fail only when electron never staged at all (a genuine dependency error). - The pack-time mirror fallback now retries the build even when the pre-fetch can't populate the dist: the wrapper lets electron-builder download Electron itself via the mirror, so the retry is no longer a no-op (it was, when electronDist was a static path). The exact 40.10.2 pin (already on main) keeps the third mode — the native @electron-internal/extract-zip win32 binding that 40.10.3/40.10.4 ship without a published prebuild — from recurring. Tests: - test_desktop_electron_pin.py: replace the static-path-matches-lockfile invariant with contracts that there is no hardcoded electronDist to drift, the builder script routes through the resolver, and the resolver uses Node module resolution + injects -c.electronDist. - test_gui_command.py: install-failure self-heal continues to build; genuine (electron-never-staged) install failure still hard-fails; pack retries under the mirror even when the pre-fetch is blocked. Salvages/supersedes the overlapping community work in NousResearch#48003 (sitkarev), NousResearch#48012 (omegazheng), NousResearch#48033 (james47kjv), and NousResearch#48082. Co-authored-by: sitkarev <59806492+sitkarev@users.noreply.github.com> Co-authored-by: omegazheng <zheng@omegasys.eu> Co-authored-by: james47kjv <220877172+james47kjv@users.noreply.github.com> * fix(desktop): narrow Electron self-heal to real missing-dist failures Follow-up on NousResearch#48091 to remove the remaining misdiagnosis risk from the installer/build fallback path (NousResearch#46785 concern): only take the Electron repair/retry path when Electron's package files are staged and dist is actually missing/corrupt. - main.py: add _electron_pkg_staged_missing_dist() and use it to gate install failure recovery; fail fast for unrelated npm install errors. - main.py/install.sh/install.ps1: run cache purge + retry only when dist is missing; do not retry unrelated tsc/vite/build failures under an Electron-specific narrative. - install.sh/install.ps1: tighten install-stage self-heal guard to require both package.json + install.js and missing dist. - tests: add coverage that install failure hard-fails when Electron dist already exists, and update retry test to reflect the tightened recovery condition. Validation: - Python tests: 64 passed - install.sh-related tests included in the run - Real mac build on this machine: - npm ci at repo root: success - cd apps/desktop && npm run pack: success - electron-builder packaged darwin arm64 and used custom unpacked Electron dist * refactor(desktop): trim electron self-heal helpers and comments Deduplicate mirror-retry into _try_redownload_electron_dist / shell counterparts; shorten wrapper and install-script commentary without changing recovery semantics. --------- Co-authored-by: sitkarev <59806492+sitkarev@users.noreply.github.com> Co-authored-by: omegazheng <zheng@omegasys.eu> Co-authored-by: james47kjv <220877172+james47kjv@users.noreply.github.com>
…ousResearch#48104) * feat(mcp): raise default tool-call timeout 120s -> 300s Port from openai/codex#28234. Long-running MCP tools (web fetches, sandboxed builds, deep-research servers) routinely exceed 120s, causing spurious timeout failures. Codex bumped its default MCP tool timeout from 120 to 300 for the same reason. - _DEFAULT_TOOL_TIMEOUT 120 -> 300 in tools/mcp_tool.py (per-server 'timeout' config override unchanged) - update test_default_timeout assertion - document the default in mcp-config-reference.md * fix(dump): show commit date instead of release date in hermes dump The version line in `hermes dump` (the top of the /debug report) appended the package release date in parentheses, which reads like a wall-clock "generated at" timestamp and confuses support triage. Replace it with the date the HEAD commit was actually made, resolved live via `git log -1 --format=%cd --date=short`, kept next to the commit SHA. On Docker/wheel installs with no .git the date resolves to '' and the suffix is simply omitted (the baked SHA still identifies the build).
…504) (NousResearch#47674) * fix(dashboard): recover the Chat tab when the agent session ends (NS-504) In the dashboard Chat tab, when the agent process exits — the user types `/exit`, or starts a new session that ends the current PTY child — the `/api/pty` WebSocket closes with a normal code (not one of the 4401/4403/4404/4408/1011 rejection codes the server emits). The frontend handled only those rejection codes; the normal-exit fallback just printed "[session ended]" into the dead terminal and stopped, with `wsRef` nulled and no respawn path. The only recovery was a full page refresh — exactly the beta report ("typing /exit breaks functionality, no way to restart without refreshing"; "starting a new session completely breaks the agent"). On a clean/normal close the Chat tab now flips `sessionEnded` and renders an in-place "Start new session" overlay (mirroring ChatSidebar's existing reconnect affordance). Clicking it bumps a `reconnectNonce` that is a dependency of the connect effect, so the effect tears down and re-runs, spawning a fresh PTY in place — no page refresh. `onopen` clears the flag so a successful reconnect dismisses the overlay. An explicit button (rather than auto-respawn) is deliberate: if the agent is crash-looping, auto-respawn would hide the failure and spin; the user stays in control. Verified against a live uvicorn `/api/pty` socket: a child that exits closes with a non-rejection code (client sees close_code None / 1000-class), which is precisely the branch that now sets sessionEnded=true. web typecheck + vite build clean. Reported via beta (NS-504). * docs(assets): add NS-504 chat session recovery infographic
) (NousResearch#48109) Weak open models (mimo, nemotron-class) that see tool-call XML/JSON sitting in file contents or tool output get primed and emit their own structured tool calls mimicking the payload — usually with an empty/whitespace name. Those calls can't be fuzzy-repaired toward a real tool, so the dispatch loop returns an error and the model retries. Before this fix, every empty-name error dumped the full tool catalog back to the model, which fed the priming loop more names to mimic and inflated context 3-4x across the retry budget. A blank/whitespace-only tool name now gets a terse anti-priming error that tells the model in-context tool-call syntax is DATA, with no catalog dump. A genuinely-wrong-but-nonempty name (a real typo) still gets the full catalog so the model can self-correct. Not a sandbox/auth boundary issue: Hermes never parses tool-call text from content into executable calls (structured tool_calls only; the lone text->call parser is the Copilot ACP transport and it also rejects empty names). The reporter's own debug dump confirms the injection never executed. Behavior-contract test added: empty-name -> terse error, no catalog; nonempty unknown -> catalog preserved. Exercised end-to-end via run_conversation against an in-process mock provider.
NousResearch#48122) The desktop self-update runs `hermes update` then `hermes desktop --build-only`, and only relaunches if the rebuild returns 0. The first `--build-only` can exit nonzero on a still-settling post-update tree or a network-blocked Electron fetch that the installer's self-heal repaired mid-run — so both updaters (the Tauri setup binary and the in-app POSIX path) bailed before the relaunch step. The update landed but the app never restarted; a manual launch worked because the heal had completed. Retry `--build-only` once in both paths before failing, mirroring the retry-once `hermes update` already does (and the CLI `hermes update`'s own desktop rebuild). A second run builds clean off the healed dist and is a near-no-op when the first actually succeeded (content-hash stamp). - update.rs: retry stage 2; add rebuild_needs_retry() + test - main.cjs: retry via new update-rebuild.cjs helper (behavior-tested)
…omposer context
- model_metadata: grok-composer-2.5-fast → 262144 (OAuth slug not in /v1/models)
- codex transport: inject native {"type":"web_search"} for is_xai_responses;
drop client web_search to avoid duplicate-name 400s
- codex adapter: do not treat in-progress server-side *_call items as incomplete
- tests: adapter, transport build_kwargs, model_metadata, oauth recovery
…tx to 200k Salvage corrections on top of @XVVH's NousResearch#44341: - Make native web_search injection a 1:1 swap for an already-present client web_search function, NOT an additive grant. The original unconditionally appended {"type":"web_search"} on every is_xai_responses turn with any tools, force-enabling Grok server-side search even when the user never enabled the web toolset (bypassing Hermes web-provider config + tool-trace plumbing). Now gated on a client web_search actually being present. - Reconcile grok-composer context to 200000 (merged in NousResearch#47908) rather than 262144; 200k is xAI's published usable context window for Composer 2.5, 262144 is the /v1/responses input+output budget. - Update tests to match scoped behavior + add a no-web-toolset guard test. - AUTHOR_MAP entry for NousResearch#44341 salvage. Incomplete-guard (server-side *_call items at in_progress no longer flip has_incomplete_items) and preflight built-in-tool allowlist kept as-is.
…der (NS-505) (NousResearch#47555) * fix(docker): supervised gateway uses --replace to take over stale holder Inside the s6 container image the per-profile gateway service rendered a bare `hermes gateway run` (no --replace). When a gateway is started OUTSIDE s6 — a stray shell `hermes gateway run`, an agent action, or the Open WebUI helper (scripts/setup_open_webui.sh) — it grabs the per-HERMES_HOME PID lock first. The supervised slot then execs the bare `gateway run`, hits the "Another gateway instance is already running" guard, exits non-zero, and s6 restarts it: a restart loop that floods the log every ~12s and never binds. The container looks up but the gateway is permanently down, and dashboard-only users (no shell) cannot recover. Render the supervised run script as `gateway run --replace` so s6 is authoritative for its slot: it reaps the stale holder via the hardened takeover path (takeover marker + SIGTERM->SIGKILL-with-confirmation + scoped-lock cleanup in gateway/run.py) and binds. This matches the systemd service path, which already builds its argv with --replace (_build_gateway_argv / 'nohup hermes gateway run --replace'), and the intent already documented in _maybe_redirect_run_to_s6_supervision. The existing HERMES_S6_SUPERVISED_CHILD sentinel still prevents the run->start->run redirect recursion. Each profile is scoped to its own HERMES_HOME and s6 guarantees one supervised instance per slot, so there is no legitimate supervised sibling for --replace to clobber. Reported via beta (NS-505): gateway.log showed PID 17907 'running (manual process)' with the guard error repeating every ~12s on v2026.6.5. Adds a regression test asserting every gateway-run exec line in the rendered script (default + named profile, both privilege branches) carries --replace, and updates the existing render-script assertion. * fix(ci): remove stray .venv symlink committed into repo The PR's commit accidentally tracked a .venv symlink pointing at the developer's local venv (mode 120000 -> /home/ben/nous/hermes-agent/.venv). The CI test/e2e/build jobs run `uv venv` to create .venv and failed with `failed to create directory .venv: File exists (os error 17)` because the checkout already contained the symlink. All test shards aborted in <15s during setup, before any test ran. Untrack the symlink and add a bare `.venv` entry to .gitignore (the existing `.venv/` rule only matches a directory, so a symlink slipped through).
…eiver + enroll CLI (NousResearch#48147) * feat(relay): authenticate the connector⇄gateway WS channel The relay gateway may be customer-managed and internet-exposed, so the connector⇄gateway channel is itself authenticated (distinct from the platform crypto the relay path sheds). Add gateway/relay/auth.py — a Python port of the connector's HMAC token + delivery-signature schemes (relayAuthToken.ts / deliverySigning.ts), verified byte-for-byte against the connector's compiled TypeScript via cross-language test vectors. Present an Authorization bearer on the /relay WS upgrade keyed by the per-gateway secret (resolved from GATEWAY_RELAY_ID / GATEWAY_RELAY_SECRET in env or config). The connector rejects an unauthenticated/invalid/ revoked upgrade with close 4401. * feat(relay): signed-HTTP inbound delivery receiver The connector delivers normalized inbound events to a tenant's gateway over a signed HTTP POST, not the outbound /relay WS: the connector instance owning a platform socket is generally not the instance a given gateway dialed out to, so inbound targets a tenant endpoint that may load-balance across gateway instances. Add gateway/relay/inbound_receiver.py — verifies x-relay-signature / x-relay-timestamp over the EXACT raw request bytes (re-serializing would break the HMAC: JS JSON.stringify is compact, Python json.dumps spaces) against the per-tenant delivery key verify list within a 300s replay window, then dispatches messages to handle_message and interrupts to the interrupt handler. Wire it into the adapter lifecycle (start in connect() when a delivery key + bind port are configured, tear down in disconnect(); a purely-outbound dev gateway runs without it). Refine test_relay_sheds_crypto to distinguish PLATFORM crypto (Discord ed25519, Twilio/WeCom HMAC — still shed) from the connector⇄gateway CHANNEL auth (intended): auth.py / inbound_receiver.py are exempt from the platform-symbol scan but still banned from importing platform-crypto modules, plus a positive guard that auth.py uses only stdlib hmac/hashlib. * feat(relay): hermes gateway enroll CLI Add the gateway half of zero-touch enrollment. `hermes gateway enroll` resolves a fresh Nous Portal access token (the tenant-proving identity), POSTs {enrollmentToken, gatewayId} to the connector's /relay/enroll, and persists GATEWAY_RELAY_ID / GATEWAY_RELAY_SECRET / GATEWAY_RELAY_DELIVERY_KEY to ~/.hermes/.env. The per-gateway secret authenticates the WS upgrade; the per-tenant delivery key verifies signed inbound deliveries. Refuses under is_managed() (hosted installs get the secret stamped in by the orchestrator). Added as an 'enroll' subcommand on the existing gateway subparser — not a new top-level command. * docs(relay): inbound is signed HTTP, not WS; document channel auth Fix the stale contract: §3/§5 said inbound rode the WS socket (single- instance only, predates the multi-instance socket-ownership + channel-auth model). Inbound + connector→gateway interrupt are signed HTTP POSTs to the tenant endpoint. Add §6.1 documenting the two channel-auth schemes (per- gateway WS-upgrade secret, per-tenant inbound delivery key) and how they differ from the platform crypto the relay path sheds. * test(relay): update build_gateway_parser callers for cmd_gateway_enroll The enroll subcommand added cmd_gateway_enroll as a required keyword-only arg to build_gateway_parser, but two existing parser-extraction tests still called it with only cmd_gateway/cmd_proxy — failing CI with TypeError. Thread the new handler through both call sites and add a test asserting `gateway enroll` dispatches to cmd_gateway_enroll with its flags parsed.
* fix(approval): honor glob command allowlist entries * fix(approval): guard allowlist globs from shell chaining
alt-glitch
pushed a commit
that referenced
this pull request
Aug 4, 2026
…e-review #4) The compression heartbeat's terminal 'context compression completed' stamp force-persists against the PARENT session id (agent.session_id at stamp time). After the out-of-place rotation the parent is archived but kept advertising a fresh last_activity_at + terminal label forever. Clear the parent row's activity labels best-effort after a committed rotation (keeps last_activity_at so idle clocks stay continuous; the child carries live labels). Regression asserts the archived parent's labels are cleared while the child's lineage is intact (sabotage-verified).
alt-glitch
pushed a commit
that referenced
this pull request
Aug 11, 2026
…on delegation callbacks (NousResearch#82592) * fix(gateway): stop frozen-preview finals and dropped idle-session delegation callbacks Two relay-plane delivery losses from the 2026-08-09 staging incident: 1. stream_consumer: the skip-redundant-finalize branch recorded _accumulated as the delivered turn-final payload even when the last ACKED edit was an earlier throttled preview snapshot, so delivered_final_matches reconciled True and the gateway suppressed the corrective final send — the user was left with a cut-off message ending in the streaming cursor. Extracted _mark_skip_redundant_finalize(): records the last acked wire payload (cursor-stripped), so a preview/final mismatch now returns False and the normal final send fires. 2. run.py: _classify_completion_target classified every ended parent session terminal unless it ended by compression. Idle/timeout session ends are the norm on scale-to-zero relay deployments and the chat route remains valid; completed async delegation results were terminally dropped. Ended parents now classify deliver unless the end was an explicit user boundary (session_reset / user_exit / session_switch). * fix(relay): drain in-flight outbound frames before transport teardown disconnect() failed every pending outbound future immediately with 'relay transport closed', so a trailing finalize edit racing turn teardown was lost even though the connector socket could still serve it. Bounded drain grace (5s) lets in-flight requests resolve; silent connectors still tear down promptly. asyncio.wait (not gather+wait_for) so a timeout doesn't cancel futures owned by the fail-remaining loop. * fix(gateway): route completion injection through the alias-aware transport resolver Third relay-plane delivery loss from the 2026-08-09 staging incidents: a delegation batch completed while the gateway was up, the watcher drained the event, and delivery vanished with no log line. _inject_watch_notification resolved its adapter with a literal p.value == platform_name scan of self.adapters — a relay-fronted gateway registers ONE adapter under Platform.RELAY fronting N logical platforms, so 'slack' never matched and the injection returned None ('no gateway route'), silently dropping the completion. The handoff path already documents this exact trap and uses resolve_delivery_transport; the injection path now does the same (native wins; relay eligible only when it fronts the logical platform), with the literal scan kept as fallback for stub runners and exotic platforms. * fix(relay): clamp disconnect drain grace to the runner's adapter-disconnect budget Review finding (JoaoMarcos44, NousResearch#82592): a fixed 5.0s drain in front of the three 1.0s sequential teardown awaits gives an 8.0s worst case inside the runner's 5.0s asyncio.wait_for(adapter.disconnect()) — tripping it cancels teardown mid-drain, skips the fail-pending loop, and leaves outbound callers blocked until _OUTBOUND_TIMEOUT_S (30s). The effective grace is now budget - 3*TEARDOWN - margin (env-aware via the same HERMES_GATEWAY_ADAPTER_DISCONNECT_TIMEOUT the runner reads), so the drain can never push teardown past its caller's budget; a budget too small for any drain disables it cleanly. * test(gateway): pin the final-send suppression contract across a behaviour matrix The gateway skips its own final send when the stream consumer claims the turn final already reached the user. Every incident in that family — NousResearch#71643 (stale finalize snapshot), NousResearch#78541 (payload-less multi-message split), NousResearch#82656 (frozen preview left with a visible cursor) — is the same failure: the consumer claimed delivery for text the platform never rendered, so the corrective send was suppressed and the answer was lost with no retry. Each was fixed with a scenario test pinned to one branch of GatewayStreamConsumer.run(). The got_done handler now has five sibling branches that each set the suppression flags and record a turn-final payload, and nothing checks them as a group: a new branch, or a new early `return True` in _send_or_edit, can reintroduce the class without failing a test. Pin the invariant instead of the branch — if the consumer offers the gateway any signal it would trust, the complete final text must have reached the wire — and assert it across {edit always / dies / never / lies} x {send always / never} x {fresh-final on / off} x {clean / interrupted stream}. The adapter records only frames that actually rendered, so an ACK the platform drops does not count as delivery. 24 honest-transport scenarios hold the invariant as a hard assertion. The 16 lying-transport scenarios are checked too; the single combination that still violates it is reported as an expected failure documenting the open exposure rather than asserting it away. Refs NousResearch#82656 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(gateway,relay): prime relay egress routing for synthetic injections + cap stale completion replay Defect #4 from the 2026-08-09 staging incidents (upgrade-robustness): after every gateway restart the durable async-delegation replay injected completions correctly (post-741663cf1) but their replies bounced at the connector — 'slack egress declined: target not routed to an onboarded tenant'. The relay adapter re-attaches tenant discriminators (metadata.scope_id / metadata.user_id) from per-chat caches warmed ONLY by inbound traffic; synthetic turns race those cold caches on every deploy, scale-to-zero wake, and crash recovery. - relay adapter: prime_routing_cache() — feeds a synthetic event's session-store origin through the same _capture_scope used for real inbound (never raises). - run.py injection path: prime the resolved adapter before handle_message (duck-typed; native adapters unaffected). - async_delegation: 48h staleness cap in restore_undelivered_completions — a pending completion older than the cap is terminally dropped (payload stays queryable) instead of re-run as a fresh full-context turn; the post-restart replay of a July session burned a 102K-token context. Also carried: JoaoMarcos44's suppression behaviour-matrix harness (cherry-picked from NousResearch#82676, authorship preserved) — 39 passed + 1 xfail (the documented ACK-then-drop transport-honesty residue). * test: use recent timestamps in restored-ownership fixtures test_restore_stamps_restored_flag persisted its completion with epoch-era toy timestamps (dispatched_at=1.0), which the new 48h replay staleness cap correctly classifies as stale — the fixture then exercised the cap instead of the restored-flag contract (CI slice 4 failure). Timestamps are now now-relative; the staleness behavior itself is pinned separately in test_relay_injection_egress_priming.py. * fix(gateway,relay): close four review findings on the relay delivery fixes Review follow-ups on this branch (NousResearch#82592): 1. HIGH — classifier/resolver mismatch (falsely-acknowledged loss). _classify_completion_target now returns "deliver" for idle-ended parents, but _resolve_async_delegation_session still dropped every non-compression-ended pin: the durable row was acked at adapter acceptance, then the injection died inside the pipeline with no retry — strictly worse than the honest terminal drop on main, and the delivery leg defect #2's fix depends on did not exist. The resolver now retargets non-user-boundary ends (idle/timeout/ lifecycle) to the chat's current session — session_entry already IS the routing key's current session for the same chat — while user boundaries (session_reset / new_session / user_exit / session_switch) stay fail-closed. Both sides share one module-level _USER_BOUNDARY_END_REASONS so the verdict and the routing decision cannot drift again; a coherence test asserts deliver-verdicts resolve non-None across representative end reasons. 2. HIGH — drain clamp missed adapter-level spend. The effective drain grace budgeted drain + 3x teardown, but RelayAdapter.disconnect spends revocation-monitor teardown + go_idle time BEFORE the transport drain inside the same runner wait_for; worst case still blew the budget and cancelled teardown mid-drain (skipping the fail-pending loop). The adapter now measures its own elapsed time and threads the REMAINING budget into transport.disconnect(budget_s=...); legacy/stub transports without the keyword fall back to the no-arg signature. 3. P1 — _request_response racing disconnect() could register a future after the fail-pending loop already ran, stranding the caller for the full _OUTBOUND_TIMEOUT_S (30s). Fail fast with the same "relay transport closed" error once _closing is set. 4. P1 — _build_process_event_source's last-resort reconstruction dropped scope_id, so a scoped relay completion whose session-store origin was unavailable primed no tenant discriminator and could still bounce off the connector's fail-closed egress guard. scope_id now threads through the reconstructed SessionSource, with a warning when a scoped chat reconstructs without one. All four: RED reproduced with the fix reverted, GREEN after; relay/ delegation delivery families pass (43 + 71 + 179 across the touched suites); full tests/gateway run shows only failures already failing identically on merge base 2446c8b (env/dep issues). * fix(gateway,relay): make pending-frame failure cancellation-safe; persist completion routing origin Two remaining review findings on this branch (NousResearch#82592): 1. Cancellation could strand outbound waiters past the fail-pending loop. transport.disconnect() failed pending futures only at the END of the drain + three teardown awaits; a cancellation landing mid-drain (the runner's wait_for budget, an outer cleanup deadline) skipped the loop entirely and left registered futures unresolved — their callers blocked until _OUTBOUND_TIMEOUT_S (30s). The budget threading added earlier shrinks the window but is not a hard guarantee. The fail-pending loop (and the going_idle ack failure) now run in a `finally`, so no exit path — normal, error, or cancelled — can leave a registered future unresolved. Idempotent: done futures are skipped, a second disconnect() pass is a no-op. 2. Durable completions did not persist their routing origin, so the scope_id threading in the fallback SessionSource reconstruction had nothing to carry on the exact path it exists for (restart replay with session store + source cache gone): the async-delegation event producers never populated scope_id and the durable rows never stored it. Dispatch now snapshots the originating turn's scope_id/user_id/user_name from the session context (_capture_routing_origin — a new HERMES_SESSION_SCOPE_ID contextvar bound by the gateway at session-bind time alongside the existing vars), stores them in the existing task_json payload (no schema migration), and re-attaches them to all three completion-event shapes (live single, live batch, crash-recovery rebuild). The gateway's fallback reconstruction then primes both discriminators after a restart. Tests: cancellation mid-drain -> every pending future resolves with "relay transport closed" (mutation: moving the loop out of the finally goes RED); second-pass disconnect idempotence; end-to-end dispatch -> owner-death recovery -> event carries scope_id -> fallback SessionSource primes it (mutations: dropping the dispatch capture or the task_json persistence both go RED); live completion event carries the origin. 94 passed + 1 xfailed across the delivery/delegation suites; tests/tools delegation family 73 passed (2 collection errors pre-existing on merge base 2446c8b). --------- Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Ben Barclay <ben@nousresearch.com>
alt-glitch
pushed a commit
that referenced
this pull request
Aug 19, 2026
refactor(relay): centralize protocol descriptors
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.
sync: defer 51 upstream commits — agent-loop-core change + new gateway relay family
Automated OpenTUI fork-maintainer tick. The merge of
upstream/main(
3769dff5dd20) into a throwaway worktree off the currentsid/opentuitip(
db879dea6a24) was clean and the full gate is green, but it crosses threeALWAYS-DEFER lines, so the live branch was left untouched. This branch holds
the cleanly-merged, gate-verified tree for your review.
Gate result (on the merged tree — green)
npm run check(ui-opentui): 825 tests passed, exit 0 (afternpm installpulled a missing dep brought by the merge).
node scripts/build.mjs: exit 0,dist/main.jsbuilt (1.6 MB).tests/agent/test_empty_tool_name_loop_dampening.py: 5 passedtests/gateway/relay/(new family): 84 passedui-opentui/) untouched by upstream.So this is mechanically safe to merge — the defer is a policy call, not a
broken tree.
Why deferred (you decide)
agent/conversation_loop.pywas modified by020e59d3cf4a(fix: dampen empty-name phantom tool-call loop). This is the agent
core loop file on the hard always-defer list. The change itself looks sound
and additive (blank/whitespace tool-name now gets a terse anti-priming error
instead of dumping the full tool catalog; nonempty typos still get the
catalog) and ships with a behavior-contract test — but core-loop edits are
yours to wave through, not mine to auto-advance.
gateway/relay/*subsystem (RelayAdapter, CapabilityDescriptor schema, transport protocol +
WebSocket transport, signed-HTTP inbound receiver, connector channel auth,
and an enroll CLI), plus changes to
tui_gateway/server.pyandgateway/run.py.This is a brand-new gateway event/RPC surface the OpenTUI
boundary/hasnever mirrored. It is flagged-off by default upstream and the engine does not
consume it yet, so nothing is broken — but a net-new gateway family is an
explicit always-defer trigger and may eventually want a
boundary/port.tui_gatewayfeature:5a00bd151896(fix(desktop): persist /title set before the first message instead of
queuing). Desktop/gateway
/titlepersistence; mirrors the messaginggateway's
_handle_title_command. Touches the gateway contract; not portedinto the engine this tick.
What I tried
upstream/maincleanly (112 files,+8365/-763), verified no conflict markers and that
ui-opentui/was untouched.risk areas (loop dampening + the new relay family). All green.
sid/opentuiand did not rebuild the live install.What you need to decide
conversation_loop.pyloop-dampening fix into the fork(it's clean + tested; likely yes).
gateway/relay/*family needs an OpenTUIboundary/port nowor can ride along flagged-off until you want relay in the OpenTUI client.
/title-before-first-message persistence fix intothe engine, or let it land as-is.
If you merge this branch into
sid/opentuiyou'll be at the current upstream tipwith all the above included; rebuild
ui-opentui/distafterward. Engine code isunchanged, so the OpenTUI client behaves identically to today either way.
Commits in this range: 51. Surfaces: 44 other, 1 tui_gateway (needs_port),
2 agent-loop (xAI codex/web_search + composer ctx), 1 agent-loop-core (defer-flagged).