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
@@ -24317,11 +24317,14 @@ function createIpcClient(options) {
24317
24317
  function scheduleReconnect() {
24318
24318
  if (closed)
24319
24319
  return;
24320
+ if (reconnectTimer)
24321
+ return;
24320
24322
  log(`reconnecting in ${currentDelay}ms`);
24321
24323
  reconnectTimer = setTimeout(() => {
24322
24324
  reconnectTimer = null;
24323
- if (!closed)
24324
- doConnect();
24325
+ if (!closed) {
24326
+ doConnect().catch(() => {});
24327
+ }
24325
24328
  }, currentDelay);
24326
24329
  currentDelay = Math.min(currentDelay * 2, maxReconnectDelayMs);
24327
24330
  }
@@ -83,3 +83,64 @@ export function determineRestartReason(opts: {
83
83
  if (sessionMarker != null) return 'crash'
84
84
  return 'fresh'
85
85
  }
86
+
87
+ /**
88
+ * Boot-reason window during which a bridge re-register reuses the reason
89
+ * the boot path already determined. Matches the restart-marker and
90
+ * operator-marker freshness windows (5 min) — a bridge that survived a
91
+ * gateway restart reconnects within seconds of the new gateway's boot,
92
+ * comfortably inside it.
93
+ */
94
+ export const BOOT_REASON_REUSE_WINDOW_MS = 5 * 60_000
95
+
96
+ /**
97
+ * Determine the restart reason for a BRIDGE RE-REGISTER (gateway.ts
98
+ * `onClientRegistered`, the `bridge-reconnect` path) — as opposed to the
99
+ * gateway's own boot path.
100
+ *
101
+ * Why this exists (fleet-audit B2, kdogg 2026-08-02 06:27 trace): on a
102
+ * planned gateway restart (`cli: restart` SIGTERM), the bridge — living
103
+ * inside the separate claude process — survives and reconnects a few
104
+ * seconds after the new gateway boots. By then the boot path has already
105
+ * READ AND CLEARED the restart / clean-shutdown markers (the 2026-05-25
106
+ * GC, gateway.ts boot path), so re-deriving the reason from disk falls
107
+ * through to the sessionMarker branch and every such re-register logs —
108
+ * and, when a chat is resolvable, POSTS a boot card claiming —
109
+ * `reason=crash` for a perfectly graceful restart. Hundreds of these per
110
+ * agent fleet-wide (lawgpt 310, reggie 179, ziggy 175, kdogg 174).
111
+ *
112
+ * Decision:
113
+ * 1. Any on-disk marker still present → normal `determineRestartReason`
114
+ * (in-gateway /restart flows where the gateway never went down write
115
+ * a marker the boot path never consumed — keep honoring it).
116
+ * 2. No markers, but the gateway booted recently (<5 min) and recorded
117
+ * the reason it determined at boot → reuse that reason. The bridge
118
+ * is re-registering into the SAME restart episode the boot path
119
+ * already classified.
120
+ * 3. Otherwise (gateway long-lived, markers absent) → fall through to
121
+ * `determineRestartReason` — a marker-less bridge re-register hours
122
+ * into a gateway's life still classifies conservatively as 'crash'
123
+ * (the claude/bridge side genuinely died and came back).
124
+ */
125
+ export function determineBridgeReconnectReason(opts: {
126
+ marker: { ts: number } | null
127
+ cleanMarker: CleanShutdownMarker | null
128
+ sessionMarker: SessionMarker | null
129
+ now: number
130
+ /** `GATEWAY_STARTED_AT_MS` of the running gateway process. */
131
+ gatewayStartedAtMs: number
132
+ /** Reason the gateway's own boot path determined (null if it never ran). */
133
+ bootReason: RestartReason | null
134
+ bootReasonReuseWindowMs?: number
135
+ cleanMaxAgeMs?: number
136
+ markerMaxAgeMs?: number
137
+ operatorMaxAgeMs?: number
138
+ }): RestartReason {
139
+ const { gatewayStartedAtMs, bootReason, bootReasonReuseWindowMs = BOOT_REASON_REUSE_WINDOW_MS } = opts
140
+ if (opts.marker == null && opts.cleanMarker == null
141
+ && bootReason != null
142
+ && opts.now - gatewayStartedAtMs < bootReasonReuseWindowMs) {
143
+ return bootReason
144
+ }
145
+ return determineRestartReason(opts)
146
+ }
@@ -17,6 +17,10 @@
17
17
  * target via `cronIdentity()`. Pure string fns — pinned in cron-session.test.ts.
18
18
  */
19
19
 
20
+ import type { InboundMessage, GatewayToClient } from './ipc-protocol.js'
21
+ import type { InboundSpool } from './inbound-spool.js'
22
+ import { redeliverBufferedInbound, type PendingInboundBuffer } from './pending-inbound-buffer.js'
23
+
20
24
  /** Suffix that distinguishes a cron-session bridge from the main agent bridge. */
21
25
  export const CRON_IDENTITY_SUFFIX = "-cron";
22
26
 
@@ -140,3 +144,65 @@ export function deliverInjectWithFallback(
140
144
  }
141
145
  return { target, delivered: false, fellBackToMain: false };
142
146
  }
147
+
148
+ /** Minimal view of the IPC client the cron-bridge register handler needs.
149
+ * A structural subset of `IpcClient` so this stays unit-testable without the
150
+ * gateway's module-load side effects. */
151
+ export interface CronBridgeRegisterClient {
152
+ agentName: string | null | undefined;
153
+ send: (msg: GatewayToClient) => void;
154
+ }
155
+
156
+ /**
157
+ * Drain a cheap-cron (`<agent>-cron`) bridge's buffered fires when it
158
+ * registers — the Tier-1 §2.4/§3.3 status-silent path (#4348).
159
+ *
160
+ * The cron bridge is STATUS-SILENT: it must NOT drive the gateway's singleton
161
+ * machinery (shadow bridge-state, warmup, boot card). But it MUST still flush
162
+ * any cron fire buffered+spooled during the boot window — a due tick that
163
+ * arrived before the cron bridge registered.
164
+ *
165
+ * THE BUG (#4348): the pre-fix path did a raw `pendingInboundBuffer.drain()` +
166
+ * `client.send()` loop and returned early WITHOUT ever reaching `spool.ack`.
167
+ * `spool.ack` lives ONLY inside `redeliverBufferedInbound` — the one chokepoint
168
+ * every other drain path (bridgeUp, idle-drain, silence-poke, turn-end) routes
169
+ * through. So the durable spool entry stayed live, boot-replay re-pushed it on
170
+ * the next restart, and the SAME cron fire re-fired: a duplicate delivery
171
+ * bounded only by the 15-min escalation sweep. This was the ONLY drain that
172
+ * bypassed the chokepoint.
173
+ *
174
+ * THE FIX: route the drain through `redeliverBufferedInbound` too, so each
175
+ * delivered fire is spool-acked exactly once and cannot re-fire after a
176
+ * restart. `beforeRedeliver` (the represent-veto) rides along by construction;
177
+ * it self-gates and is a no-op for cron-sourced fires. A `send` throw now
178
+ * re-buffers the fire (lossless) instead of dropping it — strictly safer than
179
+ * the pre-fix best-effort drop and identical to every sibling drain path.
180
+ *
181
+ * Returns the `redeliverBufferedInbound` counts for observability.
182
+ */
183
+ export function drainCronBridgeOnRegister(
184
+ client: CronBridgeRegisterClient,
185
+ buffer: PendingInboundBuffer,
186
+ spool?: InboundSpool,
187
+ log?: (line: string) => void,
188
+ ): { drained: number; redelivered: number; rebuffered: number; retracted: number } {
189
+ // Status-silent handshake ack (unchanged from the pre-fix path).
190
+ client.send({ type: "status", status: "agent_connected" });
191
+ const send = (msg: InboundMessage): boolean => {
192
+ try {
193
+ client.send(msg);
194
+ return true;
195
+ } catch {
196
+ return false;
197
+ }
198
+ };
199
+ const result = redeliverBufferedInbound(buffer, client.agentName ?? "", send, spool);
200
+ if (result.drained > 0 && log != null) {
201
+ log(
202
+ `telegram gateway: cron-bridge drain agent=${client.agentName} ` +
203
+ `drained=${result.drained} redelivered=${result.redelivered} ` +
204
+ `rebuffered=${result.rebuffered}\n`,
205
+ );
206
+ }
207
+ return result;
208
+ }
@@ -461,7 +461,7 @@ import {
461
461
  type SendReplyGatewayDeps,
462
462
  type DeliverCapturedProseDeps,
463
463
  } from './outbound-send-path.js'
464
- import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat, closeFlushCompletionWindows } from './stream-render.js'
464
+ import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat, closeFlushCompletionWindows, parkedTurnStartCount } from './stream-render.js'
465
465
  import { createNarrativeLane } from './narrative-lane.js'
