switchroom 0.18.31 → 0.18.33

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 (159) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +21 -3
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +1410 -852
  5. package/dist/host-control/main.js +22 -4
  6. package/dist/vault/approvals/kernel-server.js +21 -3
  7. package/dist/vault/broker/server.js +48 -4
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +148 -23
  10. package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
  11. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  12. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  13. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  14. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  15. package/telegram-plugin/gateway/boot-card.ts +169 -1
  16. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  17. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  18. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  19. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  20. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  21. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  22. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  23. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  24. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  25. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  26. package/telegram-plugin/gateway/gateway.ts +6403 -13456
  27. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  28. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  29. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  30. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  32. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  33. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  34. package/telegram-plugin/gateway/model-command.ts +23 -0
  35. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  36. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  37. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  38. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  39. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  40. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  41. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  42. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  43. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  44. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  45. package/telegram-plugin/gateway/turn-end.ts +606 -0
  46. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  47. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  48. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  49. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  50. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  51. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  52. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  53. package/telegram-plugin/narrative-dedup.ts +24 -1
  54. package/telegram-plugin/narrative-flush.ts +2 -2
  55. package/telegram-plugin/pending-user-notice.ts +59 -13
  56. package/telegram-plugin/render/render.ts +25 -1
  57. package/telegram-plugin/status-no-truncate.ts +13 -0
  58. package/telegram-plugin/subagent-watcher.ts +297 -31
  59. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  60. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  61. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  62. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  63. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  64. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  65. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  66. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  67. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  68. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  69. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
  70. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  71. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
  72. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  73. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  74. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  75. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  76. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  77. package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
  78. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  79. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
  80. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  81. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
  82. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
  83. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  84. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  85. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
  86. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  87. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
  88. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  89. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  90. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  91. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  92. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  93. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  94. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  95. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
  96. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  97. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  98. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  99. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  100. package/telegram-plugin/tests/model-command.test.ts +30 -0
  101. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
  102. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  103. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  104. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  105. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  106. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  107. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  108. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
  109. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  111. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  112. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  113. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  114. package/telegram-plugin/tests/render/render.test.ts +42 -0
  115. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  116. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  117. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  118. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
  119. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  120. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  121. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  122. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  123. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  124. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  125. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
  126. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  127. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  128. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  129. package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
  130. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
  131. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  132. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  133. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  134. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  135. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  136. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  137. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  138. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  139. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  140. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  141. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
  142. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  143. package/telegram-plugin/tool-activity-summary.ts +110 -38
  144. package/telegram-plugin/turn-flush-safety.ts +80 -14
  145. package/telegram-plugin/uat/restart-capability.ts +76 -0
  146. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  147. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  148. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  149. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  150. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  151. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  152. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  153. package/telegram-plugin/worker-activity-feed.ts +34 -4
  154. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  155. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  156. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  157. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  158. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  159. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `checklist_tasks_done` / `checklist_tasks_added` service-message forwarding
