5212 Commits

Author SHA1 Message Date
kshitijk4poor
173770144f fix(sessions): tell the user when a delete is refused for a live turn
The browse picker swallowed SessionActiveWriteGuardError into a generic
"Delete failed.", and the dashboard bulk delete only reported the deleted
count, silently keeping rows a live turn owns. Surface both: the picker
flashes that the session is active, and SessionsPage shows a toast with the
skipped_active count (new en key; other locales fall back to English via
defineLocale). Also move the api_server import into its sorted slot.
2026-09-27 20:49:04 +05:30
kshitijk4poor
907e3188da fix(sessions): report bulk-delete rows skipped for a live turn
delete_sessions(exclude_active_write_guards=True) dropped guarded rows
silently: the web bulk-delete endpoint returned only a count and the
dashboard removed every selected row optimistically, so refused rows
reappeared on the next reload with no explanation.

The store now appends refused ids to an optional skipped_ids list inside
the same write transaction, the endpoint returns them as skipped_active,
and SessionsPage keeps those rows listed. Also hoists the
SessionActiveWriteGuardError imports to module top (hermes_state_errors
is stdlib-only) and drops the assertion-less lineage comment in the test.
2026-09-27 20:49:04 +05:30
shali10
40523600b0 fix(sessions): refuse to delete a session row a live turn still owns (#123583)
Refactor entry-side deletion refusal to execute in-transaction via
`_write_guards_reject(conn, sid)` (#123583), per maintainer review:

- Underlying `delete_session` and `delete_sessions` now accept an opt-in
  kwarg `exclude_active_write_guards=True` running inside `_do` write
  transaction, eliminating the race condition where a turn acquires the lease
  between check and delete.
- Raises `SessionActiveWriteGuardError` when refusing single delete, leaving
  the row untouched; `delete_sessions` atomically skips active rows.
- Checks both active turn leases and compression locks via the existing
  reclaim-aware `_write_guards_reject` helper.
- Covers all user-facing delete sinks:
  * Web `DELETE /api/sessions/{id}` -> 409 Conflict
  * Web `POST /api/sessions/bulk-delete` -> skips active rows
  * Web / CLI `prune` -> passes `exclude_active_write_guards=True` so lineage
    parents of active conversations are not pruned
  * API Server `DELETE /api/sessions/{id}` -> 409 session_active_turn
  * CLI `hermes sessions delete` & `export --delete-after-verified` -> exits 1
  * CLI browse picker -> refuses active delete
  * TUI Gateway `session.delete` -> 4023 error
- Conforms to rubric with 2 targeted invariant tests in
  `tests/hermes_state/test_delete_session_write_guards.py`.
- Updates user guide and web dashboard docs for 409 / exit 1.

(cherry picked from commit 2c037a7a79dc211b49bacc72e3140951ccf900cf)
2026-09-27 20:49:04 +05:30
liuhao1024
4670467534 fix(security): remove mutable display-name from SimpleX allowlist check
The SimpleX sender allowlist (SIMPLEX_ALLOWED_USERS) previously matched
against both the stable numeric contactId (user_id) and the mutable
display name (user_name). Since any SimpleX contact can change their
localDisplayName / profile.displayName to match another user's, this
allowed an unauthorized contact to bypass the allowlist by setting a
colliding display name.

Remove the user_name check so that SIMPLEX_ALLOWED_USERS only matches
on the immutable contactId. Operators must use numeric contact IDs in
the allowlist.

Fixes #44729

(cherry picked from commit b4aa29da1567d45920f79aabdb36b44c5f87bde5)
2026-09-27 20:47:41 +05:30
kshitijk4poor
f39f76508e fix(gateway): tighten busy re-queue back-off identity check and test matrix
Review cleanups on the drain back-off:
- `_drain_after` now requires `guard`: a None default silently meant "release
  whatever guard is current", which is the guard-swap bug the parameter fixes.
- The identity rule is stated once (docstring, wrapped), and the redundant
  `pending_event is dispatched_event` disjunct is dropped: the same object
  always has an equal message_id, and when that id is empty its timestamp
  equals itself, so the remaining comparison already covers it.
- Tests drop the `_Adapter` alias and cut the hot-loop matrix from 6 to 4
  explicit cases (plain, rewrite with id, rewrite without id, steer). The
  demotion route does not interact with the identity axis, and each case
  spends a fixed 1s measuring window.
2026-09-27 20:44:38 +05:30
kshitijk4poor
c0f8ac6645 fix(gateway): keep swapped command guards and id-less rewrites bounded in drain back-off
The back-off drain's slot-empty exit released whatever guard was current, so a
/stop, /new or /reset guard swapped in during the sleep was deleted, defeating
the #48300 guard-swap protection. Capture the guard owned by the drain at spawn
and release only that; also flush the text debounce buffer before popping the
slot, like every other task exit, so a debounced text isn't orphaned.

A pre_gateway_dispatch rewrite copy of an event with no message_id was never
matched to the dispatched event, so it still hot-looped (#123229). Fall back to
the copied timestamp when message_id is empty (a genuine new message gets a
fresh one); a plain message_id==/timestamp== form breaks the runner's re-queue
of a new object with the same id.

Cap the back-off at 1s: nothing wakes the sleep, so a genuine message merged
into the slot meanwhile waited up to 5s; 1 dispatch/s is still ~250x below the
unbounded loop and needs no new wake plumbing.
2026-09-27 20:44:38 +05:30
kshitijk4poor
b6e6f00790 fix(gateway): back off only when the dispatched event bounces straight back
The _busy_requeued tag was reset only by an untagged drain, so chained queue/steer
follow-ups reaching the runner fast path (split-brain adapter, multiplex routes) backed off
exponentially again (0.05->2s gaps vs ~0.01 on main). Key the back-off on identity instead:
the drained event must be the one this task just dispatched (same object or same message_id
for rewrite-hook copies). That covers every demotion site (interrupt demotion, queue, steer
fallback, Telegram grace queue, agent-starting merge) with no per-site tagging, so the tag
and _hm_tag_busy_requeue are dropped.

A backed-off event now stays in _pending_messages during the sleep and is popped after it,
so a cancel needs no put-back and can no longer drop the older message when a newer one
took the slot. jittered_backoff import moved to module top (agent.retry_utils is stdlib-only).
2026-09-27 20:44:38 +05:30
kshitijk4poor
fc8539b7b7 fix(gateway): back off only runner-demoted events, not every None turn
The drain back-off keyed on `response is None`, but None is the normal
return for every streamed turn and for queue/steer busy modes, so
ordinary chained follow-ups were delayed 0.25s -> 5s forever. The busy
fast-path in run_inbound now tags the adapter's pending head
(`_busy_requeued`) where it demotes the same inbound event (or its
rewrite-hook copy) back into the queue (interrupt demotion, queue mode,
steer fallback); the drain backs off only for a tagged event and
otherwise resets the counter and dispatches at once — single reset owner.

Also: clear _requeue_counts on cancel_session_processing, stale-lock
heal, session end and shutdown; restore the pending event if cancelled
during the back-off sleep; reuse agent.retry_utils.jittered_backoff.
The regression test now chains 3 genuine follow-ups after a streamed
(None) turn and requires each to dispatch in <0.1s (red on pre-fold).
2026-09-27 20:44:38 +05:30
kshitijk4poor
506cc14def fix(gateway): back off busy re-queued events inside the drain task
The runner busy-demotion puts the event back into the adapter pending slot and
returns None; the in-band drain re-dispatched it at once, ~400 times/s for the
whole busy window (typing churn, log flood, platform connection storm).

Count consecutive unanswered re-queues per session on the adapter (not by event
identity, so a pre_gateway_dispatch rewrite via dataclasses.replace is still
caught), reset when a handler returns a response. First re-queue stays
immediate (restart auto-resume self-bounce); later ones back off 0.25s..5s.
The delay is slept inside the new drain task before its processing
try/finally, so cancelling during the back-off cannot reach
_finish_session_task late-arrival respawn (no concurrent handler, no
untracked task on shutdown).

Fixes #123229

Co-authored-by: ahisblessed <ahisblessed@users.noreply.github.com>
2026-09-27 20:44:38 +05:30
kshitijk4poor
40be7008e0 fix(gateway): skip the executor hop for single-profile watcher scope resolves
The off-loop scope resolve hopped to the executor on every handoff (2s)
and loop-wakeup (15s) tick, even in the default single-profile mode where
_handoff_watch_scopes does no I/O and returns [(None, None)]. The executor
is unbounded (one thread per work item), so that spawned ~34 OS threads a
minute for no work. One helper next to _handoff_watch_scopes now returns
the root poll directly when multiplex is off and only hops for the
multiplex filesystem walk; both watchers use it, replacing the local
_resolve_scopes closure. Config-less test stand-ins still resolve via the
patched resolver.

Also give the run_goals half teeth: the loop watcher's profile-gate test
patched the resolver with a lambda that recorded nothing, so reverting
run_goals stayed green. It now runs with multiplex on (required by the
short-circuit), records the calling thread and asserts off-loop; red on
the pre-fix run_goals.py.

Co-authored-by: Emir Saffar <emir.saffar@uropenn.se>
2026-09-27 18:30:25 +05:30
kshitijk4poor
9a0c0e11b4 fix(gateway): resolve startup reclaim scopes off-loop; drop dead goals fallback
The handoff watcher's one-shot startup stale-reclaim still resolved
_handoff_watch_scopes on the loop thread; route it through the same
executor hop as the per-tick resolve (shared local helper). The loop
wakeup watcher's getattr fallback was dead — its idle gate already calls
self._run_in_executor_with_context unguarded — so call the hop directly.
Trim the comments (drop host-specific incident notes).

Co-authored-by: Emir Saffar <emir.saffar@uropenn.se>
2026-09-27 18:30:25 +05:30
Emir Saffar
bf668143ad fix(gateway): resolve handoff/loop-watch scopes off the event loop
The 15s _loop_wakeup_watcher and the handoff watcher resolved watch scopes (_handoff_watch_scopes -> profiles_to_serve -> get_active_profile_name -> Path.resolve/realpath + profile-dir scans) synchronously ON the loop every pass. On a memory-thrashing host those syscalls stall past the loop-liveness watchdog 10s probe; 3 strikes -> exit 75 -> every in-flight session/cron is killed (mini wedges 24/9 20:58, 26/9 22:56, 27/9 00:21+00:33; [hermes] stack caught in posixpath.realpath). Resolve the scopes through the runner executor hop with the defensive getattr idiom from run_idle_gates.off_loop_gate (bare test stand-ins keep the historical on-loop resolve). 31 targeted tests green.

(cherry picked from commit 52952abc7f033d352fed69ac88fc1efb46110c55)
2026-09-27 18:30:25 +05:30
mooserini
fa655b2980 fix(gateway): do not auto-TTS A2A replies
voice.auto_tts (flipped globally by Desktop "Read replies aloud") made
the runner synthesize a spoken reply for every A2A text task. The A2A
adapter has no native send_voice, so delivery fell back to the media
notice — the peer received "Couldn't deliver the audio attachment."
instead of the text the agent had already produced (#90103). Inbound A2A
is MessageType.TEXT; the base adapter's own auto-TTS gate keys on
MessageType.VOICE, but the runner's fallback branch
(voice_mode is None and adapter_auto_tts) had no platform gate at all.

Two aligned gates, both field-tested by the reporter:

- _should_send_voice_reply returns False for platform 'a2a' before any
  mode/config consultation — the text reply lands normally.
- _sync_voice_mode_state_to_adapter never pushes the global speak
  default onto the A2A adapter, so the adapter-side path cannot regress
  it either.

/voice on|tts|off scoped behavior is untouched (persisted per
platform:chat_id, still honored for human platforms). Tests pin the
pair the issue asked for: A2A + global auto-TTS skips, Telegram with
the same default still voices, and the sync-side skip.

Re-implemented from PR #90121 on the GatewayVoiceMixin split (author
credited). The Desktop-scoped read-aloud preference is a separate
design change, not folded in.

Fixes #90103
2026-09-27 06:54:56 -05:00
teknium1
5a0225dfff fix(gateway): restart watcher names its checkout instead of inheriting the cwd
The detached watcher runs as `<python> -c <program>`, so `hermes_cli` resolved only
because update_completion happened to spawn it with cwd=<checkout>. The program now puts
the checkout on sys.path itself, and the bare-Python test runs it from an unrelated cwd
(red without the sys.path line).

Also drops the two unused re-export aliases in gateway.status (`_posix_is_zombie`,
`_pid_exists_win32_ctypes`): nothing imports them and neither is in the old-updater
compat surface.
2026-09-27 03:37:09 -07:00
teknium1
029445545c fix(gateway): keep the update restart watcher stdlib-only so it survives the bare store Python
After the package-manager handoff, hermes update finishes on the bare store
interpreter and spawns the detached restart watcher as sys.executable -c.
The watcher imported gateway.status (utils -> hermes_yaml -> ruamel) and
hermes_cli.config, died with ModuleNotFoundError before relaunching, and
left every manually started gateway down after the update (gated on #124649).

Move the stdlib liveness probe (zombie-aware POSIX kill(0), Windows
OpenProcess) into hermes_cli._subprocess_compat, have gateway.status's
fallback delegate to it, and import only stdlib-backed modules in the
watcher.

Fixes #124649.
2026-09-27 03:37:09 -07:00
teknium1
60e531cb52 fix(gateway): read every Hermes inline bootstrap's argv as its identity, on all OSes
The #107002 guard keeps an inline ``-c`` program's trailing argv as data. Every
Hermes launcher runs the entry point IN the ``-c`` process, so the guard hid
real gateways on every OS (#124318, #124588):

- the store launcher / Windows updater relaunch (_launchers.runtime_command)
- the published launcher script (POSIX shell launcher: every PM-install
  systemd/launchd gateway) and its Windows .cmd base64 wrapper
- the venv_sync re-entry, whose argv is assigned inside the source

gateway.status.inline_bootstrap_argv recognises exactly those emitted source
shapes, anchored at both ends so a program merely CARRYING one (the restart
watcher's respawn argv) still never matches, and rewrites the process to the
equivalent ``python -m <entry> <argv>``. /proc, psutil and ``ps`` space-join
argv, which splits the source across tokens; the shortest token run ending
in a recognised tail is the source whichever reader joined it. Both
canonical matchers (looks_like_gateway_command_line and
update_cmd_windows._hermes_holder_subcommand) use it.

Live on a real PM install (Linux, bwrap): a gateway started through the
installed launcher script or runtime_command read "Gateway is not running"
on main; with this change both read running, find_gateway_pids and
get_running_pid see them. Drops the four #124318 known_failure gates in
tests/e2e/core/windows_update.

Co-authored-by: Hermes Agent <dmyou@users.noreply.github.com>
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
Co-authored-by: DianaBudin <dianabudin0307@gmail.com>
2026-09-27 02:57:28 -07:00
dgpcboy
02cf40d99e fix(gateway): recognise the Windows launcher's inline-bootstrap gateway form 2026-09-27 02:57:28 -07:00
Brooklyn Nicholson
54fb5a42e8 fix(gateway): drop routing entries and transcript files on hard delete
DELETE /api/sessions/<id> removed only the state.db rows: the durable
channel->session routing index (gateway_routing table + sessions.json
mirror) survived, so the next Discord/Telegram message routed to the SAME
id and resurrected the deleted row, and the on-disk .json/.jsonl
transcripts plus request_dump files were never scrubbed because
sessions_dir was not passed to delete_session (#42422).

- SessionStore.remove_by_session_id: drop every entry pointing at the id
  (one channel can hold several) and persist the drop to both durable
  copies; the index is written back by the gateway process, so removing
  only DB rows elsewhere is undone by the next whole-index save.
- The API delete handler now passes the request-scoped sessions_dir and
  clears the routing entries through the runner's SessionStore.
- Deletes made out of the gateway process self-heal at routing time via the
  stale-route guard once a missing row counts as ended.

Fixes https://github.com/NousResearch/hermes-agent/issues/42422
2026-09-26 22:30:35 -05:00
Brooklyn Nicholson
981ec5d150 fix(gateway): treat a hard-deleted session row as a dead route
_is_session_ended_in_db answered False when the row was missing from a
readable owning DB, so a session hard-deleted out of the gateway process
(CLI/TUI/desktop local delete) kept its live channel->session route: the
next inbound message resolved the deleted id and run_agent's INSERT OR
IGNORE re-created the row with its old content — the deleted conversation
resurrected (#42422). A missing row is now the same verdict as an ended
one: the stale-route self-heal drops the entry, recovery finds nothing,
and the peer mints a fresh session. DB errors still answer False.
2026-09-26 22:30:35 -05:00
Brooklyn Nicholson
e7b2db974c fix(api-server): deliver transform_llm_output rewrites on streaming routes 2026-09-26 21:39:31 -05:00
Austin Pickett
10938a7cf9 fix(gateway): mark synthetic process notifications internal and stop stale reply anchors
Background process/delegation notifications re-enter the conversation as
role=user turns. Two failure modes (#52694):

- The injected MessageEvent reused evt.message_id — the id of the user
  message that ARMED the watch, hours stale by delivery — as its reply
  anchor, so the gateway posted the system notice as a reply to an old
  user message (Discord reports). Post fresh instead; topic routing
  stays intact via source.thread_id.
- The model-facing text carried no machine-provenance marker, so the
  model read the notice as something the human said. Append an explicit
  [INTERNAL NOTIFICATION — not a user message] footer (trailing, so
  start-anchored consumers keep matching) and set
  metadata.notification_origin=process_registry_synthetic.

Fixes #52694
2026-09-26 21:45:17 -04:00
kshitijk4poor
08f0f2ab18 refactor(gateway): route first-contact note through first_contact_turn_note
_hmwa_first_contact_notes re-implemented the branch logic of
agent.onboarding.first_contact_turn_note (profile_build mode check,
is_seen, mark_seen, plain-intro fallback) that the TUI already uses, so
the gateway and TUI paths could drift apart. #123987 deduplicated only
the note literal. Call the shared helper instead; it already falls back
to PLAIN_INTRO_NOTE on error, so the local try/except goes away. The
has_any_sessions() gate stays.

Suggested in review of #123987 by jonpol01.
2026-09-27 01:55:21 +05:30
engineer
2984bd80e4 fix(gateway): first-turn intro note must not swallow a real task
The zero-session first-contact sidecar note (PLAIN_INTRO_NOTE /
_hmwa_first_contact_notes) unconditionally told the model to just
introduce itself, with no carve-out for the case where the user's
first-ever message IS a real task. On a fresh tenant whose voice task
was the very first message, the model followed the note verbatim and
replied with a static 'I'm Hermes. /help shows the available commands.'
- no tool call, no attempt at the task at all. This is a third shape of
the first-turn-onboarding-hijack class (turn replaced outright, not
just augmented with the known profile-build pitch).

Fix: PLAIN_INTRO_NOTE now instructs the model to do the task first
(call whatever tools it needs) and fold the one-line intro into the
close of that same reply; only a message with no real request gets the
old bare intro behavior. Also de-duplicated the literal note text in
gateway/run_turn.py, which had drifted into an inline copy instead of
importing agent.onboarding.PLAIN_INTRO_NOTE.

(cherry picked from commit fc7c3839b0b6774133d4fe8df59a9e98d23bdd8b)
2026-09-27 01:55:21 +05:30
kshitijk4poor
d915436657 refactor(gateway): type the housekeeping pool as an Executor 2026-09-27 01:05:06 +05:30
kshitijk4poor
9ceb36e974 docs(gateway): update why housekeeping has its own pool
The _run_housekeeping_in_executor docstring justified the separate pool
by abandoned housekeeping retiring slots of a finite shared turn pool.
The turn pool is now unbounded, so that only describes history; the
remaining reason is to bound housekeeping thread growth when abandoned
workers wedge.
2026-09-27 01:05:06 +05:30
kshitijk4poor
fd86447c0c refactor(gateway): pass a pool factory instead of overloading None
_TURN_MAX_WORKERS = None meant "use _UnboundedThreadExecutor", while in
ThreadPoolExecutor(max_workers=None) the same None means min(32, cpu+4),
the capped pool this stack removes. The constant sized nothing, only
picked a branch, and anyone setting it back to an int would silently
reintroduce the queued-turn bug.

_get_or_create_pool now takes a factory callable; _get_executor builds
the unbounded executor and _get_housekeeping_executor the bounded
ThreadPoolExecutor. The constant and the None branch are gone, and the
"why unbounded" rationale moves to the _get_executor docstring.
2026-09-27 01:05:06 +05:30
kshitijk4poor
9567dd9c54 fix(gateway): close submit/shutdown race in the unbounded turn executor
submit() checked _shutdown, dropped the lock, started the thread, then
re-locked to register it. A shutdown() landing in that window marked the
pool shut, snapshotted _threads without the new worker and returned, so
_shutdown_executor counted 0 live workers while a turn body went on to
run against a closed SessionDB (the #101093 quiesce hole).

Hold the lock across the check, t.start() and _threads.add(t), like
ThreadPoolExecutor.submit. The worker only takes the lock in its finally,
so it simply waits for the add; the `started` Event is no longer needed.
A failed start() still registers nothing. Also note why cancel_futures is
ignored (no queue) and why DaemonThreadPoolExecutor isn't reused (it keeps
idle workers alive until shutdown; here threads exit with their turn).

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
2026-09-27 01:05:06 +05:30
kshitijk4poor
1b00d0ad7b docs(gateway): state the real bound on concurrent turn threads
The comment claimed turn concurrency is bounded at admission by
max_concurrent_sessions, but that defaults to unset and auto-resume is
uncapped. The real bound is one live turn per session plus turns
abandoned by the inactivity timeout, whose threads keep running. Say so,
and note in the max_concurrent_sessions docs that it is the only cap on
concurrent gateway turns.

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
2026-09-27 01:05:06 +05:30
kshitijk4poor
fb992c84bc refactor(gateway): move turn executor to its own module; register threads after start
gateway/run.py is past 6000 lines; the unbounded turn executor is a
self-contained class, so it lives in gateway/turn_executor.py and run.py
imports it.

submit() now starts the thread before adding it to _threads. An unbounded
pool's one new failure mode is Thread.start raising at the OS thread/pid
limit; previously the unstarted thread stayed in _threads and
_shutdown_executor's join() raised "cannot join thread before it is
started", skipping the #101093 quiesce decision in run_shutdown. A small
Event keeps the worker's discard from racing the add.

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
2026-09-27 01:05:06 +05:30
Kyzcreig
24758cf4b8 fix(gateway): stop capping the turn-body executor at 10 threads
A turn body holds its executor thread for the entire turn: every tool call
and model request blocks inside it. The gateway-owned turn pool was a
ThreadPoolExecutor(max_workers=10), so once ten turns were running, every
further turn that had already been accepted sat in the executor's internal
queue until a thread freed up. Nothing logged that wait.

After a restart this is the common case: every interrupted session is
auto-resumed at once, and together with new messages that fills ten slots
immediately. Measured on a production gateway, accepted turns then waited
334-2356 s before their first model call; users saw sessions as frozen until
the next restart.

Reproduced on current main: 11 bodies submitted to GatewayRunner._get_executor()
behind a threading.Barrier(11) -> 0 ran concurrently, all 11 timed out
waiting (pool max_workers = 10).

Change:
- _TURN_MAX_WORKERS = None (unbounded). Concurrency is bounded where turns are
  admitted (max_concurrent_sessions), not by a second, silent limit.
- _UnboundedThreadExecutor: one daemon thread per work item, no queue.
  ThreadPoolExecutor(max_workers=None) is min(32, cpu+4), not unbounded.
  It exposes _threads/_shutdown so _stop_pool and _shutdown_executor still
  join and count live workers at shutdown.
- The housekeeping pool keeps its bound of 4 (its callers abandon workers on
  timeout by design).

Tests:
- tests/gateway/test_turn_pool_unbounded.py (new): 60 blocking bodies meet at
  one Barrier on the live pool; a 41st body starts while 40 are parked;
  result/exception propagation and submit-after-shutdown; shutdown counts a
  wedged turn worker as live. Restoring the 10 cap turns the first two red.
- tests/gateway/test_executor_pool_isolation.py: the abandoned-housekeeping
  regression pin keeps abandoning 10 items (the old pool size) instead of
  importing the removed constant.
- 18 passed: test_turn_pool_unbounded, test_executor_pool_isolation,
  test_shutdown_executor_quiesce, test_cleanup_off_loop.

(cherry picked from commit 23ffcbfac9bda4acc7fd391649e9f2e3a773d8a1)
2026-09-27 01:05:06 +05:30
kshitijk4poor
65954ba85e test(gateway): recover the launchd wrapper argv without pinning the JXA text; make the process-group check observable
The round-trip helper re-typed the whole JXA program as a regex, so any
cosmetic wrapper edit broke three plist tests; decode the system() string
literal instead (exit decoding is covered by the macOS test that executes it).
The process-group test compared groups already inherited from the runner;
start the wrapper in a new session (as launchd does) and assert the child
stays in the wrapper's group. status.py comment: the wrapper string is JXA.
2026-09-27 01:04:39 +05:30
kshitijk4poor
e3ffee06f2 refactor(gateway): give _pinned_channel_inputs explicit inputs
The two pin helpers encode one rule (a human turn records, an internal
turn reuses) but took it in two shapes: _pinned_session_context_prompt
gets `internal` as a keyword while _pinned_channel_inputs took the whole
event and read getattr(event, ...) itself. Match the sibling:
(session_key, channel_prompt, source, *, internal), with the call sites
passing the event's values. MessageEvent.channel_prompt/internal and
SessionSource.parent_chat_id are declared dataclass fields, so the
getattr guards (including the new context-prompt call site) go too.
2026-09-27 00:42:55 +05:30
kshitijk4poor
9c13cb21d4 fix(gateway): restore pinned parent_chat_id via replace_source
_pinned_channel_inputs copied the internal-turn source with a plain
dataclasses.replace, which drops the wire-invisible provenance
(transport ref, authorization home, identity). Under multiplexing the
copied source then reaches _run_agent / _delivery_adapter_for without
its identity and falls back to heuristic profile/adapter routing.
gateway/AGENTS.md requires source copies to go through
session_identity.replace_source; use it (late import, matching the other
gateway callers) and drop the now-unused dataclasses import.
2026-09-27 00:42:55 +05:30
kshitijk4poor
7820464695 fix(gateway): internal-only sessions still pin the session-context prompt
The internal-event branch returned an unpinned render when no pin existed
yet, so sessions that only ever see internal turns (kanban/API-server
wakes, startup resumes) re-rendered the session-context prompt every turn
and lost the verbatim-reuse immunity base gave them. Reuse an existing pin
when present; otherwise fall through to render-and-pin as before.

Applies teknium1's inline review suggestion on #122048.
2026-09-27 00:42:55 +05:30
Kyzcreig
9c18b383de fix(gateway): internal events must not re-key the session-context / channel prompt pin
Internal events (kanban wakes, delegation completions, watch
notifications) carry a source rebuilt from the persisted origin: no
chat_name/user_name/message_id/parent_chat_id, and channel_prompt=None.
_pinned_session_context_prompt re-rendered from that source, so every
internal turn re-keyed the pin and the next human turn re-keyed it back
(A->B->A). Each flip rewrote already-sent system bytes and collapsed the
prompt cache to the static prefix. The same toggle happened through
channel_prompt and parent-keyed channel_overrides in the ephemeral
system prompt.

- _pinned_session_context_prompt(internal=True) reuses the existing pin
  verbatim and never re-pins.
- Human turns record (channel_prompt, parent_chat_id) in
  ConversationState.channel_pin; internal turns reuse them (main run and
  the queued follow-up path).

tests/gateway/test_internal_event_pin_wiring.py drives the real
_handle_message_with_agent human->internal->human with _run_agent
stubbed. Both tests fail on main and pass here; dropping internal= at
the call site makes both fail again.

(cherry picked from commit 3b38527ae7f0f498ca265fe9f64fcbc1c8c7765c)
2026-09-27 00:42:55 +05:30
teknium1
627344842b docs(gateway): hygiene platform-stamp comments describe the retain flag, not a stale rebuild
Since #104414 Platform is not a restore-identity field, so the gateway_hygiene
stamp no longer forces the next live turn to rebuild the prompt. The two
comments still claimed a rebuilt prompt would be "deliberately stale"; the
seed's retain flag is the mechanism now (#122822).

(cherry picked from commit 93692d5bb9360142eb61f047b441c49d3b12cef9)
2026-09-27 00:42:12 +05:30
Regina
556b8427b7 fix(compression): hygiene and gateway /compress keep the seeded system prompt
Gateway hygiene and the gateway /compress handler compact with a detached
AIAgent(enabled_toolsets=["memory"]) seeded with the session's stored prompt
(_seed_hygiene_system_prompt, 76a17046e2 / 678916b427). Since #98426 the
commit boundary always rebuilds the prompt, so the seed was discarded and the
reduced-toolset build was persisted over the live session's snapshot: the
skills index (## Skills (mandatory) / <available_skills>) and Skill Safety
Rule vanish, and every later fresh agent for that session restores the
degraded bytes verbatim (_stored_prompt_matches_runtime does not compare
platform).

The seed now marks the agent (_retain_seeded_system_prompt) and the
commit-boundary rebuild keeps the seeded bytes for it. A live agent's own
compaction still rebuilds, so builder updates keep reaching long sessions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit dd5c8ef4e845b2d34e826e4d92afad73a12eefa6)
2026-09-27 00:42:12 +05:30
kshitijk4poor
3307336404 fix(gateway): keep the /branch parent read inside the create-failed guard 2026-09-27 00:37:26 +05:30
kshitijk4poor
15e3836f16 refactor(gateway): read the /branch parent prompt inline
The try/except around the parent get_session() only added a silent path
that reproduces the bug (a child row with no system prompt). Read it inline
like the handler's other DB calls, per AGENTS.md no-defensive-wrappers.
2026-09-27 00:37:26 +05:30
AhmetArif0
e1b483a6ed fix(gateway): a /branch child row carries the parent's system prompt
The gateway /branch copies the parent transcript byte-for-byte but created
the child row without a system prompt, so the branch's first turn rebuilt
the prompt (re-probing the workspace) and lost the warm prefix cache, and
logged the null-prompt warning on every branch. 2ec12952 fixed the CLI and
TUI branch writers; the gateway is the third. Read the parent row's prompt
and pass it to create_session.

(cherry picked from commit 4103a7c66c3b86659ee2cdbd7eef37d735aff237)
2026-09-27 00:37:26 +05:30
teknium1
78b8032301 fix: streamed reply no longer repeats a word when edits fall back to a new message
_continuation_text backed the cut up to the previous space even when the visible
preview already ended on a whole word, so the continuation re-sent that word at
the seam ("the answer" + "answer to your question."). Back up only when the cut
lands inside a word (#116312 behaviour kept).

Found by the platforms E2E suite (#121409).
2026-09-26 11:24:50 -07:00
kshitijk4poor
8afaab3703 fix(gateway): restart wait survives a non-finite drain; tighten its tests
Follow-up to the salvaged restart-wait commits:

- A drain or cron timeout of .inf now means "wait indefinitely" instead of
  an OverflowError from the integer stop envelope, which crashed
  `hermes gateway restart` and made `hermes update` silently fall back to
  its 45s floor. The fleet "draining (up to Ns)" lines and the drain
  progress report format the budget instead of int()-ing it, so an
  unbounded wait no longer crashes them either (it did on main too).
- cron_drain_timeout is required: a 0.0 default meant "cron opted out",
  the under-budget this fix exists to remove.
- Docstrings describe what the budget actually covers (PID exit, not
  replacement startup).
- Tests assert the observer outlasts after-turn + the supervisor stop
  envelope and that configured cron reaches the CLI wait, instead of
  re-deriving the formula; the negative wording assertion on the pending
  footer is dropped (change-detector).
2026-09-26 20:07:47 +05:30
Brian Le
643b2091f0 fix(gateway): budget restart observer for full stop envelope
(cherry picked from commit e9b2123af68ce955dd66b027404687a0a375def3)
2026-09-26 20:07:47 +05:30
Brian Le
5aad192995 fix(gateway): include cron drain in restart exit wait
(cherry picked from commit aefbb0741ed3d8491ede6e2d26689022c69a85bc)
2026-09-26 20:07:47 +05:30
kshitijk4poor
2506f801de fix(gateway): keep the legacy base-pythonw venv overlay on non-PM installs (#122183)
The `Path(sys.prefix).resolve() != resolved_venv` guard skipped exactly the
case _ensure_windows_gateway_venv_imports exists for (264ac72b67): a gateway
restarted under uv's base pythonw.exe that still needs venv/Lib/site-packages
(MCP SDK). When sys.prefix IS the venv its site-packages is already on
sys.path, so the guard turned the function into a no-op on non-PM installs.

It is not needed for #122183: the committed_venv early return alone keeps a
PM install off the leftover pre-PM venv (and a corrupt facts.json raising
there is fail-closed, matching hermes_bootstrap). Mutation-checked with a
fake-win32 harness: removing that early return turns
test_committed_generation_blocks_the_legacy_venv_overlay red.
2026-09-26 19:57:55 +05:30
kshitijk4poor
716efc89c0 fix(gateway): never overlay the pre-PM venv on a PM-managed Windows gateway (#122183)
_ensure_windows_gateway_venv_imports prepended VIRTUAL_ENV or <root>/venv
unconditionally. On a PM install hermes_bootstrap has already activated
the store Python onto the committed generation, so that late overlay put
a foreign-ABI tree (cp311 pydantic_core under 3.14) first on sys.path and
the gateway died on import.

- committed generation present -> return: bootstrap already made the
  one activation decision; no second, late injection.
- committed_venv errors propagate (a corrupt facts.json is an error, not
  permission to load the legacy venv).
- nothing committed -> only overlay a candidate that is the running
  interpreter's own prefix, instead of parsing pyvenv.cfg versions (uv
  writes version_info, which a version-only parser misses).

Co-authored-by: Robby Slamet <robbyslmt@users.noreply.github.com>
2026-09-26 19:57:55 +05:30
kshitijk4poor
cb3142d325 test(gateway): trim replay-stamp tests to the flush invariant
The marker-preservation test is subsumed by the _db_flush_collect
regression test and the updated exact-dict assertion in
test_cached_agent_history_guard.py; keep one invariant test. Also note
why _build_replay_entry carries the stamp: replay rewrites are view-only
and marker-only flushes must skip rows already persisted.

Co-authored-by: finn763 <165816600+finn763@users.noreply.github.com>
Co-authored-by: Gaurav Saxena <gauravsaxena.jaipur@gmail.com>
2026-09-26 19:37:13 +05:30
webtecnica
9e62232b07 fix(gateway): carry _db_persisted stamp in _build_replay_entry to prevent duplicate transcript flushes (#123462)
(cherry picked from commit 7366ba26e91907641527d698a72f0f57de530eba)
2026-09-26 19:37:13 +05:30
kshitijk4poor
9fc7f17906 fix(gateway): steering an addressed message into a running turn keeps the silence fallback
Busy steer (the busy-mode and priority paths, and /steer) pushes a new message
into the running turn, which then answers it without a turn of its own.
Redirect already folded the message's reply_expected into the turn; steer
did not, so an unaddressed opener steered by an @mention could still end
on a bare silence marker. All of them now go through _fold_into_running_turn.

Also: stale comments on MessageEvent and the queued-terminal silence
verdict, and a crash-recovery case for an unaddressed row.
2026-09-26 07:21:49 +05:30
kshitijk4poor
2ed91ca39d fix(gateway): a turn that answers an addressed message keeps the silence fallback
reply_expected was read from the event that opened the turn, so an addressed
message the same turn ended up answering could still end on a bare silence
marker and vanish:

- a queued chain paired the terminal turn's display kind with the opener's
  flag; the recursive run now receives the pending event's flag, persists
  it, and returns it as queued_terminal_reply_expected beside
  queued_terminal_display_kind for the outer shaping to read;
- pending-message merges (merge_pending_message_event, text batching, busy
  debounce) and an active-turn redirect folded the new message in but kept
  the old flag; MessageEvent.absorb_reply_expected now folds it: an
  addressed message wins, then an unknown one.

Also: reply_expected is persisted only when the adapter set it, so rows on
other platforms carry no null key; the turn_params pop and getattr reads
go (turn_params already flows into TurnContext); silence_allowed is a
module import; crash recovery keeps the diagnostic-mute check on machinery
turns and applies silence_allowed only to the silence verdict; the DEBUG
line no longer fires for machinery turns.
2026-09-26 07:21:49 +05:30