466
466
  import {
467
467
  parseAgentCallback,
@@ -659,7 +659,8 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
659
659
  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
- import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback, replyCallerIsForeignSession } from './cron-session.js'
662
+ import { makeRepresentRedeliveryGuard, makeSessionBusyDrainDeferral } from './represent-delivery-guard.js'
663
+ import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback, replyCallerIsForeignSession, drainCronBridgeOnRegister } from './cron-session.js'
663
664
  import {
664
665
  ObligationLedger,
665
666
  obligationEscalationText,
@@ -856,7 +857,7 @@ import {
856
857
  resolvePersonaName, shouldSkipDuplicateBootCard,
857
858
  type BootCardHandle, type RestartReason,
858
859
  } from './boot-card.js'
859
- import { determineRestartReason } from './boot-reason.js'
860
+ import { determineRestartReason, determineBridgeReconnectReason } from './boot-reason.js'
860
861
  import { maybeRenderUpdateAnnouncement } from './update-announce.js'
861
862
  import { createIssuesCardHandle, type IssuesCardHandle } from '../issues-card.js'
862
863
  import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.js'
@@ -9162,6 +9163,8 @@ let activeBootCard: BootCardHandle | null = null
9162
9163
  // dedupe checks this so it can't race against the boot path's await.
9163
9164
  // See issue #489 (klanker msgId 4715 + 4716, 2026-05-01 10:13:15).
9164
9165
  let bootCardPending = false
9166
+ // Boot-determined restart reason — see determineBridgeReconnectReason (B2).
9167
+ let bootReasonAtStartup: RestartReason | null = null
9165
9168
 
9166
9169
  // Issues card (#428) — pinned per-agent surface listing current
9167
9170
  // unresolved entries from the issue sink (#425). Idempotent across
@@ -9788,6 +9791,11 @@ const pendingInboundBuffer = createPendingInboundBuffer({
9788
9791
  // enqueue chokepoint so a boot-replayed handback (not just the live synthesis
9789
9792
  // push) populates it. Every `subagent_handback` push funnels through here.
9790
9793
  onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts),
9794
+ // F1 (fix/represent-double-send-delivery-recheck) — delivery-time represent
9795
+ // retract. Every drain path hands its buffered inbounds through this gate; a
9796
+ // stale `obligation_represent` (its reply landed since the sweep decided it) is
9797
+ // dropped here rather than duplicated into the CLI queue. See represent-delivery-guard.ts.
9798
+ beforeRedeliver: makeRepresentRedeliveryGuard({ enabled: OBLIGATION_LEDGER_ENABLED, historyEnabled: HISTORY_ENABLED, ledger: obligationLedger, hasOutboundDeliveredSince, minReplyChars: OBLIGATION_REPRESENT_GUARD_MIN_REPLY_CHARS, log: (l) => process.stderr.write(l) }),
9791
9799
  })
