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) - }) -})