switchroom 0.18.32 → 0.19.0

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 (121) hide show
  1. package/dist/auth-broker/index.js +17 -1
  2. package/dist/cli/switchroom.js +847 -729
  3. package/dist/host-control/main.js +18 -2
  4. package/dist/vault/approvals/kernel-server.js +17 -1
  5. package/dist/vault/broker/server.js +44 -2
  6. package/package.json +2 -2
  7. package/profiles/_base/start.sh.hbs +105 -18
  8. package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
  9. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  10. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  11. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  12. package/telegram-plugin/gateway/boot-card.ts +169 -1
  13. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  14. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  16. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  17. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  18. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  19. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  20. package/telegram-plugin/gateway/gateway.ts +1347 -6758
  21. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  22. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  23. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  24. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  26. package/telegram-plugin/gateway/model-command.ts +23 -0
  27. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  28. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  29. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  30. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  31. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  32. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  33. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  34. package/telegram-plugin/gateway/turn-end.ts +606 -0
  35. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  36. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  37. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  38. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  39. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  40. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  41. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  42. package/telegram-plugin/narrative-dedup.ts +24 -1
  43. package/telegram-plugin/narrative-flush.ts +2 -2
  44. package/telegram-plugin/render/render.ts +25 -1
  45. package/telegram-plugin/status-no-truncate.ts +13 -0
  46. package/telegram-plugin/subagent-watcher.ts +186 -3
  47. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  48. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  49. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  50. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  51. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  52. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  53. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  54. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  55. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
  56. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  57. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
  58. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  59. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  60. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  61. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  62. package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
  63. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
  64. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  65. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
  66. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
  67. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
  68. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  69. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
  70. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  71. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  72. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  73. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
  74. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  75. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  76. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  77. package/telegram-plugin/tests/model-command.test.ts +30 -0
  78. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
  79. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  80. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  81. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  82. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  83. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
  84. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  85. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  86. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  87. package/telegram-plugin/tests/render/render.test.ts +42 -0
  88. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  89. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  90. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
  91. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  93. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  94. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  96. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
  97. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  98. package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
  99. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
  100. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  101. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  102. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  103. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  104. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  105. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  106. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  107. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  108. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  109. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
  110. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  111. package/telegram-plugin/tool-activity-summary.ts +83 -35
  112. package/telegram-plugin/turn-flush-safety.ts +80 -14
  113. package/telegram-plugin/uat/restart-capability.ts +76 -0
  114. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  115. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  116. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  117. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  118. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  119. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  120. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  121. package/telegram-plugin/worker-activity-feed.ts +10 -4
