switchroom 0.18.30 → 0.18.32

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 (67) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +4 -2
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +708 -255
  5. package/dist/host-control/main.js +5 -3
  6. package/dist/vault/approvals/kernel-server.js +4 -2
  7. package/dist/vault/broker/server.js +4 -2
  8. package/package.json +3 -2
  9. package/profiles/_base/start.sh.hbs +142 -7
  10. package/telegram-plugin/dist/gateway/gateway.js +25870 -25007
  11. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  12. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  13. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  14. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  15. package/telegram-plugin/gateway/gateway.ts +5479 -7069
  16. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  17. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  18. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  19. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  20. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  21. package/telegram-plugin/gateway/turn-flush-suppression.ts +82 -0
  22. package/telegram-plugin/pending-user-notice.ts +59 -13
  23. package/telegram-plugin/subagent-watcher.ts +111 -28
  24. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  25. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  26. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +16 -6
  27. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +3 -3
  28. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  29. package/telegram-plugin/tests/emission-authority-facade.test.ts +29 -19
  30. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  31. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +18 -7
  32. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +249 -0
  33. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  34. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  35. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +303 -0
  36. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -3
  37. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  38. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  39. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  40. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  41. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +4 -4
  42. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  43. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +9 -3
  44. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  45. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  46. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +2 -1
  47. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  48. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  49. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  50. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +3 -2
  51. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  52. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +291 -0
  53. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  54. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  55. package/telegram-plugin/tests/turn-flush-safety.test.ts +8 -6
  56. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +112 -0
  57. package/telegram-plugin/tests/turn-flush-suppression.test.ts +90 -0
  58. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  59. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +126 -0
  60. package/telegram-plugin/tool-activity-summary.ts +27 -3
  61. package/telegram-plugin/worker-activity-feed.ts +24 -0
  62. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  63. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  64. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  65. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  66. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  67. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -1,37 +1,25 @@
1
1
  /**
2
- * PR3c cutover flip (#2794 / #2996 item 1) — the inbound-routing flip.
2
+ * Machine-authoritative inbound routing (#2794 cutover; kill switch
3
+ * removed in #2996 P1).
3
4
  *
4
- * `handleInbound` now dispatches the machine's captured `inbound` effects
5
+ * `handleInbound` dispatches the machine's captured `inbound` effects
5
6
  * through `dispatchEffects` as the AUTHORITATIVE deliver-vs-buffer routing.
6
- * These tests pin the two contract halves the flip rests on:
7
+ * These tests pin the delivery outcomes that routing rests on: fresh turn →
8
+ * setTurnStarted + deliverToBridge (real send); mid-turn → buffer (no send,
9
+ * drainable); steer mid-turn → deliverToBridge without setTurnStarted.
7
10
  *
8
- * 1. DEFAULT ON with no env override, both the cutover gate
9
- * (`isDeliveryCutoverEnabled`) and the dispatcher (`isDispatchEnabled`)
10
- * are enabled, and the machine's effect sequences execute real I/O
11
- * (fresh turn setTurnStarted + deliverToBridge; mid-turn buffer).
11
+ * Plus the `onDeliverResult` observer: the gateway's machine-deliver path
12
+ * branches on the send outcome (delivered → steer-ack + delivery-confirm
13
+ * tracking; miss durable-buffer + restart notice), so the callback
14
+ * contract (fires once per deliverToBridge, with the real ok, and never
15
+ * breaks delivery when the observer throws) is load-bearing.
12
16
  *
13
- * 2. KILL SWITCH — `SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0` restores
14
- * legacy behavior: the gate reads legacy claudeBusyKeys (gate returns
15
- * false from `isDeliveryCutoverEnabled`) and `dispatchEffects` is a
16
- * total no-op, so the imperative twin in gateway.ts is the only
17
- * executor. Env is read at module load, so the kill-switch tests use
18
- * vi.resetModules + a fresh dynamic import.
19
- *
20
- * Plus the `onDeliverResult` observer added for the flip: the gateway's
21
- * machine-deliver path branches on the send outcome (delivered → busy-mark
22
- * + delivery-confirm tracking; miss → release + durable-buffer + restart
23
- * notice), so the callback contract (fires once per deliverToBridge, with
24
- * the real ok, and never breaks delivery when the observer throws) is
25
- * load-bearing.
17
+ * The `SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0` kill-switch subprocess suite
18
+ * and its fixture probe were deleted in #2996 P1 with the switch itself.
26
19
  */
27
20
 
28
21
  import { describe, expect, it, vi } from 'vitest'
29
- import { spawnSync } from 'node:child_process'
30
- import { join } from 'node:path'
31
- import {
32
- dispatchEffects,
33
- isDispatchEnabled,
34
- } from '../gateway/inbound-delivery-machine-dispatch'
22
+ import { dispatchEffects } from '../gateway/inbound-delivery-machine-dispatch'
35
23
  import type { DispatchCtx } from '../gateway/inbound-delivery-machine-dispatch'
