DIVE-4276: Telegram silence watchdog: a reaction or an edit counts as contact — stop nag... - #61
Merged
Merged
Conversation
… an acknowledged seat (DIVE-4276) The silence watchdog's clock was lastReplyAt, and only the `reply` tool stamped it. So a seat that reacted to an acknowledgement-only inbound — which the house rules require — was read as silent, and the next PostToolUse demanded the redundant message the reaction existed to avoid (measured on main 2026-09-11 04:39-04:40Z, 0.5.50: '275s and 11 tool calls ... Send a fresh reply'). Widening lastReplyAt is wrong in the other direction: edit_message already stamped it, and that field is also read as 'the newest inbound is ANSWERED' (resume-prompt's DIVE-1316 phantom-prompt gate), so an edit of an older status line silently buried a live question. Split the two meanings: lastContactAt reply | edit_message | any react -> silence clock lastReplyAt reply | react on the NEWEST inbound -> 'answered' markInbound now records the newest inbound's (chat, message id) so react can tell those two rows apart, and CLEARS that identity on a button-tap inbound, whose only id belongs to the bot's own keyboard message. Second fix on the row: a reply in the same parallel tool batch still nagged on the sibling's PostToolUse, because the server's stamp lands after the reply tool's own hook. The watchdog now re-reads and re-decides immediately before emitting. The decision is extracted to hooks/lib/silence-decision.ts so it can be unit-tested; the hook keeps only the I/O. Plugin bumped 0.5.50 -> 0.5.51 because installs resolve a version-pinned cache path (DIVE-4123). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main gained #60 (DIVE-4280 auto-attach), which edits the same edit_message block in plugins/telegram/server.ts and made PR #61 CONFLICTING/DIRTY at the graded head. The push broker is fast-forward-only, so the fix ships as a merge whose TREE is taken wholesale from the locally-rebased commit e62312b — 'git diff --quiet e62312b' is empty, so the content graded here is identical to the rebase ops asked for; only the history shape differs. Resolution: keep main's sendAutoAttachments(chat_id, editPlan, {}) on the edit path AND DIVE-4276's markContact() in place of markReplySent() — an edit lands on a message we already sent, so it proves liveness but cannot have answered a newer inbound. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
DIVE-4276 — a reaction or an edit counts as contact
The bug (measured on
main2026-09-11 04:39–04:40Z, telegram 0.5.50): the seat reacted 👍 to an acknowledgement-only inbound — which the house rules require ("never reply to an acknowledgement") — and the very nextPostToolUsefiredYou've gone 275s and 11 tool calls without sending a Telegram message … Send a fresh reply. lodar: "(auto-relay) hook fires even when you send emoji reaction".Cause: the watchdog's clock was
lastReplyAt, and only thereplytool stamped it. A liveness monitor that can only perceive the most expensive signal manufactures expensive signals — here, the filler message lodar has twice asked to stop receiving.Why the one-line widening is wrong.
edit_messagealready stampedlastReplyAt, and that is the opposite bug: the field is also read as "the human's newest message is answered" byhooks/lib/resume-prompt.ts(DIVE-1316's phantom-prompt gate) and by the watchdog's reply-vs-edit verb. An edit lands on a message we already sent, so it cannot have answered an inbound that arrived after it — stamping it there silently buries a live question.The fix — two clocks, because four call sites disagree about which proposition they satisfy:
lastContactAt)lastReplyAt)replyreacton the newest inboundreacton an older messageedit_messagemarkInboundnow records the newest inbound's(chat_id, message_id)soreactcan tell those two rows apart — and clears that identity on a button-tap inbound, whose only available id belongs to the bot's own keyboard message (crediting a stale id is the same error one layer down).Second fix on the row. A reply issued in the same parallel tool batch as another call still nagged on that sibling's
PostToolUse(358s): the server stampssilence.jsonwhen the reply tool returns, i.e. after the reply's own hook, so a sibling that read the file at entry sees pre-reply state. The watchdog now re-reads and re-decides immediately before emitting (only on the branch that was about to fire).Item 2 of the row is a no-op, deliberately.
5dive agent send --reply-to-chatdoes not post to Telegram — it injects areply-to-chat=<id>hint into the a2a header (src/cmd_agent_runtime.sh:2422) and the receiving agent replies through its ownreplytool, which already stamps. There is no second posting path to stamp.How it was checked
test/dive4276-contact-resets-silence.test.ts, 13 arms, two halves likea2a-turn-suppress:decideNag()— reply resets; react-on-latest resets and clearsunanswered; edit resets the clock only,unansweredsurvives; back-compat (a pre-4276silence.jsonwithlastReplyAtand nolastContactAtreads as contact); the parallel-batch race; out-of-conversation.reactconsultsisLatestInbound,edit_messagecontainsmarkContact()and notmarkReplySent(),markReplySentstamps both clocks, the hook callsdecideNagand re-reads before emitting.lastReplyAtalone → the edit arm reds;edit_messagereverted tomarkReplySent()→ the wiring arm reds.0.5.50 → 0.5.51— installs resolve a version-pinned cache path, which is exactly how DIVE-4123 shipped dead code to a pinned 0.5.49.Residual I am signing
The row's on-box VERIFY arm (paired seat → react → five tool calls → no watchdog line) was not run: this seat is not paired, and a paired seat runs the installed version-pinned copy, so the arm is only meaningful once 0.5.51 is installed. Nothing above substitutes for it. Scope held to the baseline
telegramplugin — thetelegram-{codex,grok,agy,opencode,pi}forks carry their own copies of these hooks and are untouched.Wiki:
community/wiki/a-reaction-is-contact-but-only-sometimes-an-answer.md(+ index line).🤖 Generated with Claude Code
Iteration 2 — 2026-09-11: conflict resolved at head
695bbdamain gained #60 (DIVE-4280 auto-attach), which edits the same
edit_messageblock, so this PR readCONFLICTING /
mergeStateStatus=DIRTYat the graded sha5ed3041.Resolution. The one conflict hunk is
plugins/telegram/server.ts: main addedawait sendAutoAttachments(chat_id, editPlan, {})next to themarkReplySent()this PR replaces.Both survive — main's auto-attach call is kept, and the stamp is DIVE-4276's
markContact(), becausean edit lands on a message we already sent and therefore cannot have answered a newer inbound.
Why a merge commit and not the force-push that was asked for.
5dive pushis fast-forward-onlyand exposes no force flag, and a new branch would mean a new PR — dropping this PR number, which the
row's
delivery_refand the close gate's graded-sha binding both point at. So the rebase was runlocally to PRODUCE the resolution, and that exact tree was shipped as a merge. The substitution is
checkable in one command:
Equal trees, empty
git diff e62312b 695bbda— the content graded here is identical to the rebase;only the history shape differs.
Re-run on the MERGED tree (not the pre-merge one):
bun test→ 1185 pass / 1 fail, 3820expects, 46 files. The single failure is
test/dive3809-drain-lock.test.ts, which also fails onorigin/mainin this environment (control run at3fa0743: 2 fail) — a host tmpdir/process issuein the dashboard drain-lock family, untouched by this diff. The suites main's moved code owns pass:
test/autoattach.test.ts+test/dive4276-contact-resets-silence.test.ts→ 43 pass / 0 fail.mergeablereads MERGEABLE at695bbda; bothparityarms were still running at delivery time.