@@ -0,0 +1,606 @@
1
+ /**
2
+ * turn-end.ts — the turn-END funnel + drain chokepoint (#2996 P8 PR-B).
3
+ *
4
+ * Extracted VERBATIM from gateway.ts (the 4757-5271 family): the canonical
5
+ * `endCurrentTurnAtomic` funnel, the fallback `purgeReactionTracking` purge
6
+ * (incl. the M2 pending-restart drain in its idle tail), the narrow
7
+ * `releaseTurnBufferGate` first-reply release, and the buffer-drain chokepoint
8
+ * (`performBufferDrain` / `drainBufferedIfAllowed` / `armNoReplyDrainTimer`).
9
+ *
10
+ * DI contract (the stream-render precedent): STATE stays in gateway.ts; this
11
+ * module owns only the logic. All mutable stores, live singletons and
12
+ * gateway-bound helpers arrive through `TurnEndDeps`
13
+ * (`gatewayTurnEndDeps()` in gateway.ts — exact-by-construction via
14
+ * `ReturnType`). Volatile reads (`currentTurn`, `inboundSpool`, `ipcServer`)
15
+ * cross as GETTERS so every call sees the live value; `endingTurn` stays a
16
+ * passed value (design §3 PR-B risk row). Pure decision helpers are imported
17
+ * directly — they are already extracted modules with their own suites.
18
+ *
19
+ * P9 note (clusters B/D): the module-scope `currentTurn` reads that lived in
20
+ * these bodies are funnelled through `deps.getCurrentTurn()` here, so they
21
+ * leave gateway.ts with this move (P9 §5 recommendation).
22
+ *
23
+ * gateway.ts keeps thin function-declaration wrappers with the original
24
+ * names, so every callsite (stream-render deps, halt path, disconnect-flush,
25
+ * silence-poke fallback, executeReply) is byte-identical.
26
+ */
27
+ import type { CurrentTurn, TurnEndDeps } from './gateway.js'
28
+ import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
29
+ import { chatIdOfChatKey } from './chat-key.js'
30
+ import { mayDrainBufferedInbound, shouldArmNoReplyDrain } from './serialize-drain-gate.js'
31
+ import { redeliverBufferedInbound } from './pending-inbound-buffer.js'
32
+ import { decideObligationTurnEnd } from './obligation-turn-end.js'
33
+ import { formatTurnLifecycle, detectStatusSurfaceDegraded } from './status-surface-log.js'
34
+
35
+ export function createTurnEndFunnel(deps: TurnEndDeps) {
36
+ const {
37
+ // Flags (module-load consts in gateway; captured once — same semantics).
38
+ SERIALIZE_UNTIL_REPLIED_ENABLED,
39
+ SERIALIZE_NOREPLY_DRAIN_MS,
40
+ OBLIGATION_LEDGER_ENABLED,
41
+ // Volatile getters (live reads — P9 getter funnel).
42
+ getCurrentTurn,
43
+ getInboundSpool,
44
+ sendToAgent,
45
+ // Gateway-bound helpers (by name).
46
+ turnInFlightForGate,
47
+ turnLiveForItsTopic,
48
+ endCurrentTurnForKey,
49
+ clearAllCurrentTurns,
50
+ statusKey,
51
+ reapQueuedStatus,
52
+ stopTurnTypingLoop,
53
+ stopEarlyLivenessOpen,
54
+ reconcileStatusPin,
55
+ trackRedeliveredInbound,
56
+ drainPendingSessionCommand,
57
+ triggerSelfRestart,
58
+ maybeProactiveCompact,
59
+ snapshotContextOccupancy,
60
+ emitTurnRecord,
61
+ flushPendingUserFailureNotices,
62
+ clearAnswerReadyFlushTimeout,
63
+ shadowEmit,
64
+ removeActiveReaction,
65
+ resolveAgentDirFromEnv,
66
+ // Stores (stable references owned by gateway).
67
+ activeStatusReactions,
68
+ activeReactionMsgIds,
69
+ activeTurnStartedAt,
70
+ busyAckPostedKeys,
71
+ busyAckRecheckTimers,
72
+ reactionTransitionCounts,
73
+ firstTextReplyLogged,
74
+ pendingRestarts,
75
+ pendingInboundBuffer,
76
+ obligationLedger,
77
+ } = deps
78
+
79
+ /**
80
+ * Component 1 — deliver-before-drain. The single chokepoint that both
81
+ * turn-end drain sites (`purgeReactionTracking`, `releaseTurnBufferGate`)
82
+ * route through. Drains the pending-inbound buffer ONLY when
83
+ * `mayDrainBufferedInbound` says so: claude is idle AND (the
84
+ * serialize-until-replied kill switch is off, OR there is no ending-turn
85
+ * handle, OR the ending turn delivered its final answer). A no-reply turn
86
+ * (finalAnswerDelivered=false) deliberately does NOT drain here — the
87
+ * bounded escape-hatch timer in `endCurrentTurnAtomic` covers that
88
+ * liveness case. The 300s silence-poke fallback (`redeliverBufferedInbound`
89
+ * called directly, bypassing this gate) remains the long-stop.
90
+ */
91
+ function performBufferDrain(reason: string): void {
92
+ const selfAgentForFlush = process.env.SWITCHROOM_AGENT_NAME ?? ''
93
+ if (pendingInboundBuffer.depth(selfAgentForFlush) <= 0) return
94
+ const fr = redeliverBufferedInbound(
95
+ pendingInboundBuffer,
96
+ selfAgentForFlush,
97
+ (m) => sendToAgent(selfAgentForFlush, m),
98
+ getInboundSpool(),
99
+ trackRedeliveredInbound,
100
+ )
101
+ if (fr.redelivered > 0) {
102
+ process.stderr.write(
103
+ `telegram gateway: ${reason} flushed ${fr.redelivered}/${fr.drained} ` +
104
+ `held inbound for ${selfAgentForFlush}` +
105
+ `${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ''}\n`,
106
+ )
107
+ }
108
+ }
109
+
110
+ function drainBufferedIfAllowed(endingTurn: CurrentTurn | undefined, reason: string): void {
111
+ if (
112
+ !mayDrainBufferedInbound({
113
+ turnInFlight: turnInFlightForGate(),
114
+ endingTurnFinalAnswerDelivered: endingTurn?.finalAnswerDelivered ?? null,
115
+ enabled: SERIALIZE_UNTIL_REPLIED_ENABLED,
116
+ })
117
+ ) {
118
+ return
119
+ }
120
+ performBufferDrain(reason)
121
+ }
122
+
123
+ /**
124
+ * Component 2 — bounded no-reply escape hatch (THE liveness guarantee).
125
+ *
126
+ * A turn that legitimately ends with NO reply (handback ack, NO_REPLY /
127
+ * HEARTBEAT_OK marker, silent-end, greeting already handled) sets
128
+ * `finalAnswerDelivered=false`. Under component 1's serialize gate that
129
+ * turn would block `drainBufferedIfAllowed` FOREVER — and the 300s
130
+ * silence-poke is disarmed for these silent-end turns, so without this
131
+ * timer a queued cross-topic message would never be released (a permanent
132
+ * wedge). This timer is the bounded force-drain: SERIALIZE_NOREPLY_DRAIN_MS
133
+ * (default 2500ms) after such a turn ends with a buffered inbound waiting,
134
+ * drain unconditionally — the serialize gate's delivered-check is bypassed
135
+ * because the turn ended for real with no reply coming. The drain still
136
+ * respects `turnInFlightForGate()` indirectly: if a new turn started in the
137
+ * window (e.g. the 300s fallback or another path drained first), the buffer
138
+ * is already empty so `performBufferDrain` is a depth-checked no-op.
139
+ *
140
+ * Liveness proof: a no-reply turn followed by a queued cross-topic message
141
+ * releases within SERIALIZE_NOREPLY_DRAIN_MS. The 300s silence-poke
142
+ * unwedge fallback (`redeliverBufferedInbound` at the silence-poke
143
+ * framework-fallback, called directly) remains the independent long-stop.
144
+ */
145
+ function armNoReplyDrainTimer(turn: CurrentTurn): void {
146
+ const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
147
+ // Pure guard (shared with the test): arm only for a no-reply turn that
148
+ // has a buffered inbound waiting, and only when the feature is enabled.
149
+ if (
150
+ !shouldArmNoReplyDrain({
151
+ enabled: SERIALIZE_UNTIL_REPLIED_ENABLED,
152
+ finalAnswerDelivered: turn.finalAnswerDelivered,
153
+ bufferedDepth: pendingInboundBuffer.depth(selfAgent),
154
+ })
155
+ ) {
156
+ return
157
+ }
158
+ // Idempotent: clear any prior timer for this turn before re-arming.
159
+ if (turn.noReplyDrainTimer != null) {
160
+ clearTimeout(turn.noReplyDrainTimer)
161
+ turn.noReplyDrainTimer = null
162
+ }
163
+ turn.noReplyDrainTimer = setTimeout(() => {
164
+ turn.noReplyDrainTimer = null
165
+ process.stderr.write(
166
+ `telegram gateway: no-reply bounded drain (${SERIALIZE_NOREPLY_DRAIN_MS}ms) — ` +
167
+ `turn ${turn.turnId} ended without a reply; force-draining buffered inbound\n`,
168
+ )
169
+ performBufferDrain('no-reply-bounded-drain')
170
+ }, SERIALIZE_NOREPLY_DRAIN_MS)
171
+ turn.noReplyDrainTimer.unref?.()
172
+ }
173
+
174
+ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
175
+ // Phase 2b: turn end. The key was registered via setTurnStarted when
176
+ // the inbound arrived; purge is the canonical turn-end signal.
177
+ //
178
+ // outboundEmitted: read from the explicit `endingTurn` parameter when
179
+ // provided (canonical path via endCurrentTurnAtomic — module-scope
180
+ // currentTurn is already null by the time we get here), falling back
181
+ // to `currentTurn?.replyCalled` for the legacy callsites that haven't
182
+ // been threaded yet (sibling-key purges, restart-init cleanup).
183
+ // Without this explicit-turn handoff the shadow trace would report
184
+ // outboundEmitted=false on every replied turn (the dominant happy
185
+ // path), producing strictly worse data than the blind `true` it
186
+ // replaced. Invariant #5's `lastOutboundAt` correctness depends on
187
+ // this signal being accurate.
188
+ const outboundEmitted = endingTurn != null
189
+ ? endingTurn.replyCalled === true
190
+ : getCurrentTurn()?.replyCalled === true
191
+ shadowEmit({ kind: 'turnEnd', key: key as _ChatKey, at: Date.now(), outboundEmitted })
192
+ const msgInfo = activeReactionMsgIds.get(key)
193
+ activeStatusReactions.delete(key)
194
+ activeReactionMsgIds.delete(key)
195
+ activeTurnStartedAt.delete(key)
196
+ // Component 5 (reap) — defense-in-depth. The happy path deletes the
197
+ // queued-status placeholder on the answer (executeReply / stream /
198
+ // turn-flush). This catches the abnormal turn-end (silent-marker, wedge,
199
+ // sibling purge) so a stale "Queued"/"On it" line can never dangle in
200
+ // the topic. Idempotent: a no-op when already reaped. Prefer the ending
201
+ // turn's session ids (canonical ownership); else parse the chatKey.
202
+ if (endingTurn != null) {
203
+ reapQueuedStatus(endingTurn.sessionChatId, endingTurn.sessionThreadId)
204
+ } else {
205
+ const pqChatId = chatIdOfChatKey(key as _ChatKey)
206
+ const pqThreadPart = (key as string).slice(pqChatId.length + 1)
207
+ const pqThread = pqThreadPart === '_' || pqThreadPart === '' ? null : Number(pqThreadPart)
208
+ reapQueuedStatus(pqChatId, Number.isFinite(pqThread) ? (pqThread as number) : undefined)
209
+ }
210
+ // #2995 — reset the ending turn's OWN busy-ack dedupe + cancel its pending
211
+ // deferred re-check. Per-key, not a global clear: a purge for topic A must
212
+ // not reset topic B's dedupe or kill B's armed re-check. A buffered topic's
213
+ // entry clears when ITS turn eventually runs and ends (this same path), and
214
+ // the re-check timer self-guards on turnId anyway (defense-in-depth).
215
+ busyAckPostedKeys.delete(key)
216
+ const busyAckRecheck = busyAckRecheckTimers.get(key)
217
+ if (busyAckRecheck != null) {
218
+ clearTimeout(busyAckRecheck)
219
+ busyAckRecheckTimers.delete(key)
220
+ }
221
+ // #2527: clear the per-key reaction-transition counter and first-reply
222
+ // sentinel alongside the controller so we don't leak state across turns.
223
+ reactionTransitionCounts.delete(key)
224
+ firstTextReplyLogged.delete(key)
225
+ // Human-feel UX: stop the turn-long `typing…` indicator started in
226
+ // the turn-start block. `purgeReactionTracking` is the canonical
227
+ // turn-end, so this is the single owner of the stop. (If an abnormal
228
+ // abort skips purge, the stray loop self-heals: the next turn on this
229
+ // chat calls `startTurnTypingLoop`, which stops the old interval
230
+ // first.)
231
+ // PR3 supergroup-mode: stop the per-(chat,thread) typing loop, not
232
+ // the whole chat's. Prefer the ending-turn's session ids (the
233
+ // canonical turn ownership); fall back to parsing the chatKey
234
+ // for sibling-purge / restart-cleanup callers that don't have a
235
+ // Turn handle.
236
+ if (endingTurn != null) {
237
+ stopTurnTypingLoop(endingTurn.sessionChatId, endingTurn.sessionThreadId ?? null)
238
+ } else {
239
+ const chatId = chatIdOfChatKey(key as _ChatKey)
240
+ const threadPart = (key as string).slice(chatId.length + 1)
241
+ const threadId = threadPart === '_' || threadPart === '' ? null : Number(threadPart)
242
+ stopTurnTypingLoop(chatId, Number.isFinite(threadId) ? threadId : null)
243
+ }
244
+ // Cancel the enqueue-time early-open timer (paired with
245
+ // `scheduleEarlyLivenessOpen` at turn start). `key` is the same status-key the
246
+ // timer was registered under, so this is the single owner of the cancel. A
247
+ // leaked timer would otherwise fire its `openLivenessFeedIfDue` against a
248
+ // successor turn; the timer's own turnId match is the second guard, this is
249
+ // the first. Idempotent — a no-op when no timer is registered.
250
+ stopEarlyLivenessOpen(key as string)
251
+ // Status-pin: turn end is the canonical unpin point for the foreground
252
+ // pin. `purgeReactionTracking` is the single turn-end owner (all normal /
253
+ // abnormal exit branches funnel here), so this is the one place the
254
+ // foreground status pin is dropped. Idempotent: a no-op when nothing was
255
+ // pinned (trivial turn that never opened a status message). The
256
+ // drop-on-unpin contract in reconcilePin guarantees state clears even if
257
+ // the unpin API throws — a stuck pin can never outlive its turn.
258
+ {
259
+ const pinChatId = endingTurn != null
260
+ ? endingTurn.sessionChatId
261
+ : chatIdOfChatKey(key as _ChatKey)
262
+ void reconcileStatusPin(`fg:${key}`, pinChatId, { pinned: false })
263
+ }
264
+ if (msgInfo) {
265
+ const agentDir = resolveAgentDirFromEnv()
266
+ if (agentDir != null) removeActiveReaction(agentDir, msgInfo.chatId, msgInfo.messageId)
267
+ }
268
+
269
+ // If no more active turns and a restart is pending, perform it now.
270
+ //
271
+ // Cycle BOTH the agent unit and the gateway unit (us). Rationale: users
272
+ // who ran `switchroom agent restart <name> --graceful-restart` after a
273
+ // code change expect their telegram-plugin edits to land, and that code
274
+ // only reloads when this gateway process restarts. Restarting only the
275
+ // agent unit leaves us running the stale code until something else kicks
276
+ // us over, which is a foot-gun (as observed on 2026-04-21 when a
277
+ // klanker gateway ran pre-reorder progress-card code for half a day).
278
+ //
279
+ // Use detached spawn for the combined restart so the systemctl job
280
+ // survives us getting killed by our own restart. Fire-and-forget;
281
+ // response to the client was already sent when the restart was
282
+ // scheduled, so nobody is waiting on this.
283
+ //
284
+ // Gated on the delivery machine (turns claude has actually been handed),
285
+ // not `activeTurnStartedAt.size` (receipt-eager), so a buffered topic-B
286
+ // inbound doesn't pin this gate forever while claude is genuinely idle
287
+ // (the supergroup deadlock). The turnEnd event was emitted just above
288
+ // (purgeReactionTracking head), so the machine is already idle here.
289
+ // #1556: the deterministic delivery point. claude has just gone idle —
290
+ // flush any inbound held mid-turn so the channel notification lands at
291
+ // the idle prompt and submits as a fresh turn (instead of stranding in
292
+ // the composer, the lawgpt wedge). Component 1 (deliver-before-drain):
293
+ // routed through `drainBufferedIfAllowed`, which additionally gates on
294
+ // the ending turn having delivered its reply so a buffered cross-topic
295
+ // message can't drain ahead of the just-ended turn's late reply (the
296
+ // Brevo→Meta wrong-topic bug). Zero-churn: the helper depth-checks
297
+ // first. Lossless: redeliver re-buffers any per-message miss.
298
+ drainBufferedIfAllowed(endingTurn, 'turn-complete')
299
+
300
+ // Restart / compaction stay on the bare turn-end signal (NOT the
301
+ // serialize gate): a pending self-restart or proactive compaction must
302
+ // fire when claude is idle regardless of whether the last turn replied.
303
+ if (!turnInFlightForGate()) {
304
+ // Apply any /model|/effort command queued mid-turn (#3017) BEFORE the
305
+ // restart drain reads the pending-restart map — drainPendingSessionCommand
306
+ // itself checks pendingRestarts and, when a restart is also pending,
307
+ // reports "restarting" on the ack card instead of a false confirmation.
308
+ // The sr-*→Claude menu apply may ITSELF enqueue a restart, which the
309
+ // restart drain below then picks up. Async + best-effort (own try/catch).
310
+ void drainPendingSessionCommand()
311
+ if (pendingRestarts.size > 0) {
312
+ for (const [agentName, _timestamp] of pendingRestarts.entries()) {
313
+ triggerSelfRestart(agentName, 'turn-complete-pending-restart');
314
+ pendingRestarts.delete(agentName);
315
+ }
316
+ } else {
317
+ // Strictly lower priority than a pending restart: if we just
318
+ // kicked a restart the process is going away and compacting is
319
+ // moot, so only evaluate when no restart drained this pass.
320
+ maybeProactiveCompact();
321
+ }
322
+ // Context-headroom snapshot (RFC context-headroom-surface) — write the
323
+ // current occupancy + cap so `switchroom status`/`doctor`/web can show
324
+ // headroom. Independent of proactive-compaction (writes even when no cap
325
+ // is configured) and best-effort (never throws). Runs on the same idle
326
+ // signal — never mid-turn.
327
+ snapshotContextOccupancy();
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Narrow buffer-gate release. Clears the per-key
333
+ * `activeTurnStartedAt` entry and triggers the held-inbound flush
334
+ * if the fleet went idle, WITHOUT touching the reaction
335
+ * controller, the active-reaction message-id, or the typing loop.
336
+ *
337
+ * Why split from `purgeReactionTracking`. #1718's contract keeps
338
+ * `activeStatusReactions[key]` alive across the turn so the
339
+ * working-state ladder can re-paint on every tool/thinking event
340
+ * (and the steer-vs-queue logic at the inbound handler reads the
341
+ * controller — gateway.ts:8322-8323 — to classify mid-turn
342
+ * messages). Wiping the controller mid-turn would either collapse
343
+ * the ladder to 👍 prematurely (#1713 regression) or break the
344
+ * steer detection.
345
+ *
346
+ * The BUFFER gate (`activeTurnStartedAt`) is a separate concern:
347
+ * it gates `shouldBufferInbound` (gateway.ts:8603) and the
348
+ * "claude is idle" flush at `purgeReactionTracking`'s tail. The
349
+ * #1728/#1729 fix released both halves together by gating on
350
+ * `isFinalAnswerReply`, but a trivial-prompt reply that sets
351
+ * `disable_notification: true` and is < 200 chars (e.g. the model
352
+ * mis-classifies "4" as an interim ack) returns false from
353
+ * `isFinalAnswerReply`, so neither half releases and the gate
354
+ * wedges (v0.13.30 UAT regression — every subsequent inbound logs
355
+ * `held mid-turn ... will flush on turn-complete` forever).
356
+ *
357
+ * `releaseTurnBufferGate` is called from `executeReply` on EVERY
358
+ * successful reply finalize — regardless of `isFinalAnswerReply` —
359
+ * so the buffer gate releases independently of the reaction
360
+ * state. The reaction controller stays for #1713's bidirectional
361
+ * ladder + steer detection; only the gate flips.
362
+ *
363
+ * Idempotent: a second release is a no-op `.delete()` on an
364
+ * already-empty key.
365
+ *
366
+ * @internal exported only via the `gateway.ts` module — used by
367
+ * `executeReply`'s post-send block and by tests via source-level
368
+ * pinning in `vault-approval-posture.test.ts` / wedge-guard suites.
369
+ */
370
+ function releaseTurnBufferGate(key: string, endingTurn?: CurrentTurn): void {
371
+ if (!activeTurnStartedAt.has(key)) return
372
+ activeTurnStartedAt.delete(key)
373
+ // Shadow trace so the structural turn-end metric still records.
374
+ // outboundEmitted=true is correct here — we only reach this from
375
+ // executeReply AFTER an outbound landed.
376
+ shadowEmit({ kind: 'turnEnd', key: key as _ChatKey, at: Date.now(), outboundEmitted: true })
377
+
378
+ // Mirror the deterministic-delivery flush from `purgeReactionTracking`.
379
+ // When the fleet hits zero-active-turns, drain any held inbound. This is
380
+ // the load-bearing wedge fix: the gate that pinned msg 1874+ in
381
+ // test-harness's 13:02 UAT now opens after the reply.
382
+ //
383
+ // Component 1 (deliver-before-drain): routed through the shared
384
+ // `drainBufferedIfAllowed`. `releaseTurnBufferGate` is called on EVERY
385
+ // reply finalize — interim ack AND final answer. The serialize gate
386
+ // checks `endingTurn.finalAnswerDelivered`, so an INTERIM ack ("On it")
387
+ // does NOT drain the cross-topic buffer (its turn hasn't delivered its
388
+ // real answer yet); only the final-answer reply releases it. That is
389
+ // exactly the serialize-until-replied contract. When the kill switch is
390
+ // off, or no turn handle is threaded, the helper falls back to the
391
+ // legacy drain-on-idle behaviour.
392
+ drainBufferedIfAllowed(endingTurn, 'reply-released-gate')
393
+ }
394
+
395
+ /**
396
+ * Atomic null-and-purge for a wedged turn. Every site that ends a
397
+ * turn by nulling `currentTurn` MUST also clear the turn's statusKey
398
+ * from `activeTurnStartedAt` — else a dangling entry survives and
399
+ * `#1556`'s turn-gate holds every new inbound mid-turn forever
400
+ * (gymbro / klanker held-mid-turn symptom, 2026-05-20).
401
+ *
402
+ * Pre-this, three turn-end paths (silent-marker / turn-flush /
403
+ * `turn_end`) nulled `currentTurn` on code-paths whose
404
+ * `purgeReactionTracking` calls weren't reached on every branch,
405
+ * leaving sibling entries under the turn's statusKey that the
406
+ * silence-poke framework-fallback's `purgeReactionTracking(fbKey)`
407
+ * couldn't catch (different key shape). The fallback now also sweeps
408
+ * siblings for `fbChatId` (`turn-state-purge.ts`) as defense-in-depth,
409
+ * but THIS helper closes the leak at origin: null and purge are
410
+ * inseparable at every call site.
411
+ *
412
+ * Idempotent: a second purge is a no-op `.delete()` on a key already
413
+ * gone — handlers that already purge elsewhere are unharmed.
414
+ */
415
+ function endCurrentTurnAtomic(
416
+ turn: CurrentTurn,
417
+ opts?: { deferRecord?: boolean; deferObligationClose?: boolean },
418
+ ): number | null {
419
+ // PR-4e — keyed liveness + keyed clear (leak-close-at-origin). Flag-OFF: the
420
+ // guard is `currentTurn === turn` and the clear nulls the singleton, verbatim.
421
+ // Flag-ON: the guard becomes `byKey.get(turn'sKey) === turn` (so a flip to
422
+ // another topic doesn't spuriously short-circuit THIS topic's teardown) and
423
+ // the clear does `byKey.delete(key)` + nulls the mirror iff it still points at
424
+ // `turn`. `endCurrentTurnForKey` returns false (no delete) when the entry no
425
+ // longer matches — the same early-return semantics as the old `!== turn` guard.
426
+ const key = statusKey(turn.sessionChatId, turn.sessionThreadId)
427
+ if (!turnLiveForItsTopic(turn)) return null
428
+ // PR A — the turn is ending (this is the ONE place every turn-end path funnels
429
+ // through, incl. the answer-ready flush's own synthetic turn_end). Clear the
430
+ // quiescence timer so a real turn_end that lands first cancels a pending flush,
431
+ // guaranteeing exactly-once delivery.
432
+ clearAnswerReadyFlushTimeout(turn)
433
+ endCurrentTurnForKey(turn, key) // currentTurnByKey.delete(key) + mirror clear
434
+ // Status-surface observability: one line at every turn CLEAR (with how far
435
+ // the turn got), plus a DEGRADED warning when the turn did tool work but the
436
+ // live feed never opened because its sends failed (the resume-400 signature).
437
+ const turnEndedAt = Date.now()
438
+ // 2026-07 double-reply-on-DM fix (F2) — stamp the turn's end time so the
439
+ // `findLatestEndedTurnForChat` supersede tier can be recency-bounded to the
440
+ // supersede TTL (a stale latest-ended turn must not inherit deletion
441
+ // authority over a newer turn's flush record). Set once; idempotent on the
442
+ // deferRecord flush path (which calls this synchronously before its send).
443
+ turn.endedAt = turnEndedAt
444
+ process.stderr.write(
445
+ `telegram gateway: ${formatTurnLifecycle('clear', 'turn_end', turn, turnEndedAt)}\n`,
446
+ )
447
+ // PR B — the turn-flush backstop defers the record write to its async send
448
+ // IIFE (passing `{ deferRecord: true }`) so the recorded `status` reflects the
449
+ // REAL send outcome (`turn.deliveryOutcome`) rather than the speculative
450
+ // `finalAnswerDelivered` flag set before the send ran. All synchronous
451
+ // turn-end paths still emit here, unchanged. `turnEndedAt` is returned so the
452
+ // deferred caller stamps the same ended-at (stable `duration_ms`).
453
+ if (opts?.deferRecord !== true) {
454
+ emitTurnRecord(turn, turnEndedAt)
455
+ }
456
+ const degraded = detectStatusSurfaceDegraded(turn)
457
+ if (degraded != null) {
458
+ process.stderr.write(
459
+ `telegram gateway: status-surface DEGRADED reason=${degraded.reason} ` +
460
+ `turnId=${turn.turnId} chat=${turn.sessionChatId} ` +
461
+ `thread=${turn.sessionThreadId ?? '-'} ${degraded.detail}\n`,
462
+ )
463
+ }
464
+ // PR2 obligation-ledger CLOSE-at-turn-end. Close the ended turn's obligation
465
+ // when it delivered a final answer. finalAnswerDelivered is the right signal
466
+ // HERE (not isSubstantiveFinalReply at reply-time): a SHORT genuine answer
467
+ // ("4") is final-but-not-substantive, so the reply-time substantive-close
468
+ // missed it → it looked unanswered → the idle sweep double-asked every short
469
+ // turn (canary, v0.14.59). At turn_end the #2141 logic has already demoted a
470
+ // bare interim ack to non-final, so finalAnswerDelivered===true means GENUINELY
471
+ // answered. This runs before the next idle sweep, so a short answer closes
472
+ // cleanly (no double-ask); an ack-then-ghost / no-reply turn ends with
473
+ // finalAnswerDelivered===false → stays open → re-presented (the intended
474
+ // catch). close() is a no-op for synthetic turns (turnId not in the ledger).
475
+ // No-op when the flag is off.
476
+ //
477
+ // #2624 — sr-* model short-reply cascade fix. When the model routes through
478
+ // LiteLLM (sr-* path), the claude CLI calls reply("OK", {disable_notification:
479
+ // true}) for short answers — below the 200-char backstop and notification-
480
+ // suppressed, so isFinalAnswerReply returns false and finalAnswerDelivered stays
481
+ // false. This triggers a cascade: obligation re-presents with growing 25k-token
482
+ // context, blocking the agent for minutes. The ack-then-ghost case that
483
+ // obligations are designed to catch ends via silence_fallback, NOT turn_end.
484
+ // At turn_end with replyCalled=true the model explicitly signalled completion
485
+ // AND replied, so the obligation is satisfied regardless of finalAnswerDelivered.
486
+ // #3276 finding 1 — the turn-flush backstop passes `deferObligationClose` so
487
+ // the obligation disposition reflects the REAL send outcome (resolved in its
488
+ // async finally after the bounded retry), NOT the speculative fire-time
489
+ // `finalAnswerDelivered=true`. Closing here would satisfy the obligation
490
+ // before the send is known to have landed, re-introducing the silent-drop on
491
+ // terminal failure. Every synchronous turn-end path is unchanged.
492
+ if (OBLIGATION_LEDGER_ENABLED && opts?.deferObligationClose !== true) {
493
+ if (decideObligationTurnEnd(turn.finalAnswerDelivered, turn.replyCalled) === 'close') {
494
+ obligationLedger.close(turn.turnId)
495
+ } else {
496
+ // Turn ended WITHOUT any reply (no ack, no answer). If this turn was
497
+ // handling an open obligation, stamp its grace clock so the idle sweep
498
+ // waits before re-presenting/escalating. No-op when turn.turnId isn't
499
+ // in the ledger (synthetic / already-closed turn).
500
+ obligationLedger.noteTurnEnded(turn.turnId, Date.now())
501
+ }
502
+ }
503
+ // Component 2 — clear any prior no-reply drain timer for this turn; a
504
+ // fresh end re-evaluates below. (Idempotent — null when never armed.)
505
+ if (turn.noReplyDrainTimer != null) {
506
+ clearTimeout(turn.noReplyDrainTimer)
507
+ turn.noReplyDrainTimer = null
508
+ }
509
+ // Teardown the narrative gate's early-paint timer so it can neither leak past
510
+ // the turn nor fire against a torn-down turn. (Idempotent — no-op when never
511
+ // armed / already fired. `flushPendingNarrativeAtTurnEnd` on the turn_end event
512
+ // normally disarms it first; this is the belt-and-braces teardown net.)
513
+ turn.narrativeGate?.teardown()
514
+ // Pass `turn` so purgeReactionTracking sees the authoritative
515
+ // replyCalled flag even though we just nulled module-scope
516
+ // currentTurn. Without this, the shadow trace's outboundEmitted
517
+ // would be false on every replied turn (the dominant happy path),
518
+ // producing strictly worse data than the blind `true` it replaced.
519
+ // Component 1: purgeReactionTracking runs the serialize-gated drain —
520
+ // it drains only if this turn delivered its final answer.
521
+ purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId), turn)
522
+ // Component 2 — bounded no-reply escape hatch. If this turn ended
523
+ // WITHOUT delivering (finalAnswerDelivered=false) the serialize gate
524
+ // above did NOT drain. Arm the bounded timer so a queued cross-topic
525
+ // message still releases within SERIALIZE_NOREPLY_DRAIN_MS instead of
526
+ // wedging forever. No-op when this turn delivered, when nothing is
527
+ // buffered, or when the serialize feature is off.
528
+ armNoReplyDrainTimer(turn)
529
+ // #3293 finding 1 — resolve any deferred non-operator failure notice against
530
+ // this turn's outcome: replied → the turn recovered from the error line, the
531
+ // gate drops the notice; reply-less → the turn genuinely died, the notice is
532
+ // sent now. replyCalled covers the short-answer/#2624 shape where
533
+ // finalAnswerDelivered stays false despite an explicit reply. No-op when
534
+ // nothing is pending (the overwhelmingly common path). #3294 — `key` scopes
535
+ // resolution to THIS turn's topic under keyed liveness.
536
+ flushPendingUserFailureNotices(turn.finalAnswerDelivered || turn.replyCalled, key)
537
+ return turnEndedAt
538
+ }
539
+
540
+ /**
541
+ * PR-E (#2996 P8, amendment M1) — the single auditable turn-end entry point,
542
+ * reached by production callsites ONLY when SWITCHROOM_TURN_END_FUNNEL_V2=1
543
+ * (the gateway wrappers gate on the flag; default OFF runs the PR-B paths
544
+ * verbatim). Dispatches to the EXACT legacy shape per reason — goal:
545
+ * byte-identical effects, verified by the PR-A shadowEmit sequence oracle
546
+ * run under both flag settings.
547
+ *
548
+ * FIVE end shapes (M1), not three:
549
+ * 1. 'turn-end' — the canonical funnel (endCurrentTurnAtomic).
550
+ * 2. 'fallback-purge' — purge without/with a turn handle (disconnect
551
+ * flush, silence-poke framework fallback).
552
+ * 3. 'reply-gate-release' — first-reply buffer-gate release (executeReply).
553
+ * 4. 'phantom-ttl-clear' — the /model|/effort busy-check hard-TTL clear.
554
+ * 5. 'bridge-died-clear' — the onDanglingTurnsSwept disconnect clear.
555
+ * Shapes 4-5 are GHOST-clears: the turns are dead (a stale atom / a dead
556
+ * bridge), so their effect set is EXACTLY the atom clear and nothing else —
557
+ * no purge, no obligation close, no drain, no shadow turnEnd. Routing them
558
+ * through the full funnel would close obligations and drain buffers against
559
+ * a dead bridge.
560
+ */
561
+ function endTurn(
562
+ reason: TurnEndReason,
563
+ args: {
564
+ turn?: CurrentTurn
565
+ opts?: { deferRecord?: boolean; deferObligationClose?: boolean }
566
+ key?: string
567
+ endingTurn?: CurrentTurn
568
+ } = {},
569
+ ): number | null | undefined {
570
+ switch (reason) {
571
+ case 'turn-end':
572
+ return endCurrentTurnAtomic(args.turn as CurrentTurn, args.opts)
573
+ case 'fallback-purge':
574
+ purgeReactionTracking(args.key as string, args.endingTurn)
575
+ return undefined
576
+ case 'reply-gate-release':
577
+ releaseTurnBufferGate(args.key as string, args.endingTurn)
578
+ return undefined
579
+ case 'phantom-ttl-clear':
580
+ case 'bridge-died-clear':
581
+ // M1 — atom-clear ONLY. See the docblock above; harness case 9 pins
582
+ // that a ghost-clear emits NO shadow turnEnd, closes NO obligation,
583
+ // drains NO buffer.
584
+ clearAllCurrentTurns()
585
+ return undefined
586
+ }
587
+ }
588
+
589
+ return {
590
+ performBufferDrain,
591
+ drainBufferedIfAllowed,
592
+ armNoReplyDrainTimer,
593
+ purgeReactionTracking,
594
+ releaseTurnBufferGate,
595
+ endCurrentTurnAtomic,
596
+ endTurn,
597
+ }
598
+ }
599
+
600
+ /** PR-E — the five named end shapes (amendment M1). */
601
+ export type TurnEndReason =
602
+ | 'turn-end'
603
+ | 'fallback-purge'
604
+ | 'reply-gate-release'
605
+ | 'phantom-ttl-clear'
606
+ | 'bridge-died-clear'