Commit Graph

3012 Commits

Author SHA1 Message Date
Teknium 58e3dcf3d6 chore: round-2 review nits (re-review #9)
- tests/agent/test_session_activity.py asserts against
  ACTIVITY_DESCRIPTION_MAX instead of the literal 120.
- The session-stall WARNING log line names its config knob
  (agent.session_stall_timeout) so operators can find the setting.
- hermes_state.py: collapse the triple blank line near line 191.
- hermes_cli/status.py no longer imports the private
  hermes_cli.main._relative_time: the helper moved to a public home
  (hermes_cli.timefmt.relative_time); main._relative_time stays as a
  thin back-compat wrapper (sessions_cmd and external patchers keep
  working).
2026-08-02 16:16:36 -07:00
Teknium 06bdc48c1f perf(agent,gateway): back cancel-wait polls off from 1ms to 25ms (re-review #5)
The fence-cancel poll loops (sync host wait in conversation_compression,
async hygiene wait in gateway/run) spun at 1kHz while the worker held
the fence through its lock-setup window — which rides SessionDB write
patience and can last seconds. 25ms keeps sub-tick cancel latency
without the spin.
2026-08-02 16:16:36 -07:00
Teknium 58f0fe305d fix(gateway): bound the stall-notify adapter.send (re-review #2)
A wedged adapter transport (network hang, dead websocket) previously
blocked _check_session_stalls forever: sibling candidates in the same
pass were never evaluated and the watcher stopped ticking. Wrap the
send in asyncio.wait_for (15s); on timeout log a WARNING and do NOT
latch, so the next tick retries. Regression uses a never-resolving fake
adapter and proves the pass completes, a healthy sibling candidate is
still notified in the same pass, and the watcher ticks again
(sabotage-verified against the unbounded send).
2026-08-02 16:16:36 -07:00
Teknium 89c4e26e23 fix(gateway): revalidate stall candidate immediately before /new delivery (review S2)
The stall watchdog gathered pending/activity candidates and later sent
the recovery notification from that aging snapshot — an agent that made
progress (or drained its queue) between the scan and the send received a
false stall notice mid-recovery.

Re-read the adapter pending slot, the overflow queue, and the live
activity snapshot immediately before delivery; abort the send and re-arm
the latch (pop it) when the candidate is no longer stale, so a future
genuine episode still notifies.

Race regressions: progress between scan and send aborts delivery;
pending-drained between scan and send aborts delivery; a genuinely
still-stale candidate is still delivered exactly once.

PR #76354 review, 'watchdog can send /new using a stale snapshot' /
merge gate 8.
2026-08-02 16:16:36 -07:00
Teknium 99100843c6 fix(agent,gateway): charge the idle wait from the last progress event (review S3)
Both progress-aware waits (sync compress wrapper and gateway session
hygiene) slept a FULL idle interval and only then compared progress, so
progress early in an interval let silence approach 2x the configured
idle timeout before the waiter noticed. Compute each wait slice as
idle_timeout - elapsed_since_last_progress instead.

Regression: a worker that reports progress early and then goes silent is
timed out in ~1x the idle budget, not ~2x.

PR #76354 review, 'idle timeout can allow nearly twice that silence'.
2026-08-02 16:16:36 -07:00
Teknium fdeb09a596 fix(agent): holder-qualified durable lease cancellation, cooldown ordering (review F4)
A host timeout previously left the timed-out worker holding the durable
per-session compression lock AND refreshing its lease indefinitely, so a
truly hung summary blocked every later compression attempt; and a LATE
successful summary could clear the failure cooldown the host had just
recorded.

Transplant the lease-cancellation invariants from PR #71569
(@ciabata-git): the worker publishes an idempotent, holder-scoped release
hook on the fence once it owns the durable lock (begin_lock_setup /
register_cancelled_lock_release close the acquire→publish race), the
refresher start is serialized against the release path, and the host
invokes the hook on idle timeout, hygiene timeout, and every unwind
(revoke_commit_admission now also releases). ABA safety: the SessionDB
release is holder-qualified (DELETE ... WHERE holder = ?), so a stale
release can never free a replacement holder's lease.

State ordering: the compressor consults a fence-cancellation check BEFORE
clearing the failure cooldown, so a late worker cannot undo the host's
timeout cooldown; the check is installed only for the fenced call and
removed in a finally.

Regression implements the reviewer's exact 5-step scenario: summary
blocked indefinitely → host timeout → a NEW compressor acquires the
durable lock while the old summary is STILL blocked → old worker released
→ it cannot clear cooldown, release the new holder's lease, or publish
stale state.

PR #76354 review, blocking finding 4 / merge gates 4 + 5.

Co-authored-by: ciabata-git <ciabata-git@users.noreply.github.com>
2026-08-02 16:16:36 -07:00
Teknium efdd229884 fix(agent): revoke commit admission on every host unwind (review F2)
The sync compress wrapper only handled concurrent.futures.TimeoutError;
KeyboardInterrupt, task cancellation, or any other exception while
waiting let the host unwind while the detached worker kept full commit
authority — it could later enter the commit fence and mutate durable
state (in-place archival, session rotation) behind the caller's back.

Wrap the whole host wait in try/finally: any exit that did not settle the
worker (returned result or won the fence race) revokes future commit
admission via a new lock-free CompressionCommitFence.revoke_commit_admission()
(begin_commit re-checks the flag under the fence lock, so no admitted
commit is ever abandoned mid-mutation). The gateway hygiene wait gets the
same guarantee via a BaseException handler that revokes admission and
defers helper cleanup until the worker actually returns.

Reconciliation with PR #74449 (suparious): that PR routes EXPLICIT host
interrupts into auxiliary-call cancellation; this change is the
complementary host-side guarantee that no unwind — explicit or not —
leaves an unfenced worker. The two compose (fence revocation here is the
outer safety net; #74449's aux cancellation remains the fast path) rather
than duplicating one another.

Regressions: KeyboardInterrupt and generic-exception unwinds assert the
fence is revoked WHILE the worker is still blocked pre-commit, then
release the worker and prove begin_commit() is refused.

PR #76354 review, blocking finding 2 / merge gate 2.
2026-08-02 16:16:36 -07:00
Teknium 980aea225e fix(agent): observe commit phase without the fence lock (review F1)
begin_commit() retains the fence lock until finish_commit(), so a hung
SessionDB commit made try_cancel_before_commit() return None forever and
the host spun ahead of the overrun-warning loop — a genuinely hung commit
stayed unbounded AND silent. Add a lock-free phase marker (threading.Event
set inside begin_commit while the lock is held, readable without it) and
break the host spin on commit_in_flight so the bounded overrun loop — and
its WARNING + on_commit_overrun surfacing — is reachable WHILE the commit
is still blocked. Applies to both the sync compress wrapper and the
gateway session-hygiene wait.

Regression asserts the warning and callback fire while the event-gated
fake commit is still blocked; the test releases the worker only after
those assertions (addresses helix4u's released-before-asserting callout).

PR #76354 review, blocking finding 1 / merge gate 1.
2026-08-02 16:16:36 -07:00
kshitijk4poor 962e4538da refactor: reuse existing utilities in salvaged PR #72424
Three code-reuse fixes applied during salvage:

1. Reuse _relative_time from hermes_cli/main.py instead of duplicating
   the relative-time formatting logic in hermes_cli/status.py.

2. Extract _stamp_hygiene_compression_provenance helper in gateway/run.py
   to deduplicate the two nearly-identical try/except blocks that stamp
   compression timeout/abort provenance in the hygiene path.

3. Add ContextCompressor.record_timeout_failure() method and use it from
   the in-agent compress_context timeout callback instead of re-implementing
   the (60, 300, 900) cooldown ladder inline. The existing summary-LLM
   exception handler already has this ladder — now both paths share one
   method.
2026-08-02 16:16:36 -07:00
fangliquanflq c2088efe9e feat(gateway): session activity watchdog, stall notify, compress timeout (#72424)
Three mechanisms to detect and notify when gateway sessions stall silently:

1. Mid-turn activity heartbeats stamped to SessionDB so hermes sessions list
   and hermes status show progress during long turns without new message rows.

2. Stall watchdog: when a busy session has pending inbound and the shared
   activity clock is idle past agent.session_stall_timeout (default 300),
   log a WARNING and notify the user once to try /new. Notify-only; does
   not kill the turn.

3. Compaction timeout: fenceless compress_context callers get a progress-aware
   host budget (compression.context_timeout_seconds default 120 idle,
   compression.context_total_ceiling_seconds default 600 ceiling). On timeout,
   cancel via commit fence, skip compaction without dropping messages, and
   continue the turn.

Closes #72016 (slices 1-3; slice 4 cumulative SSE stream-retry deadline
remains a follow-up).

Cherry-picked from PR #72424 by @fangliquanflq.
2026-08-02 16:16:36 -07:00
Teknium 7483745da7 feat(gateway): simplex channel enumeration + show configured platforms in hermes send --list
Builds on the adapter list_channels() hook (cherry-picked from #43545 by
@Guoen0):

- plugins/platforms/simplex: implement list_channels() — enumerates
  contacts (/contacts) and groups (/groups) over the live daemon
  WebSocket into the channel directory. Returns None when the WS is
  down so the directory falls back to session discovery instead of
  wiping known targets.
- hermes send --list: merge configured-but-undiscovered platforms into
  the listing. Previously a platform configured only via env (e.g. a
  fresh SimpleX setup used for outbound sends) was silently omitted,
  leaving users guessing at platform names.
- format_directory_for_display(): accept an explicit platforms view and
  render empty platforms with a targeting hint instead of hiding them.
- docs: simplex hermes-send section.

Reported by Fedpostoffice on Discord (simplex missing from
hermes send --list; guessed platform names simplex-chat/simplex-relay).
2026-08-02 15:08:45 -07:00
Guoen bc334f5380 Support adapter channel directory enumeration 2026-08-02 15:08:45 -07:00
Teknium 3829e34e23 feat(hooks): outbound webhooks — push signed lifecycle events to external HTTP endpoints
The inverse of the inbound webhook platform: hooks.outbound in
config.yaml lists HTTP targets + the plugin-hook events they subscribe
to (on_session_end, subagent_stop, post_tool_call, ...). Each firing
POSTs a JSON payload (same top-level shape as shell hooks' stdin wire)
signed GitHub-style with HMAC-SHA256 (X-Hermes-Signature-256).

Rides the existing hook bus — notify-only callbacks registered on the
plugin manager at the same CLI/gateway/main entry points as shell
hooks. Delivery is fire-and-forget via a bounded queue + single daemon
worker thread, so a dead endpoint can never stall a tool call. Bounded
retries (5xx/conn errors once; 4xx never). secret_env preferred over
inline secret. HERMES_SAFE_MODE skips registration. hermes hooks list
shows outbound targets with signed/UNSIGNED status.

Zero new model tools, zero new subsystems.
2026-08-02 15:01:11 -07:00
Ben Barclay d0b87dad77 fix(relay): key auto-thread rename on prospective_thread_id, not per-chat cache (#77052)
The Discord semantic thread-rename lane resolved the target thread from
`_relay_auto_thread_info`, which read a single-slot-per-parent-chat cache
(`adapter._auto_thread_by_chat[chat_id]`, populated from connector
SendResult feedback). When two auto-threads spawned from the SAME parent
channel, the second send overwrote the first's slot and the title turn's
read raced the write — so only the FIRST thread in a channel ever got its
semantic rename. Staging repro 2026-08-02: message A's thread renamed to
"A Hundred Word Sword Story", sibling message B's thread stayed stuck at
the raw first-words name.

The connector now stamps `prospective_thread_id` on the inbound (the anchor
message id, which is the id of the thread it will auto-create) — shipped for
per-thread session keying. Reuse it here: it is deterministic and
per-message, so it names the EXACT thread even when several auto-threads
share one channel. `_relay_auto_thread_info` returns it directly (with an
empty initial-name marker) and never consults the collision-prone per-chat
cache; the connector's own created-name guard (`prefer_connector_created`)
still enforces no-clobber, so no initial name is needed gateway-side. The
send-result cache path stays as a fallback for older connectors that don't
stamp the field.

Tests: two new cases in test_relay_threads.py — prospective id wins over a
poisoned cache entry, and two sibling threads in one channel each rename to
their own thread id. Full gateway session + relay suites green (211 passed).
2026-08-02 19:35:50 +00:00
kshitij d13e8f7510 fix: Discord-specific guard + cron sibling + non-regression tests
- Guard the thread-id-as-chat_id normalization to Discord only; Slack
  and Telegram adapters use parent_channel as chat_id for thread messages,
  so the unconditional version broke their handoff keys.
- Apply the same Discord-specific guard to _seed_cron_thread_session in
  cron/scheduler.py (sibling site with the same bug, docstring said
  'Mirrors _process_handoff').
- Replace the change-detector test with contract tests that verify the
  actual invariant: Discord handoff key == organic thread key, Slack
  handoff key still uses parent channel (non-regression).
2026-08-03 00:32:17 +05:30
Alejandro Moreno 3db7a405e0 fix(gateway): key CLI→platform handoff thread on thread id, not parent channel
A CLI→Discord handoff creates a dedicated thread and re-binds the CLI
session to it. It built the destination SessionSource with
chat_id = home.chat_id (the PARENT channel) while marking it
chat_type="thread" with thread_id set.

But platform adapters build organic in-thread messages with
chat_id = <thread id> (see the Discord adapter's on_message and
_build_thread_event paths). build_session_key therefore produced two
different keys for the same thread:

    handoff:  agent:main:<platform>🧵{parent}:{thread}
    organic:  agent:main:<platform>🧵{thread}:{thread}

So the next real user reply in the handoff thread resolved to a
DIFFERENT session_key and spawned a fresh session instead of continuing
the handed-off one — observed as a stray auto-titled session plus a
session_search fallback (the new session had no prior context).

Fix: for a thread destination, key on the thread's own id so the
synthetic handoff turn and later user replies share one session_key,
matching how adapters key organic in-thread messages.

Adds tests/gateway/test_handoff_thread_session_key.py, which asserts the
handoff key is byte-identical to the organic in-thread key (fails on the
old parent-channel keying, passes on the fix).
2026-08-03 00:32:17 +05:30
kshitij fb6446fc9e fix(cron): scope cron approval context per session
Replace the process-global HERMES_CRON_SESSION env var with a per-session
ContextVar so a cron tick in the gateway process cannot leak into unrelated
live gateway/API/TUI turns. The cron scheduler now sets the ContextVar
inside the job's try/finally scope and resets it on cleanup. Gateway, API
server, ACP adapter, and TUI gateway all pass cron_session='' to explicitly
mark their sessions as non-cron, masking any stale process env.

Co-authored-by: hinablue <hinablue@gmail.com>
Closes #37968
2026-08-03 00:25:20 +05:30
AlexFucuson9 9eb8e20c68 fix: use lazy logging with %s formatting in logger calls
Replace f-string interpolation in logger calls with lazy %-style
formatting across 10 files (38 instances). This follows Python logging
best practices — the message is only formatted if the log level is
enabled, avoiding unnecessary string concatenation overhead.

Files changed:
- trajectory_compressor.py (6)
- mini_swe_runner.py (2)
- agent/tool_executor.py (1)
- agent/model_metadata.py (1)
- agent/agent_runtime_helpers.py (3)
- agent/chat_completion_helpers.py (3)
- agent/conversation_loop.py (8)
- tools/skills_hub.py (2)
- tools/environments/docker.py (10)
- gateway/kanban_watchers.py (2)
2026-08-02 23:17:40 +05:30
Frowtek af077ef039 fix(api_server): run the cron-fire token verifier off the event loop
_handle_cron_fire verified the NAS-minted fire JWT by calling the
fire-verifier inline on the event loop. That verifier resolves the NAS
signing key from a JWKS URL — a synchronous HTTP GET on a cache miss (a
cold PyJWKClient, or a rotated kid the cached client doesn't know) — so a
slow or rate-limited portal stalls the whole event loop and starves every
other adapter sharing it. #64641 already documented this exact symptom
(relay 504s on high-job-count instances) and cut the fetch frequency by
caching the client per URL, but the residual cache-miss fetch still ran
inline on the loop.

Dispatch the verifier the same way the platform HTTP event verifier was
hardened: await a coroutine verifier directly, run a sync one via
asyncio.to_thread so its blocking I/O stays off the loop, and fail closed
(reject with 401, never admit the fire) if the verifier raises — this is
the only inbound that can trigger remote job execution. The verifier's
JWK-client cache is already thread-safe (threading.Lock), so moving the
call to a worker thread is safe.

Adds regression tests: a sync verifier runs on a worker thread rather
than the loop thread, a crashing verifier yields 401 with no fire, and a
coroutine verifier is awaited.
2026-08-02 23:12:22 +05:30
kshitij 3fad8fdc45 polish(mem): readonly config read, debug-level trim logs, loud frame test
Simplify-pass follow-up on the #66355 salvage:

1. _config_settings runs on EVERY trim attempt (before the cooldown
   check) and only reads — swap load_config for load_config_readonly.
   Deep-copying the whole config per attempt generates exactly the
   allocator garbage this module exists to release. Tests re-seamed.

2. Trim-failure logs demoted warning->debug at all 3 periodic sites
   (gateway housekeeping, idle reaper, slash worker): sibling failure
   branches in the same loops log at debug, and a persistent failure
   (e.g. broken import after a partial update) would otherwise warn
   every 60s forever.

3. The frame-inspection test now asserts the expected locals exist
   before reading them — a rename in _run_prompt_submit fails the test
   loudly instead of vacuously passing on None.
2026-08-02 22:44:38 +05:30
Ryder Freeman da43a8527b feat(mem): config-driven allocator trim with telemetry and lifecycle coverage
Add config-driven glibc malloc_trim for long-lived Hermes processes:
- hermes_cli/mem_trim.py: trim_memory() with configurable cooldown,
  RSS snapshot telemetry, and forced-trim INFO logging
- gateway/run.py: periodic trim in gateway housekeeping loop
- tui_gateway/server.py: trim in idle reaper (~every 5 min)
- tui_gateway/slash_worker.py: trim on turn boundary
- run_agent.py: force trim on agent close
- hermes_cli/config.py: context.memory_trim config section
  (enabled, cooldown_seconds, log_every_n, info_log_min_delta_mb)

CSA tier-4 reviewed (4 rounds, 0 HIGH/MEDIUM/CRITICAL remaining).

Supersedes PR #63708 + #64591 with enhanced telemetry and gateway/slash_worker coverage.
2026-08-02 22:44:38 +05:30
kshitij 54eafee30b refactor(gateway): single allocator for the shared routing counter
Review follow-up on the #64169 salvage: _save_entry duplicated
_snapshot_routing_locked's counter-bump line verbatim. The stale-write
protection is a total order over ONE counter — extract
_next_routing_generation_locked() so the two allocation sites can't
drift apart silently.
2026-08-02 22:33:09 +05:30
Soju06 50c4afe40a perf(gateway): single-row routing UPSERT fast path for metadata-only saves
The steady-state turn only bumps updated_at/last_prompt_tokens on one
routing entry, but persisted it through the full index rewrite twice
per turn (get_or_create_session's healthy-path bump + update_session):
every entry re-serialized, DELETE+INSERT of every gateway_routing row,
and a multi-MB sessions.json dump+fsync — ~50ms p50 at ~1,100 routing
keys in production, out of ~175ms total per-turn gateway persistence.

Metadata-only saves now UPSERT the single row via the existing
HermesDB.save_gateway_routing_entry (<1ms). Structural transitions
(create/recover/reset/switch/prune, compression-tip heals) keep the
full rewrite, which also refreshes the legacy sessions.json mirror.

Correctness: each fast save allocates a per-entry revision from the
routing generation counter under _lock, so fast and full snapshots are
totally ordered by number. Under _save_lock the UPSERT is skipped when
a newer full snapshot or a newer fast save of the same key has already
persisted, and a delayed full rewrite folds in fast records serialized
after its snapshot before writing — an older snapshot can never
overwrite a newer one, in either direction. update_session snapshots
peer fields under _lock so a concurrent reset cannot record a torn
peer row; no DB or a failed UPSERT falls back to the full rewrite so
DB-less installs keep sessions.json durable every turn.
2026-08-02 22:33:09 +05:30
Teknium 5c6cc38010 fix(secrets): scope-aware credential reads in core tool/gateway/web-server paths
TOOL_GATEWAY_USER_TOKEN (managed_tool_gateway), OPENROUTER_API_KEY presence
(openrouter_client), SUDO_PASSWORD (terminal_tool), GATEWAY_PROXY_KEY
(gateway/run), SLACK_BOT_TOKEN presence (gateway/session), and the
ELEVENLABS_API_KEY env fallback (web_server voices endpoint) now honor the
installed profile secret scope; unscoped callers keep legacy env reads via
the UnscopedSecretError fallback (Slack pattern).
2026-08-02 10:02:33 -07:00
Teknium dbbfcff56d fix(secrets): route authz gate and pairing allowlist reads through the profile secret scope
- gateway/authz_mixin.py: group chat allowlists, {PLATFORM}_ALLOW_BOTS, and
  pairing-mode allowlist presence checks now go through the file's own
  _platform_gate_env helper (scoped-authoritative under multiplex).
- gateway/pairing.py: grant-mirror/revoke allowlist READS go through
  get_secret (Slack pattern for unscoped admin/CLI callers); writes still
  use save_env_value with a TODO for profile-aware writes.
2026-08-02 10:02:33 -07:00
Da7-Tech e8c5cb5710 fix(qqbot): scope the authz, startup-validation, and direct-send QQ reads
Review follow-up: the adapter-level resolver alone left three paths
reading per-profile QQ_* values from raw os.getenv, so a secondary
multiplex profile's scoped opt-in or credentials were ignored (or the
primary's environ values leaked in):

- gateway/authz_mixin.py: route the per-platform allow-all flag and the
  per-platform/group allowlist + allow-bots reads through the
  scope-aware gateway.config._getenv. Deployment-global GATEWAY_* reads
  intentionally stay on os.getenv. This makes the same fix effective
  for every own-policy platform, not just QQ; unscoped behavior is
  byte-identical to os.getenv.
- gateway/run.py (_own_policy_open_startup_violation): resolve the
  per-platform dm/group policy and allow-all opt-in via _getenv; the
  secondary-profile caller already runs inside _profile_runtime_scope.
- tools/send_message_tool.py (_send_qqbot): the QQ_APP_ID /
  QQ_CLIENT_SECRET fallbacks now honor the active profile scope.

Tests: tests/gateway/test_qqbot_scope_paths.py covers all three paths
end-to-end (scope wins, no environ inheritance for non-opted profiles,
single-profile environ fallback unchanged); the STT suite now asserts
QQ_STT_BASE_URL and QQ_STT_MODEL scoping alongside the API key. All
five scoped-behavior tests fail on the previous commit and pass here.
2026-08-02 10:01:16 -07:00
Da7-Tech 224e59df52 fix(qqbot): resolve credentials under the active profile secret scope
The QQ adapter read QQ_APP_ID, QQ_CLIENT_SECRET, the QQ_STT_* backend
config and the QQ_ALLOW_ALL_USERS policy flag through raw os.getenv,
bypassing the active profile secret scope. In multiplex mode a secondary
profile whose secret lives in its own .env (installed as an isolated
scope, not into os.environ) would silently fall back to the
default/primary profile's value — the same cross-profile collision fixed
for the WeChat/weixin adapter in #59662.

Route these reads through a scope-aware resolver that reads the profile
scope when one is installed (secondary profiles and per-turn inbound) and
falls back to os.environ otherwise. The fallback is deliberate: the
primary/active profile is constructed without a scope and owns
os.environ, so a bare get_secret would raise UnscopedSecretError and
break its startup. Mirrors gateway.config._getenv.

Adds regression tests including active-profile-no-scope construction (the
fail-closed case), plus scope-wins-over-environ, two-profile isolation,
single-profile fallback, explicit-config precedence and STT key scoping.
2026-08-02 10:01:16 -07:00
Teknium 7d4c8f9a54 fix(secrets): scoped BLUEBUBBLES_PASSWORD + API_SERVER_KEY init read; add parametrized regression tests
api_server's __init__ API_SERVER_KEY read now matches the scoped
_expected_api_key path. tests/gateway/test_adapter_startup_secret_scope.py
asserts, for every migrated module: helper exists, scoped read wins, scoped
miss returns default (no environ borrow), unscoped-under-multiplex falls back
to environ without raising, and legacy single-profile reads still work.
2026-08-02 10:00:44 -07:00
Teknium f7efe3d766 fix(weixin): Slack-pattern secret scoping for WEIXIN_* credential reads
The adapter's __init__ and send_weixin_direct read WEIXIN_ACCOUNT_ID/
TOKEN/BASE_URL/CDN_BASE_URL via bare get_secret, which raises
UnscopedSecretError when the DEFAULT profile's adapter constructs or
sends unscoped under multiplexing (corrects the direction of #66073 /
#68854, which tried to solve this by borrowing os.environ on every
read — a cross-profile leak).

Add a module-level _wx_secret helper following the established Slack
SLACK_APP_TOKEN pattern (#59739) and WhatsApp's _get_wsecret: a SCOPED
miss returns the default (the scope is authoritative — no environ
borrow), while an UNSCOPED read under multiplex falls back to
os.environ, which is the default profile's own value.

Regression tests cover both directions: scoped construction reads the
scope's value and a scoped miss yields empty (no borrow); unscoped
construction falls back to os.environ instead of raising.
2026-08-02 09:59:52 -07:00
kshitijk4poor 4ea379ca2e fix(gateway): timed-out turn abandonment is an explicit hard stop
_abandon_timed_out_gateway_turn landed on main (eb4772ec2) after this
PR's base and still used the soft interrupt(). Every other inactivity-
timeout surface in this change (cron, gateway executor poll, delegate
children) treats a timeout as an explicit stop that may cancel a
protected compression summary — widen the same fix to this sibling.
2026-08-02 22:15:20 +05:30
Shaun Prince d15b638a88 fix(compression): let explicit interrupts cancel safely
Makes interrupt-protected context compression cancellable by an explicit
user or lifecycle stop, without weakening protection against ordinary
incoming messages, voice interjections, or active-turn redirects.

Separates explicit hard cancellation from ordinary interrupt/redirect
state with a dedicated threading.Event; introduces
AuxiliaryExplicitCancellation as an attempt-local frozen-cause signal;
isolates the synchronous provider callback in a bounded daemon worker
during protected compression; atomically linearizes Codex timeout
cleanup against explicit cancellation; propagates hard cancellation
through child agents and explicit stop surfaces; serializes hard-cancel
admission against compression commit admission with
CompressionCommitFence; aborts before session rotation or late DB commit,
restores in-place transcript mutations and compressor state, and releases
the heartbeat and compression lease.

Based on #74449 by @suparious. Resolved merge conflicts in
agent/context_compressor.py (feasibility check + try/except) and
tui_gateway/methods_session.py.
2026-08-02 22:15:20 +05:30
evgyur d080dc24a8 fix(gateway): skip topic recovery for non-DM messages 2026-08-02 21:48:46 +05:30
Hermes agent 4e0a775580 fix(state): make VACUUM interval configurable 2026-08-02 21:32:13 +05:30
kshitijk4poor 8f0f55eaac fix(api-server): epoch-gate abandoned-run reaps and cover the /v1/runs sibling
The API server intentionally lets concurrent runs share a client-provided
session_id (= process task_id), so the SSE-disconnect reap could kill a
process a still-live concurrent run spawned after the disconnecting run's
baseline — the same stale-reaper bug class the gateway path gates via
run_generation.

- Per-task-id run epochs (monotonic counter): each run claims the epoch at
  publish; a reaper holding a superseded epoch declines to kill. A missing
  entry (the run's own clear pruned it) still reaps, so the leak fix isn't
  silently disabled.
- _publish_turn_process_ownership / _clear_turn_process_ownership helpers
  replace the copy-pasted marker set/clear blocks, so attribute names and
  epoch bookkeeping can't drift between surfaces.
- /v1/runs — the third own-lifecycle surface — now records ownership and
  reaps on POST /v1/runs/{id}/stop and on server-side SSE cancellation,
  closing the remaining sibling paths of #76115.
2026-08-02 14:23:55 +05:30
kshitijk4poor eb4772ec2f fix(gateway): guard empty task_id reaps and prefer the finished worker's result
Two follow-ups from review of the salvaged fix:

- _reap_gateway_turn_processes now returns 0 for a blank task_id.
  ProcessSession.task_id defaults to empty for sessionless callers, so a
  blank turn id would match (and kill) every unrelated empty-task process
  instead of the turn's own.

- The asyncio poll loop checks executor completion BEFORE the watchdog's
  timeout flag. When both race in the same window, the completed run has
  already persisted its real reply to session history; surfacing the
  'agent inactive' diagnostic would contradict the stored transcript.
  This matches _abandon_timed_out_gateway_turn's own worker-done-wins
  tiebreak.
2026-08-02 14:23:55 +05:30
joaomarcos a35691781a fix(gateway): close cross-turn reap race and cover API-server disconnect
Addresses the hermes-sweeper review on #76188:

1. task_id is session-scoped (task_id == session_id), not turn-scoped,
   and the reap runs on a detached thread. A replacement turn could
   claim the same session and spawn a legitimate process before the
   previous turn's reaper thread actually enumerates its targets,
   killing that new process by mistake.

   Fixed by gating the reap on the existing run_generation mechanism
   (_is_session_run_current) instead of inventing a new ownership
   token: the timeout path captures its own run_generation at turn
   start, the interrupt path captures the generation immediately after
   invalidating it. If a newer turn has since claimed the session, the
   reap is skipped — that newer turn owns its own baseline, so nothing
   is left permanently unreaped.

2. gateway/platforms/api_server.py's SSE handlers for chat-completions
   and the /api/sessions responses endpoint run their own agent
   lifecycle via _run_agent() and never passed through TurnRunner, so
   client-disconnect abandonment there had no baseline and no reap —
   contradicting the PR's stated disconnect coverage. Both disconnect
   handlers now snapshot/reap through the same
   tools.process_registry primitives, via a small
   _reap_disconnected_agent_processes() helper shared by both call
   sites.
2026-08-02 14:23:55 +05:30
joaomarcos 80e4fb5995 fix(gateway): reap only the background processes an abandoned turn created
An agent turn can spawn a long-running background subprocess (e.g.
`next build`) and later be abandoned via inactivity timeout, /stop,
/new, or a client disconnect. Before this fix the gateway interrupted
the agent loop but never touched the subprocess: it kept running
inside the gateway's cgroup, unbounded, until memory pressure starved
the event loop and made every platform/cron look hung (#76115).

The process registry already knew how to kill a process tree — the
missing piece was per-turn ownership: nothing distinguished a process
that predates the turn (must survive), a process the turn started and
finished successfully (must survive), and a process an abandoned turn
left running (must be reaped).

- tools/process_registry.py: snapshot_running_ids() captures a turn's
  starting baseline; kill_started_since() reaps only IDs created after
  it, scoped to one task_id.
- gateway/turn_context.py: TurnContext carries process_task_id +
  process_baseline so the timeout/interrupt paths can reach them.
- gateway/run.py: baseline is snapshotted right before the turn's
  executor task starts; the inactivity-timeout path and the explicit
  /stop|/new|disconnect interrupt path both reap via the same helper.
  A daemon-thread watchdog backs up the asyncio-based timeout poll,
  since a starved event loop is exactly the failure mode this bug
  causes. The turn's own worker clears its ownership markers the
  instant it finishes, closing a race where a /stop landing right
  after normal completion could reap a background process the turn
  deliberately left running.

Related but insufficient on their own: #37454 (cgroup ExecStopPost
reaper only fires on service restart) and #68915 (orphaned-pipe
grandchild detection, a registry bug not a turn-lifecycle gap).
Neither ties process cleanup to turn abandonment.
2026-08-02 14:23:55 +05:30
Teknium 5438e9c629 fix(whatsapp): default-profile UnscopedSecretError fallback + full bridge env set
Follow-ups on the #75382 salvage (review findings):
- _wenv/_get_wsecret now catch UnscopedSecretError and fall back to
  os.getenv for the DEFAULT profile's adapter, which constructs and sends
  outside any _profile_runtime_scope under multiplexing — a bare
  get_secret would crash its WhatsApp path (fixing one profile by
  breaking another). Same pattern as Slack SLACK_APP_TOKEN (#59739) and
  the Matrix recovery key. Scoped misses still return the default — no
  cross-profile borrow.
- bridge_env overlay extended to the full WHATSAPP_* set bridge.js
  consumes (DEBUG, FORWARD_OWNER_MESSAGES, REPLY_PREFIX,
  MAX_MESSAGE_LENGTH, CHUNK_DELAY_MS, SEND_TIMEOUT_MS).
- Removed the always-true conditional on WHATSAPP_MODE injection.
2026-08-02 00:11:50 -07:00
x7peeps 4f4ea9a6de fix(whatsapp): route WHATSAPP_* env reads through secret scope for multiplex profiles
Fix #75349

Root cause:
Under multiplex_profiles, secondary profiles run inside
_profile_runtime_scope which installs a per-profile secret scope via
set_secret_scope.  The WhatsApp adapter (and the shared
WhatsAppBehaviorMixin + Cloud API adapter) read WHATSAPP_MODE,
WHATSAPP_DM_POLICY, etc. via raw os.getenv(), bypassing the secret
scope.  Since os.environ doesn't contain secondary profile .env values,
the bridge silently falls back to 'self-chat' and rejects all inbound
messages with self_chat_mode_rejects_non_self.

Fix:
- Add _wenv() helper in adapter.py that reads WHATSAPP_* vars through
  get_secret() (agent.secret_scope), which honors the active scope.
- Replace all os.getenv('WHATSAPP_*') calls in adapter.py,
  whatsapp_common.py, and whatsapp_cloud.py with get_secret()-based
  equivalents.
- Inject resolved WHATSAPP_* values into the bridge subprocess
  environment so the Node.js bridge (which reads process.env) sees the
  profile's own configuration.

Changes:
- plugins/platforms/whatsapp/adapter.py: 37 lines (+ helper, bridge_env
  injection, 2 os.getenv→_wenv)
- gateway/platforms/whatsapp_common.py: 13 lines (6 os.getenv→_get_wsecret)
- gateway/platforms/whatsapp_cloud.py: 21 lines (9 os.getenv→_get_wsecret)
- New regression test: 6 test cases covering scope isolation, fallback,
  and cross-profile non-leakage.
2026-08-02 00:11:50 -07:00
Bao 6ab390a476 fix(gateway): hydrate cold profile secret sources 2026-08-02 00:11:50 -07:00
tachyon-r 76cf19fee1 fix(tools): isolate model tools by multiplex profile 2026-08-02 00:11:50 -07:00
Jonny Kovacs 651c5160b7 fix(gateway): run manual /compress under the profile secret scope
Multiplexed gateways resolve credentials through the fail-closed
per-profile secret scope (agent/secret_scope.py, Workstream A): any
get_secret() read outside a set_secret_scope(...) block raises
UnscopedSecretError. The agent turn installs the scope via _run_agent's
profile-scoping wrapper, but slash-command dispatch does not — so manual
/compress reached provider resolution unscoped and every invocation on a
gateway.multiplex_profiles: true deployment failed with:

  Manual compress failed: get_secret('OPENROUTER_BASE_URL') called with
  no profile secret scope active while multiplexing is on.

Same bug class as the cron scheduler (#57692) and the /v1/runs agent
path — an un-migrated call site the fail-closed design is meant to catch.

Two changes, both required:

- _handle_compress_command becomes a profile-scoping wrapper around the
  existing handler (renamed _handle_compress_command_inner), mirroring
  _run_agent: gated on multiplex_profiles, resolves the source profile's
  home and runs the whole handler inside _profile_runtime_scope. Covers
  the coroutine-side read (_resolve_session_agent_runtime).
- The compressor call switches from a bare loop.run_in_executor(None, …)
  to the existing _run_in_executor_with_context helper, so the scope
  contextvar survives the thread hop into _compress_context, where the
  aux-client provider resolution reads credentials.

Single-profile gateways take the pass-through branch — zero behavior
change (pinned by test).

Tests: 2 added (scoped read inside the executor under fail-closed
multiplexing reproduces the field failure pre-fix; single-profile
pass-through). 162 gateway compress/multiplex-scope tests green.
2026-08-02 00:11:50 -07:00
fangliquanflq f5ca0e2f0b fix(gateway): honor empty WhatsApp allow_from over env grants
Select allowlist source by config key presence so allow_from: [] does not fall through to WHATSAPP_* env carriers on Baileys or Cloud.
2026-08-02 11:50:05 +05:30
fangliquanflq b35f219aed fix(gateway): apply WhatsApp identity aliases to cloud pairing revoke
Extend phone/JID alias matching to whatsapp_cloud and treat a removed
allowlist env key as empty so sole-entry revoke cannot revive a stale
adapter snapshot.
2026-08-02 11:50:05 +05:30
fangliquanflq 810c8777e1 fix(gateway): preserve WhatsApp allowlist config precedence on live checks
Track which source seeded the DM allowlist so live intake does not let a
stale env carrier override explicit config, while env-seeded adapters still
reread pairing mutations.
2026-08-02 11:50:05 +05:30
fangliquanflq ddfc6342ad fix(gateway): revoke WhatsApp sole allowlist entry without restart
Clear live adapter _allow_from on pairing revoke and re-check DM
allowlist authz so sole-entry removal takes effect without restart.
2026-08-02 11:50:05 +05:30
Ben Barclay a041526efe feat(gateway): key Discord auto-thread sessions on prospective_thread_id (#76513)
Live staging (2026-08-02): only the FIRST auto-thread in a channel got an
auto-title/rename. Root cause is a grouping-model mismatch — the connector
auto-threads per message (each channel message spawns its own thread), but the
gateway keyed sessions per PARENT CHANNEL, so every message after the first
reused the first message's already-titled session; auto-title short-circuited
and the rename lane never fired for later threads.

Intended model: a channel message INITIATES a session, the thread CONTINUES it.
A Discord thread created from a message reuses that message's id as the thread
id, so the connector can tell us the thread id at inbound (before the thread
exists). The paired connector change stamps it as source.prospective_thread_id;
this keys the session on it:

- SessionSource.prospective_thread_id (new field; to_dict/from_dict + the relay
  ws_transport inbound source build read it off the wire).
- build_session_key: effective_thread_id = thread_id or prospective_thread_id.
  The channel-initiating message (no thread_id, carries prospective) and the
  later follow-ups that arrive IN that thread (real thread_id ==
  prospective_thread_id) now produce the SAME key. A real thread_id always
  wins. The chat_type slot is normalized to "thread" when keying on a
  prospective id so the initiating "group"/"channel" event byte-matches the
  follow-up "thread" event. Prospective-thread sessions are shared across
  participants like any thread (not per-user).

Net effect: each distinct channel message is its own session/thread and gets
its own title + rename; follow-ups inside a thread continue that session with
full history. Additive and inert until the connector sends the field, so
non-relay and pre-deploy behaviour is byte-identical.

Tests: initiate-then-continue share one session; distinct channel messages get
distinct sessions; real thread_id wins over prospective; prospective sessions
shared across participants. Session suite 59 passed; relay suite green; ruff +
footguns clean.

Paired: gateway-gateway stamps prospective_thread_id per auto-threading instance.
2026-08-01 18:42:53 -07:00
Ben Barclay 3f497e2b4f fix(gateway): relay thread-rename must carry the parent-channel discriminator (#76465)
Live staging (2026-08-01, on a fresh instance where title generation
finally succeeded): the rename lane fired end to end, but the connector
declined the op with "discord egress declined: target not routed to an
onboarded tenant". The trace logs added earlier pinpointed it:

  discord auto-thread rename: thread=... lane=relay new_title='...'
  relay thread_rename declined ...: target not routed to an onboarded tenant
  discord auto-thread rename result: thread=... applied=False

Root cause: the connector's routedEgressGuard resolves the owning tenant
from the outbound metadata's scope_id (guild) or user_id (author). The
adapter builds those via _with_scope(chat_id), reading per-chat caches
keyed by the PARENT channel chat_id learned at inbound. The relay rename
lane called rename_thread WITHOUT parent_chat_id, so chat_id defaulted to
the THREAD id — a key the caches never held — and the op shipped with no
discriminator. resolveTenant returned undefined and egress was declined
before the op ever reached the (now-durable) no-clobber guard.

This was the true terminal blocker: every earlier fix (send-result
feedback, registration/poll ordering, connector-owned guard, durable
Redis store) was correct but sat DOWNSTREAM of this egress-routing
decline, so none of them could take effect.

Fix: the relay lane passes parent_chat_id=source.chat_id (the relay
source's chat_id IS the parent channel; the thread came from send-result
feedback). _with_scope then resolves scope_id/user_id from the
parent-channel caches and the connector routes the op to the tenant.
Scoped to the relay lane only (use_connector_guard); the native lane
renames via the direct Discord API and needs no discriminator.

Tests: adapter-level — a rename passing parent_chat_id carries the cached
scope_id, one keyed on the thread id alone does not (the regression
shape); lane-level — the late-feedback test now asserts parent_chat_id
flows through as the parent channel. Relay suite 150 passed; ruff +
footguns clean.

Connector-compatible with the deployed egress guard; no gateway-gateway
change needed.
2026-08-01 17:08:29 -07:00
Jaret Bottoms eec6d3efde fix(teams): suppress SDK import-time dotenv instead of clearing environ
Teknium review on #62947: os.environ.clear()/update around deferred
loaders is unsafe under concurrency and misses teams_pipeline's direct
adapter import.

Defer microsoft_teams binding in the Teams adapter, no-op
dotenv.load_dotenv while the SDK imports, keep api_server explicit
disable, and add SDK-import + load_gateway_config canaries.

Fixes #62935
2026-08-01 15:34:19 -07:00
KaliShodan a98c8eeed1 fix(gateway): isolate deferred platform imports from os.environ leaks
microsoft-teams-apps calls load_dotenv(find_dotenv(usecwd=True)) at import time, which can pull a root-profile .env into every gateway process during plugin_entries() discovery and break profile secret isolation.

Snapshot/restore os.environ around deferred loaders, and honor explicit api_server enabled:false the same way _enable_from_env does for other platforms.

Fixes #62935
2026-08-01 15:34:19 -07:00