3
+ * (switchroom#2996 P6 cluster F).
4
+ *
5
+ * Telegram emits these service messages when users tick or add tasks in a
6
+ * native checklist. They arrive as part of the `message` update type (no extra
7
+ * `allowed_updates` config needed). We route them to the agent as a new channel
8
+ * event with kind="checklist_task_changed" so the agent can react to user
9
+ * actions on a checklist it sent.
10
+ *
11
+ * Extracted verbatim from gateway.ts (Amendment 9 — no module-global reads).
12
+ * The two `bot.on('message:checklist_tasks_*')` registration lines stay in
13
+ * gateway.ts (order is a load-bearing invariant) and delegate here.
14
+ */
15
+
16
+ import type { Context } from 'grammy'
17
+ import type { InboundMessage } from './ipc-protocol.js'
18
+
19
+ type ChecklistTaskUpdate = {
20
+ message_checklist?: {
21
+ title?: string
22
+ tasks?: Array<{ id?: number; text?: string; is_completed?: boolean }>
23
+ }
24
+ checklist_tasks_done?: Array<{ id?: number; user?: { id?: number; username?: string }; done?: boolean }>
25
+ checklist_tasks_added?: Array<{ id?: number; text?: string; user?: { id?: number; username?: string } }>
26
+ }
27
+
28
+ export interface ChecklistHandlerDeps {
29
+ /** Access config — only `allowFrom` is consulted (same gate as inbound). */
30
+ loadAccess: () => { allowFrom: string[] }
31
+ /** Broadcast a channel event to connected bridges. */
32
+ broadcast: (msg: InboundMessage) => void
33
+ /** stderr log sink. */
34
+ log: (line: string) => void
35
+ }
36
+
37
+ export function handleChecklistUpdate(
38
+ ctx: Context,
39
+ kind: 'checklist_tasks_done' | 'checklist_tasks_added',
40
+ deps: ChecklistHandlerDeps,
41
+ ): void {
42
+ try {
43
+ const msg = ctx.message as (typeof ctx.message & ChecklistTaskUpdate) | undefined
44
+ if (!msg) return
45
+
46
+ const chat = ctx.chat
47
+ if (!chat) return
48
+
49
+ const chat_id = String(chat.id)
50
+ const access = deps.loadAccess()
51
+
52
+ // Only notify if this chat is allowlisted — same guard as inbound user messages.
53
+ // Closes #472 finding #13. Pre-fix the `&& access.allowFrom.length > 0`
54
+ // tail made this fail-OPEN when the allowlist was empty: every chat's
55
+ // checklist tasks would forward to the agent. Sibling guards (the
56
+ // inbound-message gate at line ~588 and the operator-event broadcast
57
+ // at line ~1221) are both fail-closed for empty allowlists. The
58
+ // empty-allowlist case is the most likely state for a misconfiguration
59
+ // (e.g. /unpair just ran), so fail-OPEN is the worst default.
60
+ if (!access.allowFrom.includes(chat_id)) return
61
+
62
+ const message_id = String(msg.message_id)
63
+ const ts = msg.date ?? Math.floor(Date.now() / 1000)
64
+
65
+ // Extract task updates depending on service message type
66
+ const tasksDone = msg.checklist_tasks_done ?? []
67
+ const tasksAdded = msg.checklist_tasks_added ?? []
68
+ const allTasks = kind === 'checklist_tasks_done' ? tasksDone : tasksAdded
69
+
70
+ // Build per-task channel events and broadcast each to connected bridges.
71
+ for (const task of allTasks) {
72
+ const taskId = task.id != null ? String(task.id) : '?'
73
+ const user = (task.user as { username?: string; id?: number } | undefined)
74
+ const userName = user?.username ?? (user?.id != null ? String(user.id) : 'unknown')
75
+ const state = kind === 'checklist_tasks_done'
76
+ ? ((task as { done?: boolean }).done === false ? 'undone' : 'done')
77
+ : 'added'
78
+
79
+ const inboundMsg: InboundMessage = {
80
+ type: 'inbound',
81
+ chatId: chat_id,
82
+ messageId: Number(message_id),
83
+ user: userName,
84
+ userId: user?.id ?? 0,
85
+ ts,
86
+ text: `(checklist task ${state}: id=${taskId})`,
87
+ meta: {
88
+ chat_id,
89
+ message_id,
90
+ kind: 'checklist_task_changed',
91
+ task_id: taskId,
92
+ state,
93
+ user: userName,
94
+ user_id: user?.id != null ? String(user.id) : '0',
95
+ ts: new Date(ts * 1000).toISOString(),
96
+ },
97
+ }
98
+ // allow-broadcast: informational checklist task notification, not turn-driving
99
+ deps.broadcast(inboundMsg)
100
+ deps.log(
101
+ `telegram gateway: checklist ${kind}: chat_id=${chat_id} message_id=${message_id} task_id=${taskId} state=${state} user=${userName}\n`,
102
+ )
103
+ }
104
+ } catch (err) {
105
+ deps.log(`telegram gateway: checklist handler error (${kind}): ${err}\n`)
106
+ }
107
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * delivery-confirm-wiring.ts — the deliver-until-acked SWEEP wiring
3
+ * (#2996 P8 PR-C1).
4
+ *
5
+ * Extracted VERBATIM from gateway.ts: the 5 s confirm-sweep tick body
6
+ * (`runDeliveryConfirmSweep`), the strand re-delivery (`redeliverStrandedInbound`)
7
+ * and the #2787 Mechanism A card-suspension scoper (`sweepSuspendedTargets`).
8
+ * The gateway keeps thin same-name wrappers and OWNS the `setInterval`
9
+ * (the P0c rule — a test import must stay side-effect-free).
10
+ *
11
+ * DI contract: state stays in gateway.ts (`deliveryQueue`, the pending-card
12
+ * maps, `pendingInboundBuffer`); volatile handles cross as getters. The idle
13
+ * gate deliberately reads `getCurrentTurnNull()` — the turn ATOM, not the
14
+ * machine's eager in-turn state (the documented trap: `turnInFlightForGate`
15
+ * stays set through a strand until the TTL tick, so it is NOT a usable idle
16
+ * signal here). Design §3 PR-C1 risk row; harness case 4 pins mid-turn
17
+ * suppression.
18
+ *
19
+ * `trackDelivery` enrolment callsites stay in gateway.ts unchanged — the two
20
+ * twin-body sites live inside the P7-owned handleInbound window and are P7's
21
+ * to delete (design §2 PR-C1 note).
22
+ */
23
+ import type { InboundMessage } from './ipc-protocol.js'
24
+ import type { DeliveryConfirmWiringDeps } from './gateway.js'
25
+ import {
26
+ trackDelivery,
27
+ sweep as sweepDeliveryQueue,
28
+ forgetDelivery,
29
+ isRedeliverySuspended,
30
+ type PendingDelivery,
31
+ } from './inbound-delivery-confirm.js'
32
+ import { chatKey } from './chat-key.js'
33
+
34
+ export function createDeliveryConfirmWiring(deps: DeliveryConfirmWiringDeps) {
35
+ const {
36
+ DELIVERY_CONFIRM_ENABLED,
37
+ DELIVERY_CONFIRM_TIMEOUT_MS,
38
+ getCurrentTurnNull,
39
+ sendToAgent,
40
+ deliveryQueue,
41
+ pendingInboundBuffer,
42
+ pendingPermissions,
43
+ pendingAskUser,
44
+ } = deps
45
+
46
+ // Re-deliver stranded inbounds until claude acks (the marko drop-wedge).
47
+ // Every few seconds, re-send any inbound that was handed to claude but never
48
+ // acked by an `enqueue` — it stranded unsubmitted in the composer. Re-clear
49
+ // the composer so the re-sent notification lands on a clean line, then
50
+ // re-send. Reuses the same delivery primitives; the message is never dropped.
51
+ // (Refs ipcServer / pendingInboundBuffer declared below — resolved at fire
52
+ // time, after module init.) unref so the interval never holds the process.
53
+ async function redeliverStrandedInbound(p: PendingDelivery<InboundMessage>): Promise<void> {
54
+ const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
55
+ process.stderr.write(
56
+ `telegram gateway: inbound strand (no enqueue ack) key=${p.key} — re-clearing composer + re-delivering\n`,
57
+ )
58
+ try {
59
+ const { clearAgentComposer } = await import('../../src/agents/tmux.js')
60
+ if (selfAgent) clearAgentComposer({ agentName: selfAgent })
61
+ } catch { /* best-effort; re-deliver regardless */ }
62
+ const ok = sendToAgent(selfAgent, p.inbound)
63
+ if (ok) {
64
+ // Survive an ack that raced the `await import` above: only `enqueue`
65
+ // clears tracking, so if a concurrent ack removed the entry, re-affirm
66
+ // it — never drop. Idempotent.
67
+ if (!deliveryQueue.pending.has(p.key)) {
68
+ trackDelivery(deliveryQueue, p.key, p.inbound, Date.now(), p.messageId)
69
+ }
70
+ } else {
71
+ // Bridge offline between attempts — hand off to the offline buffer
72
+ // (bridgeUp drains it) and stop tracking here; the spool owns it now.
73
+ pendingInboundBuffer.push(selfAgent, p.inbound)
74
+ forgetDelivery(deliveryQueue, p.key)
75
+ }
76
+ }
77
+ // #2787 Mechanism A — which chats/topics currently hold a live permission or
78
+ // ask_user card. A pending card is a live interaction for ITS OWN chat, so the
79
+ // confirm sweep must not re-clear the composer + re-send there. But the OLD guard
80
+ // (`pendingPermissions.size > 0 || pendingAskUser.size > 0 → return`) suspended
81
+ // the sweep GLOBALLY: one card parked in a single topic (or in the operator DM,
82
+ // a different chatId entirely) froze re-delivery of every stranded inbound across
83
+ // EVERY topic until that one card resolved. This scopes the suspension to the
84
+ // card's own target. Returns per-topic chatKeys where the topic is known and bare
85
+ // chatIds where it isn't (a card fanned to operator DMs records chatId only, and
86
+ // a whole-chat suspension is the safe conservative fallback there); the callsite
87
+ // tests a delivery entry against both renderings.
88
+ function sweepSuspendedTargets(): { keys: Set<string>; chats: Set<string> } {
89
+ const keys = new Set<string>()
90
+ const chats = new Set<string>()
91
+ for (const p of pendingPermissions.values()) {
92
+ for (const c of p.cards) {
93
+ if (c.threadId != null) keys.add(chatKey(c.chatId, c.threadId))
94
+ else chats.add(c.chatId)
95
+ }
96
+ // A permission whose card send hasn't resolved yet (cards still empty) has
97
+ // no recorded target — suspend nothing for it; the next sweep sees it once
98
+ // the send resolves, and the currentTurn guard already covers the in-turn
99
+ // window a fresh permission request lives in.
100
+ }
101
+ for (const a of pendingAskUser.values()) {
102
+ if (a.threadId != null) keys.add(chatKey(a.chatId, a.threadId))
103
+ else chats.add(a.chatId)
104
+ }
105
+ return { keys, chats }
106
+ }
107
+
108
+ // #2996 P0c/P8 PR-C1: the sweep TICK body (named in PR-A so the harness can
109
+ // drive it) — the GATEWAY still owns the `setInterval` (the P0c gate rule:
110
+ // modules export the tick body; the gateway registers the timer).
111
+ function runDeliveryConfirmSweep(): void {
112
+ if (!DELIVERY_CONFIRM_ENABLED) return
113
+ // Re-deliver ONLY when claude is genuinely idle. `currentTurn` is set solely
114
+ // by the enqueue session-event and nulled at turn-end, so `currentTurn != null`
115
+ // means a real turn is in flight — re-clearing the composer + re-sending now
116
+ // would clobber it (the exact mid-turn wedge this queue exists to prevent).
117
+ // NB: the machine's in-turn state (turnInFlightForGate) is advanced EAGERLY
118
+ // at delivery and stays set through a strand until the TTL tick, so it is
119
+ // NOT a usable "idle" signal here.
120
+ if (!getCurrentTurnNull()) return
121
+ // #2787 Mechanism A: a pending permission / ask_user card suspends re-delivery
122
+ // ONLY for its own chat/topic — never globally. Skip just the stranded entries
123
+ // whose target holds a live card; sweep the rest so unrelated topics keep
124
+ // getting re-delivered.
125
+ const suspended = sweepSuspendedTargets()
126
+ for (const p of sweepDeliveryQueue(deliveryQueue, Date.now(), DELIVERY_CONFIRM_TIMEOUT_MS)) {
127
+ if (isRedeliverySuspended(p.key, suspended)) continue
128
+ void redeliverStrandedInbound(p)
129
+ }
130
+ }
131
+
132
+ return { runDeliveryConfirmSweep, redeliverStrandedInbound, sweepSuspendedTargets }
133
+ }
@@ -37,17 +37,6 @@ export interface DisconnectFlushDeps<Ctrl extends { finalize: (reason?: 'done' |
37
37
  activeReactionMsgIds: Map<string, { chatId: string; messageId: number }>
38
38
  /** Mirror map: same keys → turn-start timestamps. */
39
39
  activeTurnStartedAt: Map<string, number>
40
- /** PR3b: keys claude has actually been handed (delivered, not just
41
- * received). Cleared on disconnect for the same reason as
42
- * activeTurnStartedAt — the bridge just died, every turn it
43
- * was handed is dead by definition. */
44
- claudeBusyKeys: Set<string>
45
- /** #2787: insertion-timestamp map for `claudeBusyKeys`, backing the orphan
46
- * reaper. MUST stay in lockstep with `claudeBusyKeys` at every clear site —
47
- * a stale timestamp surviving a clear makes a later re-marked key look
48
- * >TTL-old and get reaped against a live delivery. Deleted alongside every
49
- * `claudeBusyKeys.delete`/`.clear` below. */
50
- claudeBusyKeySince: Map<string, number>
51
40
 
52
41
  /** Open draft-stream handles keyed by chat:thread:replyId. */
53
42
  activeDraftStreams: Map<string, Stream>
@@ -96,8 +85,6 @@ export function flushOnAgentDisconnect<
96
85
  activeStatusReactions,
97
86
  activeReactionMsgIds,
98
87
  activeTurnStartedAt,
99
- claudeBusyKeys,
100
- claudeBusyKeySince,
101
88
  activeDraftStreams,
102
89
  clearActiveReactions,
103
90
  disposeProgressDriver,
@@ -125,8 +112,6 @@ export function flushOnAgentDisconnect<
125
112
  activeStatusReactions.delete(key)
126
113
  activeReactionMsgIds.delete(key)
127
114
  activeTurnStartedAt.delete(key)
128
- claudeBusyKeys.delete(key)
129
- claudeBusyKeySince.delete(key) // #2787: keep orphan-TTL map in lockstep
130
115
  }
131
116
  clearActiveReactions()
132
117
 
@@ -146,8 +131,6 @@ export function flushOnAgentDisconnect<
146
131
  for (const k of danglingKeys) {
147
132
  activeTurnStartedAt.delete(k)
148
133
  activeReactionMsgIds.delete(k)
149
- claudeBusyKeys.delete(k)
150
- claudeBusyKeySince.delete(k) // #2787: keep orphan-TTL map in lockstep
151
134
  }
152
135
  log(
153
136
  `telegram gateway: disconnect-flush swept ${danglingKeys.length} dangling turn key(s) ` +
@@ -156,33 +139,12 @@ export function flushOnAgentDisconnect<
156
139
  onDanglingTurnsSwept?.(danglingKeys)
157
140
  }
158
141
 
159
- // PR3b orphan-sweep (#1880 follow-up): claudeBusyKeys can hold keys
160
- // that activeTurnStartedAt does NOT — specifically when a synthetic
161
- // inbound (cron via onInjectInbound, reaction dispatch, vault
162
- // grant-approved / -denied / save-discarded / -failed / -completed,
163
- // button-callback) was delivered. Those paths bypass handleInbound's
164
- // fresh-turn branch (which is what would set activeTurnStartedAt),
165
- // so the sweep loop above wouldn't notice them. Pre-PR3b this was
166
- // invisible because the fleet gate read activeTurnStartedAt.size —
167
- // synthetic-only turns never registered. PR3b's claudeBusyKeys.add
168
- // is the more-accurate "claude is busy on this" gate, which means
169
- // a synthetic-delivered turn that dies WITHOUT turn_end leaves an
170
- // orphan that the activeTurnStartedAt-keyed sweep can't see.
171
- // Cure: clear any leftover busy keys here. Bridge died → every
172
- // busy key is dead by definition. Same justification as the
173
- // dangling-sweep above for activeTurnStartedAt.
174
- if (claudeBusyKeys.size > 0) {
175
- const orphanCount = claudeBusyKeys.size
176
- const orphanKeys = [...claudeBusyKeys]
177
- claudeBusyKeys.clear()
178
- // #2787: keep the orphan-TTL map in lockstep with the set it shadows.
179
- for (const k of orphanKeys) claudeBusyKeySince.delete(k)
180
- log(
181
- `telegram gateway: disconnect-flush cleared ${orphanCount} orphan claudeBusyKeys ` +
182
- `entr${orphanCount === 1 ? 'y' : 'ies'} (synthetic-inbound deliveries that never turn_ended)` +
183
- ` keys=${orphanKeys.join(',')}`,
184
- )
185
- }
142
+ // (#2996 P1) The PR3b `claudeBusyKeys` orphan-sweep that lived here was
143
+ // deleted with the set itself: the delivery machine's `bridgeDown` event
144
+ // — emitted by the gateway's onClientDisconnected alongside this flush —
145
+ // resets the machine to bridge_dead, which structurally clears the
146
+ // turn-in-flight gate for synthetic-delivered turns that died without a
147
+ // turn_end (the case the sweep existed for).
186
148
 
187
149
  // Stop coalesce timers that could emit into a finalized draft stream, but
188
150
  // preserve chats with pendingCompletion=true — those have background
@@ -0,0 +1,188 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { dirname, join } from 'node:path'
5
+
6
+ /**
7
+ * #2996 P0e — gateway.ts import-cleanliness.
8
+ *
9
+ * Prior phases (P0a/P0b/P0c) could only pin import-cleanliness with AST/source
10
+ * checks because importing gateway.ts under vitest was *impossible*: the
11
+ * module-scope `createIpcServer(...)` calls `Bun.listen` at construction, and
12
+ * `Bun` is undefined under Node/vitest, so a bare `import('./gateway.js')`
13
+ * threw `ReferenceError: Bun is not defined` before any assertion could run.
14
+ *
15
+ * P0e removes the last import-time side effects. This test therefore uses the
16
+ * thing prior phases could not: an ACTUAL runtime import of gateway.ts as the
17
+ * oracle for the import-clean half, and deterministic source-pins for the
18
+ * boot-fires-under-`isGatewayMain` half (which cannot be runtime-exercised
19
+ * under vitest — the prod boot path needs Bun, a real Bot token, and the
20
+ * vault, none of which exist here).
21
+ */
22
+
23
+ const here = dirname(fileURLToPath(import.meta.url))
24
+ const GATEWAY_SRC = readFileSync(join(here, 'gateway.ts'), 'utf8')
25
+
26
+ // The process-signal / lifecycle events gateway.ts wires on the prod boot path.
27
+ // Under a vitest import (isGatewayMain === false) it must register NONE of them.
28
+ const GATEWAY_SIGNALS = ['SIGTERM', 'SIGINT', 'beforeExit'] as const
29
+
30
+ function signalListenerCounts(): Record<string, number> {
31
+ const out: Record<string, number> = {}
32
+ for (const sig of GATEWAY_SIGNALS) out[sig] = process.listenerCount(sig)
33
+ return out
34
+ }
35
+
36
+ describe('gateway.ts import-cleanliness (#2996 P0e)', () => {
37
+ it('runtime-imports with zero side effects: resolves, no process.exit, no gateway signal handlers', async () => {
38
+ const before = signalListenerCounts()
39
+
40
+ // Any reachable process.exit during import is a hard failure ("no exit
41
+ // reachable on import"). Throw instead of exiting so the harness survives
42
+ // and the assertion is legible.
43
+ const exitSpy = vi
44
+ .spyOn(process, 'exit')
45
+ .mockImplementation(((code?: number) => {
46
+ throw new Error(`process.exit(${code}) was reached during import()`)
47
+ }) as never)
48
+
49
+ // Positive oracle that no UDS server is bound at import: if the gate
50
+ // regressed and `createIpcServer` ran, it would call this Bun.listen fake.
51
+ // With the gate intact it is never invoked. (Also proves the import no
52
+ // longer depends on a real Bun global — it resolves whether or not one
53
+ // exists.)
54
+ //
55
+ // Dual-runner note: this file runs under BOTH vitest (Node) and `bun test`
56
+ // (the plugin bun suite globs every *.test.ts). Under Node `globalThis.Bun`
57
+ // is undefined and the fake installs cleanly — that run holds the no-bind
58
+ // oracle. Under the Bun runtime the `Bun` global is READONLY (assignment
59
+ // throws "Attempted to assign to readonly property"), so the swap is
60
+ // best-effort there; the bind oracle is then carried by the vitest run and
61
+ // the source pins below, while the remaining outcome assertions (resolve /
62
+ // no exit / no listeners) still run in both runtimes.
63
+ const listenSpy = vi.fn(() => {
64
+ throw new Error('Bun.listen was called during import() — an IPC server was bound')
65
+ })
66
+ const priorBun = (globalThis as { Bun?: unknown }).Bun
67
+ let bunSwapped = false
68
+ try {
69
+ ;(globalThis as { Bun?: unknown }).Bun = { listen: listenSpy }
70
+ bunSwapped = true
71
+ } catch {
72
+ // Bun runtime — readonly global; see dual-runner note above.
73
+ }
74
+
75
+ let mod: Record<string, unknown>
76
+ try {
77
+ mod = (await import('./gateway.js')) as Record<string, unknown>
78
+ } finally {
79
+ if (bunSwapped) {
80
+ if (priorBun === undefined) delete (globalThis as { Bun?: unknown }).Bun
81
+ else (globalThis as { Bun?: unknown }).Bun = priorBun
82
+ }
83
+ exitSpy.mockRestore()
84
+ }
85
+
86
+ // Import resolved (did not throw). Under P0c-and-earlier this line was
87
+ // unreachable — the import rejected with `Bun is not defined`.
88
+ expect(mod).toBeTruthy()
89
+
90
+ // No IPC server bound (Node run; under Bun the swap is best-effort and the
91
+ // spy simply stays uninstalled + uncalled).
92
+ expect(listenSpy).not.toHaveBeenCalled()
93
+
94
+ // No exit reachable on import.
95
+ expect(exitSpy).not.toHaveBeenCalled()
96
+
97
+ // No gateway-owned SIGTERM / SIGINT / beforeExit handlers registered.
98
+ // (vitest owns a baseline uncaughtException/unhandledRejection listener of
99
+ // its own, so those two are asserted via the delta only.)
100
+ const after = signalListenerCounts()
101
+ for (const sig of GATEWAY_SIGNALS) {
102
+ // Runner-agnostic single-arg expect (bun's expect has no message arg):
103
+ // encode the signal name in the compared value so a failure is legible.
104
+ expect(`${sig}:+${after[sig] - before[sig]}`).toBe(`${sig}:+0`)
105
+ }
106
+ }, 60_000)
107
+
108
+ it('does not add uncaughtException / unhandledRejection handlers on import', async () => {
109
+ // Distinct from the signal test: gateway.ts registers TWO families of these
110
+ // (installGlobalErrorHandlers @ ~910 and its own shutdown-routing handlers
111
+ // @ ~29926/29935). Both call process.exit, both are gated on P0e. Assert the
112
+ // delta the import contributed is zero (vitest's own baseline is subtracted).
113
+ const before = {
114
+ uncaughtException: process.listenerCount('uncaughtException'),
115
+ unhandledRejection: process.listenerCount('unhandledRejection'),
116
+ }
117
+ const mod = await import('./gateway.js')
118
+ expect(mod).toBeTruthy()
119
+ const after = {
120
+ uncaughtException: process.listenerCount('uncaughtException'),
121
+ unhandledRejection: process.listenerCount('unhandledRejection'),
122
+ }
123
+ expect(after.uncaughtException - before.uncaughtException).toBe(0)
124
+ expect(after.unhandledRejection - before.unhandledRejection).toBe(0)
125
+ }, 60_000)
126
+
127
+ describe('boot wiring is gated on isGatewayMain (source pins — the prod path still fires)', () => {
128
+ // These pin, deterministically, that each side-effecting construct remains
129
+ // (a) gated so the import stays clean, and (b) present so the prod boot path
130
+ // (isGatewayMain === true) still constructs it. They guard against a future
131
+ // edit silently ungating one of them (re-inflating the import surface) OR
132
+ // deleting the construction (breaking boot).
133
+
134
+ it('IPC server construction is deferred behind isGatewayMain', () => {
135
+ expect(GATEWAY_SRC).toMatch(/let ipcServer!: IpcServer/)
136
+ expect(GATEWAY_SRC).toMatch(/if \(isGatewayMain\) ipcServer = createIpcServer\(\{/)
137
+ // No ungated `const ipcServer = createIpcServer` remains.
138
+ expect(GATEWAY_SRC).not.toMatch(/const ipcServer[^\n]*= createIpcServer/)
139
+ })
140
+
141
+ it('inbound spool construction is deferred behind isGatewayMain', () => {
142
+ expect(GATEWAY_SRC).toMatch(
143
+ /let inboundSpool: ReturnType<typeof createInboundSpool> \| undefined/,
144
+ )
145
+ expect(GATEWAY_SRC).toMatch(/if \(isGatewayMain\) inboundSpool = STATIC/)
146
+ expect(GATEWAY_SRC).not.toMatch(/const inboundSpool = STATIC/)
147
+ })
148
+
149
+ it('global error handlers + beforeExit are gated on isGatewayMain', () => {
150
+ // installPosthogErrorHandlers must not sit ungated at module scope.
151
+ expect(GATEWAY_SRC).not.toMatch(/^installPosthogErrorHandlers\(\)/m)
152
+ expect(GATEWAY_SRC).toMatch(
153
+ /if \(isGatewayMain\) \{\n {2}installPosthogErrorHandlers\(\)/,
154
+ )
155
+ })
156
+
157
+ it('signal + crash handlers are gated on isGatewayMain', () => {
158
+ // No ungated (column-0) process.on wiring survives.
159
+ expect(GATEWAY_SRC).not.toMatch(/^process\.on\('SIGTERM'/m)
160
+ expect(GATEWAY_SRC).not.toMatch(/^process\.on\('SIGINT'/m)
161
+ expect(GATEWAY_SRC).not.toMatch(/^process\.on\('unhandledRejection'/m)
162
+ expect(GATEWAY_SRC).not.toMatch(/^process\.on\('uncaughtException'/m)
163
+ // The gateway's beforeExit analytics flush is likewise not at column 0.
164
+ expect(GATEWAY_SRC).not.toMatch(/^process\.on\('beforeExit'/m)
165
+ })
166
+
167
+ it('startup reaction sweep (module-load IO) is gated on isGatewayMain', () => {
168
+ expect(GATEWAY_SRC).toMatch(
169
+ /if \(isGatewayMain\) \{\n {2}const startupAgentDir = resolveAgentDirFromEnv\(\)/,
170
+ )
171
+ })
172
+
173
+ it('preserves prod construction order: inboundSpool before pendingInboundBuffer before ipcServer', () => {
174
+ const iSpool = GATEWAY_SRC.indexOf('if (isGatewayMain) inboundSpool = STATIC')
175
+ const iPending = GATEWAY_SRC.indexOf('const pendingInboundBuffer = createPendingInboundBuffer(')
176
+ const iIpc = GATEWAY_SRC.indexOf('if (isGatewayMain) ipcServer = createIpcServer({')
177
+ expect(iSpool).toBeGreaterThan(0)
178
+ expect(iPending).toBeGreaterThan(0)
179
+ expect(iIpc).toBeGreaterThan(0)
180
+ expect(iSpool).toBeLessThan(iPending)
181
+ expect(iPending).toBeLessThan(iIpc)
182
+ })
183
+
184
+ it('startGateway() remains the single guarded boot invocation', () => {
185
+ expect(GATEWAY_SRC).toMatch(/if \(isGatewayMain\) void startGateway\(\)/)
186
+ })
187
+ })
188
+ })