9792
9800
 
9793
9801
  // PR2 obligation-ledger idle sweep. Re-present an OPEN obligation only at a
@@ -10360,17 +10368,13 @@ if (isGatewayMain) ipcServer = createIpcServer({
10360
10368
 
10361
10369
  onClientRegistered(client: IpcClient) {
10362
10370
  process.stderr.write(`telegram gateway: bridge registered — agent=${client.agentName}\n`)
10363
- // Cheap-cron (§2.4/§3.3): a `<agent>-cron` bridge is the Tier-1 cheap
10364
- // session. It is STATUS-SILENT — it must NOT drive the gateway's
10365
- // singleton machinery (shadow bridge-state, warmup, boot card, which all
10366
- // track the MAIN agent's liveness). Drain any buffered cron fire to it
10367
- // (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.
10368
10376
  if (isCronIdentity(client.agentName)) {
10369
- client.send({ type: 'status', status: 'agent_connected' })
10370
- const pending = pendingInboundBuffer.drain(client.agentName ?? '')
10371
- for (const m of pending) {
10372
- try { client.send(m) } catch { /* cron fire drop — best-effort, like today's cron */ }
10373
- }
10377
+ drainCronBridgeOnRegister(client, pendingInboundBuffer, inboundSpool ?? undefined, (l) => process.stderr.write(l))
10374
10378
  return
10375
10379
  }
10376
10380
  // Phase 2b shadow: ONLY emit bridgeUp for the REAL bridge sidecar
@@ -10444,15 +10448,12 @@ if (isGatewayMain) ipcServer = createIpcServer({
10444
10448
  })
10445
10449
  }
10446
10450
 
10447
- // If the agent reconnected after a /restart (or any restart), post a boot
10448
- // card. The restart-marker carries the ack chat; if absent we fall back to
10449
- // resolveBootChatId so crash-recovery reconnects also get a card.
10450
- //
10451
- // Skip if the boot path already posted a card OR is currently in-flight
10452
- // on its sendMessage await (issue #489 — klanker msgId 4715+4716,
10453
- // 2026-05-01). The original dedupe (msgId 2245+2248, 2026-04-26) only
10454
- // covered the post-resolution case; bootCardPending closes the in-flight
10455
- // 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`.
10456
10457
  const dedupeDecision = shouldSkipDuplicateBootCard({ activeBootCard, bootCardPending }, 'bridge-reconnect')
10457
10458
  if (dedupeDecision.skip) {
10458
10459
  process.stderr.write(`telegram gateway: bridge-reconnect: skipping boot card (${dedupeDecision.reason})\n`)
@@ -10469,7 +10470,9 @@ if (isGatewayMain) ipcServer = createIpcServer({
10469
10470
  clearRestartMarker()
10470
10471
  }
10471
10472
 
10472
- 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 })
10473
10476
  const target = resolveBootChatId(marker, markerAgeMs)
10474
10477
 
10475
10478
  if (target) {
@@ -11742,30 +11745,21 @@ if (isGatewayMain) (() => { // #2996 P0c: gated — starts webhook-ingest UDS s
11742
11745
  })()
11743
11746
 
11744
11747
  // ─── Opportunistic idle-drain of pendingInboundBuffer ─────────────────────
11745
- // pendingInboundBuffer otherwise drains only on (a) bridge re-register
11746
- // (onClientRegistered) or (b) the silence-poke framework fallback
11747
- // clearing a wedged turn (#1546). NEITHER fires when a message is
11748
- // buffered during a bridge-IPC flap that then settles with no
11749
- // subsequent clean re-register AND claude is idle (no active turn
11750
- // silence-poke never arms). The message orphans until a manual restart
11751
- // (finn, 2026-05-19 buffered "verify with mff-query.py cashflow"
11752
- // while idle; last `bridge registered` predated the buffer push, so
11753
- // onClientRegistered's drain never ran for it).
11754
- //
11755
- // This is the third drain trigger. It's gated to be zero-cost and
11756
- // zero-churn: skip entirely when nothing is buffered (one Map.get, no
11757
- // log), when the bridge isn't alive (exactly sendToAgent's own guard —
11758
- // so we never drain into a dead bridge and re-buffer/log-spin), OR
11759
- // when a turn is in flight. The turn gate is #1556: a message
11760
- // delivered while a turn is active is NOT safely queued by the bridge
11761
- // — claude types it into its TUI composer and the auto-submit races
11762
- // turn-completion, stranding it (the lawgpt wedge). Draining only at
11763
- // `activeTurnStartedAt.size === 0` guarantees the channel notification
11764
- // lands at an idle prompt and submits as a fresh turn. Only when there
11765
- // IS a buffered message AND a live bridge AND no active turn do we
11766
- // reuse the #1546 `redeliverBufferedInbound` (lossless: re-buffers any
11767
- // per-message miss).
11748
+ // The THIRD drain trigger, beside bridge re-register (onClientRegistered) and
11749
+ // the silence-poke framework fallback (#1546): neither fires when a message is
11750
+ // buffered during a bridge-IPC flap that settles with no clean re-register while
11751
+ // claude is idle, so it orphans until a manual restart (finn, 2026-05-19). Gated
11752
+ // zero-cost/zero-churn: skip when nothing is buffered, when the bridge is dead,
11753
+ // or when a turn is in flight (#1556 — a message delivered mid-turn is typed into
11754
+ // claude's TUI composer and the auto-submit races turn-completion, stranding it).
11768
11755
  const IDLE_DRAIN_INTERVAL_MS = 5000
11756
+ // F2 (drain half) — bounded busy-defer. A ~5-min poke can clear the machine turn
11757
+ // while the CLI is still producing the answer; draining a represent into that busy
11758
+ // session re-queues it BEHIND the real reply → a duplicate. Defer while busy,
11759
+ // bounded so a wedged session still drains (F1 then retracts it post-reply).
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)
11769
11763
  if (isGatewayMain && !STATIC) {
11770
11764
  setInterval(() => {
11771
11765
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
@@ -11775,8 +11769,11 @@ if (isGatewayMain && !STATIC) {
11775
11769
  () => {
11776
11770
  // #1556: never drain mid-turn — that re-creates the composer
11777
11771
  // wedge this buffer exists to prevent. Gate reads the delivery
11778
- // machine (turnInFlightForGate).
11772
+ // machine (turnInFlightForGate) AND, per F2, stream-render's live-turn
11773
+ // mirror (a poke can clear the former while the latter is still busy).
11779
11774
  if (turnInFlightForGate()) return false
11775
+ const sessionBusy = (currentTurn != null && currentTurn.endedAt == null) || parkedTurnStartCount() > 0
11776
+ if (idleDrainBusyDefer(sessionBusy, Date.now())) return false
11780
11777
  const c = ipcServer.getClient(selfAgent)
11781
11778
  return c != null && c.isAlive()
11782
11779
  },
@@ -23492,15 +23489,14 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23492
23489
  } else {
23493
23490
  const markerAgeMs = marker ? nowMs - marker.ts : undefined
23494
23491
  const reason = determineRestartReason({ marker, cleanMarker, sessionMarker: storedSession, now: nowMs })
23492
+ // Markers were cleared above — stash for bridge re-register (B2).
23493
+ bootReasonAtStartup = reason
23495
23494
  const target = resolveBootChatId(marker, markerAgeMs)
23496
23495
 
23497
- // Issue #92: when reason='crash' AND no chat is resolvable,
23498
- // the gateway used to silently skip the only signal a user
23499
- // got was their next message landing on a fresh process. Now
23500
- // we always surface unplanned crashes via the operator-events
23501
- // pipeline, which broadcasts to access.allowFrom (same path
23502
- // permission requests use). The pipeline's per-agent per-kind
23503
- // 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).
23504
23500
  if (reason === 'crash') {
23505
23501
  const cleanMarkerStale = cleanMarker
23506
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),
@@ -178,6 +178,15 @@ export class ObligationLedger {
178
178
  return this.open.has(originTurnId)
179
179
  }
180
180
 
181
+ /** The open obligation for `originTurnId`, or undefined if none is open. Read-
182
+ * only accessor for the delivery-time represent re-check (represent-delivery-
183
+ * guard.ts): a represent buffered while the obligation was open must be re-
184
+ * evaluated against the obligation's CURRENT cutoff at the moment it is handed
185
+ * to the CLI bridge, since a reply may have landed between decision and drain. */
186
+ get(originTurnId: string): Obligation | undefined {
187
+ return this.open.get(originTurnId)
188
+ }
189
+
181
190
  hasOpen(): boolean {
182
191
  return this.open.size > 0
183
192
  }
@@ -23,6 +23,7 @@ import { buildObligationRepresentInbound, type Obligation } from './obligation-l
23
23
  import { driveEscalation } from './escalation-drive.js'
24
24
  import { shouldSuppressRepresent } from './represent-guard.js'
25
25
  import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
26
+ import { parkedTurnStartCount } from './stream-render.js'
26
27
 
27
28
  export function createObligationWiring(deps: ObligationWiringDeps) {
28
29
  const {
@@ -161,13 +162,26 @@ function obligationSweep(): void {
161
162
  if (turnInFlightForGate()) return // a turn is running — let it finish/answer
162
163
  const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
163
164
  const now = Date.now()
165
+ // Session-busy signal (F2, decision half). A ~5-min silence poke can clear the
166
+ // machine turn (`turnInFlightForGate()` above → false) while the CLI session is
167
+ // STILL producing the answer — stream-render's live-turn mirror (`currentTurn`
168
+ // whose endedAt is null, or a parked turn-start) still knows it is busy. Without
169
+ // this, the sweep decides + buffers a represent mid-answer; the real reply lands
170
+ // seconds later and the model consumes the queued represent → a duplicate answer
171
+ // (the two-authorities-disagree defect). We treat a busy mirror the SAME as
172
+ // in-flight background work: defer the represent, BOUNDED by the same grace, so a
173
+ // wedged/hung session cannot silence a genuinely-unanswered turn forever.
174
+ const liveTurn = getCurrentTurn()
175
+ const sessionBusy = (liveTurn != null && liveTurn.endedAt == null) || parkedTurnStartCount() > 0
164
176
  // Background-work grace: while genuine autonomous sub-agent work is in flight
165
177
  // (a running worker, or an orphaned foreground sub-agent — neither visible to
166
- // the turn machine), an obligation younger than the ceiling is NOT re-presented
167
- // /escalated. Bounded by OBLIGATION_BACKGROUND_WORK_GRACE_MS so escalation
168
- // always eventually fires. =0 disables it.
178
+ // the turn machine), OR the CLI session is still busy behind a poke-cleared
179
+ // machine turn, an obligation younger than the ceiling is NOT re-presented
180
+ // /escalated. Bounded by OBLIGATION_BACKGROUND_WORK_GRACE_MS (measured from
181
+ // openedAt in decideAtIdle) so escalation always eventually fires. =0 disables it.
169
182
  const backgroundWorkActive =
170
- OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && agentHasInFlightBackgroundWork(now)
183
+ OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 &&
184
+ (agentHasInFlightBackgroundWork(now) || sessionBusy)
171
185
  // Grace window: skip an obligation whose handling turn ended < grace ago — its
172
186
  // trailing slow/worker answer may still be landing (over-escalation fix).
173
187
  // Per-represent grace: skip an obligation re-presented < grace ago — prevents
@@ -189,6 +203,7 @@ function obligationSweep(): void {
189
203
  lastBgWorkDeferLogMs = now
190
204
  process.stderr.write(
191
205
  `telegram gateway: obligation sweep deferred — in-flight autonomous sub-agent work ` +
206
+ `or busy CLI session behind a poke-cleared turn ` +
192
207
  `(${obligationLedger.size()} open, bounded ${Math.round(OBLIGATION_BACKGROUND_WORK_GRACE_MS / 60_000)}m from receipt)\n`,
193
208
  )
194
209
  }
@@ -49,6 +49,16 @@ export interface PendingInboundBuffer {
49
49
  depth: (agent: string) => number
50
50
  /** Test-only: total depth across all agents. */
51
51
  totalDepth: () => number
52
+ /**
53
+ * Delivery-time gate consulted by `redeliverBufferedInbound` for EVERY message
54
+ * it is about to hand to the bridge. Returns TRUE to proceed, FALSE to RETRACT
55
+ * (drop) the message (the spool entry is acked so it is not boot-replayed, and
56
+ * it is NOT re-buffered). The gateway wires this to the represent delivery
57
+ * re-check (represent-delivery-guard.ts): a represent buffered while its
58
+ * obligation was open must be re-evaluated at drain time, since a reply may have
59
+ * landed between the sweep's decision and this drain. Undefined ⇒ never retract.
60
+ */
61
+ beforeRedeliver?: (msg: InboundMessage) => boolean
52
62
  }
53
63
 
54
64
  export interface PendingInboundBufferOptions {
@@ -94,6 +104,13 @@ export interface PendingInboundBufferOptions {
94
104
  * breaks the push hot path.
95
105
  */
96
106
  onHandbackEnqueue?: (chatId: string, threadId: number | undefined, ts: number) => void
107
+ /**
108
+ * Delivery-time retract gate — see `PendingInboundBuffer.beforeRedeliver`. The
109
+ * gateway supplies the represent delivery re-check here so EVERY drain path
110
+ * (idle-drain, bridge re-register, silence-poke fallback, turn-end) inherits it
111
+ * by construction rather than by per-call-site discipline.
112
+ */
113
+ beforeRedeliver?: (msg: InboundMessage) => boolean
97
114
  }
98
115
 
99
116
  /**
@@ -123,16 +140,38 @@ export function redeliverBufferedInbound(
123
140
  // silently dropped (clerk 2026-06-03). `send` returning true only means
124
141
  // the bytes reached the bridge, NOT that claude consumed them.
125
142
  onDelivered?: (merged: InboundMessage, originals: InboundMessage[]) => void,
126
- ): { drained: number; redelivered: number; rebuffered: number } {
143
+ ): { drained: number; redelivered: number; rebuffered: number; retracted: number } {
127
144
  const pending = buffer.drain(agent)
128
145
  let redelivered = 0
129
146
  let rebuffered = 0
147
+ let retracted = 0
130
148
  // Collapse consecutive same-sender Telegram user messages into one turn
131
149
  // (see planBufferedRedelivery) so a forwarded burst that spanned a turn
132
150
  // boundary doesn't fan out into N sequential replies. System inbounds
133
151
  // (vault grants, approvals, cron, handbacks — anything with meta.source)
134
152
  // are never merged and are delivered individually exactly as before.
135
153
  for (const { merged, originals } of planBufferedRedelivery(pending)) {
154
+ // Delivery-time retract (F1): a buffered `obligation_represent` whose reply
155
+ // has landed since the sweep decided it is stale — drop it rather than hand
156
+ // a duplicate to the CLI queue. Ack the spool so it is not boot-replayed and
157
+ // do NOT re-buffer. The gateway closure closes the ledger + logs the retract.
158
+ // FAIL-OPEN: a throwing predicate must never silence or lose a real message,
159
+ // nor abort the drain loop (which would strand every following message and
160
+ // crash the setInterval tick). On throw we treat it as "deliver".
161
+ let proceed = true
162
+ if (buffer.beforeRedeliver != null) {
163
+ try {
164
+ proceed = buffer.beforeRedeliver(merged)
165
+ } catch (e) {
166
+ proceed = true
167
+ process.stderr.write(`redeliver beforeRedeliver threw — failing open (deliver): ${String(e)}\n`)
168
+ }
169
+ }
170
+ if (!proceed) {
171
+ for (const o of originals) spool?.ack(o)
172
+ retracted += originals.length
173
+ continue
174
+ }
136
175
  let delivered = false
137
176
  try {
138
177
  delivered = send(merged)
@@ -157,7 +196,7 @@ export function redeliverBufferedInbound(
157
196
  rebuffered += originals.length
158
197
  }
159
198
  }
160
- return { drained: pending.length, redelivered, rebuffered }
199
+ return { drained: pending.length, redelivered, rebuffered, retracted }
161
200
  }
162
201
 
163
202
  /** True when `msg` is an ordinary Telegram user message eligible to be
@@ -312,7 +351,7 @@ export function idleDrainTick(
312
351
  // enrols redelivered inbounds in the deliver-until-acked queue (parity with
313
352
  // the bridgeUp drain — clerk lost-message incident, 2026-06-03).
314
353
  onDelivered?: (merged: InboundMessage, originals: InboundMessage[]) => void,
315
- ): { drained: number; redelivered: number; rebuffered: number } | null {
354
+ ): { drained: number; redelivered: number; rebuffered: number; retracted: number } | null {
316
355
  if (!agent) return null
317
356
  if (buffer.depth(agent) === 0) return null
318
357
  if (!isBridgeAlive()) return null
@@ -412,5 +451,6 @@ export function createPendingInboundBuffer(
412
451
  for (const q of queues.values()) n += q.length
413
452
  return n
414
453
  },
454
+ beforeRedeliver: opts.beforeRedeliver,
415
455
  }
416
456
  }