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).
- Config docs now describe session_stall_timeout precisely: a RECOVERY
notifier for an in-process AIAgent with an adapter-queued follow-up —
not a general gateway/session stall detector — with a per-AIAgent scan
cadence (not globally coordinated per durable session).
- import_sessions documents the deliberate export-includes /
import-resets asymmetry for the activity fields (no resurrected
'working' labels on machines where no agent runs), with a regression
pinning both halves.
- Strip trailing whitespace in contributors/emails/fangliquan@qq.com
(git diff --check housekeeping).
PR #76354 review, scope/contract items + housekeeping.
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.
Activity heartbeat writes and turn-end label clears ran synchronously on
the response-critical path with the full ~20s routine write-patience
budget — under contention an otherwise-finished reply could stall for
seconds just to update observation labels, mimicking the very stall the
watchdog detects.
touch_session_activity and clear_session_activity_labels now use a
dedicated 0.5s patience budget (they are observation-only; the next
heartbeat window retries naturally), and a no-op label clear (labels
already empty) skips the write transaction entirely.
Regressions: with another connection holding BEGIN IMMEDIATE, both writes
give up well under the routine budget; the no-op clear performs zero
write transactions.
PR #76354 review, 'activity writes are synchronous on critical paths' /
merge gate 9.
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.
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).
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).
- 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).
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).
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
pcm_to_wav staged every captured utterance in a NamedTemporaryFile just to
hand ffmpeg an input path, then unlinked it. Feed the PCM to ffmpeg's stdin
instead: one fewer file created, written, read back and removed per voice
utterance, and the try/finally cleanup goes away with it.
The WAV output deliberately still goes to output_path rather than being
captured from stdout. ffmpeg cannot seek on a pipe, so a piped WAV is
written with placeholder 0xFFFFFFFF RIFF/data chunk sizes -- Python's wave
module then reports 2147483647 frames for a 1s clip, and strict readers
misjudge the length. Writing to the real path lets ffmpeg seek back and
patch the header.
Tests cover both halves: that the PCM goes over stdin with no temp file,
and (when ffmpeg is installed) that the resulting header reports the true
frame count.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_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.
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.
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.
Follow-up to the salvaged #59076 commit:
- Replace the bare get_secret import with a module-level Slack-pattern
helper (_get_esecret): try get_secret, on UnscopedSecretError fall back
to os.getenv. The DEFAULT profile's email adapter constructs UNSCOPED
under multiplexing, where a bare get_secret raises and would crash the
email path on startup — the exact WhatsApp defect fixed in 5438e9c629
(whatsapp_common._get_wsecret).
- Extend scope coverage to the remaining scope-blind reads:
EMAIL_IMAP_PORT / EMAIL_SMTP_PORT / EMAIL_POLL_INTERVAL (_esecret_int
replacing utils.env_int) and EMAIL_TRUST_FROM_HEADER (_esecret_bool
replacing utils.env_bool).
- Add tests: default-profile unscoped-under-multiplex construction, and
scoped ports/trust-flag no-environ-inheritance.
The email adapter (plugins/platforms/email/adapter.py) read
EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_IMAP_HOST, EMAIL_SMTP_HOST,
EMAIL_ALLOWED_USERS, and EMAIL_ALLOW_ALL_USERS via os.getenv()
directly. In a multiplexed gateway, os.environ holds the default
profile's .env values, so every secondary profile inherited the
default profile's email credentials instead of its own.
This was a sibling of the api_server env-leak bug (#52307/#50051):
the same os.getenv→get_secret migration that PR #50094 applies to
gateway/config.py, but for the email adapter itself, which neither
PR #50094 nor #51374 covers.
Changes:
- plugins/platforms/email/adapter.py: replace os.getenv with
agent.secret_scope.get_secret for all EMAIL_* credential reads
(adapter __init__, check_email_requirements, _allowlist_in_effect,
_dispatch_message allowlist gate, _send_email SMTP helper).
- gateway/config.py: add _getenv/_getenv_str/_getenv_int helpers
(from PR #50094) and replace os.getenv with _getenv for the email
block in _apply_env_overrides, so config.platforms[EMAIL].extra
is populated from the scoped value.
- tests/gateway/test_email_secret_scope.py: 5 new tests covering
scoped credential reads, environ fallback without scope, missing-
key-no-leak, allowlist scoping, and check_email_requirements scoping.
Related: #50051, #52307, PR #50094, PR #51374
The cherry-picked #60420 hunks that converted gateway/authz_mixin.py are
dropped here: main's _auth_env/_platform_gate_env supersede them, and the
remaining authz_mixin raw-read conversions (allow-all flag + allowlists at
L459/501/879-885) land in a separate PR. Until that PR flips the allow-all
read to scope-authoritative semantics, the cross-profile environ-opt-in
inheritance case is a known gap — pin it as strict xfail so the separate PR
flips it green.
Follow-up to the review-hardening commit: the existing cases exercised
QQ_ALLOW_ALL_USERS but not the QQ_ALLOWED_USERS read at authz_mixin.py
line 459, so a revert of that line to raw os.getenv would still pass.
Add a scoped-allowlist DM case (scope admits the sender, environ does
not) plus its isolation counterpart (a secondary scope listing a
different user must not inherit the primary's environ allowlist). Both
fail if line 459 reverts to os.getenv.
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.
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.
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.
Fixes#69379 (v2026.7.20 Docker multiplex regression: the scoped runner
reload dropped API_SERVER_* set via the container environment, silently
losing the api_server platform) by the canonical mechanism — corrects
the direction of #69524, which patched gateway/config._getenv to fall
through to os.environ on EVERY scoped miss, re-opening the
cross-profile borrow for all credentials.
API_SERVER_ENABLED / API_SERVER_HOST / API_SERVER_PORT /
API_SERVER_CORS_ORIGINS are deployment listener settings (Docker
compose environment: block, systemd Environment=), not profile
secrets: they join _GLOBAL_ENV_EXACT so get_secret reads them from
os.environ regardless of scope. API_SERVER_KEY is deliberately NOT
allowlisted — it IS a credential and stays profile-scoped, which keeps
tests/gateway/test_config.py's secondary-profile isolation semantics
intact (a secondary profile without the key still doesn't bind a
listener).
Ports #69524's regression test in the corrected form: container-env
API_SERVER_* stays visible during the scoped runner reload while the
key resolves through the profile scope; plus unit tests locking the
allowlist membership and the deliberate exclusion of API_SERVER_KEY.
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.
Every reply paid one extra Discord API round trip: the text send path,
the voice send path, and the edit path each called fetch_message() just
to obtain a reference or an editable handle. Discord resolves
message_reference payloads from ids alone, and PartialMessage.edit()
works without a fetch — so build MessageReference directly (with
fail_if_not_exists=False, preserving the deleted-target behavior the
existing send-side 10008 retry already covered) and use
channel.get_partial_message() for edits. Overflow continuations keep
threading via an ids-built reference fallback for PartialMessage.
Measured by call count (deterministic): reply sends and edits now make
ZERO fetch_message calls where they made 1 per reply and 1 per edit
(including every streaming edit tick).
Tests: pin that first-mode replies construct the reference without any
fetch, deleted-target retry test updated to assert fetch await_count==0
(retry now happens purely send-side), overflow/edit mocks retargeted
from fetch_message to get_partial_message (any fetch regression breaks
all five). Note: 4 discord-suite failures are pre-existing ordering
flakes — identical with the change stashed on clean main.
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.
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.
dbbb10d39 shipped without direct test coverage for its own new logic
— the same gap teknium's review flagged on the competing PR. Close it:
- _reap_gateway_turn_processes: skips when is_still_current() is
False, proceeds when True, fails open (reaps) if the check itself
raises rather than silently disabling the leak fix.
- _abandon_timed_out_gateway_turn: still marks the turn abandoned
(interrupt fires) even when the reap itself is skipped.
- api_server._reap_disconnected_agent_processes: reaps the
baseline-diff for an owned turn, no-ops when the agent never
recorded ownership markers.
- APIServerAdapter._run_agent: markers are populated with the right
task_id/baseline during the turn and cleared once it completes,
closing the same race window fixed in gateway/run.py for this
separate agent-lifecycle surface.
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.
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.
The Matrix adapter read MATRIX_RECOVERY_KEY via os.getenv, so under
gateway.multiplex_profiles every profile resolved the default profile's
key. That produced "recovery key verification failed: Key MAC does not
match" and broke E2EE for secondary profiles (#69090).
Route the read through agent.secret_scope.get_secret, which honors the
active profile's scope, with an os.getenv fallback for an unscoped read
under multiplex (default-profile startup loop) — mirroring the Slack
app-token pattern (#59739). Applied to both the startup verification
site and the status diagnostic.
Fixes#69090
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.
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.
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.
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.
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.
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
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
A successful finalize edit can carry only the last streamed preview
snapshot: deltas generated between the last preview edit and stream
completion never reach any Bot API call, yet final_response_sent /
final_content_delivered were set from the call's success and suppressed
the gateway's normal final send — losing the tail permanently.
The stream consumer now records the exact cleaned payload of every
turn-final delivery (delivered_final_matches tri-state), and gateway/run.py
reconciles that record against the completed final_response before
trusting either suppression flag. On a demonstrable mismatch it edits the
streamed message up to the complete response, falling back to the normal
final send if the edit fails. Multi-message split deliveries and legacy
paths without a record keep the existing flag-trusting behavior, so
overflow splits and the failed-finalize handling (#51828/#33793) are
untouched.
Fixes#71643
Live staging (2026-08-01): relay semantic thread rename still declined
silently despite both #74482 and #75581 deployed — thread kept its
initial-words name, session title generated fine. Root cause is the
no-clobber guard string mismatch (see paired gateway-gateway PR): the
gateway can't reproduce the thread's initial name byte-for-byte, so the
connector's only_if_current_name check always failed.
- relay rename lane now passes prefer_connector_created=True instead of
the fragile initial-name string; the connector resolves the guard from
its own created-name memory. Native-marker lane keeps the legacy
only_if_current_name string (source carries the real initial name).
- rename_thread: prefer_connector_created param -> only_if_connector_created
on the wire, precedence over the legacy string.
- INFO logs at rename dispatch (thread/lane/new_title) and result
(applied=bool): the whole failure hunt needed telemetry the gateway
never emitted — this makes the outcome visible in fly logs.
Tests: connector-guard wire shape + precedence over legacy string; the
title-turn race test updated to assert the connector-owned guard. Relay
suite 149 passed; ruff + footguns clean.
Guard the early-auth pairing gap: unknown DMs must reach the gateway when
pairing is the effective unauthorized-DM behavior, while ignore mode and
unauthorized group senders stay rejected.
Google API and authentication packages permit vulnerable httplib2 and pyasn1
transitives, while the Workspace and Google Chat runtime installers previously
treated any importable version as sufficient. Existing environments could
therefore remain vulnerable after the project dependency pins were repaired.
Carry the fixed versions through the Google and Vertex extras, lazy feature
requirements, lockfile, and both runtime installers. Route the documented
Google Chat installation path through its maintained secure requirements
instead of an unconstrained direct pip command.
Detect stale distributions, install only unsatisfied requirements, and verify
the result before continuing. Behavioral tests cover those repair invariants
without freezing manifests, lockfiles, or complete package sets.
Related #72108
Extracted from #72840
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
Fix-up for the cherry-picked cooldown persistence: the PR's tests mocked
the DB (SimpleNamespace(_db=MagicMock())), which cannot prove the cooldown
survives a restart. Replace with the production shape — a real SessionDB
on disk behind the real AsyncSessionDB facade — and add a restart
regression: fail a hygiene compression on runner #1, tear it down, build a
fresh GatewayRunner on the SAME database, and assert the cooldown is still
honored (no compression agent instantiated). Also updates the timeout test
to assert the DB-backed record_compression_failure_cooldown write instead
of the removed in-memory dict.
Sabotage-verified: reverting gateway/run.py to the in-memory dict makes
the restart test fail.
Follow-ups to the previous commit (#74155 by @Drexuxux):
- enrich_model_switch_warnings_for_gateway() -> merge_preflight_compression_warning()
still called the sync resolve_display_context_length() provider probe ladder
inline in both async /model call sites; dispatch it via asyncio.to_thread.
- Replace the inspect.getsource() test (source-reading tests are banned by
AGENTS.md) with behavioral tests that drive the real _handle_model_command:
assert the resolver runs off the loop thread and that the warning enrichment
is dispatched through asyncio.to_thread.