Files
hermes-agent/plugins/platforms
Kyzcreig ee8a3ded96 fix(gateway): move the thread-participation persist off the event loop
`ThreadParticipationTracker._save` (gateway/platforms/helpers.py) ends in
`atomic_json_write` -> `os.replace`, whose duration is unbounded under
filesystem pressure. All five call sites are coroutines on the inbound-message
or slash-command path:

    plugins/platforms/matrix/adapter.py   _resolve_message_context
                                          create_handoff_thread
    plugins/platforms/discord/adapter.py  _handle_message (x2)
                                          _handle_thread_create_slash

So that rename was paid inline on the running loop, stalling every other
adapter's polling, every in-flight turn and every heartbeat for as long as it
took.

The fix is at the CHOKE POINT rather than at five call sites:

  - `mark_async` does the in-memory insert synchronously and offloads only the
    persist via `asyncio.to_thread`. The insert must stay synchronous because
    both adapters gate on `thread_id in self._threads` immediately after
    marking; deferring it would make mention-gating depend on executor
    availability.
  - all five coroutine call sites now await it.
  - `mark` keeps its exact synchronous contract for the non-loop callers.
  - an RLock is added in the SAME commit that introduces the concurrency: the
    event loop used to serialize every caller by accident, and
    `atomic_json_write` makes each write atomic without making
    check/insert/trim/write atomic. Without it two concurrent marks lose one.

Enforcement is an AST class sweep, not an inventory: no `async def` under
gateway/ or plugins/ may call `<x>._threads.mark(...)`, and every
`mark_async(...)` must be awaited -- an un-awaited one never runs at all, so
the thread is neither persisted nor recorded in memory and mention-gating
re-prompts forever in a thread the bot already joined. A new adapter fails the
gate without anyone remembering a list.

tests/gateway/test_discord_thread_slash_expired_defer.py stubbed the tracker
with `SimpleNamespace(mark=...)`; it now uses the real tracker against a
tmp_path, so the test cannot rot silently the next time this surface moves, and
it additionally asserts the thread really was recorded.

Verified on this exact head, PYTHONPATH pinned to the worktree:
  tests/gateway/test_thread_tracker_mark_off_loop.py             7 passed
  expired-defer + admission-exemption + off-loop                10 passed
  -k "discord or matrix or thread" over tests/gateway  1136 passed, 13 failed
The 13 failures are INHERITED: a clean worktree at upstream/main 75e9567ca7
with none of these changes fails the identical 13.

Every guard is gate-proven -- reverting the RLock, the to_thread, one call
site, one `await`, the sync insert, or the dedupe short-circuit each fails its
own test and only its own.

(cherry picked from commit 6fc8037d292ec051fe76ad213903b2ee5534080a)
2026-09-22 15:54:29 +05:30
..
…
…
…
…