Commit Graph

207 Commits

Author SHA1 Message Date
kshitijk4poor 726f0ce1b5 fix: keep original entry object on same-id recovery — preserve live state (review follow-up)
Review found _create_entry_from_recovered_row builds a minimal entry:
replacing the live object would silently drop model_override, token/cost
counters, resume_pending/queued-work markers, and metadata. Keep the
original entry (routing is unchanged, so no sessions.json rewrite either)
and log at INFO — this is a success path, not a corrective action.
Regression test now asserts state preservation, reopen_session call, and
no save.
2026-08-27 20:06:16 +05:30
Jackal991 ff3f25e041 fix(gateway): keep sessions.json entry when startup recovery succeeds with same session id
Closes #95957
2026-08-27 20:06:16 +05:30
Teknium 1a95d0d58e Merge branch 'pr-81234' into salv/81234-retry-carrier 2026-08-24 03:15:07 -07:00
fangliquanflq 80cec2785d fix(gateway): preserve routing state across recovery 2026-08-23 18:25:12 -07:00
fangliquanflq 4b659f0e33 fix(gateway): retry failed session database opens 2026-08-23 18:25:12 -07:00
poisdahl fd41164861 fix(history): keep carrier rewinds race-safe after refresh 2026-08-22 17:30:35 +02:00
poisdahl a5b326a471 Merge remote-tracking branch 'origin/main' into agent/81234-merge-20260821
# Conflicts:
#	tests/agent/test_reference_handoff_active_turn.py
2026-08-22 16:47:39 +02:00
kshitijk4poor c45e2b19c3 fix(state): guard gateway FTS rebuild + comment early flag-set
Add the foreign-holder guard to gateway/session.py::_rebuild_fts_once(),
the third FTS rebuild path that was not covered by the original fix.
Also add a comment explaining why _fts_runtime_rebuild_attempted is set
before the foreign-holder check: the fail-open path that follows
persists FTS_STALE_KEY so the next startup retries via _recover_stale_fts.
2026-08-22 03:56:13 +05:30
poisdahl abf87e7248 Merge current main into composite-carrier fix 2026-08-21 15:56:45 +02:00
Teknium 6f2dbf3a05 fix(gateway): widen scope-aware session DB resolution to the runner and release cached handles
Follow-up to the salvaged #88632 (Jack Lau) fix for #88532, extending the
same repair to the sibling frozen-at-init handle and closing the handle
lifecycle gap the per-path cache introduces.

1. GatewayRunner._session_db had the identical bug class: bound once as
   AsyncSessionDB(SessionDB()) in __init__ on the root home, while /resume,
   /title, /history and session search all execute inside
   _profile_runtime_scope on a multiplexed gateway. Convert it to the same
   property-with-pin pattern: per-access resolution of _default_db_path(),
   one cached AsyncSessionDB per resolved path under a lock, and assignment
   preserved as an explicit pin (many suites install fakes or None).
   Construction-time priming keeps the #88235 init-failure broadcast at
   startup.

2. Handle lifecycle: the per-path caches accumulate one open SessionDB per
   profile served, but the shutdown path closed only store._db /
   runner._session_db - which now resolve just the shutdown task's own
   (root) scope. Secondary profiles' handles would strand their WAL write
   locks until process exit, recreating the abandoned-handle leak
   b454e4da76 fixed and breaking --replace restarts with 'database is
   locked'. Add close_all_db_handles() / close_all_session_db_handles()
   sweeps and call both from the gateway teardown path. SessionDB.close()
   is idempotent, so the root handle being closed by both the legacy loop
   and the sweep is safe.

Tests: sweep coverage plus a runner-property scope/pin/cache test in
tests/gateway/test_multiplex_session_db_profile_scope.py.
2026-08-17 16:34:10 -07:00
Jack Lau 17ba992108 fix(gateway): resolve the session DB inside the active profile scope
Fixes #88532.

A multiplexed gateway serves every profile from one process, but
SessionStore bound a single SessionDB during __init__:

    self._db = SessionDB()

