fix(telegram): make the StopFailure send leg observable and never silently lost (DIVE-4401) - #66
Merged
Merged
Conversation
…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>
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-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 sameshouldSendas 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:
sendMessagereturnedvoidand never looked at the response.fetchresolves on a 4xx, so a rejection (bad token, bot removed from the group, a
message_thread_idfor a topic that no longer exists) read exactly like adelivered message.
catch {}.different rungs and left no trace of it:
mainhad a Telegram DM 11 minutesbefore the wall →
getCallerChat→ the operator's DM;olivia's turn wasautonomous →
getGroupTopics→ the agent's own forum topic, which is thedocumented 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.ts—sendMessagereturns whether Telegram accepted themessage and reports on stderr:
REJECTED <chat[:topic]> — http <status> — <description>,FAILED … — <network error>, and a missingTELEGRAM_BOT_TOKEN.TELEGRAM_API_BASEexists 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— logsroute=caller|group-topics|all-allowedwith 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.
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 oneartifact 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 asubprocess 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 eventloop, 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.
route=caller;send …: okgetCallerChatcontrol)REJECTED -100…:1417 — http 400 — message thread not foundhttp 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 test→ 1194 pass / 0 fail (1186 beforethese 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-resetchats log has no arm (driving it needs a live tmux pane).🤖 Generated with Claude Code