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
@@ -1,192 +0,0 @@
1
- /**
2
- * #2787 Mechanism B — gateway-glue coverage for the `claudeBusyKeys` orphan
3
- * reaper and its lockstep shadow timestamp map.
4
- *
5
- * `gateway.ts` cannot be imported by a unit test — it boots the Telegram bot and
6
- * the IPC listener on module load. So this harness wires the SAME containers the
7
- * gateway owns (`claudeBusyKeys: Set`, `claudeBusyKeySince: Map`), the SAME
8
- * delivery-confirm queue used as the reaper's proof gate, and the REAL
9
- * `flushOnAgentDisconnect` used at bridge death — exactly as the gateway does
10
- * (see `markClaudeBusyForInbound`, `reapOrphanBusyKeysNow`, the delivery-confirm
11
- * sweep, and the `flushOnAgentDisconnect({ claudeBusyKeys, claudeBusyKeySince })`
12
- * call). It reproduces the two real defects the pure-helper tests could not:
13
- *
14
- * 1. Lockstep across a disconnect → reconnect → re-mark: a stale
15
- * pre-disconnect timestamp must NOT survive to make a freshly-marked key
16
- * look orphan-old.
17
- * 2. Slow-delivery safety: a merely-slow (up to ~5-min, #1922) delivery whose
18
- * key is still tracked in the confirm queue must NOT be reaped even long
19
- * past the grace, because `currentTurn == null` during the eager-mark→
20
- * enqueue window is not proof of idle.
21
- */
22
-
23
- import { describe, it, expect, vi } from 'vitest'
24
- import {
25
- markBusyKeyLockstep,
26
- reapOrphanBusyKeys,
27
- clearBusyKeyLockstep,
28
- } from '../gateway/busy-key-reaper.js'
29
- import { flushOnAgentDisconnect } from '../gateway/disconnect-flush.js'
30
- import {
31
- createDeliveryQueue,
32
- trackDelivery,
33
- ackDelivery,
34
- } from '../gateway/inbound-delivery-confirm.js'
35
-
36
- const TTL = 30_000
37
-
38
- /** The gateway's `reapOrphanBusyKeysNow` glue, reproduced verbatim: no turn in
39
- * flight, reap keyed on set membership + a delivery-queue proof gate. */
40
- function reap(
41
- keys: Set<string>,
42
- since: Map<string, number>,
43
- q: ReturnType<typeof createDeliveryQueue>,
44
- now: number,
45
- ttlMs = TTL,
46
- ): string[] {
47
- return reapOrphanBusyKeys(keys, since, now, {
48
- ttlMs,
49
- hasPendingDelivery: (key) => q.pending.has(key),
50
- log: () => {},
51
- })
52
- }
53
-
54
- /** Minimal disconnect-flush deps carrying the two containers under test — the
55
- * real bridge-death path that clears busy keys. Mirrors the gateway's call. */
56
- function flushDeps(claudeBusyKeys: Set<string>, claudeBusyKeySince: Map<string, number>) {
57
- return {
58
- agentName: 'clerk',
59
- activeStatusReactions: new Map(),
60
- activeReactionMsgIds: new Map(),
61
- activeTurnStartedAt: new Map<string, number>(),
62
- claudeBusyKeys,
63
- claudeBusyKeySince,
64
- activeDraftStreams: new Map(),
65
- clearActiveReactions: vi.fn(),
66
- disposeProgressDriver: vi.fn(),
67
- stopTurnTypingLoops: vi.fn(), // #2650: required dep — turn-typing sweep on bridge death
68
- log: vi.fn(),
69
- }
70
- }
71
-
72
- describe('#2787 Mechanism B — orphan reaper (gateway glue)', () => {
73
- it('does NOT reap a slow-but-real delivery still tracked in the confirm queue, even far past the grace', () => {
74
- const keys = new Set<string>()
75
- const since = new Map<string, number>()
76
- const q = createDeliveryQueue<{ text: string }>()
77
-
78
- // Eager mark at delivery + track for ack (the normal user-inbound glue).
79
- markBusyKeyLockstep(keys, since, '-100:4', 0)
80
- trackDelivery(q, '-100:4', { text: 'slow but real' }, 0, '9001')
81
-
82
- // currentTurn is still null 6 MINUTES later (the #1922 tail) — but the
83
- // delivery is still pending its enqueue ack. The proof gate must protect it.
84
- expect(reap(keys, since, q, 360_000)).toEqual([])
85
- expect(keys.has('-100:4')).toBe(true)
86
-
87
- // Once claude finally acks (enqueue lands), the queue entry clears; the
88
- // gateway would then hold currentTurn and clear busy at turn_end.
89
- expect(ackDelivery(q, '-100:4', '9001')).toBe(true)
90
- })
91
-
92
- it('reaps a TRUE orphan — busy-marked, no pending delivery, past the grace', () => {
93
- const keys = new Set<string>()
94
- const since = new Map<string, number>()
95
- const q = createDeliveryQueue()
96
-
97
- // A steer/interrupt inbound marks busy but is excluded from tracking
98
- // (shouldTrackDelivery=false) — no queue entry. Its turn vanished without
99
- // clearing busy: a genuine stuck marker.
100
- markBusyKeyLockstep(keys, since, '555:_', 0)
101
- expect(reap(keys, since, q, TTL)).toEqual(['555:_'])
102
- expect(keys.has('555:_')).toBe(false)
103
- expect(since.has('555:_')).toBe(false)
104
- })
105
-
106
- it('does not reap before the grace elapses', () => {
107
- const keys = new Set<string>()
108
- const since = new Map<string, number>()
109
- const q = createDeliveryQueue()
110
- markBusyKeyLockstep(keys, since, '555:_', 0)
111
- expect(reap(keys, since, q, TTL - 1)).toEqual([])
112
- expect(keys.has('555:_')).toBe(true)
113
- })
114
-
115
- it('disconnect → reconnect → re-mark: fresh timestamp, key NOT reaped as stale (the lockstep fix)', () => {
116
- const keys = new Set<string>()
117
- const since = new Map<string, number>()
118
- const q = createDeliveryQueue()
119
-
120
- // t=0 first mark.
121
- markBusyKeyLockstep(keys, since, '-100:4', 0)
122
-
123
- // Bridge dies at t=1000 — the REAL disconnect flush clears BOTH containers
124
- // in lockstep (belt-and-suspenders to the set-membership guard).
125
- flushOnAgentDisconnect(flushDeps(keys, since))
126
- expect(keys.size).toBe(0)
127
- expect(since.size).toBe(0)
128
-
129
- // Reconnect and re-mark the SAME key at t=300_000 (well after the old stamp).
130
- markBusyKeyLockstep(keys, since, '-100:4', 300_000)
131
-
132
- // Pre-fix (stale t=0 stamp surviving the disconnect + a `!since.has` guard
133
- // declining to re-stamp) the age would read 300_000ms ≫ grace and the key
134
- // would be reaped THE INSTANT it was re-marked — re-opening the idle-drain
135
- // while claude is about to process this very inbound. With the fix the stamp
136
- // is fresh (t=300_000), so at t just past the re-mark it is NOT reaped.
137
- expect(reap(keys, since, q, 305_000)).toEqual([]) // age 5s < 30s grace
138
- expect(keys.has('-100:4')).toBe(true)
139
-
140
- // ...and it DOES reap once the fresh grace genuinely elapses.
141
- expect(reap(keys, since, q, 300_000 + TTL)).toEqual(['-100:4'])
142
- })
143
-
144
- it('disconnect while a delivery is pending: reconnect re-mark stays protected by the proof gate', () => {
145
- const keys = new Set<string>()
146
- const since = new Map<string, number>()
147
- const q = createDeliveryQueue<{ text: string }>()
148
-
149
- markBusyKeyLockstep(keys, since, '-100:4', 0)
150
- trackDelivery(q, '-100:4', { text: 'inflight' }, 0, '42')
151
-
152
- // Bridge flap: disconnect clears busy state; the offline buffer / re-deliver
153
- // loop still owns the inbound. Reconnect re-marks + re-tracks it.
154
- flushOnAgentDisconnect(flushDeps(keys, since))
155
- markBusyKeyLockstep(keys, since, '-100:4', 5_000)
156
- trackDelivery(q, '-100:4', { text: 'inflight' }, 5_000, '42')
157
-
158
- // Even minutes later, still pending its ack → never reaped.
159
- expect(reap(keys, since, q, 5_000 + 400_000)).toEqual([])
160
- expect(keys.has('-100:4')).toBe(true)
161
- })
162
-
163
- it('prunes shadow-map entries whose key already left the set (no unbounded growth)', () => {
164
- const keys = new Set<string>()
165
- const since = new Map<string, number>()
166
- const q = createDeliveryQueue()
167
- // Simulate a direct clear that (hypothetically) left the map behind.
168
- since.set('ghost:_', 0)
169
- reap(keys, since, q, 999_999)
170
- expect(since.has('ghost:_')).toBe(false)
171
- })
172
-
173
- it('re-mark of an already-busy key does NOT reset its timestamp (measures the true dangle)', () => {
174
- const keys = new Set<string>()
175
- const since = new Map<string, number>()
176
- const q = createDeliveryQueue()
177
- markBusyKeyLockstep(keys, since, 'k:_', 0)
178
- markBusyKeyLockstep(keys, since, 'k:_', 20_000) // still busy → no re-stamp
179
- expect(since.get('k:_')).toBe(0)
180
- // Orphaned (no pending delivery) → reaps on the ORIGINAL stamp's grace.
181
- expect(reap(keys, since, q, TTL)).toEqual(['k:_'])
182
- })
183
-
184
- it('clearBusyKeyLockstep removes from both containers', () => {
185
- const keys = new Set<string>()
186
- const since = new Map<string, number>()
187
- markBusyKeyLockstep(keys, since, 'k:_', 0)
188
- clearBusyKeyLockstep(keys, since, 'k:_')
189
- expect(keys.has('k:_')).toBe(false)
190
- expect(since.has('k:_')).toBe(false)
191
- })
192
- })
@@ -1,75 +0,0 @@
1
- /**
2
- * Subprocess probe for the SWITCHROOM_DELIVERY_MACHINE_CUTOVER kill switch.
3
- *
4
- * The cutover env var is read at MODULE LOAD in both
5
- * `inbound-delivery-machine-dispatch.ts` and
6
- * `inbound-delivery-machine-shadow.ts`, so an in-process test can't flip it
7
- * after import (and bun's `vi` shim has no resetModules/stubEnv). The
8
- * cutover-flip test spawns this script under `bun` with the env it wants
9
- * and asserts the JSON verdict printed on stdout.
10
- *
11
- * Output: one JSON line —
12
- * {
13
- * dispatchEnabled, cutoverEnabled, shadowEnabled,
14
- * sendCalls, buffered, setTurnStartedCalls, deliverResults
15
- * }
16
- * after driving a fresh-turn inbound effect set through dispatchEffects
17
- * against a recording fake ctx.
18
- */
19
-
20
- import { dispatchEffects, isDispatchEnabled } from '../../gateway/inbound-delivery-machine-dispatch'
21
- import {
22
- isDeliveryCutoverEnabled,
23
- __shadowEnabledForTests,
24
- } from '../../gateway/inbound-delivery-machine-shadow'
25
- import { createPendingInboundBuffer } from '../../gateway/pending-inbound-buffer'
26
- import { createPendingPermissionBuffer } from '../../gateway/pending-permission-decisions'
27
- import { initialState, transition, type ChatKey } from '../../gateway/inbound-delivery-machine'
28
-
29
- const KEY = '111:_' as ChatKey
30
- const msg = { type: 'inbound', chatId: '111', messageId: 42, text: 'hello' }
31
-
32
- const alive = transition(initialState(), { kind: 'bridgeUp', at: 1000 }).state
33
- const { effects } = transition(alive, {
34
- kind: 'inbound',
35
- key: KEY,
36
- msg: { msgId: 42, isSteering: false, payload: msg },
37
- at: 2000,
38
- })
39
-
40
- let sendCalls = 0
41
- const buffer = createPendingInboundBuffer()
42
- let setTurnStartedCalls = 0
43
- const deliverResults: boolean[] = []
44
-
45
- dispatchEffects(effects, {
46
- selfAgent: 'probe-agent',
47
- ipcServer: {
48
- sendToAgent: () => {
49
- sendCalls++
50
- return true
51
- },
52
- } as never,
53
- pendingInboundBuffer: buffer,
54
- inboundSpool: null,
55
- pendingPermissionBuffer: createPendingPermissionBuffer(),
56
- log: () => {},
57
- onSetTurnStarted: () => {
58
- setTurnStartedCalls++
59
- },
60
- onDeliverResult: (_k, ok) => {
61
- deliverResults.push(ok)
62
- },
63
- })
64
-
65
- process.stdout.write(
66
- JSON.stringify({
67
- dispatchEnabled: isDispatchEnabled(),
68
- cutoverEnabled: isDeliveryCutoverEnabled(),
69
- shadowEnabled: __shadowEnabledForTests(),
70
- sendCalls,
71
- buffered: buffer.drain('probe-agent').length,
72
- setTurnStartedCalls,
73
- deliverResults,
74
- }) + '\n',
75
- )
@@ -1,171 +0,0 @@
1
- /**
2
- * Drift-catching scaffolding for the inbound-delivery state-machine
3
- * cutover (#2794). The turn-in-flight gate is now machine-authoritative
4
- * (`isMachineInTurn`), but the legacy imperative `claudeBusyKeys` set is
5
- * still maintained in parallel. These tests assert the two AGREE on every
6
- * well-formed turn schedule, and pin the ONE intended divergence (the
7
- * orphan-dangle the machine self-heals) so future edits can't silently
8
- * introduce a dangerous `machine_over_holds` drift without turning a test
9
- * red.
10
- *
11
- * This is the "assertion that machine and shadow agree" from the #2794
12
- * time-box plan: it turns the standing triple-maintenance drift risk into
13
- * a CI gate.
14
- */
15
-
16
- import { describe, expect, it, beforeEach } from 'vitest'
17
- import {
18
- shadowEmit,
19
- isMachineInTurn,
20
- __shadowResetForTests,
21
- } from '../gateway/inbound-delivery-machine-shadow.js'
22
- import { TURN_TTL_MS, type ChatKey } from '../gateway/inbound-delivery-machine.js'
23
- import {
24
- gateParityDivergence,
25
- isDangerousGateDivergence,
26
- probeGateParity,
27
- } from '../gateway/gate-parity-probe.js'
28
-
29
- const KEY_A = '111:_' as ChatKey
30
- const KEY_B = '222:_' as ChatKey
31
-
32
- /**
33
- * Reference model of the imperative `claudeBusyKeys` set — a per-delivery
34
- * Set stamped on a fresh-turn delivery and deleted on the matching
35
- * turnEnd. This mirrors gateway.ts's markBusyKeyLockstep / claudeBusyKeys.delete
36
- * lifecycle closely enough to detect real drift in the gate view.
37
- */
38
- class BusyKeysModel {
39
- private readonly keys = new Set<string>()
40
- stampFreshTurn(key: string) {
41
- this.keys.add(key)
42
- }
43
- endTurn(key: string) {
44
- this.keys.delete(key)
45
- }
46
- get size() {
47
- return this.keys.size
48
- }
49
- }
50
-
51
- describe('gateParityDivergence classifier', () => {
52
- it('agree → none (both idle, both busy)', () => {
53
- expect(gateParityDivergence(false, 0)).toBe('none')
54
- expect(gateParityDivergence(true, 1)).toBe('none')
55
- expect(gateParityDivergence(true, 3)).toBe('none')
56
- })
57
-
58
- it('machine idle, busyKeys held → busykeys_dangle (benign self-heal)', () => {
59
- expect(gateParityDivergence(false, 1)).toBe('busykeys_dangle')
60
- expect(isDangerousGateDivergence('busykeys_dangle')).toBe(false)
61
- })
62
-
63
- it('machine in-flight, busyKeys empty → machine_over_holds (dangerous)', () => {
64
- expect(gateParityDivergence(true, 0)).toBe('machine_over_holds')
65
- expect(isDangerousGateDivergence('machine_over_holds')).toBe(true)
66
- })
67
-
68
- it('probeGateParity returns the machine value unchanged (no behaviour change)', () => {
69
- const lines: string[] = []
70
- const log = (l: string) => lines.push(l)
71
- expect(probeGateParity(true, 1, log)).toBe(true)
72
- expect(probeGateParity(false, 0, log)).toBe(false)
73
- expect(probeGateParity(false, 1, log)).toBe(false)
74
- expect(probeGateParity(true, 0, log)).toBe(true)
75
- // Only the dangerous over-hold emits a drift line.
76
- expect(lines).toHaveLength(1)
77
- expect(lines[0]).toContain('gw-trace gate-drift kind=machine_over_holds')
78
- })
79
- })
80
-
81
- describe('machine ↔ claudeBusyKeys parity on well-formed schedules', () => {
82
- beforeEach(() => __shadowResetForTests())
83
-
84
- it('single turn: agree at every step', () => {
85
- const busy = new BusyKeysModel()
86
- shadowEmit({ kind: 'bridgeUp', at: 1000 })
87
- expect(gateParityDivergence(isMachineInTurn(), busy.size)).toBe('none')
88
-
89
- // Fresh inbound → machine goes in_turn; imperative stamps the key.
90
- shadowEmit({ kind: 'inbound', key: KEY_A, msg: { msgId: 1, isSteering: false, payload: null }, at: 2000 })
91
- busy.stampFreshTurn(KEY_A)
92
- expect(gateParityDivergence(isMachineInTurn(), busy.size)).toBe('none')
93
- expect(isMachineInTurn()).toBe(true)
94
-
95
- // turnEnd → both reopen.
96
- shadowEmit({ kind: 'turnEnd', key: KEY_A, at: 3000, outboundEmitted: true })
97
- busy.endTurn(KEY_A)
98
- expect(gateParityDivergence(isMachineInTurn(), busy.size)).toBe('none')
99
- expect(isMachineInTurn()).toBe(false)
100
- })
101
-
102
- it('sequential turns across two chats: no drift', () => {
103
- const busy = new BusyKeysModel()
104
- shadowEmit({ kind: 'bridgeUp', at: 1000 })
105
-
106
- const schedule: Array<[ChatKey, number]> = [
107
- [KEY_A, 2000],
108
- [KEY_B, 4000],
109
- [KEY_A, 6000],
110
- ]
111
- let t = 2000
112
- for (const [key, start] of schedule) {
113
- shadowEmit({ kind: 'inbound', key, msg: { msgId: t, isSteering: false, payload: null }, at: start })
114
- busy.stampFreshTurn(key)
115
- expect(gateParityDivergence(isMachineInTurn(), busy.size)).toBe('none')
116
- shadowEmit({ kind: 'turnEnd', key, at: start + 500, outboundEmitted: true })
117
- busy.endTurn(key)
118
- expect(gateParityDivergence(isMachineInTurn(), busy.size)).toBe('none')
119
- t = start + 500
120
- }
121
- })
122
-
123
- it('mid-turn sibling inbound is buffered, not a new turn: parity holds', () => {
124
- const busy = new BusyKeysModel()
125
- shadowEmit({ kind: 'bridgeUp', at: 1000 })
126
-
127
- shadowEmit({ kind: 'inbound', key: KEY_A, msg: { msgId: 1, isSteering: false, payload: null }, at: 2000 })
128
- busy.stampFreshTurn(KEY_A)
129
- // Sibling arrives mid-turn: machine buffers (no new activeTurn); the
130
- // imperative gate is already busy so it does NOT stamp a fresh key.
131
- shadowEmit({ kind: 'inbound', key: KEY_B, msg: { msgId: 2, isSteering: false, payload: null }, at: 2500 })
132
- expect(gateParityDivergence(isMachineInTurn(), busy.size)).toBe('none')
133
-
134
- shadowEmit({ kind: 'turnEnd', key: KEY_A, at: 3000, outboundEmitted: true })
135
- busy.endTurn(KEY_A)
136
- expect(gateParityDivergence(isMachineInTurn(), busy.size)).toBe('none')
137
- })
138
- })
139
-
140
- describe('the ONE intended divergence: orphaned turnStart', () => {
141
- beforeEach(() => __shadowResetForTests())
142
-
143
- it('machine self-heals the dangle; imperative set holds the orphan (busykeys_dangle, benign)', () => {
144
- const busy = new BusyKeysModel()
145
- shadowEmit({ kind: 'bridgeUp', at: 1000 })
146
-
147
- // Turn A opens; turn B opens before A's turnEnd ever lands. The
148
- // imperative set stamps BOTH; only B's turnEnd arrives, leaving A as
149
- // an orphan key — the gymbro/clerk 5-min dangle.
150
- shadowEmit({ kind: 'turnStart', key: KEY_A, at: 2000 })
151
- busy.stampFreshTurn(KEY_A)
152
- shadowEmit({ kind: 'turnStart', key: KEY_B, at: 3000 })
153
- busy.stampFreshTurn(KEY_B)
154
- shadowEmit({ kind: 'turnEnd', key: KEY_B, at: 4000, outboundEmitted: true })
155
- busy.endTurn(KEY_B)
156
-
157
- // Machine still holds activeTurn=A (single-activeTurn); imperative
158
- // holds A too → they agree here.
159
- expect(gateParityDivergence(isMachineInTurn(), busy.size)).toBe('none')
160
-
161
- // TTL tick past A's start: the machine self-heals to idle, but the
162
- // imperative set NEVER gets A's turnEnd → orphan dangles forever.
163
- shadowEmit({ kind: 'tick', now: 2000 + TURN_TTL_MS + 1 })
164
- expect(isMachineInTurn()).toBe(false)
165
- const d = gateParityDivergence(isMachineInTurn(), busy.size)
166
- expect(d).toBe('busykeys_dangle')
167
- // Benign: this is exactly the wedge the machine was made authoritative
168
- // to kill — it must NOT trip the drift alarm.
169
- expect(isDangerousGateDivergence(d)).toBe(false)
170
- })
171
- })
@@ -1,217 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
-
3
- /**
4
- * PR3b regression pin: supergroup-mode parallel-turns deadlock fix.
5
- *
6
- * The bug. Pre-fix, the gateway used ONE map `activeTurnStartedAt`
7
- * for two distinct concerns:
8
- * (a) "user-visible turn started" — set eagerly in the fresh-turn
9
- * branch on inbound RECEIPT, used by per-key reads (status-query
10
- * metric, wedge detection, progress timeout, etc.) that want a
11
- * receipt-side timestamp.
12
- * (b) "claude is currently busy on this turn" — the fleet-wide gate
13
- * at purgeReactionTracking / releaseTurnBufferGate /
14
- * idle-drain / inbound buffer-or-deliver, where `.size === 0`
15
- * means "claude is idle, safe to flush buffered inbound."
16
- *
17
- * Under fleet-shared / DM topology the two concerns coincide — every
18
- * received inbound is delivered to claude — so the singleton worked.
19
- *
20
- * Under SUPERGROUP-OWNED topology (one agent owns the whole
21
- * supergroup, multiple topics share the gateway process), they
22
- * diverge:
23
- *
24
- * 1. Topic A delivers + processes — keyA in activeTurnStartedAt
25
- * (set on receipt) AND claudeBusyKeys (set on delivery).
26
- * 2. Topic B inbound arrives → fresh-turn branch eagerly sets
27
- * keyB in activeTurnStartedAt, displays 👀 / starts typing.
28
- * 3. `decideInboundDelivery` reads `turnInFlight = .size > 0` →
29
- * TRUE (keyA present) → B is buffered, NOT delivered to claude.
30
- * 4. A's turn_end → purgeReactionTracking deletes keyA →
31
- * `.size === 0` check fires the held-inbound flush. BUT under
32
- * old (singleton) semantics, keyB is STILL in
33
- * activeTurnStartedAt (set in step 2, never cleared because B
34
- * never started in claude so no turn_end ever fires for B).
35
- * 5. `.size > 0` (keyB lingers) → flush never runs → B's buffered
36
- * msg never delivered → B's user sees 👀 forever.
37
- *
38
- * DEADLOCK.
39
- *
40
- * The fix splits concerns. `activeTurnStartedAt` keeps semantic (a)
41
- * — set on receipt, all per-key reads use it. `claudeBusyKeys` (new)
42
- * carries semantic (b) — set ONLY on successful sendToAgent
43
- * (delivery), cleared on turn_end / disconnect / buffer-gate-release.
44
- * Fleet gates switch to claudeBusyKeys. The deadlock breaks because
45
- * step 5's gate now reads `claudeBusyKeys.size` which only ever held
46
- * keyA (delivered) → after step 4's delete, size === 0 → flush →
47
- * B's buffered msg delivered → claudeBusyKeys.add(keyB) → ... →
48
- * B's eventual turn_end clears keyB.
49
- *
50
- * This test pins the load-bearing invariants. The actual wiring
51
- * lives in `gateway.ts` and `disconnect-flush.ts`; this file is the
52
- * structural-contract regression guard so a future "let's simplify
53
- * the gates back to one map" refactor fails loudly here.
54
- */
55
-
56
- describe('PR3b parallel-turns deadlock fix: claudeBusyKeys decoupled from activeTurnStartedAt', () => {
57
- function makeState() {
58
- const activeTurnStartedAt = new Map<string, number>()
59
- const claudeBusyKeys = new Set<string>()
60
- return { activeTurnStartedAt, claudeBusyKeys }
61
- }
62
-
63
- // Mirror the gateway's fresh-turn-on-receipt path (the part that
64
- // updates the two maps). Stripped to ONLY the state mutations we
65
- // care about for this invariant.
66
- function receiveInbound(state: ReturnType<typeof makeState>, key: string, at: number): void {
67
- state.activeTurnStartedAt.set(key, at)
68
- // CRITICAL: claudeBusyKeys is NOT touched here. The whole point
69
- // of the split is that receipt ≠ delivery.
70
- }
71
-
72
- function deliverToClaude(state: ReturnType<typeof makeState>, key: string): void {
73
- // Mirror of markClaudeBusyForInbound at every successful
74
- // sendToAgent callsite in gateway.ts.
75
- state.claudeBusyKeys.add(key)
76
- }
77
-
78
- function turnEnd(state: ReturnType<typeof makeState>, key: string): void {
79
- // Mirror of purgeReactionTracking + releaseTurnBufferGate —
80
- // both maps cleared together at turn_end.
81
- state.activeTurnStartedAt.delete(key)
82
- state.claudeBusyKeys.delete(key)
83
- }
84
-
85
- function fleetGateOpen(state: ReturnType<typeof makeState>): boolean {
86
- // Mirror of the four fleet-wide gates:
87
- // purgeReactionTracking line 1393: if (claudeBusyKeys.size === 0)
88
- // releaseTurnBufferGate line 1484: if (claudeBusyKeys.size === 0)
89
- // onScheduleRestart line 4020: turnInFlight = .size > 0
90
- // idle-drain tick line 4343: if (.size > 0) return false
91
- // handleInbound line 8087: turnInFlightAtReceipt = .size > 0
92
- return state.claudeBusyKeys.size === 0
93
- }
94
-
95
- it('pre-fix scenario: singleton map deadlocks supergroup-mode parallel turns', () => {
96
- // Simulate the BROKEN behavior — one map serving both concerns
97
- // (i.e. what would happen if claudeBusyKeys did NOT exist and
98
- // the gates read activeTurnStartedAt.size). This is the
99
- // *opposite* of the test below; documents what we're fixing.
100
- const legacyState = new Map<string, number>()
101
- const legacyFleetGate = () => legacyState.size === 0
102
-
103
- // Step 1: A delivered (eager set on receipt)
104
- legacyState.set('keyA', 100)
105
- // Step 2: B received but buffered (eager set fires regardless)
106
- legacyState.set('keyB', 200)
107
- expect(legacyFleetGate()).toBe(false)
108
-
109
- // Step 4: A's turn_end clears keyA
110
- legacyState.delete('keyA')
111
- // DEADLOCK — keyB lingers forever because B never started
112
- // in claude so no turn_end ever fires for B.
113
- expect(legacyFleetGate()).toBe(false)
114
- expect(legacyState.has('keyB')).toBe(true)
115
- })
116
-
117
- it('post-fix scenario: split maps let A.turn_end unblock B', () => {
118
- const state = makeState()
119
-
120
- // Step 1: A's inbound received AND delivered to claude.
121
- receiveInbound(state, 'keyA', 100)
122
- deliverToClaude(state, 'keyA')
123
- expect(state.activeTurnStartedAt.has('keyA')).toBe(true)
124
- expect(state.claudeBusyKeys.has('keyA')).toBe(true)
125
-
126
- // Step 2: B's inbound received, but `decideInboundDelivery`
127
- // returned buffer-until-idle (turnInFlightAtReceipt was true
128
- // because keyA is in claudeBusyKeys). B's fresh-turn branch
129
- // STILL fired — eager activeTurnStartedAt[keyB] set —
130
- // for the user-visible 👀 / typing indicator.
131
- receiveInbound(state, 'keyB', 200)
132
- // CRITICAL: claudeBusyKeys does NOT contain keyB because B was
133
- // buffered, not delivered. The split semantics is the entire
134
- // PR3b contract.
135
- expect(state.activeTurnStartedAt.has('keyB')).toBe(true)
136
- expect(state.claudeBusyKeys.has('keyB')).toBe(false)
137
- expect(state.claudeBusyKeys.size).toBe(1)
138
- expect(fleetGateOpen(state)).toBe(false)
139
-
140
- // Step 4: A's turn_end clears keyA from BOTH maps.
141
- turnEnd(state, 'keyA')
142
-
143
- // Step 5 (the deadlock fix): fleet gate now OPENS because
144
- // claudeBusyKeys is empty, even though activeTurnStartedAt
145
- // still has keyB (which is fine — that's the user-visible
146
- // receipt timestamp, not the claude-busy flag).
147
- expect(state.claudeBusyKeys.size).toBe(0)
148
- expect(state.activeTurnStartedAt.has('keyB')).toBe(true)
149
- expect(fleetGateOpen(state)).toBe(true)
150
-
151
- // The flush triggers → B's buffered msg gets sent →
152
- // deliverToClaude fires for keyB → busy gate closes again.
153
- deliverToClaude(state, 'keyB')
154
- expect(fleetGateOpen(state)).toBe(false)
155
-
156
- // B's turn_end completes the cycle.
157
- turnEnd(state, 'keyB')
158
- expect(state.activeTurnStartedAt.size).toBe(0)
159
- expect(state.claudeBusyKeys.size).toBe(0)
160
- expect(fleetGateOpen(state)).toBe(true)
161
- })
162
-
163
- it('per-key reads (priorTurnStartedAt timing, status-query metric) keep working on activeTurnStartedAt', () => {
164
- // The split must not regress per-key timestamp reads. These all
165
- // want the RECEIPT timestamp (the user's send-time, not when
166
- // claude finally got to it), so they correctly read
167
- // activeTurnStartedAt — preserved across the buffer window.
168
- const state = makeState()
169
- receiveInbound(state, 'keyA', 100)
170
- deliverToClaude(state, 'keyA')
171
- receiveInbound(state, 'keyB', 200) // buffered, no deliverToClaude
172
-
173
- // A second message on keyB arriving during the buffer window —
174
- // mid-turn classification reads activeTurnStartedAt.get(keyB)
175
- // for `priorTurnStartedAt`, which must still be 200 (the
176
- // original receipt time, even though B never reached claude).
177
- expect(state.activeTurnStartedAt.get('keyB')).toBe(200)
178
- // And keyA's receipt timestamp is preserved too — A's
179
- // follow-ups during its own processing window get accurate
180
- // secondsSinceTurnStart metric.
181
- expect(state.activeTurnStartedAt.get('keyA')).toBe(100)
182
- })
183
-
184
- it('disconnect-flush sweeps both maps together (the bridge died, all in-flight turns are dead)', () => {
185
- // Mirror of the disconnect-flush sweep loop in disconnect-flush.ts —
186
- // a registered-agent disconnect clears both maps because every
187
- // turn it was processing is dead by definition.
188
- const state = makeState()
189
- receiveInbound(state, 'keyA', 100)
190
- deliverToClaude(state, 'keyA')
191
- receiveInbound(state, 'keyB', 200) // buffered
192
-
193
- // Bridge dies (claude crashed mid-A). Sweep both maps.
194
- for (const k of [...state.activeTurnStartedAt.keys()]) {
195
- state.activeTurnStartedAt.delete(k)
196
- state.claudeBusyKeys.delete(k)
197
- }
198
-
199
- expect(state.activeTurnStartedAt.size).toBe(0)
200
- expect(state.claudeBusyKeys.size).toBe(0)
201
- expect(fleetGateOpen(state)).toBe(true)
202
- })
203
-
204
- it('idempotent: multiple inbounds for the same key (e.g. A user spamming) are a single entry', () => {
205
- // Set semantics — A's 5 follow-up messages while A is processing
206
- // collapse to a single claudeBusyKeys entry. Turn_end clears
207
- // once; size accounting stays correct.
208
- const state = makeState()
209
- receiveInbound(state, 'keyA', 100)
210
- deliverToClaude(state, 'keyA')
211
- deliverToClaude(state, 'keyA')
212
- deliverToClaude(state, 'keyA')
213
- expect(state.claudeBusyKeys.size).toBe(1)
214
- turnEnd(state, 'keyA')
215
- expect(state.claudeBusyKeys.size).toBe(0)
216
- })
217
- })