Skip to content

DIVE-4276: Telegram silence watchdog: a reaction or an edit counts as contact — stop nag... - #61

Merged
5dive-bot merged 2 commits into
mainfrom
dive-4276-contact-stamp
Sep 11, 2026
Merged

DIVE-4276: Telegram silence watchdog: a reaction or an edit counts as contact — stop nag...#61
5dive-bot merged 2 commits into
mainfrom
dive-4276-contact-stamp

Conversation

@5dive-bot

@5dive-bot 5dive-bot commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

DIVE-4276 — a reaction or an edit counts as contact

The bug (measured on main 2026-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 next PostToolUse fired You'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 the reply tool 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_message already stamped lastReplyAt, and that is the opposite bug: the field is also read as "the human's newest message is answered" by hooks/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:

act sign of life (lastContactAt) newest inbound answered (lastReplyAt)
reply
react on the newest inbound
react on an older message
edit_message

markInbound now records the newest inbound's (chat_id, message_id) so react can 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 stamps silence.json when 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-chat does not post to Telegram — it injects a reply-to-chat=<id> hint into the a2a header (src/cmd_agent_runtime.sh:2422) and the receiving agent replies through its own reply tool, which already stamps. There is no second posting path to stamp.

How it was checked

  • New test/dive4276-contact-resets-silence.test.ts, 13 arms, two halves like a2a-turn-suppress:
    • BEHAVIOR over the extracted decideNag() — reply resets; react-on-latest resets and clears unanswered; edit resets the clock only, unanswered survives; back-compat (a pre-4276 silence.json with lastReplyAt and no lastContactAt reads as contact); the parallel-batch race; out-of-conversation.
    • Negative arms: inbound → nothing still fires after the threshold; stale contact still fires.
    • WIRING (static parse — importing a server long-polls Telegram): react consults isLatestInbound, edit_message contains markContact() and not markReplySent(), markReplySent stamps both clocks, the hook calls decideNag and re-reads before emitting.
  • Mutations, 2/2 killed: clock reverted to lastReplyAt alone → the edit arm reds; edit_message reverted to markReplySent() → the wiring arm reds.
  • Full repo suite green: 1155 pass / 0 fail (45 files).
  • Version bumped 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 telegram plugin — the telegram-{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 695bbda

main gained #60 (DIVE-4280 auto-attach), which edits the same edit_message block, so this PR read
CONFLICTING / mergeStateStatus=DIRTY at the graded sha 5ed3041.

Resolution. The one conflict hunk is plugins/telegram/server.ts: main added
await sendAutoAttachments(chat_id, editPlan, {}) next to the markReplySent() this PR replaces.
Both survive — main's auto-attach call is kept, and the stamp is DIVE-4276's markContact(), because
an 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 push is fast-forward-only
and exposes no force flag, and a new branch would mean a new PR — dropping this PR number, which the
row's delivery_ref and the close gate's graded-sha binding both point at. So the rebase was run
locally to PRODUCE the resolution, and that exact tree was shipped as a merge. The substitution is
checkable in one command:

git rev-parse <rebased e62312b>^{tree} <merged 695bbda>^{tree}
06f5fc1968fb8721005ed25254fe48a6af013a97
06f5fc1968fb8721005ed25254fe48a6af013a97

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 test1185 pass / 1 fail, 3820
expects, 46 files. The single failure is test/dive3809-drain-lock.test.ts, which also fails on
origin/main in this environment
(control run at 3fa0743: 2 fail) — a host tmpdir/process issue
in 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.

mergeable reads MERGEABLE at 695bbda; both parity arms were still running at delivery time.

lodar and others added 2 commits September 11, 2026 04:51
… 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>
@5dive-bot
5dive-bot merged commit 2f53352 into main Sep 11, 2026
2 checks passed
@5dive-bot
5dive-bot deleted the dive-4276-contact-stamp branch September 11, 2026 15:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants