feat(gateway): trace every inbound message end-to-end in gateway.log - #29
Merged
Merged
Conversation
Duplicate user turns (one Slack message answered twice) could not be diagnosed from the logs: the busy path was debug-only, steer() logged nothing when it accepted text, the adapter's dedup dropped redeliveries silently, the relay ingress had no logging at all, and messages. platform_message_id is never persisted (the gateway write is skipped because the agent already flushed the rows). Adds INFO lines keyed by the platform message_id at every point an inbound message can change state: - slack adapter: delivering event to gateway (with age = how long ago the user sent it and ingress = how long we held the Socket Mode envelope, the mechanism behind Slack's redelivery), dropped duplicate event, dropped message_changed for an already-delivered ts - relay adapter: delivering event to gateway (had no logging at all) - gateway: inbound message now carries message_id, plus a starting turn line that ties the id to the session key, busy follow-up (mode / steered / redirected), queued follow-up on both queue funnels, and draining queued follow-up as a new turn - base adapter: the three pending-slot parking sites are INFO with the id - run_agent.steer(): steer received with turn_active / turn_age, so a steer accepted after the turn already ended (accepted, never injected, and not queued either) is distinguishable from a real injection - turn_finalizer: turn ended with an undelivered /steer Behavior is unchanged; _active_turn_started_at is a diagnostics-only marker that nothing branches on. Documented in the messaging guide. Co-authored-by: Junie <junie@jetbrains.com>
19 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Makes an inbound message traceable end-to-end in
gateway.log, keyed by the platformmessage_id, so that "the bot answered the same message twice" (and its mirror image, "the bot never answered a message that clearly arrived") can be diagnosed from a normal INFO-level log instead of a guess.Background: in a production Slack session we found user turns duplicated — one message from the user, two identical
role=userrows instate.db, two answers from the agent (~10% of user turns in the 20260811 dump; reproduced again on 12.08 and 13.08). The mechanism of the second copy was reconstructible from the code: the redelivered copy arrives while the agent is busy, takes a pending slot (or is accepted bysteer()), and the post-turn drain replays it as a brand-new user turn. But the question that actually decides the fix — did the platform deliver the message twice, or did Hermes turn one delivery into two turns? — could not be answered from the logs at all:inbound message:(gateway/run.py) is logged only for the message that starts a turn. A copy that arrives while the agent is busy produces no INFO line whatsoever: the busy branches arelogger.debug, andbusy_ack_enabled: falsesuppresses even the user-visible Slack bubble.run_agent.steer()logs nothing when it accepts text — so "spliced into the live turn" and "accepted microseconds after the turn ended, never injected, and not queued either (the gateway reads the returnedTrueas delivered)" look identical. The second case silently loses the message; we hit it twice.team_id:event_ts, TTL 3600s) and itsmessage_changedguard bothreturnsilently, so a suppressed redelivery is invisible — "Slack sent it twice and we caught one" cannot be told apart from "Slack sent it once".[Slack] event received …exists but sits behindlogger.isEnabledFor(DEBUG), and turning DEBUG on for a live gateway is not an option (volume + payload contents).gateway/relay/adapter.py) had no logging at all.state.dbdoes not work either:messages.platform_message_idis empty on every row (the gateway-side write is skipped because the agent already flushed the rows), so a stored user turn cannot be attributed to a platform message.This PR is logging only — no behavior change. The duplicate-turn guard itself is deliberately not included: the primary cause of the double ingress is still unknown, and a guard keyed on
message_iddrops inbound messages, which is a worse failure than a duplicate if the key turns out to be wrong (Slack reusestsfor edits). It is parked onnikita.barkov/gateway-duplicate-turn-guardand will be proposed separately once these logs name the cause. What these lines already produced on a live run: a first delivery withage=8.7s(ingress stall vs±0.5son the other 22 messages that day) followed by two Socket Mode redeliveries of the samets— i.e. the ack-timeout mechanism caught in the act, which is exactly what was missing before.Related Issue
None in this fork. Upstream tracks the same class as
type/bug: NousResearch#84417 (P1, "post-compression session replays an earlier user prompt as a fresh turn") with the open PR NousResearch#84589, which covers the desktop/TUI queue only — the messaging gateway path (gateway/run.py+gateway/platforms/base.py._pending_messages) is not covered there.Type of Change
Changes Made
plugins/platforms/slack/adapter.py: new_slack_event_age()helper (Slackts→ seconds since the user pressed Enter) and one INFO line per event that actually reaches the gateway —[Slack] delivering event to gateway: ts=… type=… subtype=… channel=… thread_ts=… user=… age=… ingress=….ingressis a stopwatch over the adapter leg (thread-parent fetch,users.info, thread-context hydration, attachment downloads): Socket Mode acks an envelope only after that coroutine returns, soingressabove the ack window is the direct cause of a platform redelivery. The two previously silent drops are INFO now:dropped duplicate event(TTL cache) anddropped message_changed for already-delivered ts(edit/unfurl guard).gateway/relay/adapter.py:[relay] delivering event to gateway: message_id=…— the same anchor for relay-fronted platforms, wrapped in atry/exceptso logging can never break ingress. There was no logging on this path at all.gateway/run.py:inbound message:now carriesmessage_id; newstarting turn: session=… message_id=…right after the session is resolved (theinbound messageline is emitted before session resolution, so without this line the id and the session-keyed busy/queue/drain lines cannot be joined); newbusy follow-up: session=… message_id=… mode=… steered=… redirected=… text_len=…;queued follow-upon both queue funnels (FIFO and merge-into-slot) and on the queue-cap drop;Draining queued follow-up … as a new turn: message_id=…; the leftover-steer delivery raised from DEBUG to INFO with the session key and length.gateway/platforms/base.py: the three pending-slot parking sites (photo burst, text debounce, generic queue) raised to INFO withmessage_id.run_agent.py(AIAgent.steer()):steer received: turn_active=… turn_age=… merging=… len=…. This is the line that separates a real injection from a steer accepted after the turn already ended — the case where the message is lost, because the gateway skips queueing whensteer()returnsTrue.agent/turn_finalizer.py:Turn ended with an undelivered /steer …— marks the exact moment a user turn is about to be manufactured from steer text (or, when the caller cannot deliver it, where that text is dropped).agent/conversation_loop.py+agent/turn_finalizer.py:agent._active_turn_started_at— a wall-clock marker set at turn start and cleared at turn end, read only by the log line above. Nothing branches on it.website/docs/user-guide/messaging/index.md: new "Tracing an Inbound Message" section — a table of the lines and how to read them (samemessage_idin astarting turnline and in abusy follow-up/Draining queued follow-upline = one message answered twice; onedelivering event to gatewayline + two turns = the duplicate was created inside Hermes; twodeliveringlines = the platform sent it twice, andtype/subtype/ageshow why the dedup key did not match).Not included on purpose: persisting
messages.platform_message_id(a schema/write-path change, not observability), and any dedup/guard logic.How to Test
scripts/run_tests.sh tests/run_agent tests/agent— 5121 passed.scripts/run_tests.sh tests/plugins— 1306 passed.scripts/run_tests.sh tests/run_agent/test_steer.py tests/agent/test_turn_finalizer_cleanup_guard.py— 31 passed (the steer and finalizer paths that gained the new lines).scripts/run_tests.sh tests/gateway— 6 pre-existing failures on macOS (wecom,readiness,api_server,systemd_notify,shutdown_forensics). Verified as baseline:git stash && scripts/run_tests.sh tests/gateway/test_wecom_callback.py tests/gateway/test_readiness.pyfails identically on a clean tree.[Slack] dropped duplicate event ts=1786605877.882029 type=app_mentionimmediately before[Slack] delivering event to gateway: ts=1786605877.882029 type=message— themessage+app_mentionpair Slack emits for one mention, with the adapter dedup doing its job;age=8.7son a first delivery (vs±0.5son every other message that day) followed bydropped duplicate eventfor the sametsat +9s and +67s — a Socket Mode ack timeout and the two redeliveries it caused;busy follow-up: … mode=steer steered=Trueon a message that never produced a user turn, together withsteer received: turn_active=False— the message-loss case, now visible instead of silent.grep -n "_active_turn_started_at" -r . --include=*.pyshows exactly three sites — set, clear, and the log line.Checklist
Code
nikita.barkov/gateway-duplicate-turn-guardtests/gatewayfailures are a pre-existing macOS baseline, reproduced on a clean treesteer(),finalize_turn) keep their existing coverage green, and asserting on log strings would be a change-detector testDocumentation & Housekeeping
website/docs/user-guide/messaging/index.md, new "Tracing an Inbound Message" sectioncli-config.yaml.example— N/A, no config keys added or changedCONTRIBUTING.md/AGENTS.md— N/A, no architecture or workflow changetime.time()/time.monotonic()onlyNotes
Log volume: these are per-inbound-message lines (one to three per message), not per-token or per-iteration, so the added volume is bounded by chat traffic. No message text is logged beyond what
inbound message:already truncates; the steer/leftover lines cap at 40–60 characters.Next step once the cause is named: either fix the ingress stall so Slack stops redelivering at all (ack the envelope before the network calls in
_handle_slack_message), or land the parked idempotency guard — and makesteer()returnFalsewhen no turn is live, which is the message-loss bug these logs surfaced.