36
24
  import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer'
37
25
  import { createPendingPermissionBuffer } from '../gateway/pending-permission-decisions'
@@ -41,9 +29,6 @@ import {
41
29
  type ChatKey,
42
30
  type State,
43
31
  } from '../gateway/inbound-delivery-machine'
44
- import {
45
- isDeliveryCutoverEnabled,
46
- } from '../gateway/inbound-delivery-machine-shadow'
47
32
 
48
33
  const KEY = '111:_' as ChatKey
49
34
 
@@ -87,13 +72,7 @@ function aliveIdle(): State {
87
72
  return transition(initialState(), { kind: 'bridgeUp', at: 1000 }).state
88
73
  }
89
74
 
90
- describe('cutover defaultON without env override', () => {
91
- it('dispatcher and gate are enabled by default (no kill-switch in test env)', () => {
92
- expect(process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER).toBeUndefined()
93
- expect(isDispatchEnabled()).toBe(true)
94
- expect(isDeliveryCutoverEnabled()).toBe(true)
95
- })
96
-
75
+ describe('machine-authoritative routingdelivery outcomes', () => {
97
76
  it('fresh-turn inbound: machine effects execute setTurnStarted + deliverToBridge', () => {
98
77
  const state = aliveIdle()
99
78
  const msg = ipcMsg()
@@ -240,79 +219,71 @@ describe('onDeliverResult observer contract (the flip’s post-send branch sourc
240
219
  })
241
220
  })
242
221
 
243
- describe('machine deliver path — send-miss busy-key handling (review fix, PR #3012)', () => {
244
- // Mirrors the gateway's machine-deliver wiring: onSetTurnStarted stamps
245
- // the busy mirror AND records the key; the miss branch releases ONLY a
246
- // key THIS dispatch stamped. A steer-miss must keep the original turn's
247
- // key (the twin never reserves for steers), else the wiped key fires a
248
- // false machine_over_holds parity drift and the pending-restart gate
249
- // (claudeBusyKeys.size) could green-light a mid-turn restart.
250
- function runMissHarness(
251
- effects: ReturnType<typeof transition>['effects'],
252
- busyKeys: Set<string>,
253
- ): { stamped: string | null } {
222
+ describe('machine deliver path — steer vs fresh-turn setTurnStarted contract', () => {
223
+ // The gateway's machine-deliver wiring keys "was this a fresh turn?" off
224
+ // the setTurnStarted effect: a mid-turn steer emits deliverToBridge
225
+ // WITHOUT setTurnStarted (it amends the running turn, it does not open
226
+ // one). A machine change that started emitting setTurnStarted for steers
227
+ // would mis-classify steers as fresh turns at the live call site.
228
+ it('steer mid-turn emits NO setTurnStarted even on a send-miss', () => {
229
+ const s1 = transition(aliveIdle(), {
230
+ kind: 'inbound',
231
+ key: KEY,
232
+ msg: machineMsg(ipcMsg()),
233
+ at: 2000,
234
+ }).state
235
+ const { effects } = transition(s1, {
236
+ kind: 'inbound',
237
+ key: KEY,
238
+ msg: machineMsg(ipcMsg(), true),
239
+ at: 3000,
240
+ })
241
+ expect(effects.some((e) => e.kind === 'setTurnStarted')).toBe(false)
254
242
  let stamped: string | null = null
255
- let delivered = false
243
+ let delivered = true
256
244
  const { ctx } = makeCtx({ ipcServer: { sendToAgent: vi.fn(() => false) } as never })
257
245
  dispatchEffects(effects, {
258
246
  ...ctx,
259
247
  onSetTurnStarted: (k) => {
260
248
  stamped = k
261
- busyKeys.add(k)
262
249
  },
263
250
  onDeliverResult: (_k, ok) => {
264
251
  delivered = ok
265
252
  },
266
253
  })
254
+ expect(stamped).toBeNull()
267
255
  expect(delivered).toBe(false)
268
- // Gateway miss branch (guarded release):
269
- if (stamped != null) busyKeys.delete(stamped)
270
- return { stamped }
271
- }
256
+ })
272
257
 
273
- it('fresh-turn miss: the key THIS dispatch stamped is released', () => {
274
- const busyKeys = new Set<string>()
258
+ it('fresh turn fires setTurnStarted exactly once, before the send', () => {
275
259
  const { effects } = transition(aliveIdle(), {
276
260
  kind: 'inbound',
277
261
  key: KEY,
278
262
  msg: machineMsg(ipcMsg()),
279
263
  at: 2000,
280
264
  })
281
- const { stamped } = runMissHarness(effects, busyKeys)
282
- expect(stamped).toBe(KEY)
283
- expect(busyKeys.has(KEY)).toBe(false)
284
- })
285
-
286
- it('steer miss: no setTurnStarted fired, the ORIGINAL turn\'s key survives', () => {
287
- // Original fresh turn holds the busy key.
288
- const busyKeys = new Set<string>([KEY])
289
- const s1 = transition(aliveIdle(), {
290
- kind: 'inbound',
291
- key: KEY,
292
- msg: machineMsg(ipcMsg()),
293
- at: 2000,
294
- }).state
295
- const { effects } = transition(s1, {
296
- kind: 'inbound',
297
- key: KEY,
298
- msg: machineMsg(ipcMsg(), true),
299
- at: 3000,
265
+ const order: string[] = []
266
+ const sendToAgent = vi.fn(() => {
267
+ order.push('send')
268
+ return true
300
269
  })
301
- expect(effects.some((e) => e.kind === 'setTurnStarted')).toBe(false)
302
- const { stamped } = runMissHarness(effects, busyKeys)
303
- expect(stamped).toBeNull()
304
- // The in-flight turn's key was NOT wiped by the steer's send-miss.
305
- expect(busyKeys.has(KEY)).toBe(true)
270
+ const { ctx } = makeCtx({ ipcServer: { sendToAgent } as never })
271
+ dispatchEffects(effects, {
272
+ ...ctx,
273
+ onSetTurnStarted: () => order.push('setTurnStarted'),
274
+ })
275
+ expect(order).toEqual(['setTurnStarted', 'send'])
306
276
  })
307
277
  })
308
278
 
309
279
  describe('carve-out routing anchors (gateway keys off these machine facts)', () => {
310
280
  it('PIN: bridge-dead inbound emits the exact `inbound_bridge_dead_buffer` trace stage', () => {
311
281
  // gateway.ts's machineBridgeDead carve-out matches this string verbatim
312
- // to route bridge-dead inbounds to the imperative twin. A rename here
313
- // silently reroutes them to the machine's buffer+persist (losing the
314
- // twin's shouldTrackDelivery drop semantics + restart notice) — this
315
- // pin makes the rename loud. Deleted in PR4 with the twin.
282
+ // to route bridge-dead inbounds to the imperative delivery body. A
283
+ // rename here silently reroutes them to the machine's buffer+persist
284
+ // (losing the shouldTrackDelivery drop semantics + restart notice) —
285
+ // this pin makes the rename loud. Deleted with the carve-out when the
286
+ // machine grows bridge_dead send semantics (#2794 PR4 remainder).
316
287
  const dead = initialState() // bridge_dead is the initial global state
317
288
  const { effects } = transition(dead, {
318
289
  kind: 'inbound',
@@ -349,70 +320,3 @@ describe('carve-out routing anchors (gateway keys off these machine facts)', ()
349
320
  expect(effects.some((e) => e.kind === 'deliverToBridge')).toBe(false)
350
321
  })
351
322
  })
352
-
353
- // The env var is read at MODULE LOAD in the dispatch + shadow modules, and
354
- // bun's `vi` shim has no resetModules/stubEnv — so the kill-switch/default
355
- // assertions run the fixture probe in a SUBPROCESS with a controlled env
356
- // (same pattern as bridge-anonymous-refuse.test.ts).
357
- describe('kill switch — SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0 restores legacy (subprocess)', () => {
358
- const PROBE = join(__dirname, 'fixtures', 'cutover-killswitch-probe.ts')
359
-
360
- function bunBin(): string | null {
361
- const r = spawnSync('which', ['bun'], { encoding: 'utf-8' })
362
- const p = r.status === 0 ? r.stdout.trim() : ''
363
- return p !== '' ? p : null
364
- }
365
-
366
- function runProbe(env: Record<string, string | undefined>) {
367
- const bun = bunBin()
368
- if (bun == null) return null
369
- const r = spawnSync(bun, ['run', PROBE], {
370
- encoding: 'utf-8',
371
- env: { ...process.env, ...env },
372
- timeout: 30_000,
373
- })
374
- expect(r.status).toBe(0)
375
- const line = r.stdout.trim().split('\n').pop() ?? ''
376
- return JSON.parse(line) as {
377
- dispatchEnabled: boolean
378
- cutoverEnabled: boolean
379
- shadowEnabled: boolean
380
- sendCalls: number
381
- buffered: number
382
- setTurnStartedCalls: number
383
- deliverResults: boolean[]
384
- }
385
- }
386
-
387
- it('default (no env): machine authoritative — dispatcher executes the deliver', () => {
388
- const v = runProbe({ SWITCHROOM_DELIVERY_MACHINE_CUTOVER: undefined })
389
- if (v == null) return // bun not on PATH — covered by the in-process default tests above
390
- expect(v.dispatchEnabled).toBe(true)
391
- expect(v.cutoverEnabled).toBe(true)
392
- expect(v.sendCalls).toBe(1)
393
- expect(v.setTurnStartedCalls).toBe(1)
394
- expect(v.deliverResults).toEqual([true])
395
- expect(v.buffered).toBe(0)
396
- })
397
-
398
- it('=0: dispatcher is a total no-op and the gate reverts to legacy; shadow stays on', () => {
399
- const v = runProbe({ SWITCHROOM_DELIVERY_MACHINE_CUTOVER: '0' })
400
- if (v == null) return
401
- expect(v.dispatchEnabled).toBe(false)
402
- expect(v.cutoverEnabled).toBe(false)
403
- // Shadow trace continues for the bake; only authoritative reads flip off.
404
- expect(v.shadowEnabled).toBe(true)
405
- expect(v.sendCalls).toBe(0)
406
- expect(v.buffered).toBe(0)
407
- expect(v.setTurnStartedCalls).toBe(0)
408
- expect(v.deliverResults).toEqual([])
409
- })
410
-
411
- it('=1 (explicit on): identical to the default', () => {
412
- const v = runProbe({ SWITCHROOM_DELIVERY_MACHINE_CUTOVER: '1' })
413
- if (v == null) return
414
- expect(v.dispatchEnabled).toBe(true)
415
- expect(v.cutoverEnabled).toBe(true)
416
- expect(v.sendCalls).toBe(1)
417
- })
418
- })
@@ -1,13 +1,14 @@
1
1
  /**
2
- * PR3b cutover — the turn-in-flight GATE now reads the delivery state
3
- * machine (`isMachineInTurn`) instead of the PR3b `claudeBusyKeys` set.
2
+ * The turn-in-flight GATE reads the delivery state machine
3
+ * (`isMachineInTurn`) since #2996 P1 the machine is the SOLE authority
4
+ * (the legacy `claudeBusyKeys` set was deleted).
4
5
  *
5
- * The bug this closes (gymbro/clerk, 2026-05-28): `claudeBusyKeys` is a
6
- * per-delivery Set — every delivery `.add`s a key, but turn-end `.delete`s
7
- * exactly one. When a turn-end is missed (or fires under a non-matching
8
- * key) the set keeps an orphan, `size > 0` reads true forever, and EVERY
9
- * subsequent inbound buffers as "held mid-turn" until the 5-min
10
- * framework-fallback force-drains it.
6
+ * The bug the machine gate closes (gymbro/clerk, 2026-05-28): the legacy
7
+ * set was per-delivery — every delivery `.add`ed a key, but turn-end
8
+ * `.delete`d exactly one. When a turn-end was missed (or fired under a
9
+ * non-matching key) the set kept an orphan, `size > 0` read true forever,
10
+ * and EVERY subsequent inbound buffered as "held mid-turn" until the 5-min
11
+ * framework-fallback force-drained it.
11
12
  *
12
13
  * The machine cannot accumulate orphans: global state holds ONE
13
14
  * `activeTurn`, so any matching turnEnd returns it to idle, and the TTL
@@ -19,7 +20,6 @@ import { describe, expect, it, beforeEach } from 'vitest'
19
20
  import {
20
21
  shadowEmit,
21
22
  isMachineInTurn,
22
- isDeliveryCutoverEnabled,
23
23
  __shadowResetForTests,
24
24
  } from '../gateway/inbound-delivery-machine-shadow.js'
25
25
  import { TURN_TTL_MS, type ChatKey } from '../gateway/inbound-delivery-machine.js'
@@ -31,13 +31,9 @@ function inbound(key: ChatKey, at: number, msgId = 1) {
31
31
  shadowEmit({ kind: 'inbound', key, msg: { msgId, isSteering: false, payload: null }, at })
32
32
  }
33
33
 
34
- describe('PR3b cutover gate accessors', () => {
34
+ describe('machine turn-in-flight gate accessors (sole authority, #2996 P1)', () => {
35
35
  beforeEach(() => __shadowResetForTests())
36
36
 
37
- it('enabled by default (shadow on, no kill-switch in test env)', () => {
38
- expect(isDeliveryCutoverEnabled()).toBe(true)
39
- })
40
-
41
37
  it('reads idle before any turn (bridge alive)', () => {
42
38
  shadowEmit({ kind: 'bridgeUp', at: 1000 })
43
39
  expect(isMachineInTurn()).toBe(false)
@@ -18,13 +18,12 @@
18
18
  * 3. Invariant #1 at the dispatch layer: an inbound is delivered XOR
19
19
  * (buffered AND persisted) — never both, never neither.
20
20
  *
21
- * It does NOT invoke the imperative twins (`markClaudeBusyForInbound`,
22
- * the legacy `turnInFlightForGate` claudeBusyKeys branch,
23
- * `dispatchPermissionVerdict`, the silence-poke ladder), so it is NOT a
24
- * proof that machine path and imperative path produce identical I/O.
25
- * Twin-equivalence evidence for PR4 (twin deletion) must come from the
26
- * live shadow trace + gate-parity-probe bake per the RFC's cutover
27
- * protocol — do NOT cite this file as that proof.
21
+ * It does NOT invoke the remaining imperative delivery body (the twin
22
+ * carve-outs for `!`-interrupt / bridge_dead, `dispatchPermissionVerdict`,
23
+ * the silence-poke ladder), so it is NOT a proof that machine path and
24
+ * imperative path produce identical I/O. (#2996 P1 deleted the legacy
25
+ * gate/claudeBusyKeys twins after the cutover bake; the carve-out body's
26
+ * eventual deletion the #2794 PR4 remainder needs its own evidence.)
28
27
  *
29
28
  * What it DOES gate: if a future edit adds an effect kind the
30
29
  * dispatcher doesn't execute, the "no unhandled effect" assertion fails
@@ -17,7 +17,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
17
17
  import {
18
18
  __dispatchOneForTests,
19
19
  dispatchEffects,
20
- isDispatchEnabled,
21
20
  } from '../gateway/inbound-delivery-machine-dispatch'
22
21
  import type { DispatchCtx } from '../gateway/inbound-delivery-machine-dispatch'
23
22
  import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer'
@@ -50,12 +49,6 @@ function makeCtx(overrides?: Partial<DispatchCtx>): {
50
49
  return { ctx, logs, sendToAgent, clientSend, inbound, perm }
51
50
  }
52
51
 
53
- describe('isDispatchEnabled', () => {
54
- it('defaults to true when the kill-switch env-var is unset', () => {
55
- expect(isDispatchEnabled()).toBe(true)
56
- })
57
- })
58
-
59
52
  describe('dispatchEffects — drainBuffer', () => {
60
53
  it('redelivers buffered inbound to the registered client', () => {
61
54
  const { ctx, clientSend, inbound } = makeCtx()
@@ -175,15 +168,6 @@ describe('dispatchEffects — bridgeUp composite (all three effects in order)',
175
168
  })
176
169
  })
177
170
 
178
- describe('dispatchEffects — kill switch', () => {
179
- // The kill switch is checked at module-load time so we can't toggle
180
- // it inside the test process easily. We validate the contract that
181
- // the helper exists and respects the env-var — see isDispatchEnabled.
182
- it('exposes the kill-switch check', () => {
183
- expect(typeof isDispatchEnabled()).toBe('boolean')
184
- })
185
- })
186
-
187
171
  describe('dispatchEffects — inbound-routing effects (PR3c wiring)', () => {
188
172
  const k = 'c1:_' as never
189
173
  const ipcMsg = { type: 'inbound' as const, id: 'm1', chat_id: '1', text: 'hi', meta: { source: 'telegram' } }
@@ -9,10 +9,10 @@
9
9
  * — before the permission-reply / `/auth` paste-back / interrupt-empty /
10
10
  * secret-detect-drop intercepts — an intercepted message (e.g. an approval
11
11
  * reply) drove the machine idle→`bridge_alive_in_turn`, then early-returned
12
- * as an intercept: no delivery, no claudeBusyKeys mark, and critically no
13
- * `turnEnd`. The machine held the gate closed until the 5-min TTL tick
14
- * force-cleared it, buffering every inbound (including `/usage`) meanwhile —
15
- * the dangerous `machine_over_holds` divergence gate-parity-probe.ts flags.
12
+ * as an intercept: no delivery, and critically no `turnEnd`. The machine
13
+ * held the gate closed until the 5-min TTL tick force-cleared it, buffering
14
+ * every inbound (including `/usage`) meanwhile — the dangerous over-hold
15
+ * direction the bake-era gate-parity probe flagged.
16
16
  *
17
17
  * The behaviour lives inside the un-exported `handleInbound` closure, so —
18
18
  * mirroring the other gateway-*.test.ts source-pins — we assert on source
@@ -227,39 +227,94 @@ describe('PendingUserNoticeGate — notice fires IFF the turn dies reply-less',
227
227
  }
228
228
  }
229
229
 
230
+ // The single-turn norm: the error surfaced while topic A's turn was live, so
231
+ // the notice is keyed to A and topic A's turn end resolves it. `TOPIC_A` is a
232
+ // `statusKey(chatId, threadId)`-shaped value.
233
+ const TOPIC_A = '5000000002'
234
+ const TOPIC_B = '5000000009:42'
235
+
230
236
  it('DROPS the notice when the turn recovered (delivered a reply)', () => {
231
237
  const gate = new PendingUserNoticeGate()
232
- gate.schedule(makeNotice())
233
- expect(gate.resolveTurnEnd(true, 1_001_000)).toEqual([])
238
+ gate.schedule(makeNotice({ key: TOPIC_A }))
239
+ expect(gate.resolveTurnEnd(TOPIC_A, true, 1_001_000)).toEqual([])
234
240
  // And it does not resurrect on a later failed turn end.
235
- expect(gate.resolveTurnEnd(false, 1_002_000)).toEqual([])
241
+ expect(gate.resolveTurnEnd(TOPIC_A, false, 1_002_000)).toEqual([])
236
242
  })
237
243
 
238
244
  it('SENDS the notice exactly once when the turn ends without a reply', () => {
239
245
  const gate = new PendingUserNoticeGate()
240
- const notice = makeNotice()
241
- gate.schedule(notice)
242
- const flushed = gate.resolveTurnEnd(false, 1_001_000)
246
+ gate.schedule(makeNotice({ key: TOPIC_A }))
247
+ const flushed = gate.resolveTurnEnd(TOPIC_A, false, 1_001_000)
243
248
  expect(flushed).toHaveLength(1)
244
249
  expect(flushed[0].chatIds).toEqual(['5000000002', '5000000003'])
245
250
  // Exactly once — a second turn end flushes nothing.
246
- expect(gate.resolveTurnEnd(false, 1_002_000)).toEqual([])
251
+ expect(gate.resolveTurnEnd(TOPIC_A, false, 1_002_000)).toEqual([])
247
252
  })
248
253
 
249
- it('collapses an error burst within one turn to ONE pending notice per agent', () => {
254
+ it('collapses an error burst within one turn to ONE pending notice per topic', () => {
250
255
  const gate = new PendingUserNoticeGate()
251
- gate.schedule(makeNotice({ atMs: 1_000_000 }))
252
- gate.schedule(makeNotice({ atMs: 1_000_500 }))
253
- gate.schedule(makeNotice({ atMs: 1_001_000 }))
254
- expect(gate.resolveTurnEnd(false, 1_002_000)).toHaveLength(1)
256
+ gate.schedule(makeNotice({ atMs: 1_000_000, key: TOPIC_A }))
257
+ gate.schedule(makeNotice({ atMs: 1_000_500, key: TOPIC_A }))
258
+ gate.schedule(makeNotice({ atMs: 1_001_000, key: TOPIC_A }))
259
+ expect(gate.resolveTurnEnd(TOPIC_A, false, 1_002_000)).toHaveLength(1)
255
260
  })
256
261
 
257
262
  it('expires an unresolved notice after the TTL (bias to silence over false failure)', () => {
258
263
  const gate = new PendingUserNoticeGate()
259
- gate.schedule(makeNotice({ atMs: 1_000_000 }))
264
+ gate.schedule(makeNotice({ atMs: 1_000_000, key: TOPIC_A }))
260
265
  const after = 1_000_000 + PENDING_USER_NOTICE_TTL_MS + 1
261
266
  expect(gate.hasPending(after)).toBe(false)
262
- expect(gate.resolveTurnEnd(false, after)).toEqual([])
267
+ expect(gate.resolveTurnEnd(TOPIC_A, false, after)).toEqual([])
268
+ })
269
+
270
+ // ── #3294 — keyed by turn/topic so concurrent per-topic turns don't
271
+ // cross-resolve each other's pending notices. ─────────────────────────────
272
+ it("does NOT flush topic A's notice when a DIFFERENT topic's turn ends reply-less", () => {
273
+ const gate = new PendingUserNoticeGate()
274
+ gate.schedule(makeNotice({ atMs: 1_000_000, key: TOPIC_A }))
275
+ // Topic B's turn ends WITHOUT a reply. Pre-#3294 this flushed A's notice
276
+ // (a false "couldn't complete" for a turn that may still recover). Now B's
277
+ // end resolves nothing keyed to A.
278
+ expect(gate.resolveTurnEnd(TOPIC_B, false, 1_000_500)).toEqual([])
279
+ // A's notice is still pending, and A's OWN reply-less end flushes it.
280
+ expect(gate.hasPending(1_000_600)).toBe(true)
281
+ const flushed = gate.resolveTurnEnd(TOPIC_A, false, 1_000_700)
282
+ expect(flushed).toHaveLength(1)
283
+ expect(flushed[0].key).toBe(TOPIC_A)
284
+ })
285
+
286
+ it("does NOT drop topic A's notice when a DIFFERENT topic's turn recovers", () => {
287
+ const gate = new PendingUserNoticeGate()
288
+ gate.schedule(makeNotice({ atMs: 1_000_000, key: TOPIC_A }))
289
+ // Topic B's turn recovered (delivered a reply). It must not drop A's notice.
290
+ expect(gate.resolveTurnEnd(TOPIC_B, true, 1_000_500)).toEqual([])
291
+ // A's notice survives and still flushes on A's reply-less end.
292
+ expect(gate.resolveTurnEnd(TOPIC_A, false, 1_000_700)).toHaveLength(1)
293
+ })
294
+
295
+ it('keeps a per-topic pending notice for each concurrent topic (no cross-collapse)', () => {
296
+ const gate = new PendingUserNoticeGate()
297
+ gate.schedule(makeNotice({ atMs: 1_000_000, key: TOPIC_A, chatIds: ['5000000002'] }))
298
+ gate.schedule(makeNotice({ atMs: 1_000_100, key: TOPIC_B, chatIds: ['5000000009'] }))
299
+ // A's end flushes only A.
300
+ const a = gate.resolveTurnEnd(TOPIC_A, false, 1_000_200)
301
+ expect(a).toHaveLength(1)
302
+ expect(a[0].chatIds).toEqual(['5000000002'])
303
+ // B is still pending and flushes on B's end.
304
+ const b = gate.resolveTurnEnd(TOPIC_B, false, 1_000_300)
305
+ expect(b).toHaveLength(1)
306
+ expect(b[0].chatIds).toEqual(['5000000009'])
307
+ })
308
+
309
+ it('an UNATTRIBUTED (undefined-key) notice resolves on any turn end (legacy single-turn behavior)', () => {
310
+ const gate = new PendingUserNoticeGate()
311
+ // No key — the error arrived with no live turn (between turns / post-poke).
312
+ gate.schedule(makeNotice({ atMs: 1_000_000 }))
313
+ // Any topic's reply-less end flushes it (preserves pre-#3294 behavior so a
314
+ // genuine death on a single-turn gateway still notifies the user).
315
+ const flushed = gate.resolveTurnEnd(TOPIC_B, false, 1_000_500)
316
+ expect(flushed).toHaveLength(1)
317
+ expect(flushed[0].key).toBeUndefined()
263
318
  })
264
319
  })
265
320
 
@@ -12,6 +12,13 @@ import { describe, it, expect } from 'vitest'
12
12
  import { readFileSync } from 'node:fs'
13
13
  import { resolve } from 'node:path'
14
14
 
15
+ // #2996 P2: executeReply's body moved VERBATIM to outbound-send-path.ts
16
+ // (`sendReply`); the reply-path routing assertions read the window there.
17
+ const sendPathSrc = readFileSync(
18
+ resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
19
+ 'utf-8',
20
+ )
21
+ const sendReplyFn = sendPathSrc.split('export async function sendReply(')[1]?.split('\nexport ')[0] ?? ''
15
22
  const gatewaySrc = readFileSync(
16
23
  resolve(__dirname, '..', 'gateway', 'gateway.ts'),
17
24
  'utf-8',
@@ -54,7 +61,7 @@ describe('component 3 — turn-origin reply routing', () => {
54
61
  })
55
62
 
56
63
  it('executeReply resolves the answer thread via the origin turn, not the live currentTurn', () => {
57
- const fn = gatewaySrc.split('async function executeReply')[1]?.split('\nasync function ')[0] ?? ''
64
+ const fn = sendReplyFn
58
65
  expect(fn).toMatch(/TURN_ORIGIN_ROUTING_ENABLED/)
59
66
  expect(fn).toMatch(/findTurnByOriginId\(args\.origin_turn_id/)
60
67
  // The resolution + reply-route telemetry go through resolveAnswerThreadWithLog,
@@ -85,8 +92,7 @@ describe('framework-owned origin recovery (determinism residual, 2026-06-05)', (
85
92
  })
86
93
 
87
94
  it('the reply path recovers origin from the quoted message_id when the model omits the echo', () => {
88
- for (const name of ['executeReply']) {
89
- const fn = gatewaySrc.split(new RegExp(`async function ${name}`))[1]?.split('\nasync function ')[0] ?? ''
95
+ for (const fn of [sendReplyFn]) {
90
96
  // Echo first (authoritative), quoted message_id as the framework fallback.
91
97
  expect(fn).toMatch(/const echoedTurn = findTurnByOriginId\(args\.origin_turn_id/)
92
98
  // Quoted lookup is CHAT-SCOPED (cross-chat message-id collision guard).
@@ -620,3 +620,43 @@ describe("ObligationLedger — escalation suppression predicate (Fix 4)", () =>
620
620
  expect(snapshots[1]).toEqual([]);
621
621
  });
622
622
  });
623
+
624
+ // #3282 — the durable captured-answer snapshot attaches to the OPEN obligation
625
+ // so a partial backstop delivery is resumed (tail only) rather than regenerated.
626
+ describe("ObligationLedger — noteCapturedDelivery (#3282)", () => {
627
+ function input(id: string, openedAt: number) {
628
+ return { originTurnId: id, chatId: "-100123", threadId: 3, messageId: 1, text: "q", openedAt };
629
+ }
630
+ const snap = { chunks: ["c0", "c1"], chunkStates: [{ index: 0, messageIds: [10], confirmed: true }] };
631
+
632
+ it("attaches the snapshot to an OPEN obligation and persists it", () => {
633
+ const snapshots: Obligation[][] = [];
634
+ const L = new ObligationLedger(2, { onChange: (s) => snapshots.push(s) });
635
+ L.openIfAbsent(input("c:3#1", 1000));
636
+ L.noteCapturedDelivery("c:3#1", snap);
637
+ expect(L.list()[0]!.capturedDelivery).toEqual(snap);
638
+ // The mutation persisted (onChange fired again after open).
639
+ expect(snapshots.at(-1)![0]!.capturedDelivery).toEqual(snap);
640
+ });
641
+
642
+ it("is a no-op for an obligation that is not open (a delivered turn has none to resume)", () => {
643
+ const L = new ObligationLedger(2);
644
+ L.noteCapturedDelivery("c:3#missing", snap); // never opened
645
+ expect(L.isOpen("c:3#missing")).toBe(false);
646
+ });
647
+
648
+ it("ignores an EMPTY snapshot (nothing to resume)", () => {
649
+ const L = new ObligationLedger(2);
650
+ L.openIfAbsent(input("c:3#1", 1000));
651
+ L.noteCapturedDelivery("c:3#1", { chunks: [], chunkStates: [] });
652
+ expect(L.list()[0]!.capturedDelivery).toBeUndefined();
653
+ });
654
+
655
+ it("close drops the snapshot with the obligation (bounded lifetime, A6)", () => {
656
+ const L = new ObligationLedger(2);
657
+ L.openIfAbsent(input("c:3#1", 1000));
658
+ L.noteCapturedDelivery("c:3#1", snap);
659
+ expect(L.close("c:3#1")).toBe(true);
660
+ expect(L.isOpen("c:3#1")).toBe(false);
661
+ });
662
+ });
@@ -118,6 +118,49 @@ describe("obligation-store", () => {
118
118
  expect(loaded[0]!.representCount).toBe(1);
119
119
  });
120
120
 
121
+ it("round-trips a valid capturedDelivery snapshot through persist → load (#3282)", () => {
122
+ const { fs } = memFs();
123
+ const capturedDelivery = {
124
+ chunks: ["c0", "c1"],
125
+ chunkStates: [
126
+ { index: 0, messageIds: [10], confirmed: true },
127
+ { index: 1, messageIds: [11, 12], confirmed: false },
128
+ ],
129
+ };
130
+ persistObligations(PATH, fs, [ob("c:3#715", { capturedDelivery })]);
131
+ const loaded = loadObligations(PATH, fs);
132
+ expect(loaded).toHaveLength(1);
133
+ expect(loaded[0]!.capturedDelivery).toEqual(capturedDelivery);
134
+ });
135
+
136
+ it("STRIPS a malformed capturedDelivery (fail-open to fresh-generation represent) (#3282)", () => {
137
+ // A partial write / forward-incompatible shape must not crash the resume: the
138
+ // row survives (obligation preserved), the bad blob is dropped.
139
+ const bad = {
140
+ v: 1,
141
+ obligations: [
142
+ { ...ob("c:3#715"), capturedDelivery: { chunks: "not-an-array", chunkStates: [] } },
143
+ { ...ob("c:3#716"), capturedDelivery: { chunks: ["c0"], chunkStates: [{ index: 0 }] } },
144
+ ],
145
+ };
146
+ const { fs } = memFs({ [PATH]: JSON.stringify(bad) });
147
+ const loaded = loadObligations(PATH, fs);
148
+ expect(loaded.map((o) => o.originTurnId)).toEqual(["c:3#715", "c:3#716"]);
149
+ expect(loaded[0]!.capturedDelivery).toBeUndefined();
150
+ expect(loaded[1]!.capturedDelivery).toBeUndefined();
151
+ });
152
+
153
+ it("STRIPS an empty-chunks capturedDelivery (nothing to resume → fresh-gen, bounded) (#3282)", () => {
154
+ const bad = {
155
+ v: 1,
156
+ obligations: [{ ...ob("c:3#715"), capturedDelivery: { chunks: [], chunkStates: [] } }],
157
+ };
158
+ const { fs } = memFs({ [PATH]: JSON.stringify(bad) });
159
+ const loaded = loadObligations(PATH, fs);
160
+ expect(loaded).toHaveLength(1);
161
+ expect(loaded[0]!.capturedDelivery).toBeUndefined();
162
+ });
163
+
121
164
  it("never throws on a write failure — degrades to in-memory (logs)", () => {
122
165
  const logs: string[] = [];
123
166
  const fs: ObligationStoreFsSeam = {
@@ -143,7 +143,8 @@ describe('resolution clears the durable store (Defect A)', () => {
143
143
 
144
144
  describe('TTL expiry wakes the parked agent (Defect B)', () => {
145
145
  it('the periodic reaper runs the approval-card expiry sweep inside try/catch', () => {
146
- const reaper = slice(GATEWAY, 'const pendingStateReaper = setInterval', 12000)
146
+ // Anchor tolerates the #2996 P0c `isGatewayMain ? setInterval(...)` boot gate.
147
+ const reaper = slice(GATEWAY, 'const pendingStateReaper = ', 12000)
147
148
  // The sweep call must be guarded like the sibling sweepStaleTurnActiveMarker:
148
149
  // an escaped throw inside this setInterval callback reaches uncaughtException
149
150
  // and takes the whole gateway down.