SessionDB(db_path=None) resolves _default_db_path() at call time and
does follow the context-local HERMES_HOME override, so the path
machinery was already correct. The problem was when it ran: at
construction, on the process's own root home, long before any inbound
event enters _profile_runtime_scope. Every profile's rows therefore
landed in the root state.db, even though the scope had redirected
get_hermes_home() correctly for the turn (that helper's own docstring
lists "sessions" among what it scopes).

The rows still carry the right profile_name, stamped from
source.profile by the same handler, so nothing in the data looks wrong.
The only visible symptom is the desktop listing a profile's session
under the default bot: _open_session_db_for_profile opens
profiles/<name>/state.db, which never received the write.

Look the handle up through a property instead, resolving the active
scope per access and caching one handle per resolved path so a hot
inbound path opens SQLite once per profile rather than once per
message. Construction stays under the cache lock so a concurrent first
message on a profile cannot open and then leak a second handle.

Assignment is preserved as an explicit pin, which is what the existing
suites rely on when they install a fake handle or disable the DB with
store._db = None, and a pin keeps winning across scope changes.

Behavior is unchanged when no profile scope is active, so single-profile
gateways resolve exactly the path they did before. This does not migrate
rows that already landed in the root store; those stay where they are.
2026-08-17 16:34:10 -07:00
poisdahl 67ee481621 fix(gateway): preserve pending transcript state on failed rewinds 2026-08-15 22:43:00 +02:00
poisdahl 3f075d41dd Merge remote-tracking branch 'origin/main' into codex/81234-live-main-final
# Conflicts:
#	tui_gateway/methods_prompt.py
#	tui_gateway/server.py
2026-08-15 12:05:14 +02:00
Teknium 8dc5608a78 fix(compression): adopt live continuation tip at flush across multi-hop chains
A turn writing against a session already closed by compression died with
session_persistence_failed and a misleading "this is often a full disk"
dialog, even though the store was healthy and a live continuation existed
(#82001). Depth-1 recovery (find_live_compression_child) could not resolve
lineages with >=2 compression hops (root -> mid -> tip), reproduced
independently on two- and three-hop chains.

- run_agent.py flush chokepoint: on CompressionSessionClosedError, resolve
  tip = db.get_compression_tip(old_id) (canonical bounded transitive walk),
  adopt only when tip != old_id AND the tip row is live, retry the flush
  exactly once (adoption budget); otherwise fail closed.
- gateway/session.py append_to_transcript: replace the depth-1 live-child
  lookup with the same tip + liveness contract, so gateway transcript
  reroutes follow full chains.
- agent/conversation_compression.py _adopt_live_compression_child: turn-start
  recovery preflight now resolves via get_compression_tip with the same
  liveness check, closing the last depth-1 consumer in this family.
- classify_persistence_error: new "compression_closed" bucket; the turn-end
  explanation names compression rotation and tells the client to refresh the
  session id instead of blaming a full disk.

Tests: depth-1 adoption, multi-hop chain adoption (agent + gateway), fail
closed with no continuation / stale-closed (ws_orphan_reap) tip, exactly-once
adoption budget, and error-wording guards (compression-closed never mentions
disk; real disk failures keep disk guidance).

Closes #82001

Co-authored-by: Al3xand3r1987 <125030427+Al3xand3r1987@users.noreply.github.com>
Co-authored-by: yuzilongleif-collab <235949691+yuzilongleif-collab@users.noreply.github.com>
2026-08-14 21:39:44 -07:00
Teknium 3885c1096a fix(gateway): stop internal bookkeeping writes from advancing the session activity clock
set_session_metadata() and advance_compression_session()'s repoint both
stamped entry.updated_at = now. updated_at is the user-activity clock that
drives idle/daily reset policy and the restart-resume freshness gate
(suspend_recently_active, #85709), so a background metadata write (e.g.
Slack thread watermark) or a background compression repoint on a long-idle
session could make it look freshly active and get it falsely resume_pending
after a gateway restart.

These are the last internal stamp sites after 784f733cf (recover) and
5462f689b (touch_activity gating): drop the stamps, keep the durable save.

Follow-up to #85895 (closed) — credit @GodsBoy for the report-side push and
@chelsealong for the analysis on #85709.
2026-08-14 13:08:39 -07:00
Teknium 8dc9401d7e fix(gateway): persist internal synthetic turns typed as internal_notification (#82888)
Async-delegation batch completions and background watch notifications
re-enter the gateway as synthetic MessageEvent(internal=True) turns via
_inject_watch_notification, but were persisted as bare role='user' rows —
indistinguishable from real user input in transcripts and the desktop UI.

Thread the event's internal flag through to persistence: when
event.internal is set, the turn's persisted user row is stamped
display_kind='internal_notification' (the existing DB-only presentation
sidecar used by auto_continue / model_switch rows). Wired through
_run_agent → _run_agent_inner → TurnContext → run_conversation's
persist_user_display_kind, and onto the three gateway-side fallback user
rows (transient failure, no-new-messages, pre-run crash), whose
append_to_transcript writer now forwards display_kind/display_metadata
to SessionDB.append_message.

Invariants preserved: role stays 'user' (alternation untouched), no new
injections, no past-context mutation, and display_kind is already popped
from every provider-bound copy in conversation_loop, so replayed sessions
never leak the marker to the API.

Regression tests: internal turn marked, real user turn unmarked, fallback
rows marked/unmarked per event, and a DB round-trip proving replay keeps
role/content intact while the provider copy drops the marker.
2026-08-14 01:08:18 -07:00
poisdahl 3e5e4c5d20 fix(agent): preserve live turns in compaction carriers 2026-08-13 21:38:30 +02:00
embwl0x 5462f689ba fix(gateway): keep internal wakes from extending sessions 2026-08-13 12:04:35 -07:00
embwl0x ce89afa59c fix(sessions): keep reset conversations listable 2026-08-13 23:45:21 +05:30
GodsBoy f46c600a54 feat(gateway): allow plugins to inject session messages 2026-08-12 18:25:33 -07:00
Trevin Chow c8f235a106 feat(gateway): allow selective multiplex profile serving 2026-08-10 22:48:24 -07:00
Hill Chitsanupong 784f733cf8 fix(gateway): honor session_reset policy when recovering sessions
Both session recovery paths (the startup stale-entry repoint and the
lazy in-message recovery) rebuilt the routing entry with updated_at=now
and never consulted _should_reset, so an opt-in idle/daily session_reset
policy was silently dead across any gateway restart: a recovered session
always looked freshly active, and since every subsequent message bumps
updated_at, a session recovered stale could then never age out at all.

Fix in three parts:

- _create_entry_from_recovered_row derives updated_at from the durable
  last_activity_at the finder already returns on the row (no extra DB
  round-trip; the original PR added SessionDB.get_last_activity for
  this, unnecessary post-#82633), falling back to created_at. An
  invalid or missing started_at now maps to epoch 0 instead of now — an
  invalid durable timestamp must look old, never freshly active.
  reset_had_activity is set from the row's durable activity/message
  signals so the continuity hint stays accurate.

- _recover_session_from_db evaluates _should_reset on the rebuilt entry:
  an overdue session is durably promoted to a reset boundary
  (promote_to_session_reset, falling back to end_session) and the stale
  mapping is dropped instead of repointed.

- _query_recoverable_session no longer reopens the row; the
  get_or_create_session recovery phase evaluates _should_reset first and
  either feeds the normal auto-reset create path (reset notice,
  prev_session_id continuity, durable promotion) or reopens and
  publishes the recovered entry exactly as before.

Behavior is unchanged under the default session_reset mode "none":
_should_reset returns None there, so recovery still resumes every
recoverable row — only users who opted into idle/daily resets see the
policy actually applied across restarts.

Cherry-picked from #78618 and adapted to the #82633 finder.
(cherry picked from commit 31c71f762961638c199287fc6ffe836115c4892b)
2026-08-09 15:02:27 -07:00
Teknium de0f20ff05 fix(gateway): spool cap-dropped pending transcript messages instead of discarding
When the per-session pending transcript queue hits _MAX_PENDING_PER_SESSION
(200) while the session DB is broken, the gateway previously popped the
oldest message and discarded it permanently — silent user data loss during
live operation (#78182). The on-disk pending spool only ran at shutdown via
flush_pending_to_file.

Extend that existing spool machinery for runtime drops:

- gateway/shutdown_flush.py: add spool_dropped_transcript_message() and
  drain_transcript_spool(), reusing _get_flush_dir/_write_payload (same
  atomic-JSON pending_messages/ spool format). recover_pending_to_db()
  now also replays transcript_cap_drop payloads left over across restarts.
- gateway/session.py: on cap eviction, spool the dropped message and log a
  WARNING that includes the spool path; if spooling fails, degrade to the
  previous drop-and-warn behavior. On the next fully successful transcript
  flush for that session, drain and replay spooled messages in drop order;
  replay failures keep the spool files for the next attempt.
- tests/gateway/test_pending_queue_spool.py: drop→spool→drain roundtrip,
  per-session drain isolation, spool-failure degradation, replay-failure
  retention, and spool primitive ordering/reason filtering.

No new config; extends existing flush_pending_to_file infrastructure per
AGENTS.md guidance.

Refs #82616, #78182
2026-08-09 14:08:51 -07:00
Teknium d2a4d373eb fix(gateway): make session identity durable so chat continuity survives crashes and restarts
Root cause of #82616: gateway session identity (session_key/chat_id/
origin_json) was written best-effort in a separate UPDATE after row
creation, both reset-path DB writes swallowed failures silently
(logger.debug / bare print), transcript reads ignored the reroute map
that writes follow, and restart recovery ranked candidate rows by
started_at while hard-rejecting empty rows. A single failed write could
therefore strand the live conversation in an unroutable orphan row while
a days-old zombie kept the routing key — after any gateway restart the
chat silently resumed the zombie (user-visible context loss, 5 confirmed
incidents on one install since June).

Four class fixes:

1. Identity lands atomically in the session INSERT: origin_json and
   display_name join _insert_session_row's column list + COALESCE
   backfill; both gateway creation paths (get_or_create + reset) pass
   full identity including parent_session_id lineage (fixes #12857).

2. record_gateway_session_peer self-heals: when the target row is
   missing (failed/deferred create, crash window) it INSERTs the row
   with full identity instead of silently no-opping — every per-turn
   peer refresh is now a repair opportunity, and an identity-less lazy
   writer (update_token_counts/record_auxiliary_usage) can never leave
   a gateway session permanently unroutable.

3. load_transcript follows the write-side reroute chain and the durable
   compression tip before querying, so reads can no longer return 0
   rows for a session whose messages live under its compression child;
   read exceptions are WARNING, distinguishable from an empty result.

4. find_latest_gateway_session_for_peer ranks by
   COALESCE(last_activity_at, started_at) (message-bearing rows first)
   and returns an empty-but-keyed row instead of None — a zombie
   predecessor can no longer beat the live conversation, and recovery
   never mints a fresh id when a keyed row exists.

Reset-path DB write failures now log at WARNING with the routing
consequence spelled out.

Tests: tests/gateway/test_session_continuity_82616.py (11 tests) —
sabotage-verified: 6/11 fail without the fixes. E2E incident replay
(real SessionDB, temp HERMES_HOME) confirms the production shape now
resolves to the live session.

Fixes #82616. Related: #12857, #78182 (read-path half), #79576.
2026-08-09 12:37:06 -07:00
kshitij b5c2116783 fix: defer O(n) fallback_data construction to failure path in _save_entry
The entry_data/lock_held path (used by mark_turn_active/clear_turn_active)
was eagerly constructing a full O(n) snapshot of all routing entries under
the lock on every turn, even when the DB upsert would succeed.  Defer the
fallback_data construction to the except branch where it's actually needed,
and respect lock_held to avoid re-acquiring a non-reentrant _lock.
2026-08-08 01:12:16 +05:30
Dominic Bejar 5ff328cc76 fix(gateway): make active turn markers failure-atomic 2026-08-08 01:12:16 +05:30
Dominic Bejar 59a128c6fb fix(gateway): harden active turn marker lifecycle 2026-08-08 01:12:16 +05:30
Dominic Bejar 6774760b6f fix(gateway): recover exact turns after unclean exits 2026-08-08 01:12:16 +05:30
kshitij 2d9b809ff0 fix(yuanbao): preserve archived history on recall redaction
Sibling-site fix for #80216: yuanbao recall redaction also calls
rewrite_transcript() and was subject to the same archived-history
data loss when active_only defaulted to False. Pass active_only=True
at both yuanbao call sites — load_transcript only returns active
rows, so the redacted content is in the active set and the archived
pre-compaction history should survive the rewrite.

Also drops the stale 'callers that mean to purge (e.g. yuanbao
recall redaction) keep the default' note from the rewrite_transcript
docstring — no caller intentionally purges archived rows.
2026-08-07 13:56:18 +05:30
poisdahl 30c1421acf fix(gateway): make retry archive preservation fail-safe 2026-08-07 13:56:18 +05:30
Adolanium 56fbac6b38 fix(gateway): preserve archived compaction history on /retry
/retry truncates the live transcript to before the last user message
and persists it via SessionStore.rewrite_transcript, which calls
replace_messages() with the default active_only=False. That DELETEs
every row for the session, including the soft-archived
active=0/compacted=1 rows that in-place compaction keeps on disk
(#38763), so any /retry after a compaction permanently wiped the
archived history. #57803 named this call site as a residual gap after
its global-default approach was rejected; the TUI sibling was fixed
in #80195.

The handler now probes has_archived_messages() (new SessionStore
wrapper, auto-exposed through AsyncSessionStore) and passes
active_only=True when archives exist, so only the live rows are
replaced. rewrite_transcript gains an active_only parameter that
defaults to False, keeping the destructive semantics yuanbao recall
redaction depends on. Also corrects the rewrite_transcript docstring,
which still listed /undo as a caller even though /undo soft-archives
via rewind_session.

The regression test drives _handle_retry_command against a real
SessionStore and SessionDB seeded with archived compaction rows and
asserts the archives survive.
2026-08-07 13:56:18 +05:30
Teknium 19fc9c103e fix(tests): fail hard when pytest resolves the production state.db (live-DB isolation guard)
Forensics on a live developer machine found pytest fixture rows inside the
REAL ~/.hermes/state.db — sessions with chat_id 'chat-1', '123', 'wx-chat',
and gateway_routing rows whose scope was literally under /tmp/pytest-of-*/.
A pytest-spawned process also opened the live DB and flipped its journal
mode (journal_mode=DELETE fallback on SQLite 3.50.4) under the WAL-mode
gateway writer, destroying committed transcripts ("Persisted transcript
lagged live cached history ... possible FTS write corruption", 15+
occurrences). The existing live-system guard covers kill primitives but not
the SessionDB/SessionStore write paths.

Root cause (leak vector): the session-level HERMES_HOME sandbox in
tests/conftest.py only created a tempdir when HERMES_HOME was UNSET. On a
machine where the shell (e.g. gateway-launched, or an exported
HERMES_HOME=~/.hermes) hands pytest the production home, the sandbox was
skipped entirely — every argless SessionDB()/SessionStore() and every
collection-time DEFAULT_DB_PATH froze onto the real state.db.

Fixes (fail the class, one owner):

* hermes_state._ensure_test_isolation(): single choke point wired into
  SessionDB.__init__ (every construction, incl. read_only). Under pytest
  (PYTEST_CURRENT_TEST / PYTEST_VERSION — inherited by subprocess
  children), a db path resolving to <real-root>/state.db or
  <real-root>/profiles/<name>/state.db raises RuntimeError('live-system
  guard: ...') before any connection, mkdir, or journal-mode pragma.
* tests/conftest.py: session sandbox now also tempdir-redirects a pre-set
  HERMES_HOME that points at the production root (the actual escape
  vector); kanban deny-list capture updated to match. New autouse
  _state_db_write_guard fixture honors the existing
  @pytest.mark.live_system_guard_bypass marker as the escape hatch and
  feeds custom (non-~/.hermes) production roots into the guard deny-list.
* gateway/session.py: SessionStore.__init__ no longer swallows the guard's
  RuntimeError into the JSONL fallback — guard trips are loud.
* tests/hermes_state/test_live_db_isolation_guard.py: behavioral
  regression tests — production paths (direct, profile, read-only,
  unnormalized, default-resolution) raise; tmp HERMES_HOME works; bypass
  marker works; SessionStore re-raises guard errors but still degrades on
  ordinary failures; subprocess child without HERMES_HOME is refused while
  a hermetic child succeeds.

No new HERMES_* env vars; no hardcoded ~/.hermes (platform root comes from
hermes_constants._get_platform_default_hermes_home()).
2026-08-06 07:49:50 -07:00
skywind be3be06182 perf: replace COUNT(*) with LIMIT-based existence checks
Two places were using SELECT COUNT(*) when they only needed a boolean:
- has_any_sessions() called session_count() > 1 (full table scan)
- delete_session() used SELECT COUNT(*) WHERE id=? (full matching scan)

Fix:
- Add session_count_ge(n) to SessionDB — short-circuits via
  SELECT 1 FROM sessions LIMIT n, returns bool
- has_any_sessions() uses session_count_ge(2) instead of session_count() > 1
- delete_session() uses SELECT 1 ... LIMIT 1 with fetchone() is None
- Add tests for session_count_ge
2026-08-03 17:31:29 +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
tachyon-r 76cf19fee1 fix(tools): isolate model tools by multiplex profile 2026-08-02 00:11:50 -07:00
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
GodsBoy fa9e967a2d fix(gateway): scope session lists before limiting 2026-07-31 22:36:55 -07:00
teknium1 5b751dc0ad chore: remove unused imports and dead locals (ruff F401/F841 sweep)
Cleans F401 unused imports and F841 dead local assignments across
root *.py, agent/, hermes_cli/, tools/, gateway/, cron/, tui_gateway/
(tests/, plugins/, skills/ excluded).

Intentionally KEPT (false positives / test-patch surfaces):
- agent/transports/__init__.py package re-exports
- cli.py browser_connect re-exports (DEFAULT_BROWSER_CDP_URL area,
  used by tests/cli/test_cli_browser_connect.py)
- hermes_cli/main.py _prompt_auth_credentials_choice /
  _model_flow_bedrock_api_key (accessed via main_mod attr in tests)
- gateway/run.py aliased replay_cleanup + whatsapp_identity re-exports
  and _PORT_BINDING_PLATFORM_VALUES (test-referenced)
- hermes_cli/web_server.py get_running_pid (tests monkeypatch it) and
  _OAUTH_TOKEN_URL availability probe
- hermes_cli/config.py get_process_hermes_home re-export (noqa'd F811
  chain) and yaml availability-probe import
- hermes_cli/nous_subscription.py managed_nous_tools_enabled
  (tests patch hermes_cli.nous_subscription.managed_nous_tools_enabled)
- try/except ImportError availability probes (env_loader, tts_tool,
  mcp_tool, web_server anthropic OAuth block)
- tools/web_tools.py noqa F401 re-exports
- hermes_cli/setup_whatsapp_cloud.py:263 'proceed' skipped: possible
  missing-guard bug, flagged for separate review
- unused function parameters (signature changes out of scope)

Side-effect RHS calls preserved where only the binding was dead
(e.g. web_server proc = _spawn_hermes_action -> bare call).
2026-07-29 11:53:39 -07:00
Anthony Ruiz 0ee8d41878 fix(compression): recover rotated session lineage 2026-07-24 16:00:34 -07:00
Teknium c1c991ae9e fix(gateway): unify legacy-Slack-key recovery on the claim-once path
Conflict-resolution follow-up composing #68925 (Bob) with the already-
applied #20583/#66398 (jordanhubbard) recovery design:

- #68925's caller-level second _query_recoverable_session pass (via
  lookup_session_key=) referenced a variable that no longer exists —
  the legacy exact-key fallback now lives INSIDE
  _query_recoverable_session, which also claims the legacy key once per
  process and rewrites the peer row to the scoped key. Drop the dead
  caller-level pass.
- Keep #68925's _recovered_row_matches_source_scope origin guard wired
  into both recovery paths: a scoped channel lookup refuses rows whose
  recorded origin names another workspace (or no workspace at all).
- Routing-index migration adoption policy documented at the site:
  origin names a workspace -> exact match only; scope-less DM -> first
  workspace claims once; scope-less channel -> refuse.
2026-07-23 12:00:34 -07:00
Bob 06be0e69b6 fix(gateway): namespace Slack sessions by workspace 2026-07-23 12:00:34 -07:00
Jordan Hubbard a60b00e12d fix(slack): isolate workspace-local routing 2026-07-23 12:00:34 -07:00
metamon 91799405aa fix(gateway): hint Slack/Discord channels at the prior auto-reset session
Salvaged from PR #36220, ported onto the current SessionStore (SQLite-
backed get_or_create_session; activity check is last_prompt_tokens) and
the sidecar-note reset path (context notes now ride turn_sidecar_notes
instead of prepending to context_prompt).

Long-lived Slack/Discord channels/threads lose their context on
daily/idle session resets, and the agent can bind a new request to an
unrelated recent session (observed: a Discord thread reset caused a PR
in the wrong repository). Record prev_session_id when an auto-reset
replaces a session with real activity, persist it, and append a
deterministic one-line hint to the auto-reset context note pointing the
agent at session_search for that specific prior session. No LLM calls,
no channel-history APIs, no extra DB lookups; other platforms and
activity-free resets are untouched.

Refs #36220. Co-authored-by: metamon <269728612+metamon-p@users.noreply.github.com>
2026-07-23 11:49:44 -07:00
Teknium e027f43f70 fix(gateway): key Slack capability gate into the prompt pin; defer positive note to tool schemas
Follow-up to the #68627 cherry-pick (cluster C15 — Slack platform
capability-note accuracy; earliest report/fix: #6545 by @daikeren):

1. Session/prompt stability: the pinned session-context render
   (_pinned_session_context_prompt) is keyed by _ephemeral_change_key,
   whose contract requires every rendered input to appear in the key.
   The new _slack_tools_loaded() gate reads config + the live MCP
   registration map, so its state is now hashed into the key exactly
   like the existing Discord gate — a gate flip re-renders ONCE (a
   legitimate bust); within a session the note stays byte-stable for
   the life of the conversation (A/B: the new parity test fails with
   this key change reverted, passes with it).

2. Derived, non-overpromising positive note: rather than hardcoding a
   capability list that can drift stale again (the original bug class),
   the tools-present note tells the agent to consult the actual loaded
   Slack tool schemas for supported operations — the schemas ARE the
   source of truth, so the note cannot overclaim ops a given Slack
   toolset/MCP server doesn't expose (e.g. a read-only history server).

3. Tests: parity test proving a gate flip changes both render and key;
   byte-stability test proving three consecutive turns in one Slack
   session return the identical pinned object (sha256-equal); autouse
   fixture pins the new gate so key<->render parity is env-independent.
2026-07-23 11:32:24 -07:00
ygd58 96f21e8a54 fix(gateway): make Slack platform note capability-aware when slack tools present
Ports #63234 forward onto current main per teknium1's review.

gateway/session.py hard-coded the stale-API disclaimer for every Slack
session regardless of whether Slack tools were actually loaded. This
contradicted the system prompt when MCP or native slack tools were
present, causing the agent to refuse Slack API actions it could
actually perform (issue #6536).

Per review, the original predicate only checked the native 'slack'
toolset, missing Slack MCP servers (registered under mcp-<server> in
tools/mcp_tool.py) entirely. _slack_tools_loaded() now checks two
independent paths:

1. Native 'slack' toolset + SLACK_BOT_TOKEN (as before, but now calls
   _get_platform_tools() with include_default_mcp_servers=True instead
   of False, so a default-enabled MCP server also counts).
2. A connected MCP server that has ACTUALLY registered tools into the
   live registry (new tools.mcp_tool.get_registered_mcp_server_names()),
   whose name suggests Slack. This is session-scoped in the sense that
   matters here: MCP servers connect once per gateway process (not
   per-session), so checking the live per-server tool-registration map
   is the correct availability-filtered signal -- unlike the earlier
   get_all_tool_names() approach this replaces, which conflated ALL
   built-in tool names process-wide, this only inspects the small,
   purpose-built MCP server-name map.

Added a real regression test that registers a tool via the actual
tools.mcp_tool._track_mcp_tool_server() tracking function (not a mock
of the capability check) to verify a genuine Slack MCP server is
detected, plus a negative case for an unrelated MCP server.

5/5 Slack-specific tests pass; 126/126 in the full
tests/gateway/test_session.py file.
2026-07-23 11:32:24 -07:00
LeonSGP43 503c0c0e51 fix(slack): expose shared-thread author mention target
In shared Slack threads the model saw only [sender name] prefixes, with
no verifiable current-author Slack user ID — so 'mention me again'
requests could bind to a stale or unrelated <@U...> pulled from names,
memory, or prior history (#17916).

Two cooperating changes:
- The shared-session sender prefix on Slack now carries the current
  author's ID from the event envelope:
  '[Alice | Slack user <@U123>] ...' — per-turn data, so it does not
  touch the cached system prompt.
- The Slack platform notes gain a shared-thread instruction to use the
  current turn's sender prefix as the only verified mention target and
  never guess or reuse historical mentions.

Fixes #17916.

Salvaged from #18711 by @LeonSGP43 (asdigitos), rebased over the
sender-name neutralization added on main (the ID is appended after
neutralizing the display name; the ID itself comes from the Slack
event, not user-editable text).
2026-07-22 07:22:55 -07:00
heathley ad4034711d fix(slack): refresh active thread context on explicit mention
Once a thread has an active session, a later reply that explicitly
@mentions the bot did not re-fetch Slack thread context, so the agent
missed messages added to the thread after the initial hydrate (e.g.
other bots/integrations replying in multi-agent workflows). The
explicit mention is a fresh intent signal and now triggers a refresh.

Mechanics:
- SessionEntry gains a small persisted metadata dict, with
  SessionStore.get/set_session_metadata accessors (survives gateway
  restarts via the routing index).
- The adapter stores a per-thread consumption watermark
  (slack_thread_watermark:<channel>:<thread>) recording the last
  thread ts the session consumed.
- On explicit mention in an active thread, _fetch_thread_context runs
  with force_refresh=True (bypassing the TTL cache) and after_ts=<the
  watermark>, so only NOT-yet-seen messages are injected — as part of
  the new turn via channel_context. Prior conversation history is
  never rewritten, preserving prompt caching.
- _fetch_thread_context caches raw conversations.replies payloads so
  watermark-scoped re-formatting needs no extra API call; formatting
  is split into _format_thread_context.
- Thread session keys are built once in _build_thread_session_key
  (shared by the wake gate and the watermark accessors), still via
  build_session_key().

Fixes #23918. Supersedes #62299 (keyword-triggered refresh limited to
'investigate' prompts — the mention signal is the general fix).

Salvaged from #23927 by @heathley, rebased onto the plugin adapter
layout and rerouted through channel_context instead of text-prepend.
2026-07-22 07:22:55 -07:00
Z 93ff129b51 fix(gateway): follow async completions across compression 2026-07-22 06:56:08 -07:00