Commit graph

2516 commits

Author SHA1 Message Date
kshitijk4poor
915f1bf1bc fix(api): reserve cron fire work during drain 2026-07-13 23:26:10 +05:30
kshitijk4poor
ffc10cc659 fix(gateway): quiesce API and cron work during drains
Reserve API requests before their first await, include every drain-owned work type in runtime status, and pause local cron dispatch while a gateway drain is active.
2026-07-13 23:26:10 +05:30
kshitijk4poor
104ffeae23 fix(gateway): complete API-server shutdown drain 2026-07-13 23:26:10 +05:30
Bartok9
021ee34546 fix(gateway): drain in-flight api_server runs on shutdown
Closes #63529

Root cause: GatewayRunner._drain_active_agents only waited on
_running_agents + cron in-flight counts. Desktop/API sessions are
tracked solely inside APIServerAdapter (_inflight_agent_runs +
_active_run_agents), so stop/restart logged active_at_start=0 and
systemd SIGKILL'd mid-tool work.

Fix: APIServerAdapter.active_agent_work_count() plus
GatewayRunner._active_api_run_count() folded into the drain wait,
status updates, timeout result, and shutdown logs — same pattern as
_active_cron_job_count for #60432.

Verification: pytest tests/gateway/test_api_server_active_work_drain.py
tests/gateway/test_cron_active_work_drain.py -q → 19 passed
2026-07-13 23:26:10 +05:30
teknium1
d0e9a42cec fix(delegation): harden durable completion delivery 2026-07-13 07:28:21 -07:00
teknium1
67f4e1b4a9 feat(delegation): persist background completions 2026-07-13 07:28:21 -07:00
John Lussier
94a7705bdd fix(gateway): deduplicate completion delivery 2026-07-13 07:28:21 -07:00
Teknium
d48bf743f2 fix(approval): scope smart deny owner overrides to one operation
Co-authored-by: Sergei Ivanov <kavi@local.hermes>
2026-07-13 04:31:55 -07:00
SahilRakhaiya05
f96b2e6ef7 fix(whatsapp_cloud): gate interactive taps on DM allowlist 2026-07-13 16:15:04 +05:30
Teknium
8030b01a2a fix(kanban): harden durable artifact handoff 2026-07-12 23:09:41 -07:00
teknium1
837077dfae fix(api): stop producers after run transport expires 2026-07-12 04:15:47 -07:00
teknium1
8f18fa104f fix(api): separate run control from stream lifetime 2026-07-12 04:15:47 -07:00
teknium1
1da89a5f3d fix(api): keep live runs tracked past stream ttl 2026-07-12 04:15:47 -07:00
Teknium
7550c594ce
feat(reasoning): add max and ultra effort levels (#62650) 2026-07-12 00:26:49 -07:00
kshitijk4poor
4281151ae8 test(gateway): cover effective context budget 2026-07-11 23:46:13 +05:30
kshitijk4poor
265ac7d812 fix(gateway): honor runtime context budgets 2026-07-11 23:46:13 +05:30
kshitijk4poor
ff84e3f7d5 fix(gateway): scope queued context references 2026-07-11 23:46:13 +05:30
kshitijk4poor
37a942650f fix(gateway): scope context refs to runtime profile 2026-07-11 23:46:13 +05:30
TheTom
4df6e6280d fix(gateway): @ context reference expansion never ran (AttributeError)
GatewayRunner._prepare_inbound_message_text's "@" context-reference
block read self._model / self._base_url to resolve the model for
get_model_context_length_async. GatewayRunner never sets either
attribute (copy-pasted from HermesCLI in da44c196b, which does carry
self.model/self.base_url). Every message containing "@" raised
AttributeError inside the try block, silently swallowed by the
surrounding except Exception at debug level, so
preprocess_context_references_async never ran and @file:/@folder:/@diff/
etc. references passed through to the model unexpanded.

Fix: resolve model/provider/base_url via
self._resolve_session_agent_runtime(source=, session_key=,
user_config=), the same session-aware resolution the hygiene
compression block already uses a few hundred lines later in this file.

Also raise the swallow log from debug to warning (with exc_info at
debug) so a future regression here is visible instead of silent.
2026-07-11 23:46:13 +05:30
teknium1
6142203bd7 fix(gateway): ground readiness in live runtime state 2026-07-11 08:42:21 -07:00
teknium1
f9728af5e2 feat(gateway): add authenticated runtime readiness checks 2026-07-11 08:42:21 -07:00
kshitijk4poor
4aa499ff9f fix(telegram): harden flood fallback recovery
Keep empty-tail recovery scoped to the current stream segment and bound fallback flood retries. Preserve Telegram's server retry hint without blocking final delivery through a long cooldown.
2026-07-11 11:13:50 +05:30
Gille
04898631cb fix(telegram): recover final delivery after stream flood 2026-07-11 11:13:50 +05:30
kshitijk4poor
b196ce80c8 fix(gateway): unify routing save and reset races 2026-07-10 12:38:48 +05:30
kshitijk4poor
b3f77f5c82 fix(gateway): close SessionStore concurrency gaps 2026-07-10 12:38:48 +05:30
kshitijk4poor
9d38a2309e fix(gateway): enforce one async SessionStore boundary 2026-07-10 12:38:48 +05:30
kenyonxu
08e9dcf182 fix(gateway): move all I/O out of session_store._lock in get_or_create_session
The second lock block in get_or_create_session held self._lock during six
blocking operations on every inbound message: _is_session_ended_in_db
(SQLite SELECT), _should_reset (callback), _save (SQLite write + JSON write
+ os.fsync), and _recover_session_from_db (SQLite SELECT + UPDATE).

A code comment at line 1607 claimed 'SQLite calls are made outside the
lock' -- true only for _compression_tip_for_session_id, which was moved
out in a prior fix. The remaining I/O was never addressed.

Restructure into a four-phase lock/no-lock split that mirrors the pattern
already established at the bottom of the function:

  Phase 1  (lock)    -- read entry + session_id
  Phase 1b (no lock) -- stale check + reset policy
  Phase 2  (lock)    -- apply decisions to _entries, capture snapshot + flags
  Phase 3  (no lock) -- recovery DB query, _save from snapshot, end/create

_save_entries(snapshot) replaces _save() to avoid dict-mutation races when
called outside the lock. _query_recoverable_session splits the DB I/O out
of _recover_session_from_db so only the _entries assignment needs the lock.

Three early returns inside the lock block are eliminated in favour of a
unified save + return path.
2026-07-10 12:38:48 +05:30
kenyonxu
94c2a4016b fix(gateway): offload both blocking sources in compression-in-flight check (#5)
The sync _session_has_compression_in_flight sat on the message hot path
and blocked the event loop twice: under session_store._lock during
_ensure_loaded_locked (JSON read) and via db.get_compression_lock_holder
(SQLite SELECT). Async-ify the method and offload both sources via
asyncio.to_thread; await the call site in _handle_active_session_busy_message.
2026-07-10 12:38:48 +05:30
kenyonxu
24ea21993f fix(gateway): offload session store calls off the event loop via asyncio.to_thread
Every inbound message calls get_or_create_session which synchronously
executes _is_session_ended_in_db → db.get_session → conn.execute on
the asyncio event loop. On a ~1.4GB state.db, this blocks the loop
for seconds to minutes, starving Discord heartbeats.

Upstream #55159 fixed the same pattern for self._session_db in
gateway/run.py but missed SessionStore._db in gateway/session.py.

This follows the exact same approach as #55159:
- session.py internals stay fully synchronous (zero changes)
- Threading.Lock contract is preserved
- All hot-path callers in run.py and slash_commands.py wrap calls
  with await asyncio.to_thread(self.session_store.method, ...)

Affected: get_or_create_session, switch_session, update_session,
load_transcript, rewrite_transcript, rewind_session, reset_session,
set_model_override, _save — ~24 call sites across 2 files.

# Conflicts:
#	gateway/slash_commands.py
2026-07-10 12:38:48 +05:30
teknium1
a0972b9748 fix: widen None-deref guards to config-derived sibling sites + tests
Sibling sites of the salvaged #55997 fix, all reading user-editable
config values through .get(key, '').method(): MoA slot provider/model
labels, gateway quick-command alias targets (2 sites), gateway.proxy_url,
and gateway.relay_url. Regression tests for the contributor's two sites
plus the MoA labels.
2026-07-09 21:10:07 -07:00
luxuguang-leo
276542c729 fix(qqbot): add is_reconnect param to QQAdapter.connect for gateway reconnect compat
The base adapter's  signature was updated to include
, which the reconnect watcher passes as
 during reconnection. All other platform adapters were
updated, but QQAdapter was missed, causing:

    TypeError: QQAdapter.connect() got an unexpected keyword argument 'is_reconnect'

This leads to an infinite retry loop since every reconnect attempt fails
immediately with the same TypeError.

Fix: add  to QQAdapter.connect()'s signature.
QQBot has no server-side update queue, so the flag is accepted only for
interface conformance.

Test: new test_connect_accepts_is_reconnect_param verifies both
adapter.connect() and adapter.connect(is_reconnect=True) succeed without
raising.
2026-07-09 19:09:38 -07:00
sharziki
a7f65e3bcd fix(gateway): tolerate scalar gateway config block
The streaming fallback path read yaml_cfg.get("gateway", {}).get("streaming") when top-level streaming was absent or malformed. If a user accidentally set gateway to a scalar value, config loading crashed with AttributeError instead of ignoring the malformed block and using defaults.

Read the gateway block once, verify it is a mapping before accessing nested streaming, and keep the existing gateway.platforms fallback using the same checked value.

Adds a regression test for config.yaml containing gateway: disabled.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 18:23:27 -07:00
sharziki
50c66b2f8e fix(gateway): ignore malformed config sections
GatewayConfig.from_dict(), PlatformConfig.from_dict(), SessionResetPolicy.from_dict(), and StreamingConfig.from_dict() assumed their input sections were mappings. A malformed scalar from legacy gateway.json or an internal caller could crash config loading with AttributeError before env overrides/defaults had a chance to recover.

Coerce non-mapping sections to empty dicts, skip malformed platform entries, and keep valid sibling platform configs loading normally.

Tests cover scalar platform blocks, scalar nested reset/streaming sections, and malformed PlatformConfig home_channel/extra values.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 18:23:27 -07:00
AlexFucuson9
549b87c9aa fix(gateway): prevent hygiene compression from destroying archived transcript
When gateway session-hygiene auto-compression fires with in-place
compaction, the flow was:

1. _compress_context() calls archive_and_compact() — soft-archives old
   rows (active=0, compacted=1) and inserts compacted messages as the
   new active set.  This is the non-destructive, durable path.

2. The hygiene handler then called rewrite_transcript() — which calls
   replace_messages(active_only=False) — DELETEing ALL rows including
   the just-archived turns.  Silent permanent data loss (#61145).

The interactive /compress handler had the same bug.

Fix: only call rewrite_transcript() when session rotation produced a new
session id (legacy path).  When in-place compaction succeeded, skip the
rewrite — archive_and_compact() already handled persistence.

Closes #61145.
2026-07-09 18:04:00 -07:00
embwl0x
d23990f527 fix(gateway): offload channel directory session scans 2026-07-09 06:27:04 -07:00
kshitijk4poor
0a01b2087d fix(gateway): harden fallback-chain refresh from review findings
Follow-ups on the #60987 salvage (review pass):
- _refresh_fallback_model: keep last known-good chain on transient
  config.yaml read/parse failure (user mid-edit, torn write) — only a
  successful read that lacks the key clears the chain. Previously a
  refresh error wiped a cached agent's working fallback for the turn.
- Move the cached-agent refresh+apply OUTSIDE the agent-cache lock:
  config.yaml read is disk I/O and the idle-sweep watcher contends on
  that lock (same reasoning as #52197). Per-session turn serialization
  keeps the post-lock apply safe.
- _apply_fallback_chain_to_agent: clear _unavailable_fallback_keys when
  chain content actually changes, so an entry re-configured mid-uptime
  (e.g. credentials added) is retried instead of staying suppressed for
  the cached agent's lifetime; no-op refreshes keep the memo.
- Tests: cwd-independent source pin (Path(__file__) anchor), pin the
  reuse-path apply call, + regression tests for last-known-good, memo
  clear-on-change, memo keep-on-unchanged (mutation-verified).
2026-07-09 02:22:12 +05:30
HexLab98
be1346cf2a fix(gateway): reload fallback_providers on live agent create/reuse
Gateway froze the fallback chain at process start while cron reloads it
per job, so a chain configured after hermes gateway was running never
reached messaging sessions. Refresh from disk on agent create and when
reusing a cached agent.

Fixes #60955.

(cherry picked from commit b64e7155b2)
2026-07-09 02:22:12 +05:30
0disoft
7d0ddbb2ff fix(dashboard): cache gateway PID status probes
(cherry picked from commit a2bbe564ad)
2026-07-09 01:19:07 +05:30
nullptr0807
d6a275b735 fix(gateway): compact hygiene transcripts in place 2026-07-08 12:35:50 -07:00
teknium1
ae5e39005b fix(gateway): run webhook route scripts off the event loop + AUTHOR_MAP entry
- run_route_script shells out with subprocess.run (up to 30s timeout); wrap
  the call in asyncio.to_thread so a slow script can't stall every other
  webhook and gateway task on the loop.
- scripts/release.py: map grace@weeb.onl -> evelynburger for the salvaged
  contributor commit.
2026-07-08 08:10:55 -07:00
Grace
0cf2e39c41 feat(gateway): add webhook payload filters 2026-07-08 08:10:55 -07:00
teknium1
75efd73961 fix(gateway): never resurrect ended sessions for delegation completions; /new severs in-flight delegations
Completes the session-binding class on the gateway surface (#55578),
matching the TUI rules:

1. Fail-closed pinning: switch_session() re-opens ended sessions, so
   pinning a completion to a spawning session that has since ENDED
   (user /new, closed rotation) would resurrect a conversation the user
   explicitly ended and inject into it. The injection path now checks
   the pinned row's ended_at first and drops the injection with a
   WARNING when the spawning session is dead or unknown - the result
   stays in the delegation records.

2. /new ends the old conversation's delegations: _handle_reset_command
   calls interrupt_for_session() with the expiring durable session id
   (matching the parent_session_id pin stamped at dispatch) plus the
   routing key as fallback, so a reset can't leave dangling subagents
   whose completions have no live owner.

interrupt_for_session() gains the parent_session_id selector because a
gateway chat's session_key (the platform conversation key) survives a
reset while the session id rotates - key-based matching alone could
never sever a gateway conversation's delegations.
2026-07-08 08:10:28 -07:00
nankingjing
d39c62409b fix(delegate): pin async completion to spawning parent session (#57498)
Background delegate_task completions only carried session_key. When multiple
active sessions shared a routing peer, get_or_create_session could recover the
latest ended_at IS NULL row and inject the subagent result into the wrong
session.

Capture parent_agent.session_id at dispatch time, include it on async-delegation
completion events, and pin gateway routing via switch_session when the
synthetic completion message is handled.

Fixes #57498
2026-07-08 08:10:28 -07:00
loongfay
b848fcbf11 feat(Yuanbao) optimizes media resource processing speed: parallel download 2026-07-08 08:05:45 -07:00
loongfay
63c4100fe6 perf(yuanbao): bounded-concurrency inbound media resolve 2026-07-08 08:05:45 -07:00
Tony Simons
f75f3cd713 fix(delegation): route async delegate_task results back to originating session
The completion event already carries the dispatching session's session_key
(captured at dispatch time in delegate_tool.py:2798), but the delivery
router ignored it — results landed in whatever session was active at
completion time instead of the session that dispatched the subagent.

Changes:
- drain_notifications() in process_registry.py: optional session_key
  filter. Non-matching async_delegation events are re-queued instead of
  consumed, so they remain available for the correct session's drain.
- cli.py process_loop: passes active session_key to drain_notifications()
- tui_gateway/server.py post-turn drain: passes session_key from the
  TUI session dict
- gateway/run.py _build_process_event_source: logs warning when routing
  metadata is unresolvable (previously silent drop)
- Regression tests verifying session-scoped drain filtering

Fixes #58684
2026-07-08 07:39:52 -07:00
Dan Schnurbusch
aab351bfa6 fix(delegation): route async results to origin session
Carry the live TUI session id with async delegation completion events and prefer the commissioning UI session when desktop pollers share the completion queue. Resolve compressed session keys to their continuation before treating events as orphaned, and capture the live parent agent session id for TUI/ACP dispatch.
2026-07-08 07:06:15 -07:00
Ben Barclay
f64e4f4f57
feat(gateway): generic OIDC client-credentials relay provisioning (NAS-free) (#60730)
For air-gapped / self-hosted-IdP deploys with NO Nous Portal, let the gateway
obtain its caller-identity bearer from a generic OAuth2 client_credentials grant
against the operator's own IdP (e.g. Microsoft Entra ID) instead of only
resolve_nous_access_token(). The connector's OIDC tenant resolver reads a claim
(default tid) off that token as the tenant.

- gateway/relay: new canonical _resolve_relay_identity_token() — client_credentials
  when gateway.idp.token_url (or GATEWAY_RELAY_IDP_* env) is set, else Nous Portal
  (unchanged default). Wired into self_provision_relay().
- hermes_cli/gateway_enroll: _resolve_identity_token() delegates to the canonical
  resolver so the enroll CLI and the runtime self-provision path share ONE impl.

Config via gateway.idp.{token_url,client_id,client_secret,scope} in config.yaml
(env override GATEWAY_RELAY_IDP_*). No behaviour change when unset.

Tests: tests/gateway/relay/test_identity_token_resolver.py (6 — mode selection,
request shape, config/env precedence, fail-closed). Relay suite 162 pass.

Validated via the cross-repo gateway<->connector live E2E (provision, managed
self-provision, inbound round-trip, /link) against a connector running the OIDC
tenant resolver with zero NAS config.
2026-07-08 16:55:32 +10:00
joaomarcos
24e9ed73c2 fix(gateway,cron): make shutdown drain visible to in-flight cron work
Cron jobs run through cron/scheduler.py's own ThreadPoolExecutor via a
standalone AIAgent (run_job/run_one_job), entirely outside
GatewayRunner._running_agents -- the dict _drain_active_agents() and
every other active-work check on that class reads. A gateway shutdown
(/update, /restart, and SIGUSR1 all funnel through the same stop())
could log active_at_start=0 and immediately kill tool subprocesses
while a cron job's terminal command was still running, with no wait
and no indication anything was interrupted.

Real-world impact (from the issue): a scheduled daily briefing cron
job was in flight during /update, its tool subprocess got killed
by the unconditional shutdown cleanup, and the job was never marked
failed -- it simply never completed or delivered, with no error
surfaced anywhere. A repro with a 30-minute `sleep` cron job in flight
during /update reproduced the same pattern: subprocess killed at
+0.22s of drain (active_at_start=0), the job's agent thread continued
in-process and produced a plausible-looking final response from the
truncated tool output, and the scheduler marked the run successful.

Root cause is layered, not a single line:

1. GatewayRunner._drain_active_agents() only waits on _running_agents.
   Cron work was invisible to it, so drain returned instantly whenever
   the only active work was a cron job.
2. Even with visibility, the shutdown's final tool-subprocess kill
   (process_registry.kill_all()) is a global, unconditional sweep with
   no per-job targeting -- a long-running cron job that outlives the
   drain timeout still gets its subprocess killed.
3. cron/scheduler.py had no way to detect that a job's tool subprocess
   was killed out from under it mid-run; the agent thread kept going
   and its eventual (often degraded but plausible-looking) response
   got reported as a normal successful completion.

Fix, three parts:

- cron/scheduler.py: expose get_running_job_ids() (thread-safe
  snapshot of the existing _running_job_ids set, already used to
  prevent double-dispatch) so the gateway can read cron's in-flight
  state without reaching into private module internals.

- gateway/run.py: GatewayRunner._active_cron_job_count() reads that
  snapshot. _drain_active_agents() now waits on
  (_running_agents OR active cron jobs), so a cron-only workload gets
  the same bounded wait chat sessions already get instead of an
  instant active_at_start=0. Shutdown drain logging gains
  cron_active_at_start/cron_active_now fields alongside the existing
  ones (unchanged, for compat).

- cron/scheduler.py: mark_running_jobs_interrupted(reason), called by
  gateway/run.py's _kill_tool_subprocesses() right after
  process_registry.kill_all(), marks every job still in
  _running_job_ids at that instant as failed/interrupted via the
  existing mark_job_run() -- and records the job IDs in
  _interrupted_job_ids BEFORE writing, so run_one_job()'s own
  eventual completion for the same run (racing in its own thread)
  checks that flag and skips its normal write instead of clobbering
  the interrupted status with a false "ok" produced from the
  now-truncated tool output. This does not attempt to correlate a
  killed PID to a specific job ID (process_registry tracks PIDs, not
  job IDs) -- any job still dispatched at the moment of a forced kill
  is treated as interrupted, matching the existing coarser precedent
  set by _interrupt_running_agents(), which interrupts every entry in
  _running_agents on a drain timeout without per-agent correlation
  either.

Deliberately out of scope (flagged in the issue as a separate,
lower-priority concern): startup-time reconciliation of cron runs that
started but never reached a terminal status.

Testing:

- tests/cron/test_shutdown_interrupt.py (12 tests): get_running_job_ids
  snapshot semantics, mark_running_jobs_interrupted marking/no-op/
  partial-failure behavior, and -- the core race guard -- run_one_job
  skipping its own last_status write (both the success path and the
  exception path) when the shutdown path already marked the run
  interrupted, with a control test proving ordinary un-interrupted
  completions are unaffected.

- tests/gateway/test_cron_active_work_drain.py (9 tests):
  _active_cron_job_count reading cron state and failing closed (0) if
  the cron module is unavailable; _drain_active_agents waiting for an
  in-flight cron job the same way it waits for chat sessions, timing
  out if the job outruns the window, and leaving existing chat-session
  drain behavior unchanged; a full runner.stop() integration test
  (drain-timeout path) proving mark_running_jobs_interrupted actually
  fires with the right job ID when a tool subprocess is force-killed,
  plus a no-op control when nothing cron-related is in flight.

- tests/gateway/test_shutdown_cache_cleanup.py: added
  _active_cron_job_count() to that file's hand-rolled _FakeGateway test
  double, which stop() now calls -- without it those 8 pre-existing
  tests AttributeError (caught by fail-then-pass below, not a
  production bug).

Fail-then-pass: reverted gateway/run.py + cron/scheduler.py, all 21
new tests fail (fixture/attribute errors -- the feature doesn't exist
yet); restored, all 21 pass.

Regression check: ran the full plausibly-affected surface --
tests/gateway/{test_gateway_shutdown,test_restart_drain,
test_restart_notification,test_restart_redelivery_dedup,
test_restart_resume_pending,test_restart_service_detection,
test_shutdown_cache_cleanup,test_stuck_loop,test_clean_shutdown_marker,
test_external_drain_control,test_session_state_cleanup,
test_update_command,test_update_streaming}.py plus tests/cron/ (944
tests) -- against a clean upstream/main checkout and against this
branch. Diffed the two FAILED lists: identical, 20 pre-existing
failures on both sides (Windows-locale/cp1252 file-encoding issues and
Unix-permission-bit assertions that don't apply on this Windows dev
box), zero new failures, zero fixed-by-accident. The 8
test_shutdown_cache_cleanup.py failures found mid-development were
from the _FakeGateway gap above, fixed in the same commit and
confirmed clean on the final rerun (diff against baseline: exit 0).

Fixes #60432
2026-07-07 22:15:04 -07:00
HexLab98
862aee4956 fix(gateway): drain in-flight cron jobs before shutdown tool kill
/update and other shutdown paths only waited on gateway session agents,
so active cron tool work was killed immediately in final-cleanup while
the scheduler could still mark the job successful (#60432).
2026-07-07 22:15:04 -07:00