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.
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.
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)
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)
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.
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.
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).
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).
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>
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>
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>
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)
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
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.
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.
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>
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
_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.
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
_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.
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)
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.
_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.
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>
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>
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>
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)
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.
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.
_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.
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.
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)
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)
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)
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.
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)
_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).
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).
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.
_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>
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>
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.
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.