From 93dcae5a3c24dbfe610ac5d04cd0692d09f3daab Mon Sep 17 00:00:00 2001 From: lodar Date: Wed, 2 Sep 2026 10:30:35 +0000 Subject: [PATCH] =?UTF-8?q?fix(telegram):=20remove=20the=20autonomous-sile?= =?UTF-8?q?nce=20notice=20=E2=80=94=20it=20only=20ever=20landed=20as=20a?= =?UTF-8?q?=20DM=20nag=20(DIVE-3910)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lodar, 2026-09-02: "I feel this works wrongly. better to kill completely. I never seen its been useful. its just confuses clients." DIVE-3422 added a notice after 3 consecutive channel-silent turns, so an autonomous session reporting into the void could be told apart from a hung one. Its own comment named the failure mode it had to avoid: "the same text in a human's DM is a nag, and a nag gets muted." It targeted `getGroupTopics()` first and fell back to `getAllowedChatIds()` — i.e. the human's DM — whenever no group topic was configured. No seat on this host configures one, so the fallback was not an edge case, it was the only path the notice ever took. It also fired wrongly. It reached the paired user at 10:27:55Z claiming 3 silent turns on a session that had replied to him at 10:25:50Z and 10:26Z, so the documented negative control (any turn reaching the channel resets the run) did not hold for a session that was actively conversing. Removes the call site, `lib/autonomous-silence.ts` and its unit file. The agent-side `silence-watchdog` hooks are deliberately UNTOUCHED: they nag the agent in its own transcript, never the user, and they are what keeps a session from going quiet on him. bun test 1048 pass / 0 fail; generator parity byte-exact on both committed forks. --- .../telegram/hooks/lib/autonomous-silence.ts | 99 ---------- plugins/telegram/hooks/stop-reply-check.ts | 43 +---- test/dive3422-autonomous-silence.test.ts | 180 ------------------ 3 files changed, 5 insertions(+), 317 deletions(-) delete mode 100644 plugins/telegram/hooks/lib/autonomous-silence.ts delete mode 100644 test/dive3422-autonomous-silence.test.ts diff --git a/plugins/telegram/hooks/lib/autonomous-silence.ts b/plugins/telegram/hooks/lib/autonomous-silence.ts deleted file mode 100644 index f3025f0..0000000 --- a/plugins/telegram/hooks/lib/autonomous-silence.ts +++ /dev/null @@ -1,99 +0,0 @@ -// DIVE-3422: an AUTONOMOUS session has no chat scope, so nothing it writes -// can reach anyone — and every other guard in this plugin is scoped to a -// caller that an autonomous session does not have. -// -// Measured 2026-08-15/16 (olivia, then re-measured by dev3 against -// origin/main). A `/goal` session ran 342 transcript entries over 52 minutes, -// wrote every progress report, its completion report and a security -// escalation to the TERMINAL, and reached the channel zero times. Replayed -// through the installed `analyzeTurn`, every turn of it read -// `{hadInbound:false, lastChatId:null, hadSend:false}` — so -// stop-reply-check's `if (!a.hadInbound || !a.lastChatId ...) exit(0)` was -// CORRECT to exit on all of them. Nothing was broken; the session was simply -// never in any guard's scope. From outside, an agent reporting diligently -// into the void is indistinguishable from one that has hung or crashed. -// -// The one branch that already solves this shape is stop-reply-check's -// session-limit notice: on a null caller chat it falls back to the configured -// chats, precisely so "an autonomous-turn limit hit still pings someone". -// This is that branch's second instance, not a new mechanism. -// -// THE CONSTRAINT THAT SHAPES EVERYTHING BELOW (acceptance item 3 on the row): -// an over-firing nag gets muted, and a muted warning is the silence we -// started with. An autonomous session is SUPPOSED to be quiet, so "warn after -// N silent turns, every turn" would fire forever on a healthy cron seat. Two -// things hold that line: the run RESETS to zero the moment the agent reaches -// the channel (the negative-control arm — that is the arm that matters), and -// after the first notice it re-fires only on a large multiple. - -// Turns of consecutive channel-silence before the first notice. 3 is chosen -// against the incident: it would have fired ~28 minutes in, with 5 of the 8 -// wasted driver turns still ahead of it. The single retune knob. -export const SILENT_RUN_FIRST = 3 -// After the first notice, re-fire only on multiples of this. Deliberately far -// above FIRST: the second notice is "this is still going", not a nag. -export const SILENT_RUN_REPEAT = 25 - -// The only facts about a turn this decision needs. Kept as a plain shape -// rather than a TurnAnalysis so the decision is testable without building a -// synthetic transcript, and so it cannot accidentally read anything else. -export type SilentTurn = { - hadInbound: boolean - a2aTurn: boolean - hadSend: boolean - hasText: boolean -} - -export type SilentRunDecision = { - // The new consecutive-silent-turn count to persist. - count: number - // Emit the notice for this turn. - notify: boolean -} - -// Pure. Given the run length so far and this turn, return the new run length -// and whether to speak. No I/O, no clock — every arm below is directly -// assertable. -export function nextSilentRun(prev: number, t: SilentTurn): SilentRunDecision { - // NEGATIVE CONTROL. The agent reached the channel this turn (hadSend), or - // was addressed on it (hadInbound — that turn is the existing relay path's - // business, and it will either send or deliberately stay quiet). Either way - // the session is NOT dark, so the run ends. An agent that is replying - // correctly can never accumulate a run, which is the whole guarantee. - if (t.hadSend || t.hadInbound) return { count: 0, notify: false } - - // An inter-agent turn is neither silence toward the user nor a reply to - // them (DIVE-1323: its output belongs on the a2a channel). Leave the run - // untouched — an a2a burst inside an otherwise-dark session must neither - // launder the silence away nor count as more of it. - if (t.a2aTurn) return { count: prev, notify: false } - - // A turn that produced no assistant text has nothing to report, so it is - // not evidence of unreported work. Tool-only turns are ordinary mid-task - // shape and must not push the count toward a notice on their own. - if (!t.hasText) return { count: prev, notify: false } - - const count = prev + 1 - const notify = - count === SILENT_RUN_FIRST || - (count > SILENT_RUN_FIRST && count % SILENT_RUN_REPEAT === 0) - return { count, notify } -} - -// Telegram caps a message at 4096; leave generous headroom for the prefix and -// for the transport's own truncation. -const TAIL_LIMIT = 1200 - -// The notice. lodar's recovery move in the incident was to send ANOTHER -// message into the silence, which produced another silent turn — the failure -// mode consumes the attempts to escape it. So the notice has to carry enough -// to make that move unnecessary: what is happening, for how long, and what -// the agent actually last said. -export function composeSilentRunNotice(count: number, lastText: string): string { - const trimmed = lastText.trim() - const tail = - trimmed.length > TAIL_LIMIT ? `${trimmed.slice(0, TAIL_LIMIT)}…` : trimmed - // No em-dash in user-facing copy (house style). - const head = `🔇 Still working, but nothing has reached this channel for ${count} turns. You are seeing this because otherwise you would see nothing at all.` - return tail ? `${head}\n\nLatest from the transcript:\n${tail}` : head -} diff --git a/plugins/telegram/hooks/stop-reply-check.ts b/plugins/telegram/hooks/stop-reply-check.ts index c4dde96..e0aece1 100755 --- a/plugins/telegram/hooks/stop-reply-check.ts +++ b/plugins/telegram/hooks/stop-reply-check.ts @@ -40,7 +40,6 @@ import { sendMessage, getToken } from './lib/telegram' import { emitBlock } from './lib/output' import { TG_TOOL_PREFIX, typingStopFile } from './lib/paths' import { getAllowedChatIds, getCallerChat, getGroupTopics, type CallerChat } from './lib/access' -import { nextSilentRun, composeSilentRunNotice } from './lib/autonomous-silence' import { parseResetEpoch } from './lib/time' import type { HookPayload, TranscriptEntry, TranscriptContentBlock } from './lib/types' @@ -211,43 +210,11 @@ const a = analyzeTurn(entries, TG_TOOL_PREFIX) // already false for such a turn (the guard below would exit too), but assert // it explicitly so the golden tripwire can lock the invariant against a future // analyzeTurn refactor that might change turn-boundary detection. -// DIVE-3422: BEFORE the a2a and inbound exits, because the case this covers -// is the one that leaves through them. An autonomous session (cron, `/goal`, -// any turn with no inbound) has no caller chat, so every branch -// below correctly exits and NOTHING the agent writes reaches anyone — the -// agent meanwhile reads its own transcript as evidence that it reported. -// Track the run of consecutive silent turns and, past a threshold, speak once -// into the agent's own group topic. Every arm of the decision is in -// ./lib/autonomous-silence; the negative control (any turn that reaches the -// channel resets the run to zero) lives there and is unit-locked. -const silentRunFile = join(tmpdir(), `5dive-tg-silentrun-${lockKey}.count`) -if (getToken()) { - const prevRun = (() => { - try { return parseInt(readFileSync(silentRunFile, 'utf8').trim(), 10) || 0 } catch { return 0 } - })() - const decision = nextSilentRun(prevRun, { - hadInbound: a.hadInbound, - a2aTurn: a.a2aTurn, - hadSend: a.hadSend, - hasText: a.texts.some(t => t.trim().length > 0), - }) - if (decision.count !== prevRun) { - try { writeFileSync(silentRunFile, String(decision.count)) } catch { /* stale count only */ } - } - if (decision.notify) { - // The agent's own forum topic first, allowed chats only if no group is - // configured. An autonomous session reporting into its own topic is - // information; the same text in a human's DM is a nag, and a nag gets - // muted — which is the silence this exists to end. - const topics = getGroupTopics() - const targets: CallerChat[] = topics.length > 0 - ? topics - : getAllowedChatIds().map(chatId => ({ chatId })) - const lastText = [...a.texts].reverse().find(t => t.trim().length > 0) ?? '' - const notice = composeSilentRunNotice(decision.count, lastText) - await Promise.all(targets.map(t => sendMessage(t.chatId, notice, t.threadId))) - } -} +// DIVE-3910: the autonomous-silence notice was REMOVED here (lodar, 2026-09-02: +// "I feel this works wrongly ... I never seen its been useful ... it just confuses +// clients"). It fell back to a human's DM whenever no group topic was configured, +// which is every seat here, so the one shape its own comment called a nag was the +// only shape it ever took. The agent-side silence-watchdog is untouched. // DIVE-1323 (continued): suppress the human-DM reflex on an inter-agent turn. if (a.a2aTurn) process.exit(0) diff --git a/test/dive3422-autonomous-silence.test.ts b/test/dive3422-autonomous-silence.test.ts deleted file mode 100644 index 3d2be54..0000000 --- a/test/dive3422-autonomous-silence.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -// DIVE-3422: an autonomous session has no chat scope, so nothing it writes -// reaches anyone — and every existing guard is scoped to a caller it does not -// have. -// -// THE INCIDENT: a `/goal` session (342 entries, 52 minutes) wrote every -// progress report, its completion report and a security escalation to the -// terminal. Replayed through the installed analyzeTurn, every turn read -// {hadInbound:false, lastChatId:null, hadSend:false}, so stop-reply-check -// exited clean on all of them — correctly. The session was never in scope. -// -// THE ROW'S ACCEPTANCE ITEM 3 is what this suite is really about: "an -// over-firing nag gets muted, and a muted warning is the silence we started -// with." So the arm that matters is the NEGATIVE one — an agent that IS -// reaching the channel must never accumulate a run, and a session past the -// first notice must not speak again until a large multiple. -// -// Locks two halves so a regression can't land quietly: -// 1. BEHAVIOR — every arm of nextSilentRun, including both negative ones. -// 2. WIRING — stop-reply-check actually consults it, and does so BEFORE the -// a2a/inbound exits that the silent case leaves through. A refactor that -// drops the call, or sinks it below those exits, trips CI even though -// the pure decision is still perfect. - -import { describe, test, expect } from 'bun:test' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { - nextSilentRun, - composeSilentRunNotice, - SILENT_RUN_FIRST, - SILENT_RUN_REPEAT, - type SilentTurn, -} from '../plugins/telegram/hooks/lib/autonomous-silence' - -const HOOKS = join(import.meta.dir, '..', 'plugins', 'telegram', 'hooks') - -// A turn of the incident: autonomous, no inbound, agent talked to the -// transcript and to nobody else. -const DARK: SilentTurn = { hadInbound: false, a2aTurn: false, hadSend: false, hasText: true } - -// Drive a sequence of turns from zero and collect the turn indices that spoke. -function run(turns: SilentTurn[]): { count: number; firedAt: number[] } { - let count = 0 - const firedAt: number[] = [] - turns.forEach((t, i) => { - const d = nextSilentRun(count, t) - count = d.count - if (d.notify) firedAt.push(i + 1) - }) - return { count, firedAt } -} - -describe('DIVE-3422: when an autonomous session goes dark', () => { - test('the incident shape fires exactly once, at the threshold', () => { - const { count, firedAt } = run(Array(SILENT_RUN_FIRST).fill(DARK)) - expect(count).toBe(SILENT_RUN_FIRST) - expect(firedAt).toEqual([SILENT_RUN_FIRST]) - }) - - test('below the threshold it says nothing (a short quiet task is not a defect)', () => { - const { firedAt } = run(Array(SILENT_RUN_FIRST - 1).fill(DARK)) - expect(firedAt).toEqual([]) - }) - - test('a turn with no assistant text does not advance the run', () => { - const toolOnly: SilentTurn = { ...DARK, hasText: false } - const { count, firedAt } = run([DARK, toolOnly, toolOnly, DARK]) - expect(count).toBe(2) - expect(firedAt).toEqual([]) - }) -}) - -describe('DIVE-3422 NEGATIVE CONTROL: an agent that is replying is never warned', () => { - test('a reply this turn resets the run to zero', () => { - const replied: SilentTurn = { ...DARK, hadSend: true } - const d = nextSilentRun(SILENT_RUN_FIRST - 1, replied) - expect(d.count).toBe(0) - expect(d.notify).toBe(false) - }) - - test('a healthy paired session — reply every turn — never fires, ever', () => { - const replied: SilentTurn = { ...DARK, hadInbound: true, hadSend: true } - const { count, firedAt } = run(Array(SILENT_RUN_REPEAT * 4).fill(replied)) - expect(count).toBe(0) - expect(firedAt).toEqual([]) - }) - - test('a single reply mid-drought disarms it: the run restarts from zero', () => { - const replied: SilentTurn = { ...DARK, hadSend: true } - // Two dark turns, a reply, then two more dark turns = never 3 in a row. - const { count, firedAt } = run([DARK, DARK, replied, DARK, DARK]) - expect(count).toBe(2) - expect(firedAt).toEqual([]) - }) - - test('an inbound turn resets too — that turn is the auto-relay path’s business', () => { - const inbound: SilentTurn = { ...DARK, hadInbound: true, hadSend: false } - const { count, firedAt } = run([DARK, DARK, inbound, DARK, DARK]) - expect(count).toBe(2) - expect(firedAt).toEqual([]) - }) - - test('an a2a turn is neutral: it neither launders the silence nor deepens it', () => { - const a2a: SilentTurn = { ...DARK, a2aTurn: true } - // DIVE-1323 says an a2a turn's output belongs on the a2a channel, so it is - // not a reply to the user — but it is not unreported user-facing work - // either. The run is left exactly where it was. - expect(nextSilentRun(2, a2a)).toEqual({ count: 2, notify: false }) - const { firedAt } = run([DARK, a2a, DARK, a2a, DARK]) - expect(firedAt).toEqual([SILENT_RUN_FIRST + 2]) // the 3rd DARK turn, at index 5 - }) -}) - -describe('DIVE-3422: it does not become the nag it was built to avoid', () => { - test('a long dark run speaks at the threshold and then only on a large multiple', () => { - const { firedAt } = run(Array(SILENT_RUN_REPEAT * 2).fill(DARK)) - expect(firedAt).toEqual([SILENT_RUN_FIRST, SILENT_RUN_REPEAT, SILENT_RUN_REPEAT * 2]) - }) - - test('the turns either side of a repeat are silent', () => { - expect(nextSilentRun(SILENT_RUN_REPEAT - 2, DARK).notify).toBe(false) - expect(nextSilentRun(SILENT_RUN_REPEAT - 1, DARK).notify).toBe(true) - expect(nextSilentRun(SILENT_RUN_REPEAT, DARK).notify).toBe(false) - }) - - test('the repeat interval is far above the first-fire threshold', () => { - // Guards the retune knob: dropping REPEAT to near FIRST turns this into a - // per-turn nag on any long autonomous seat. - expect(SILENT_RUN_REPEAT).toBeGreaterThan(SILENT_RUN_FIRST * 5) - }) -}) - -describe('DIVE-3422: the notice carries enough to end the silence', () => { - test('it names the run length and quotes the last transcript text', () => { - const msg = composeSilentRunNotice(3, 'CI is green, pushing the branch now.') - expect(msg).toContain('3 turns') - expect(msg).toContain('CI is green, pushing the branch now.') - }) - - test('a long tail is truncated, not dropped (Telegram caps at 4096)', () => { - const msg = composeSilentRunNotice(25, 'x'.repeat(9000)) - expect(msg.length).toBeLessThan(4096) - expect(msg).toContain('…') - }) - - test('no transcript text still produces a usable notice', () => { - const msg = composeSilentRunNotice(3, ' ') - expect(msg).toContain('3 turns') - expect(msg).not.toContain('Latest from the transcript') - }) -}) - -describe('DIVE-3422 WIRING: the hook consults it, above the exits it leaks through', () => { - const src = readFileSync(join(HOOKS, 'stop-reply-check.ts'), 'utf8') - - test('stop-reply-check calls nextSilentRun', () => { - expect(src).toContain('nextSilentRun(') - }) - - test('it runs BEFORE the a2a exit and the inbound exit', () => { - // This is the whole point. The autonomous case exits at - // `if (!a.hadInbound || ...) process.exit(0)`; a check placed after that - // line is unreachable for exactly the sessions it is meant to cover. - const call = src.indexOf('nextSilentRun(') - const a2aExit = src.indexOf('if (a.a2aTurn) process.exit(0)') - const inboundExit = src.indexOf('if (!a.hadInbound') - expect(call).toBeGreaterThan(-1) - expect(a2aExit).toBeGreaterThan(-1) - expect(inboundExit).toBeGreaterThan(-1) - expect(call).toBeLessThan(a2aExit) - expect(call).toBeLessThan(inboundExit) - }) - - test('it routes to the group topic first, allowed chats only as fallback', () => { - expect(src).toContain('getGroupTopics()') - const topics = src.indexOf('getGroupTopics()') - const fallback = src.indexOf('getAllowedChatIds().map(chatId => ({ chatId }))', topics) - expect(fallback).toBeGreaterThan(topics) - }) -})