switchroom 0.20.7 → 0.20.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -660,7 +660,7 @@ import { handleRequestMs365Approval } from './ms365-write-approval.js'
660
660
  import { buildDiffPreviewCard } from './diff-preview-card.js'
661
661
  import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
662
662
  import { makeRepresentRedeliveryGuard, makeSessionBusyDrainDeferral } from './represent-delivery-guard.js'
663
- import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback, replyCallerIsForeignSession } from './cron-session.js'
663
+ import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback, replyCallerIsForeignSession, drainCronBridgeOnRegister } from './cron-session.js'
664
664
  import {
665
665
  ObligationLedger,
666
666
  obligationEscalationText,
@@ -857,7 +857,7 @@ import {
857
857
  resolvePersonaName, shouldSkipDuplicateBootCard,
858
858
  type BootCardHandle, type RestartReason,
859
859
  } from './boot-card.js'
860
- import { determineRestartReason } from './boot-reason.js'
860
+ import { determineRestartReason, determineBridgeReconnectReason } from './boot-reason.js'
861
861
  import { maybeRenderUpdateAnnouncement } from './update-announce.js'
862
862
  import { createIssuesCardHandle, type IssuesCardHandle } from '../issues-card.js'
863
863
  import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.js'
@@ -9163,6 +9163,8 @@ let activeBootCard: BootCardHandle | null = null
9163
9163
  // dedupe checks this so it can't race against the boot path's await.
9164
9164
  // See issue #489 (klanker msgId 4715 + 4716, 2026-05-01 10:13:15).
9165
9165
  let bootCardPending = false
9166
+ // Boot-determined restart reason — see determineBridgeReconnectReason (B2).
9167
+ let bootReasonAtStartup: RestartReason | null = null
9166
9168
 
9167
9169
  // Issues card (#428) — pinned per-agent surface listing current
9168
9170
  // unresolved entries from the issue sink (#425). Idempotent across
@@ -10366,17 +10368,13 @@ if (isGatewayMain) ipcServer = createIpcServer({
10366
10368
 
10367
10369
  onClientRegistered(client: IpcClient) {
10368
10370
  process.stderr.write(`telegram gateway: bridge registered — agent=${client.agentName}\n`)
10369
- // Cheap-cron (§2.4/§3.3): a `<agent>-cron` bridge is the Tier-1 cheap
10370
- // session. It is STATUS-SILENT — it must NOT drive the gateway's
10371
- // singleton machinery (shadow bridge-state, warmup, boot card, which all
10372
- // track the MAIN agent's liveness). Drain any buffered cron fire to it
10373
- // (so a fire that triggered a lazy spawn lands), then return early.
10371
+ // Cheap-cron (§2.4/§3.3): a `<agent>-cron` bridge is the Tier-1 cheap,
10372
+ // STATUS-SILENT session — it must NOT drive the gateway's singleton
10373
+ // machinery. Drain any boot-window cron fire to it, then return early.
10374
+ // #4348: the drain routes through `redeliverBufferedInbound` (the shared
10375
+ // ack chokepoint) so a spooled fire is acked and never re-fires on restart.
10374
10376
  if (isCronIdentity(client.agentName)) {
10375
- client.send({ type: 'status', status: 'agent_connected' })
10376
- const pending = pendingInboundBuffer.drain(client.agentName ?? '')
10377
- for (const m of pending) {
10378
- try { client.send(m) } catch { /* cron fire drop — best-effort, like today's cron */ }
10379
- }
10377
+ drainCronBridgeOnRegister(client, pendingInboundBuffer, inboundSpool ?? undefined, (l) => process.stderr.write(l))
10380
10378
  return
10381
10379
  }
10382
10380
  // Phase 2b shadow: ONLY emit bridgeUp for the REAL bridge sidecar
@@ -10450,15 +10448,12 @@ if (isGatewayMain) ipcServer = createIpcServer({
10450
10448
  })
10451
10449
  }
10452
10450
 
10453
- // If the agent reconnected after a /restart (or any restart), post a boot
10454
- // card. The restart-marker carries the ack chat; if absent we fall back to
10455
- // resolveBootChatId so crash-recovery reconnects also get a card.
10456
- //
10457
- // Skip if the boot path already posted a card OR is currently in-flight
10458
- // on its sendMessage await (issue #489 — klanker msgId 4715+4716,
10459
- // 2026-05-01). The original dedupe (msgId 2245+2248, 2026-04-26) only
10460
- // covered the post-resolution case; bootCardPending closes the in-flight
10461
- // race window. See `shouldSkipDuplicateBootCard`.
10451
+ // If the agent reconnected after a restart, post a boot card. The
10452
+ // restart-marker carries the ack chat; else fall back to resolveBootChatId
10453
+ // so crash-recovery reconnects also get a card. Skip if the boot path
10454
+ // already posted a card OR its sendMessage is in-flight (issue #489,
10455
+ // klanker 2026-05-01; earlier dedupe 2026-04-26 only covered the
10456
+ // post-resolution case). See `shouldSkipDuplicateBootCard`.
10462
10457
  const dedupeDecision = shouldSkipDuplicateBootCard({ activeBootCard, bootCardPending }, 'bridge-reconnect')
10463
10458
  if (dedupeDecision.skip) {
10464
10459
  process.stderr.write(`telegram gateway: bridge-reconnect: skipping boot card (${dedupeDecision.reason})\n`)
@@ -10475,7 +10470,9 @@ if (isGatewayMain) ipcServer = createIpcServer({
10475
10470
  clearRestartMarker()
10476
10471
  }
10477
10472
 
10478
- const reason = determineRestartReason({ marker, cleanMarker, sessionMarker: storedSession, now: nowMs })
10473
+ // B2: boot path cleared the markers reuse its reason (boot-reason.ts).
10474
+ const reason = determineBridgeReconnectReason({ marker, cleanMarker, sessionMarker: storedSession,
10475
+ now: nowMs, gatewayStartedAtMs: GATEWAY_STARTED_AT_MS, bootReason: bootReasonAtStartup })
10479
10476
  const target = resolveBootChatId(marker, markerAgeMs)
10480
10477
 
10481
10478
  if (target) {
@@ -11760,7 +11757,9 @@ const IDLE_DRAIN_INTERVAL_MS = 5000
11760
11757
  // while the CLI is still producing the answer; draining a represent into that busy
11761
11758
  // session re-queues it BEHIND the real reply → a duplicate. Defer while busy,
11762
11759
  // bounded so a wedged session still drains (F1 then retracts it post-reply).
11763
- const idleDrainBusyDefer = makeSessionBusyDrainDeferral(OBLIGATION_BACKGROUND_WORK_GRACE_MS)
11760
+ // staleGap = 3 poll intervals: a longer gap between busy consultations means the
11761
+ // buffer drained via a non-idle path so the next one is a NEW episode (#4341 f/u).
11762
+ const idleDrainBusyDefer = makeSessionBusyDrainDeferral(OBLIGATION_BACKGROUND_WORK_GRACE_MS, IDLE_DRAIN_INTERVAL_MS * 3)
11764
11763
  if (isGatewayMain && !STATIC) {
11765
11764
  setInterval(() => {
11766
11765
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
@@ -23490,15 +23489,14 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23490
23489
  } else {
23491
23490
  const markerAgeMs = marker ? nowMs - marker.ts : undefined
23492
23491
  const reason = determineRestartReason({ marker, cleanMarker, sessionMarker: storedSession, now: nowMs })
23492
+ // Markers were cleared above — stash for bridge re-register (B2).
23493
+ bootReasonAtStartup = reason
23493
23494
  const target = resolveBootChatId(marker, markerAgeMs)
23494
23495
 
23495
- // Issue #92: when reason='crash' AND no chat is resolvable,
23496
- // the gateway used to silently skip the only signal a user
23497
- // got was their next message landing on a fresh process. Now
23498
- // we always surface unplanned crashes via the operator-events
23499
- // pipeline, which broadcasts to access.allowFrom (same path
23500
- // permission requests use). The pipeline's per-agent per-kind
23501
- // cooldown protects against crash loops spamming the chat.
23496
+ // Issue #92: when reason='crash' AND no chat is resolvable, the
23497
+ // gateway used to silently skip. Always surface unplanned crashes
23498
+ // via the operator-events pipeline (broadcasts to access.allowFrom;
23499
+ // its per-agent per-kind cooldown absorbs crash loops).
23502
23500
  if (reason === 'crash') {
23503
23501
  const cleanMarkerStale = cleanMarker
23504
23502
  ? !shouldSuppressRecoveryBanner(cleanMarker, nowMs, CLEAN_SHUTDOWN_MAX_AGE_MS)
@@ -56,6 +56,7 @@ import {
56
56
  appendActivityLabel, clipNarrative, formatStepSuffix, renderActivityFeedWithNested,
57
57
  } from '../tool-activity-summary.js'
58
58
  import { evaluatePostAnswerLiveness } from '../turn-liveness-floor.js'
59
+ import { isSilentSentinelCardOutcome } from '../turn-flush-safety.js'
59
60
  import { clearActivityCardRecord, writeActivityCardRecord } from './activity-card-store.js'
60
61
  import { chatKeyWithSuffix } from './chat-key.js'
61
62
  import {
@@ -880,7 +881,26 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
880
881
  // before the delete. turn-end stays the idempotent backstop: it no-ops
881
882
  // once nothing is claimed for the key.
882
883
  await reconcileStatusPin(`fg:${statusKey(chat, thread)}`, chat, { pinned: false })
883
- if (CLEAR_STATUS_ON_COMPLETION) {
884
+ // #4348 — silent-sentinel suppression. A turn whose entire user-facing
885
+ // outcome is a bare NO_REPLY / HEARTBEAT_OK (the sentinel-reply-guard
886
+ // dropped a sentinel-only reply, or the flush safety net classified the
887
+ // captured text as silent) said nothing to the user, so its activity/
888
+ // telemetry card is pure noise — most visibly on the forced-synthesis
889
+ // handback turns a background sub-agent injects. DELETE the card rather
890
+ // than finalizing a visible `done · … · ✓ NO_REPLY` record. Deterministic
891
+ // (reuses the shared `turn-flush-safety` silent predicates, no model
892
+ // behaviour) and normal-case-safe (`finalAnswerEverDelivered` keeps every
893
+ // card for a turn that actually delivered a substantive answer). The
894
+ // `finalHtmlOverride` finalize path is only taken by the foreground
895
+ // handoff-clear, which fires on a delivered final answer — so this gate
896
+ // never contends with it.
897
+ const silentSentinelTurn = isSilentSentinelCardOutcome({
898
+ replyCalled: turn.replyCalled,
899
+ lastReplyText: turn.lastReplyText,
900
+ capturedText: turn.capturedText,
901
+ finalAnswerEverDelivered: turn.finalAnswerEverDelivered,
902
+ })
903
+ if (CLEAR_STATUS_ON_COMPLETION || silentSentinelTurn) {
884
904
  try {
885
905
  await robustApiCall(
886
906
  () => bot.api.deleteMessage(chat, id),
@@ -137,19 +137,50 @@ export function makeRepresentRedeliveryGuard(
137
137
  * ordinary busy turn (which ends well within the bound) defers cleanly and never
138
138
  * consumes the wedge budget.
139
139
  *
140
+ * Episode isolation (#4341 follow-up): `deferringSince` used to reset ONLY on a
141
+ * `busy=false` call, but the predicate is not consulted on every drain path. When
142
+ * a buffer is emptied by a bridge re-register (`onClientRegistered`) while the
143
+ * session is still busy, the idle-drain gate is never invoked with `busy=false`,
144
+ * so `deferringSince` stays pinned at the old t0. A later, UNRELATED deferral
145
+ * episode then computes `now - t0 >= boundMs` immediately and drains a represent
146
+ * into a mid-answer session — reopening the duplicate window this guard closes.
147
+ * Fix: a fresh deferral episode also starts the clock at `now` when the predicate
148
+ * has not been consulted within `staleGapMs`. The idle-drain gate polls on a
149
+ * fixed short interval while (and only while) the buffer is non-empty, so a gap
150
+ * between consecutive consultations longer than a few poll intervals means the
151
+ * prior episode's buffer drained via a non-idle path and this is a new episode.
152
+ *
140
153
  * `boundMs <= 0` disables the busy-defer entirely (kill switch / parity with the
141
- * background-work grace being disabled).
154
+ * background-work grace being disabled). `staleGapMs <= 0` disables the
155
+ * gap-based episode reset (leaving only the `busy=false` reset).
142
156
  */
157
+ export const DEFAULT_DRAIN_DEFER_STALE_GAP_MS = 15_000
158
+
143
159
  export function makeSessionBusyDrainDeferral(
144
160
  boundMs: number,
161
+ staleGapMs: number = DEFAULT_DRAIN_DEFER_STALE_GAP_MS,
145
162
  ): (busy: boolean, now: number) => boolean {
146
163
  let deferringSince: number | null = null
164
+ let lastCallAt: number | null = null
147
165
  return (busy, now) => {
166
+ const gapSinceLastCall = lastCallAt == null ? null : now - lastCallAt
167
+ lastCallAt = now
148
168
  if (!busy || boundMs <= 0) {
149
169
  deferringSince = null
150
170
  return false
151
171
  }
152
- if (deferringSince == null) deferringSince = now
172
+ // Start (or restart) the clock at the top of a NEW deferral episode:
173
+ // - we were not deferring (deferringSince cleared by a busy=false call), OR
174
+ // - the predicate has not been consulted within staleGapMs, which means the
175
+ // prior episode's buffer drained via a path that never calls us with
176
+ // busy=false (bridge re-register) and left deferringSince pinned at a
177
+ // stale t0. Either way a fresh episode's bound must run from `now`.
178
+ if (
179
+ deferringSince == null ||
180
+ (staleGapMs > 0 && gapSinceLastCall != null && gapSinceLastCall > staleGapMs)
181
+ ) {
182
+ deferringSince = now
183
+ }
153
184
  // Bounded: stop deferring once we have been busy past the ceiling, so a
154
185
  // wedged/hung session still eventually drains the buffered represent.
155
186
  return now - deferringSince < boundMs
@@ -1138,8 +1138,17 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
1138
1138
  }
1139
1139
  if (QUEUED_CARD_ENABLED && !handbackOwnsSurface) {
1140
1140
  const cardChatId = ev.chatId
1141
- const replyToRaw = ev.messageId != null ? Number(ev.messageId) : null
1142
- const replyTo = replyToRaw != null && Number.isFinite(replyToRaw) ? replyToRaw : null
1141
+ // Reply-anchor ONLY to a plausible real Telegram message id. Synthetic
1142
+ // enqueues (subagent handback, boot resume, cron) fabricate `messageId`
1143
+ // from `Date.now()` (~1.78e13) — finite, so a bare `Number.isFinite`
1144
+ // guard passes it, but `reply_parameters.message_id` hard-rejects
1145
+ // anything beyond signed int32 with 400 `field "message_id" must be a
1146
+ // valid Number` (`allow_sending_without_reply` does NOT bypass the
1147
+ // range check), killing the whole card send (overlord
1148
+ // gateway-supervisor.log 2026-08-04, e.g. msg=1785846295635). Same bug
1149
+ // class as the resume-dark-feed incident — reuse its guard, don't
1150
+ // re-derive a weaker one. An unanchored card is fine; no card is not.
1151
+ const replyTo = parseSourceMessageId(ev.messageId)
1143
1152
  void openQueuedCard(deps, cardChatId, enqThreadIdNum ?? null, replyTo).then((cardId) => {
1144
1153
  if (cardId == null) return
1145
1154
  // Still parked → adopt on the next dequeue. Otherwise the entry already
@@ -164,3 +164,91 @@ describe('determineRestartReason', () => {
164
164
  expect(result).toBe('crash')
165
165
  })
166
166
  })
167
+
168
+ // ── determineBridgeReconnectReason (fleet-audit B2) ────────────────────────
169
+ //
170
+ // Live trace this pins (kdogg gateway-supervisor.log, 2026-08-02):
171
+ // 06:27:43 shutdown.clean_marker_written reason="cli: restart"
172
+ // 06:27:46 boot.clean_shutdown_detected → boot path logs reason=graceful
173
+ // …and CLEARS the clean-shutdown marker (2026-05-25 GC)
174
+ // 06:27:55 surviving bridge re-registers → old code re-derived from the
175
+ // now-empty disk and logged reason=crash for a graceful restart.
176
+ // Fleet-wide: lawgpt 310 / reggie 179 / ziggy 175 / kdogg 174 such lines.
177
+
178
+ import { determineBridgeReconnectReason } from '../gateway/boot-reason.js'
179
+
180
+ describe('determineBridgeReconnectReason', () => {
181
+ it('reuses the boot-determined reason when the boot path consumed the markers (kdogg 2026-08-02 trace)', () => {
182
+ const result = determineBridgeReconnectReason({
183
+ marker: null, // cleared by the boot path
184
+ cleanMarker: null, // cleared by the boot path
185
+ sessionMarker: sessionMarker(),
186
+ now: NOW,
187
+ gatewayStartedAtMs: NOW - 10_000, // bridge re-registered 10s after boot
188
+ bootReason: 'graceful',
189
+ })
190
+ // Old behavior (plain determineRestartReason on the empty disk state)
191
+ // returned 'crash' here — the B2 mislabel.
192
+ expect(result).toBe('graceful')
193
+ })
194
+
195
+ it('a still-present fresh restart marker wins over the cached boot reason', () => {
196
+ const result = determineBridgeReconnectReason({
197
+ marker: recentMarker(10_000),
198
+ cleanMarker: null,
199
+ sessionMarker: sessionMarker(),
200
+ now: NOW,
201
+ gatewayStartedAtMs: NOW - 10_000,
202
+ bootReason: 'graceful',
203
+ })
204
+ expect(result).toBe('planned')
205
+ })
206
+
207
+ it('a still-present fresh clean-shutdown marker wins over the cached boot reason', () => {
208
+ const result = determineBridgeReconnectReason({
209
+ marker: null,
210
+ cleanMarker: recentCleanMarker(5_000),
211
+ sessionMarker: sessionMarker(),
212
+ now: NOW,
213
+ gatewayStartedAtMs: NOW - 10_000,
214
+ bootReason: 'crash',
215
+ })
216
+ expect(result).toBe('graceful')
217
+ })
218
+
219
+ it('late re-register (gateway up past the reuse window) still classifies as crash', () => {
220
+ const result = determineBridgeReconnectReason({
221
+ marker: null,
222
+ cleanMarker: null,
223
+ sessionMarker: sessionMarker(),
224
+ now: NOW,
225
+ gatewayStartedAtMs: NOW - 6 * 60_000, // gateway alive 6 min — bridge side died
226
+ bootReason: 'graceful',
227
+ })
228
+ expect(result).toBe('crash')
229
+ })
230
+
231
+ it('no recorded boot reason falls back to the plain derivation', () => {
232
+ const result = determineBridgeReconnectReason({
233
+ marker: null,
234
+ cleanMarker: null,
235
+ sessionMarker: sessionMarker(),
236
+ now: NOW,
237
+ gatewayStartedAtMs: NOW - 10_000,
238
+ bootReason: null,
239
+ })
240
+ expect(result).toBe('crash')
241
+ })
242
+
243
+ it('fresh first boot (no session marker) reuses bootReason fresh', () => {
244
+ const result = determineBridgeReconnectReason({
245
+ marker: null,
246
+ cleanMarker: null,
247
+ sessionMarker: null,
248
+ now: NOW,
249
+ gatewayStartedAtMs: NOW - 3_000,
250
+ bootReason: 'fresh',
251
+ })
252
+ expect(result).toBe('fresh')
253
+ })
254
+ })
@@ -0,0 +1,150 @@
1
+ /**
2
+ * #4348 — the Tier-1 cheap-cron bridge-register drain must route through the
3
+ * shared `redeliverBufferedInbound` chokepoint so a delivered cron fire is
4
+ * `spool.ack`'d exactly once and CANNOT re-fire after a restart.
5
+ *
6
+ * The bug: `onClientRegistered`'s `<agent>-cron` branch did a raw
7
+ * `pendingInboundBuffer.drain()` + `client.send()` loop and returned early,
8
+ * never reaching `spool.ack` (which lives only inside
9
+ * `redeliverBufferedInbound`). A due cron tick spooled during the boot window
10
+ * (before the cron bridge registered) stayed live in the durable spool, so
11
+ * boot-replay re-pushed it on the next restart and the SAME fire was delivered
12
+ * a second time — a duplicate cron delivery bounded only by the 15-min
13
+ * escalation sweep.
14
+ *
15
+ * These tests assert the OUTCOME on the real drain seam
16
+ * (`drainCronBridgeOnRegister`), against a REAL spool + buffer:
17
+ * 1. the boot-window fire is delivered to the cron bridge, AND
18
+ * 2. its durable spool entry is acked (liveCount → 0), AND
19
+ * 3. a simulated restart's boot-replay finds nothing to re-push, so the fire
20
+ * does NOT re-fire.
21
+ *
22
+ * RED-before-GREEN: with the pre-#4348 raw-drain body the fire is delivered but
23
+ * the spool entry stays live, so assertions (2)+(3) fail. With the fix routing
24
+ * through `redeliverBufferedInbound` they pass.
25
+ */
26
+
27
+ import { describe, it, expect } from 'vitest'
28
+ import {
29
+ createInboundSpool,
30
+ type InboundSpoolFsSeam,
31
+ type InboundSpool,
32
+ } from '../gateway/inbound-spool.js'
33
+ import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer.js'
34
+ import { drainCronBridgeOnRegister, cronIdentity } from '../gateway/cron-session.js'
35
+ import type { InboundMessage } from '../gateway/ipc-protocol.js'
36
+
37
+ const SPOOL_PATH = '/state/agent/telegram/inbound-spool.jsonl'
38
+
39
+ /** In-memory fake fs for the spool — models append + atomic-rename compaction.
40
+ * Shared across "restarts" so the durable JSONL survives, exactly like the
41
+ * persistent per-agent volume. */
42
+ function fakeFs(): InboundSpoolFsSeam {
43
+ const files = new Map<string, string>()
44
+ return {
45
+ appendFileSync: (p, d) => files.set(p, (files.get(p) ?? '') + d),
46
+ readFileSync: (p) => files.get(p) ?? '',
47
+ writeFileSync: (p, d) => files.set(p, d),
48
+ renameSync: (from, to) => {
49
+ files.set(to, files.get(from) ?? '')
50
+ files.delete(from)
51
+ },
52
+ existsSync: (p) => files.has(p),
53
+ statSizeSync: (p) => Buffer.byteLength(files.get(p) ?? ''),
54
+ fsyncFileSync: () => {},
55
+ fsyncDirSync: () => {},
56
+ }
57
+ }
58
+
59
+ function cronFire(over: Partial<InboundMessage> = {}): InboundMessage {
60
+ return {
61
+ type: 'inbound',
62
+ chatId: 'c1',
63
+ messageId: 0, // synthetic — cron fires carry no Telegram messageId
64
+ user: 'system',
65
+ userId: 0,
66
+ ts: 1000,
67
+ text: 'Time for the daily digest',
68
+ meta: { source: 'cron', session: 'cron' },
69
+ ...over,
70
+ } as InboundMessage
71
+ }
72
+
73
+ /** A capturing stand-in for the just-registered cron IPC client. */
74
+ function fakeClient(agentName: string): {
75
+ agentName: string
76
+ send: (msg: unknown) => void
77
+ sent: unknown[]
78
+ } {
79
+ const sent: unknown[] = []
80
+ return { agentName, send: (m) => void sent.push(m), sent }
81
+ }
82
+
83
+ /** Simulate the gateway's boot-replay: re-push every live (un-acked) spool
84
+ * entry into a fresh in-memory buffer, exactly as gateway.ts does at boot. */
85
+ function bootReplayInto(spool: InboundSpool): ReturnType<typeof createPendingInboundBuffer> {
86
+ const buffer = createPendingInboundBuffer({ log: () => {}, spool })
87
+ for (const { agent, msg } of spool.liveEntries()) buffer.push(agent, msg)
88
+ return buffer
89
+ }
90
+
91
+ describe('#4348 cron-bridge drain routes through the spool-ack chokepoint', () => {
92
+ it('acks the boot-window cron fire and does not re-fire after restart', () => {
93
+ const fs = fakeFs()
94
+ const spool = createInboundSpool({ path: SPOOL_PATH, fs, log: () => {} })
95
+ const cronAgent = cronIdentity('overlord') // "overlord-cron"
96
+
97
+ // Boot window: a due cron tick arrives BEFORE the cron bridge registers.
98
+ // It is buffered (in-memory) AND durably spooled by the same push.
99
+ const buffer = createPendingInboundBuffer({ log: () => {}, spool })
100
+ buffer.push(cronAgent, cronFire())
101
+ expect(spool.liveCount()).toBe(1) // durably recorded, not yet delivered
102
+
103
+ // The cron bridge registers → the drain seam under test runs.
104
+ const client = fakeClient(cronAgent)
105
+ const result = drainCronBridgeOnRegister(client, buffer, spool)
106
+
107
+ // (1) The fire was delivered to the cron bridge.
108
+ expect(result.drained).toBe(1)
109
+ expect(result.redelivered).toBe(1)
110
+ const deliveredFires = client.sent.filter(
111
+ (m): m is InboundMessage => (m as InboundMessage).type === 'inbound',
112
+ )
113
+ expect(deliveredFires).toHaveLength(1)
114
+ expect(deliveredFires[0]!.text).toBe('Time for the daily digest')
115
+
116
+ // (2) The durable spool entry is tombstoned — the whole point of #4348.
117
+ expect(spool.liveCount()).toBe(0)
118
+ expect(spool.liveEntries()).toHaveLength(0)
119
+
120
+ // (3) Simulated restart: boot-replay finds nothing to re-push, so the SAME
121
+ // fire does NOT re-fire. (Pre-fix this replayed the un-acked entry.)
122
+ const afterRestart = bootReplayInto(spool)
123
+ expect(afterRestart.drain(cronAgent)).toHaveLength(0)
124
+ })
125
+
126
+ it('a send failure re-buffers the fire and leaves the spool entry live (lossless)', () => {
127
+ const fs = fakeFs()
128
+ const spool = createInboundSpool({ path: SPOOL_PATH, fs, log: () => {} })
129
+ const cronAgent = cronIdentity('overlord')
130
+
131
+ const buffer = createPendingInboundBuffer({ log: () => {}, spool })
132
+ buffer.push(cronAgent, cronFire())
133
+
134
+ // Client whose send throws for inbound fires (bridge wedged mid-drain).
135
+ const throwing = {
136
+ agentName: cronAgent,
137
+ send: (m: unknown) => {
138
+ if ((m as InboundMessage).type === 'inbound') throw new Error('socket gone')
139
+ },
140
+ }
141
+ const result = drainCronBridgeOnRegister(throwing, buffer, spool)
142
+
143
+ // Not delivered → re-buffered, and the spool entry stays LIVE so the next
144
+ // register (or boot-replay) retries it. Nothing is dropped, nothing acked.
145
+ expect(result.redelivered).toBe(0)
146
+ expect(result.rebuffered).toBe(1)
147
+ expect(spool.liveCount()).toBe(1)
148
+ expect(buffer.drain(cronAgent)).toHaveLength(1)
149
+ })
150
+ })
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Regression test for fleet-audit B2 — the bridge's background reconnect
3
+ * loop must never surface a failed connect attempt as a process-level
4
+ * unhandledRejection.
5
+ *
6
+ * Live evidence (kdogg, 2026-07-17):
7
+ * bridge-crash.log: `unhandledRejection … Error: Failed to connect
8
+ * | at doConnect (dist/server.js:24188) …`
9
+ *
10
+ * Mechanism: `scheduleReconnect()`'s timer invoked `doConnect()` without a
11
+ * rejection handler. `doConnect` returns a promise that REJECTS whenever
12
+ * the `Bun.connect` attempt fails (gateway restarting out from under a
13
+ * long-lived bridge — the exact window every planned `docker restart`
14
+ * opens). The initial-connect call sites attach a `.catch`; the retry
15
+ * timer did not, so every failed background retry escaped as an
16
+ * unhandledRejection. Pre-#3033 that killed the bridge process outright
17
+ * (Claude Code never respawns a dead MCP server → mute agent); post-#3033
18
+ * it still spams `bridge-crash.log` with pseudo-crash breadcrumbs and
19
+ * leans on a global process handler for survival.
20
+ *
21
+ * Outcome asserted: with nothing listening on the socket path, the client
22
+ * runs through its initial attempt AND several background retries without
23
+ * a single unhandledRejection reaching the process. RED without the
24
+ * `.catch` in scheduleReconnect's timer, GREEN with it.
25
+ *
26
+ * Run with: bun test telegram-plugin/tests/ipc-client-reconnect-rejection.test.ts
27
+ */
28
+ import { describe, it, expect } from "bun:test";
29
+ import { tmpdir } from "node:os";
30
+ import { join } from "node:path";
31
+ import { createIpcClient } from "../bridge/ipc-client.js";
32
+
33
+ describe("ipc-client background reconnect", () => {
34
+ it("a failed reconnect attempt never escapes as a process-level unhandledRejection", async () => {
35
+ const captured: unknown[] = [];
36
+ const onUnhandled = (err: unknown) => {
37
+ captured.push(err);
38
+ };
39
+ process.on("unhandledRejection", onUnhandled);
40
+
41
+ // Nothing listens here — every connect attempt fails, exercising both
42
+ // the (already-handled) initial attempt and the retry-timer path.
43
+ const socketPath = join(tmpdir(), `ipc-b2-${crypto.randomUUID()}.sock`);
44
+
45
+ const handle = await createIpcClient({
46
+ socketPath,
47
+ agentName: "b2-test",
48
+ onInbound: () => {},
49
+ onPermission: () => {},
50
+ onStatus: () => {},
51
+ log: () => {},
52
+ reconnectDelayMs: 15,
53
+ maxReconnectDelayMs: 30,
54
+ });
55
+
56
+ try {
57
+ // Long enough for several background retries (15ms, 30ms, 30ms, …)
58
+ // plus the macrotask on which unhandledRejection is delivered.
59
+ await new Promise((resolve) => setTimeout(resolve, 300));
60
+ } finally {
61
+ handle.close();
62
+ // Give any in-flight rejection its delivery tick before detaching.
63
+ await new Promise((resolve) => setTimeout(resolve, 50));
64
+ process.off("unhandledRejection", onUnhandled);
65
+ }
66
+
67
+ expect(handle.isConnected()).toBe(false);
68
+ expect(captured).toEqual([]);
69
+ });
70
+ });
@@ -282,6 +282,92 @@ describe('narrative-lane golden — clearActivitySummary finalize', () => {
282
282
  })
283
283
  })
284
284
 
285
+ // ── #4348 — silent-sentinel activity card is SUPPRESSED (deleted, not finalized) ─
286
+ // A turn whose entire user-facing outcome is a bare NO_REPLY / HEARTBEAT_OK
287
+ // (the sentinel-reply-guard dropped the sentinel-only reply, or the flush net
288
+ // classified the captured text as silent) must leave NO visible telemetry card
289
+ // in the chat — most visibly on the forced-synthesis handback turns a
290
+ // background sub-agent injects. A real reply's card must still finalize.
291
+ describe('narrative-lane golden — silent-sentinel card suppression (#4348)', () => {
292
+ // Open the feed card on a fresh (working) turn FIRST — the card-open gate
293
+ // (mayOpenActivityCard) won't open one once the answer is already delivered —
294
+ // then stamp the turn's terminal outcome, exactly as the real turn does:
295
+ // the card opens mid-work, and lastReplyText / finalAnswerEverDelivered are
296
+ // only known at turn end.
297
+ async function openThenClear(over: Partial<CurrentTurn>) {
298
+ const { lane, calls } = makeLane()
299
+ const turn = makeLaneTurn(lane)
300
+ lane.showNarrativeStep(turn, 'Doing the work now')
301
+ await turn.activityInFlight
302
+ const cardId = turn.activityMessageId
303
+ expect(cardId).not.toBeNull()
304
+ Object.assign(turn, over)
305
+ lane.clearActivitySummary(turn)
306
+ await settle()
307
+ return { calls, cardId, turn }
308
+ }
309
+
310
+ it('reply("NO_REPLY") turn: DELETES the card, never edits a done record', async () => {
311
+ // The guard drops the sentinel-only reply before chat, but the blocked
312
+ // tool_use still stamps lastReplyText — the exact `✓ NO_REPLY` noise card.
313
+ const { calls, cardId, turn } = await openThenClear({
314
+ replyCalled: true,
315
+ lastReplyText: 'NO_REPLY',
316
+ finalAnswerEverDelivered: false,
317
+ })
318
+ expect(calls.filter((c) => c.method === 'deleteMessage' && c.message_id === cardId)).toHaveLength(1)
319
+ expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId)).toHaveLength(0)
320
+ expect(turn.activityMessageId).toBeNull()
321
+ })
322
+
323
+ it('HEARTBEAT_OK. (trailing punctuation, case-insensitive) is also suppressed', async () => {
324
+ const { calls, cardId } = await openThenClear({
325
+ replyCalled: true,
326
+ lastReplyText: 'heartbeat_ok.',
327
+ finalAnswerEverDelivered: false,
328
+ })
329
+ expect(calls.filter((c) => c.method === 'deleteMessage' && c.message_id === cardId)).toHaveLength(1)
330
+ expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId)).toHaveLength(0)
331
+ })
332
+
333
+ it('flush path (no reply): prose + trailing NO_REPLY (H6/#2053) is suppressed', async () => {
334
+ const { calls, cardId } = await openThenClear({
335
+ replyCalled: false,
336
+ lastReplyText: '',
337
+ capturedText: ["Nothing actionable in today's digest.", 'NO_REPLY'],
338
+ finalAnswerEverDelivered: false,
339
+ })
340
+ expect(calls.filter((c) => c.method === 'deleteMessage' && c.message_id === cardId)).toHaveLength(1)
341
+ expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId)).toHaveLength(0)
342
+ })
343
+
344
+ it('normal reply turn: still FINALIZES the card (edit, no delete)', async () => {
345
+ // The guarantee the fix must not break: a real answer keeps its record.
346
+ const { calls, cardId, turn } = await openThenClear({
347
+ replyCalled: true,
348
+ lastReplyText: 'The fix is deployed and the tests are green.',
349
+ finalAnswerEverDelivered: true,
350
+ })
351
+ expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId).length)
352
+ .toBeGreaterThanOrEqual(1)
353
+ expect(calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
354
+ expect(turn.activityMessageId).toBeNull()
355
+ })
356
+
357
+ it('reply "prose\\nNO_REPLY" that WAS delivered (finalAnswerEverDelivered) keeps its card', async () => {
358
+ // The guard does NOT drop a reply with non-marker content, so its prose
359
+ // reached chat — endsWithSilentMarker must NOT suppress a delivered reply.
360
+ const { calls, cardId } = await openThenClear({
361
+ replyCalled: true,
362
+ lastReplyText: 'Here is the full answer to your question.\nNO_REPLY',
363
+ finalAnswerEverDelivered: true,
364
+ })
365
+ expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId).length)
366
+ .toBeGreaterThanOrEqual(1)
367
+ expect(calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
368
+ })
369
+ })
370
+
285
371
  // ── THREE-MODULE cross-surface dedup (Amendment 1) ────────────────────────
286
372
  // The REAL P4-A handleSessionEvent turn-flush, with the REAL P4-B lane wired
287
373
  // into its deps, records the delivered answer into ONE OutboundDedupCache;