switchroom 0.20.6 → 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.
Files changed (30) hide show
  1. package/dist/agent-scheduler/index.js +106 -12
  2. package/dist/auth-broker/index.js +71 -9
  3. package/dist/cli/notion-write-pretool.mjs +68 -7
  4. package/dist/cli/switchroom.js +387 -30
  5. package/dist/host-control/main.js +72 -10
  6. package/dist/vault/approvals/kernel-server.js +71 -9
  7. package/dist/vault/broker/server.js +71 -9
  8. package/package.json +1 -1
  9. package/telegram-plugin/bridge/ipc-client.ts +17 -1
  10. package/telegram-plugin/dist/bridge/bridge.js +5 -2
  11. package/telegram-plugin/dist/gateway/gateway.js +342 -120
  12. package/telegram-plugin/dist/server.js +5 -2
  13. package/telegram-plugin/gateway/boot-reason.ts +61 -0
  14. package/telegram-plugin/gateway/cron-session.ts +66 -0
  15. package/telegram-plugin/gateway/gateway.ts +50 -54
  16. package/telegram-plugin/gateway/narrative-lane.ts +21 -1
  17. package/telegram-plugin/gateway/obligation-ledger.ts +9 -0
  18. package/telegram-plugin/gateway/obligation-wiring.ts +19 -4
  19. package/telegram-plugin/gateway/pending-inbound-buffer.ts +43 -3
  20. package/telegram-plugin/gateway/represent-delivery-guard.ts +188 -0
  21. package/telegram-plugin/gateway/stream-render.ts +24 -2
  22. package/telegram-plugin/tests/boot-card-reason.test.ts +88 -0
  23. package/telegram-plugin/tests/cron-bridge-drain-spool-ack.test.ts +150 -0
  24. package/telegram-plugin/tests/ipc-client-reconnect-rejection.test.ts +70 -0
  25. package/telegram-plugin/tests/narrative-lane-golden.test.ts +86 -0
  26. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +145 -9
  27. package/telegram-plugin/tests/queued-card-surface.test.ts +66 -0
  28. package/telegram-plugin/tests/represent-guard.test.ts +295 -0
  29. package/telegram-plugin/tests/turn-flush-safety.test.ts +83 -0
  30. package/telegram-plugin/turn-flush-safety.ts +79 -0
