Skip to content

fix(telegram): make the StopFailure send leg observable and never silently lost (DIVE-4401) - #66

Merged
5dive-bot merged 2 commits into
mainfrom
dive-4401-send-leg
Sep 13, 2026
Merged

fix(telegram): make the StopFailure send leg observable and never silently lost (DIVE-4401)#66
5dive-bot merged 2 commits into
mainfrom
dive-4401-send-leg

Conversation

@5dive-bot

Copy link
Copy Markdown
Collaborator

DIVE-4401 iteration 2 — the send/route leg

Iteration 1 (5dive-ai/5dive#921) closed deliverable 4 and the pane-side half of
deliverable 1, and the verifier banked it. It also found — and the verifier
confirmed at source — that the Team wall was not a classification miss. This
PR finishes the leg that finding named, in the repo the row scopes.

What was actually wrong

The hook's send DECISION is provable for the measured 2026-09-13 04:20Z episode
without any log: resume-after-reset's spawn is gated on the same
shouldSend as the DM (stopfailure-notify.ts, DIVE-1107), and both seats'
helper logs exist and parked to reset_epoch=1789290000. So SEND, on both.

Everything after the decision was unrecorded:

  • sendMessage returned void and never looked at the response. fetch
    resolves on a 4xx, so a rejection (bad token, bot removed from the group, a
    message_thread_id for a topic that no longer exists) read exactly like a
    delivered message.
  • A network throw was swallowed by a bare catch {}.
  • Nothing logged where the notice was routed. The two seats took two
    different rungs and left no trace of it: main had a Telegram DM 11 minutes
    before the wall → getCallerChat → the operator's DM; olivia's turn was
    autonomous → getGroupTopics → the agent's own forum topic, which is the
    documented behaviour and is not the DM the operator was watching.

So "the bot never sent it", "the bot sent it somewhere you weren't reading" and
"Telegram rejected it" — three symptoms with three different fixes — were one
indistinguishable silence, and the episode could not be attributed hours later.

Changes

  • lib/telegram.tssendMessage returns whether Telegram accepted the
    message and reports on stderr: REJECTED <chat[:topic]> — http <status> — <description>, FAILED … — <network error>, and a missing TELEGRAM_BOT_TOKEN.
    TELEGRAM_API_BASE exists so the leg can be pointed at a stub and asserted;
    it had no driven coverage precisely because it could not be pointed anywhere.
  • stopfailure-notify.ts — logs route=caller|group-topics|all-allowed
    with its targets, then each send's outcome. Routing is unchanged (row
    deliverables 2/5): a Team wall lands exactly where a Pro/Max wall lands.
  • Fallback on TOTAL failure only — if every routed send fails, retry on the
    paired chats not yet tried, so a dead topic degrades to a DM instead of to
    silence. A successful routed send never fans out (asserted).
  • resume-after-reset.ts — logs the chats it was handed. Its log is the one
    artifact of a wall episode that survives the systemd respawn, and it printed
    everything except the answer.

Evidence

test/stopfailure-send-leg.test.ts — 8 arms, executing the real hook in a
subprocess against an out-of-process Bot API stub (out-of-process is
load-bearing: the hook is driven with spawnSync, which blocks the test's event
loop, so an in-process server could never answer and every arm would time out
and assert nothing).

Both fixtures are the measured shapes; the wall copy is the measured Team copy.

arm asserts
caller turn one transmission, to the DM, no topic; route=caller; send …: ok
Team vs Pro/Max identical chat + topic for both wall texts
autonomous turn the group topic, not the DM (the verifier's empty-getCallerChat control)
5 repeats 1 SEND + 4 suppress and exactly one transmission
rejection REJECTED -100…:1417 — http 400 — message thread not found
fallback topic rejected → the DM receives it, logged
no fan-out a delivered notice contacts exactly one chat
nowhere to fall back http 403 — bot was blocked by the user + "no other paired chat"

Mutation, anchored per arm: ignore res.ok (the pre-fix behaviour) → 3 red ·
drop the fallback → 2 red · drop the route trace → 3 red · drop the per-send
outcome → 2 red. No mutant reds another's exclusive arms.

Regression: full repo suite bun test1194 pass / 0 fail (1186 before
these 8 arms).

Not claimed

Whether Telegram accepted the 04:20Z sends is unknowable now — the code that
would have recorded it is the code this PR fixes. The one-line
resume-after-reset chats log has no arm (driving it needs a live tmux pane).

🤖 Generated with Claude Code

lodar and others added 2 commits September 13, 2026 05:58
…ently lost (DIVE-4401)

The Team usage wall was not a classification miss. On the measured 04:20Z
episode both seats DECIDED to send: the recovery helper's spawn is gated on the
same `shouldSend` as the DM, and both helpers ran and parked to 09:00 UTC.
Everything after that decision was unrecorded — sendMessage returned void,
`fetch` resolves on a 4xx, and a network throw died in a bare `catch {}` — so
"never sent", "sent to a forum topic nobody was reading" and "Telegram rejected
it" were one indistinguishable silence.

- sendMessage returns whether Telegram accepted the message and reports a
  rejection (status + description), a network failure, and a missing token on
  stderr. TELEGRAM_API_BASE makes the leg drivable by a test.
- stopfailure-notify logs route=caller|group-topics|all-allowed with its
  targets, then the outcome of each send. Routing itself is unchanged.
- When EVERY routed send fails, fall back to the paired chats not yet tried, so
  a dead topic degrades to a DM instead of to silence; a successful send never
  fans out.
- resume-after-reset logs the chats it was handed — its log is the artifact that
  survives the systemd respawn, and it printed everything except the answer.

8 driven arms in test/stopfailure-send-leg.test.ts execute the hook against a
local Bot API stub on both measured shapes (main: DM 11 min before the wall →
caller; olivia: autonomous turn → the agent's own topic). Mutation-anchored:
ignoring res.ok reds 3, dropping the fallback reds 2, dropping the route trace
reds 3, dropping the per-send outcome reds 2. Full repo suite 1194 pass / 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…table notice say so (DIVE-4401)

Iteration 2's harness was not measuring the product. fireHook set HOME but not
TELEGRAM_STATE_DIR while spreading ...process.env into the child, and paths.ts
resolves `process.env.TELEGRAM_STATE_DIR ?? join(homedir(), ...)` — env FIRST.
So an ambient value from another file in the same bun process (resume-prompt.test.ts
set it at module scope with no restore) outranked the temp HOME: the hook read no
access.json, getGroupTopics()/getAllowedChatIds() came back empty, and it took
route=all-allowed targets=(none). Four arms reddened in CI and were green alone.

Worse, the four that stayed green passed VACUOUSLY under the same fault:
isAllowedChat fails open on an empty allowlist (DIVE-3422), so a caller turn takes
its destination from the transcript and sends whether or not the fixture was read.

- fireHook passes TELEGRAM_STATE_DIR explicitly (as undefined-guard.test.ts and
  resume-prompt.test.ts already do) and takes it as a parameter, so the
  config-absent case can be driven deliberately.
- resume-prompt.test.ts restores the env it sets, so it stops being a trap.
- New paired arms over the only caller-turn routing fact that lives solely in
  access.json — the fallback list: config read -> falls back to the second paired
  DM; config absent -> nothing to fall back to, and it says so.
- New arm: targets=(none) transmitted nothing and exited 0 silently, which is the
  row's own symptom. The hook now states the notice was lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@5dive-bot
5dive-bot merged commit 8e5a4f6 into main Sep 13, 2026
2 checks passed
@5dive-bot
5dive-bot deleted the dive-4401-send-leg branch September 13, 2026 08:05
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