@@ -0,0 +1,188 @@
1
+ /**
2
+ * represent-delivery-guard.ts — the DELIVERY-time half of the duplicate-represent
3
+ * defence (fix/represent-double-send-delivery-recheck).
4
+ *
5
+ * Background — the race this closes (verified from history.db, 2026):
6
+ * 1. A ~5-min silence poke fires the framework fallback, which clears the
7
+ * machine turn (`turnInFlightForGate()` → false) while the CLI session is
8
+ * STILL producing the answer (it lands ~17s later). stream-render's live-turn
9
+ * mirror (`currentTurn`/`parkedTurnStarts`) still knows the session is busy.
10
+ * 2. With the machine turn cleared, `obligationSweep` runs; `shouldSuppressRepresent`
11
+ * correctly returns false (nothing has been sent YET) and the represent is
12
+ * buffered; the idle-drain flushes it into the live CLI queue.
13
+ * 3. ~13s later the real answer is delivered + recorded (ledger closed by the
14
+ * normal path). Then the model consumes the already-queued represent and
15
+ * answers a SECOND time → the duplicate.
16
+ *
17
+ * The represent guard never FAILED — it was consulted BEFORE the reply existed,
18
+ * and there was no re-check between decision → delivery → model consumption. This
19
+ * module adds that missing re-check (F1) plus the bounded busy-defer for the
20
+ * idle-drain gate (F2). Layer F2 (obligation-wiring's sweep + this drain defer)
21
+ * keeps the represent BUFFERED while the session is busy so that, by the time a
22
+ * drain actually hands it to the bridge, the real answer has landed and been
23
+ * recorded — at which point F1 retracts the now-stale represent. `recordOutbound`
24
+ * is a synchronous SQLite write that completes before the reply tool result
25
+ * returns, so a post-reply drain observes the row.
26
+ *
27
+ * Both halves are PURE-ish factories (no Telegram, no SQLite) so the whole
28
+ * decision is executable in a unit test; the gateway injects the ledger accessor,
29
+ * the outbound-history predicate, and the logger.
30
+ */
31
+
32
+ import type { InboundMessage } from './ipc-protocol.js'
33
+ import { shouldSuppressRepresent, type RepresentGuardObligation } from './represent-guard.js'
34
+
35
+ /** The minimal ledger surface the delivery re-check needs. */
36
+ export interface RepresentDeliveryLedger {
37
+ /** The CURRENTLY-open obligation for `originTurnId`, or undefined if none is
38
+ * open (already closed — answered or cancelled — since the represent was
39
+ * buffered). */
40
+ get(originTurnId: string): RepresentGuardObligation | undefined
41
+ /** Close the obligation. Idempotent; returns whether an entry was removed. */
42
+ close(originTurnId: string | null | undefined): boolean
43
+ }
44
+
45
+ export interface RepresentRedeliveryGuardDeps {
46
+ /** OBLIGATION_LEDGER_ENABLED — when off, never retract (nothing is tracked). */
47
+ enabled: boolean
48
+ /** HISTORY_ENABLED — forwarded to the pure guard (no history ⇒ never suppress). */
49
+ historyEnabled: boolean
50
+ ledger: RepresentDeliveryLedger
51
+ /** history.hasOutboundDeliveredSince, already curried with the chat query. */
52
+ hasOutboundDeliveredSince: (
53
+ chatId: string,
54
+ sinceMs: number,
55
+ threadId?: number,
56
+ minChars?: number,
57
+ ) => boolean
58
+ /** The represent guard's OWN low reply-length threshold (a terse-but-real reply
59
+ * must still suppress the duplicate — #2472/#2474). */
60
+ minReplyChars: number
61
+ log: (line: string) => void
62
+ }
63
+
64
+ /**
65
+ * Build the `beforeRedeliver` predicate the pending-inbound buffer consults for
66
+ * EVERY message it is about to hand to the CLI bridge. Returns TRUE to proceed
67
+ * with delivery, FALSE to RETRACT (drop) the message.
68
+ *
69
+ * Only `obligation_represent` inbounds are ever retracted; everything else always
70
+ * proceeds. A represent is retracted when, AT DELIVERY TIME:
71
+ * - the obligation is no longer open (a reply landed and the normal close path
72
+ * fired, or it was cancelled) — re-delivering would duplicate / re-ask a
73
+ * resolved question; OR
74
+ * - it IS still open but `shouldSuppressRepresent` now returns true against the
75
+ * obligation's CURRENT cutoff (a reply landed since decision, but the normal
76
+ * close path missed it because its routing did not resolve back to the origin
77
+ * — the exact #2472 gap, now caught at delivery instead of only at decision).
78
+ *
79
+ * The cutoff is the obligation's own (`openedAt` for the first represent,
80
+ * `lastRepresentedAt` thereafter) — encoded inside `shouldSuppressRepresent`, so
81
+ * an OLD reply to a DIFFERENT earlier question does not suppress a legitimate new
82
+ * represent (#2472 second-represent semantics). The genuine plain-text-no-reply
83
+ * case (#2788) records NO outbound row, so the predicate reports false at BOTH
84
+ * decision and here ⇒ the represent still fires exactly once.
85
+ */
86
+ export function makeRepresentRedeliveryGuard(
87
+ deps: RepresentRedeliveryGuardDeps,
88
+ ): (msg: InboundMessage) => boolean {
89
+ return (msg) => {
90
+ if (!deps.enabled) return true
91
+ if (msg.meta?.source !== 'obligation_represent') return true
92
+ const originTurnId = msg.meta?.origin_turn_id
93
+ if (originTurnId == null) return true
94
+
95
+ const o = deps.ledger.get(originTurnId)
96
+ if (o == null) {
97
+ // Closed since this represent was buffered — a reply landed (normal close)
98
+ // or the turn was cancelled/interrupted. Either way the buffered represent
99
+ // is stale: drop it rather than re-ask an already-resolved obligation.
100
+ deps.log(
101
+ `telegram gateway: represent retracted at delivery — obligation already ` +
102
+ `closed since decision (no re-fire) origin=${originTurnId}\n`,
103
+ )
104
+ return false
105
+ }
106
+
107
+ const suppress = shouldSuppressRepresent(o, {
108
+ historyEnabled: deps.historyEnabled,
109
+ hasOutboundDeliveredSince: (chatId, sinceMs, threadId) =>
110
+ deps.hasOutboundDeliveredSince(chatId, sinceMs, threadId, deps.minReplyChars),
111
+ })
112
+ if (suppress) {
113
+ // A reply landed since this obligation's cutoff but the normal close path
114
+ // missed it. Close the ledger entry ourselves and drop the represent.
115
+ deps.ledger.close(originTurnId)
116
+ deps.log(
117
+ `telegram gateway: represent retracted at delivery — reply landed since ` +
118
+ `decision (no re-fire) origin=${originTurnId}\n`,
119
+ )
120
+ return false
121
+ }
122
+ return true
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Build the BOUNDED busy-defer predicate for the idle-drain gate (F2, drain
128
+ * half). Returns TRUE while the drain should be DEFERRED because the session is
129
+ * busy (stream-render's live-turn mirror), FALSE once it is safe to drain.
130
+ *
131
+ * Why bounded: the drain must not hand a buffered represent to a session that is
132
+ * mid-answer (that re-queues it BEHIND the real answer → the duplicate). But a
133
+ * session that is WEDGED busy forever must not silence a buffered represent
134
+ * forever (red-team #2). So the deferral is capped: once the session has been
135
+ * continuously busy for longer than `boundMs`, the drain proceeds regardless. The
136
+ * clock resets to "not deferring" the moment the session reads idle, so an
137
+ * ordinary busy turn (which ends well within the bound) defers cleanly and never
138
+ * consumes the wedge budget.
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
+ *
153
+ * `boundMs <= 0` disables the busy-defer entirely (kill switch / parity with the
154
+ * background-work grace being disabled). `staleGapMs <= 0` disables the
155
+ * gap-based episode reset (leaving only the `busy=false` reset).
156
+ */
157
+ export const DEFAULT_DRAIN_DEFER_STALE_GAP_MS = 15_000
158
+
159
+ export function makeSessionBusyDrainDeferral(
160
+ boundMs: number,
161
+ staleGapMs: number = DEFAULT_DRAIN_DEFER_STALE_GAP_MS,
162
+ ): (busy: boolean, now: number) => boolean {
163
+ let deferringSince: number | null = null
164
+ let lastCallAt: number | null = null
165
+ return (busy, now) => {
166
+ const gapSinceLastCall = lastCallAt == null ? null : now - lastCallAt
167
+ lastCallAt = now
168
+ if (!busy || boundMs <= 0) {
169
+ deferringSince = null
170
+ return false
171
+ }
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
+ }
184
+ // Bounded: stop deferring once we have been busy past the ceiling, so a
185
+ // wedged/hung session still eventually drains the buffered represent.
186
+ return now - deferringSince < boundMs
187
+ }
188
+ }
@@ -279,6 +279,19 @@ export function __resetParkedTurnStartsForTest(): void {
279
279
  export function __parkedTurnStartCountForTest(): number {
280
280
  return parkedTurnStarts.length
281
281
  }
282
+ /**
283
+ * Production accessor for the parked-turn-start count — the second half of the
284
+ * stream-render "session busy" signal (the first being a live `currentTurn`
285
+ * whose `endedAt == null`). A parked turn-start means the CLI has an enqueued
286
+ * message it is about to turn on, so the session is NOT idle even when the
287
+ * gateway's machine-turn gate (`turnInFlightForGate`) reads clear — the exact
288
+ * disagreement a ~5-min silence poke opens (it clears the machine turn while the
289
+ * CLI is still producing the answer). The obligation sweep and the idle-drain
290
+ * consult this so they do not treat a poke-cleared-but-busy session as idle.
291
+ */
292
+ export function parkedTurnStartCount(): number {
293
+ return parkedTurnStarts.length
294
+ }
282
295
 
283
296
  /**
284
297
  * Silence-fallback unwedge for the parked store (the two-state desync fix).
@@ -1125,8 +1138,17 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
1125
1138
  }
1126
1139
  if (QUEUED_CARD_ENABLED && !handbackOwnsSurface) {
1127
1140
  const cardChatId = ev.chatId
1128
- const replyToRaw = ev.messageId != null ? Number(ev.messageId) : null
1129
- 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)
1130
1152
  void openQueuedCard(deps, cardChatId, enqThreadIdNum ?? null, replyTo).then((cardId) => {
1131
1153
  if (cardId == null) return
1132
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;