switchroom 0.18.31 → 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 (65) 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 +693 -253
  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 +43 -5
  10. package/telegram-plugin/dist/gateway/gateway.js +25868 -25038
  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 +7195 -8837
  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/pending-user-notice.ts +59 -13
  22. package/telegram-plugin/subagent-watcher.ts +111 -28
  23. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  24. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  25. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +16 -6
  26. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +3 -3
  27. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  28. package/telegram-plugin/tests/emission-authority-facade.test.ts +29 -19
  29. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  30. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +18 -7
  31. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +249 -0
  32. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  33. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  34. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +303 -0
  35. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -3
  36. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  37. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  38. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  39. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  40. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +4 -4
  41. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  42. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +9 -3
  43. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  44. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  45. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +2 -1
  46. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  47. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  48. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  49. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +3 -2
  50. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  51. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +291 -0
  52. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  53. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  54. package/telegram-plugin/tests/turn-flush-safety.test.ts +8 -6
  55. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +112 -0
  56. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  57. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +126 -0
  58. package/telegram-plugin/tool-activity-summary.ts +27 -3
  59. package/telegram-plugin/worker-activity-feed.ts +24 -0
  60. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  61. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  62. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  63. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  64. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  65. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -36,6 +36,13 @@ const gatewaySrc = readFileSync(
36
36
  resolve(__dirname, '..', 'gateway', 'gateway.ts'),
37
37
  'utf-8',
38
38
  )
39
+ // #2996 P2: executeReply's body moved verbatim to outbound-send-path.ts
40
+ // (`sendReply`); the post-send block assertions read there. The
41
+ // `releaseTurnBufferGate` helper itself stays in gateway.ts.
42
+ const sendPathSrc = readFileSync(
43
+ resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
44
+ 'utf-8',
45
+ )
39
46
 
40
47
  describe('buffer-gate release decoupled from final-answer classification', () => {
41
48
  // Extract the helper's docstring (everything between the matching
@@ -102,8 +109,8 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
102
109
  it('executeReply calls releaseTurnBufferGate OUTSIDE the isFinalAnswerReply branch', () => {
103
110
  // Slice the executeReply post-send block (between the anchor
104
111
  // comments and the next exported function).
105
- const post = gatewaySrc.split("fresh sendMessage from reply tool is a user-visible")[1] ?? ''
106
- const slice = post.split('\nasync function ')[0] ?? ''
112
+ const post = sendPathSrc.split("fresh sendMessage from reply tool is a user-visible")[1] ?? ''
113
+ const slice = post.split('\nexport async function ')[0] ?? ''
107
114
  // The narrow `isFinalAnswerReply`-gated finalize MUST stay (it
108
115
  // emits the 👍 reaction on the final-answer happy path).
109
116
  expect(slice).toMatch(/isFinalAnswerReply\(/)
@@ -131,9 +138,12 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
131
138
  // helper is narrow on purpose. If future code adds new
132
139
  // callsites that aren't turn-terminal, the steer-vs-queue
133
140
  // semantics could drift.
134
- const callMatches = gatewaySrc.match(/releaseTurnBufferGate\(/g) ?? []
135
- // Definition + 2 callsites = 3:
136
- // - executeReply's post-send block (reply-finalize, the original).
141
+ // #2996 P2: the reply-finalize callsite moved (verbatim) into
142
+ // outbound-send-path.ts `sendReply`; gateway.ts keeps the definition,
143
+ // the halt callsite, and the deps-injection reference.
144
+ const callMatches = (gatewaySrc + sendPathSrc).match(/releaseTurnBufferGate\(/g) ?? []
145
+ // Definition + deps-interface member + 2 callsites = 4:
146
+ // - sendReply's post-send block (reply-finalize, the original).
137
147
  // - executeHaltNow (#3020): an interrupt-cancelled turn never reaches
138
148
  // reply-finalize (the C-c killed it and no replacement inbound
139
149
  // follows), so the halt IS that turn's terminal — releasing there is
@@ -142,6 +152,6 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
142
152
  // with executeStreamReply.)
143
153
  // If this count grows the test catches it; reviewer must justify
144
154
  // any new callsite.
145
- expect(callMatches.length).toBe(3)
155
+ expect(callMatches.length).toBe(4)
146
156
  })
147
157
  })
@@ -115,9 +115,9 @@ describe("agent button taps use the turn-gate (mid-turn → buffer)", () => {
115
115
  });
116
116
 
117
117
  it("buffers + spools on bridge-offline (existing behaviour intact)", () => {
118
- // The idle-branch send missed → the reservation is released in lockstep
119
- // and the tap is spooled to the pending-inbound buffer for replay.
120
- expect(body).toMatch(/claudeBusyKeys\.delete\(reservedBusyKey\)/);
118
+ // The idle-branch send missed → the tap is spooled to the
119
+ // pending-inbound buffer for replay. (#2996 P1: the legacy busy-key
120
+ // reservation/release pair was deleted with the claudeBusyKeys set.)
121
121
  expect(body).toMatch(/return 'buffered-bridge-offline'/);
122
122
  // The bridge-offline buffer push is the LAST push (after the send miss),
123
123
  // so it always spools before returning the offline outcome.
@@ -0,0 +1,358 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest'
2
+
3
+ // The dispatcher's resume closure reaches for the durable outbound-text oracle
4
+ // (history.hasOutboundWithText) to reconcile crash-idempotency. Mock it so these
5
+ // stay pure vitest unit tests (no bun:sqlite / real DB) and we can control the
6
+ // "did this chunk's text land durably?" answer per scenario.
7
+ let mockOracle: (chatId: string, text: string, threadId: number | null, since: number) => boolean = () => false
8
+ vi.mock('../history.js', () => ({
9
+ hasOutboundWithText: (chatId: string, text: string, threadId: number | null, since: number) =>
10
+ mockOracle(chatId, text, threadId, since),
11
+ }))
12
+
13
+ import {
14
+ BackstopDeliveryLedger,
15
+ runBackstopDelivery,
16
+ type ReadBackResult,
17
+ } from '../gateway/backstop-delivery.js'
18
+ import { ObligationLedger, type Obligation } from '../gateway/obligation-ledger.js'
19
+ import {
20
+ buildCapturedDeliverySnapshot,
21
+ hydrateResumeLedger,
22
+ createCapturedResumeDispatcher,
23
+ type CapturedResumeArgs,
24
+ } from '../gateway/captured-answer-resume.js'
25
+
26
+ /**
27
+ * #3282 — a partial turn-flush backstop that left an obligation OPEN used to be
28
+ * re-presented as a FRESH model generation, re-posting chunks that already
29
+ * landed (the user saw the head of the answer twice). These assert the
30
+ * deterministic OUTCOME of the fix — the represent is now a byte-identical
31
+ * captured-answer RESUME of ONLY the non-`landed-confirmed` tail:
32
+ * (a) 3-chunk answer, tail fails ⇒ resume sends EXACTLY the unlanded tail,
33
+ * chunk 0 is NEVER re-sent, and the chat ends with each chunk once;
34
+ * (b) a restart between landing and confirmation does NOT re-send a chunk the
35
+ * durable oracle proves landed;
36
+ * (c) a fully-landed answer's represent sends NOTHING.
37
+ * A test that would still pass if the resume re-sent a confirmed chunk is not a
38
+ * test — every scenario asserts the exact `sendChunk` index set.
39
+ */
40
+
41
+ beforeEach(() => {
42
+ mockOracle = () => false
43
+ })
44
+
45
+ function obligation(over: Partial<Obligation> = {}): Obligation {
46
+ return {
47
+ originTurnId: '#t1',
48
+ chatId: '900',
49
+ threadId: undefined,
50
+ messageId: 42,
51
+ text: 'the question',
52
+ openedAt: 1000,
53
+ representCount: 0,
54
+ ...over,
55
+ }
56
+ }
57
+
58
+ /** Simulate the ORIGINAL partial backstop delivery into `ledger`, then snapshot
59
+ * it — the exact input the represent resume consumes. `landPlan(i)` returns the
60
+ * per-chunk behavior: 'ok' (lands + confirms), 'throw' (send fails → unsent),
61
+ * or 'unconfirmed' (lands but read-back ambiguous → landed-unconfirmed). */
62
+ async function seedPartial(
63
+ ledger: BackstopDeliveryLedger,
64
+ turnId: string,
65
+ chunks: string[],
66
+ landPlan: (i: number) => 'ok' | 'throw' | 'unconfirmed',
67
+ ): Promise<void> {
68
+ await runBackstopDelivery(
69
+ ledger,
70
+ turnId,
71
+ chunks,
72
+ null,
73
+ {
74
+ sendChunk: async (i: number) => {
75
+ if (landPlan(i) === 'throw') throw new Error('FLOOD_WAIT_ACTIVE')
76
+ return [5000 + i]
77
+ },
78
+ readBack: async (i: number): Promise<ReadBackResult> =>
79
+ landPlan(i) === 'unconfirmed' ? 'ambiguous' : 'exists',
80
+ },
81
+ 1, // a single attempt → a genuine partial (no in-fire retry masking it)
82
+ )
83
+ }
84
+
85
+ /** A fake `deliverAnswer` that faithfully replicates the gateway's RESUME path:
86
+ * run the injected `hydrate`, then `runBackstopDelivery` over the captured
87
+ * chunks with a `sendChunk` that records which indices are (re-)sent. */
88
+ function makeResumeDeliverAnswer(ledger: BackstopDeliveryLedger, sentIdx: number[]) {
89
+ return async (a: CapturedResumeArgs): Promise<{ delivered: boolean; sentIds: number[] }> => {
90
+ a.resume.hydrate(ledger, a.turnId)
91
+ const res = await runBackstopDelivery(
92
+ ledger,
93
+ a.turnId,
94
+ a.resume.snapshot.chunks,
95
+ a.cardMessageId,
96
+ {
97
+ sendChunk: async (i: number) => {
98
+ sentIdx.push(i)
99
+ return [7000 + i]
100
+ },
101
+ readBack: async (): Promise<ReadBackResult> => 'exists',
102
+ },
103
+ 3,
104
+ )
105
+ return { delivered: res.delivered, sentIds: res.sentIds }
106
+ }
107
+ }
108
+
109
+ async function flush(dispatcher: { inFlight: () => string[] }): Promise<void> {
110
+ for (let i = 0; i < 50 && dispatcher.inFlight().length > 0; i++) {
111
+ await new Promise((r) => setTimeout(r, 0))
112
+ }
113
+ }
114
+
115
+ describe('#3282 buildCapturedDeliverySnapshot — captures per-chunk landed state', () => {
116
+ it('captures landed chunks (ids + confirmed flag); an unsent chunk is absent', async () => {
117
+ const ledger = new BackstopDeliveryLedger()
118
+ const chunks = ['c0', 'c1', 'c2']
119
+ // chunk 0 confirmed, chunk 1 landed-unconfirmed, chunk 2 never sent.
120
+ await seedPartial(ledger, '#s', chunks, (i) => (i === 0 ? 'ok' : i === 1 ? 'unconfirmed' : 'throw'))
121
+ const snap = buildCapturedDeliverySnapshot(ledger, '#s', chunks)
122
+ expect(snap.chunks).toEqual(chunks)
123
+ const byIndex = Object.fromEntries(snap.chunkStates.map((s) => [s.index, s]))
124
+ expect(byIndex[0]).toMatchObject({ index: 0, confirmed: true })
125
+ expect(byIndex[0].messageIds.length).toBeGreaterThan(0)
126
+ expect(byIndex[1]).toMatchObject({ index: 1, confirmed: false })
127
+ expect(byIndex[2]).toBeUndefined() // never landed → not in the snapshot
128
+ })
129
+ })
130
+
131
+ describe('#3282 hydrateResumeLedger — rebuilds ledger + oracle reconcile', () => {
132
+ it('confirmed chunk ⇒ excluded from the resume send set; unsent chunk ⇒ included', () => {
133
+ const l = new BackstopDeliveryLedger()
134
+ const snap = {
135
+ chunks: ['c0', 'c1', 'c2'],
136
+ chunkStates: [{ index: 0, messageIds: [10], confirmed: true }],
137
+ }
138
+ hydrateResumeLedger(l, '#h', snap, () => false)
139
+ expect(l.hasConfirmedChunk('#h', 0)).toBe(true)
140
+ // chunk 0 confirmed → not resent; chunks 1,2 unsent → the resume tail.
141
+ expect(l.unsentIndices('#h', 3)).toEqual([1, 2])
142
+ })
143
+
144
+ it('landed-unconfirmed chunk ⇒ re-PROBED (landedUnconfirmed), not re-sent', () => {
145
+ const l = new BackstopDeliveryLedger()
146
+ const snap = {
147
+ chunks: ['c0', 'c1'],
148
+ chunkStates: [{ index: 0, messageIds: [10], confirmed: false }],
149
+ }
150
+ hydrateResumeLedger(l, '#h', snap, () => false)
151
+ expect(l.landedUnconfirmedIndices('#h', 2)).toEqual([0]) // will be re-probed
152
+ expect(l.unsentIndices('#h', 2)).toEqual([1]) // only chunk 1 is unsent
153
+ })
154
+
155
+ it('oracle proves a landed-unconfirmed chunk landed ⇒ CONFIRMED (crash-idempotency A5)', () => {
156
+ const l = new BackstopDeliveryLedger()
157
+ const snap = {
158
+ chunks: ['c0', 'c1'],
159
+ chunkStates: [{ index: 0, messageIds: [10], confirmed: false }],
160
+ }
161
+ // The durable outbound row for c0 exists → confirm without re-probe/re-send.
162
+ hydrateResumeLedger(l, '#h', snap, (txt) => txt === 'c0')
163
+ expect(l.hasConfirmedChunk('#h', 0)).toBe(true)
164
+ expect(l.landedUnconfirmedIndices('#h', 2)).toEqual([])
165
+ expect(l.unsentIndices('#h', 2)).toEqual([1])
166
+ })
167
+
168
+ it('a chunkState with no message ids is left unsent (never fabricates a landing)', () => {
169
+ const l = new BackstopDeliveryLedger()
170
+ const snap = { chunks: ['c0'], chunkStates: [{ index: 0, messageIds: [], confirmed: false }] }
171
+ hydrateResumeLedger(l, '#h', snap, () => false)
172
+ expect(l.unsentIndices('#h', 1)).toEqual([0])
173
+ })
174
+ })
175
+
176
+ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome', () => {
177
+ it('(a) partial [0 confirmed, 1+2 unsent] ⇒ resume sends EXACTLY [1,2]; chunk 0 never re-sent; each chunk once', async () => {
178
+ const chunks = ['c0', 'c1', 'c2']
179
+ // Original delivery: chunk 0 lands+confirms, chunks 1,2 fail (unsent).
180
+ const orig = new BackstopDeliveryLedger()
181
+ await seedPartial(orig, '#t1', chunks, (i) => (i === 0 ? 'ok' : 'throw'))
182
+ const snapshot = buildCapturedDeliverySnapshot(orig, '#t1', chunks)
183
+ orig.clear('#t1') // the turn-flush GCs the in-memory ledger (state is durable)
184
+
185
+ // The represent runs on a FRESH ledger (as after a sweep / restart).
186
+ const resumeLedger = new BackstopDeliveryLedger()
187
+ const sentIdx: number[] = []
188
+ const oblLedger = new ObligationLedger(2)
189
+ const supersedes: Array<{ ids: number[] }> = []
190
+ const o = obligation({ capturedDelivery: snapshot })
191
+ oblLedger.openIfAbsent(o)
192
+ oblLedger.noteCapturedDelivery('#t1', snapshot)
193
+
194
+ const dispatcher = createCapturedResumeDispatcher({
195
+ deliverAnswer: makeResumeDeliverAnswer(resumeLedger, sentIdx),
196
+ obligationLedger: oblLedger,
197
+ backstopDeliveryLedger: resumeLedger,
198
+ flushedTurnSupersede: { record: (_c, _t, rec) => supersedes.push({ ids: rec.messageIds }) },
199
+ historyEnabled: false,
200
+ })
201
+ dispatcher.dispatch(oblLedger.list()[0])
202
+ await flush(dispatcher)
203
+
204
+ // The core anti-regression assertion: ONLY the unlanded tail is (re-)sent.
205
+ expect(sentIdx.sort()).toEqual([1, 2])
206
+ expect(sentIdx).not.toContain(0) // chunk 0 (already landed) NEVER re-posted
207
+ expect(oblLedger.isOpen('#t1')).toBe(false) // fully delivered ⇒ closed
208
+ // §4 — the supersede record covers the WHOLE delivered set (original landed
209
+ // chunk 0's id 5000 + the resumed tail's ids), keyed by turn so a late reply
210
+ // corrects the complete answer in place.
211
+ expect(supersedes.at(-1)?.ids).toEqual([5000, 7001, 7002])
212
+ })
213
+
214
+ it('(b) restart between landing and confirmation ⇒ oracle-confirmed chunk NOT re-sent', async () => {
215
+ const chunks = ['c0', 'c1']
216
+ // Snapshot has chunk 0 landed-unconfirmed (crashed before read-back), chunk 1 unsent.
217
+ const snapshot = {
218
+ chunks,
219
+ chunkStates: [{ index: 0, messageIds: [5000], confirmed: false }],
220
+ }
221
+ const resumeLedger = new BackstopDeliveryLedger()
222
+ const sentIdx: number[] = []
223
+ const oblLedger = new ObligationLedger(2)
224
+ oblLedger.openIfAbsent(obligation({ capturedDelivery: snapshot }))
225
+ oblLedger.noteCapturedDelivery('#t1', snapshot)
226
+
227
+ // The durable oracle proves chunk 0's text landed → confirm, don't re-send.
228
+ mockOracle = (_c, txt) => txt === 'c0'
229
+
230
+ const dispatcher = createCapturedResumeDispatcher({
231
+ deliverAnswer: makeResumeDeliverAnswer(resumeLedger, sentIdx),
232
+ obligationLedger: oblLedger,
233
+ backstopDeliveryLedger: resumeLedger,
234
+ flushedTurnSupersede: { record: () => {} },
235
+ historyEnabled: true,
236
+ })
237
+ dispatcher.dispatch(oblLedger.list()[0])
238
+ await flush(dispatcher)
239
+
240
+ expect(sentIdx).toEqual([1]) // chunk 0 reconciled-confirmed, only chunk 1 sent
241
+ expect(sentIdx).not.toContain(0)
242
+ expect(oblLedger.isOpen('#t1')).toBe(false)
243
+ })
244
+
245
+ it('(c) fully-landed answer ⇒ resume sends NOTHING and closes the obligation', async () => {
246
+ const chunks = ['c0', 'c1']
247
+ const snapshot = {
248
+ chunks,
249
+ chunkStates: [
250
+ { index: 0, messageIds: [5000], confirmed: true },
251
+ { index: 1, messageIds: [5001], confirmed: true },
252
+ ],
253
+ }
254
+ const resumeLedger = new BackstopDeliveryLedger()
255
+ const sentIdx: number[] = []
256
+ const oblLedger = new ObligationLedger(2)
257
+ oblLedger.openIfAbsent(obligation({ capturedDelivery: snapshot }))
258
+ oblLedger.noteCapturedDelivery('#t1', snapshot)
259
+
260
+ const dispatcher = createCapturedResumeDispatcher({
261
+ deliverAnswer: makeResumeDeliverAnswer(resumeLedger, sentIdx),
262
+ obligationLedger: oblLedger,
263
+ backstopDeliveryLedger: resumeLedger,
264
+ flushedTurnSupersede: { record: () => {} },
265
+ historyEnabled: false,
266
+ })
267
+ dispatcher.dispatch(oblLedger.list()[0])
268
+ await flush(dispatcher)
269
+
270
+ expect(sentIdx).toEqual([]) // nothing re-posted
271
+ expect(oblLedger.isOpen('#t1')).toBe(false)
272
+ })
273
+
274
+ it('a partial RESUME (tail still fails) leaves the obligation OPEN + consumes represent budget', async () => {
275
+ const chunks = ['c0', 'c1']
276
+ const snapshot = {
277
+ chunks,
278
+ chunkStates: [{ index: 0, messageIds: [5000], confirmed: true }],
279
+ }
280
+ const resumeLedger = new BackstopDeliveryLedger()
281
+ const oblLedger = new ObligationLedger(2)
282
+ oblLedger.openIfAbsent(obligation({ capturedDelivery: snapshot }))
283
+ oblLedger.noteCapturedDelivery('#t1', snapshot)
284
+
285
+ // A resume whose tail send keeps throwing ⇒ not delivered ⇒ obligation stays OPEN.
286
+ const dispatcher = createCapturedResumeDispatcher({
287
+ deliverAnswer: async (a) => {
288
+ a.resume.hydrate(resumeLedger, a.turnId)
289
+ const res = await runBackstopDelivery(
290
+ resumeLedger, a.turnId, a.resume.snapshot.chunks, a.cardMessageId,
291
+ { sendChunk: async () => { throw new Error('FLOOD_WAIT_ACTIVE') }, readBack: async () => 'exists' as ReadBackResult },
292
+ 2,
293
+ )
294
+ return { delivered: res.delivered, sentIds: res.sentIds }
295
+ },
296
+ obligationLedger: oblLedger,
297
+ backstopDeliveryLedger: resumeLedger,
298
+ flushedTurnSupersede: { record: () => {} },
299
+ historyEnabled: false,
300
+ })
301
+ dispatcher.dispatch(oblLedger.list()[0])
302
+ await flush(dispatcher)
303
+
304
+ expect(oblLedger.isOpen('#t1')).toBe(true) // left OPEN for the next paced sweep
305
+ expect(oblLedger.list()[0].representCount).toBe(1) // budget consumed → bounded ladder
306
+ })
307
+
308
+ it('de-dupes concurrent dispatches for the same origin (only one resume runs)', async () => {
309
+ const chunks = ['c0', 'c1']
310
+ const orig = new BackstopDeliveryLedger()
311
+ await seedPartial(orig, '#t1', chunks, (i) => (i === 0 ? 'ok' : 'throw'))
312
+ const snapshot = buildCapturedDeliverySnapshot(orig, '#t1', chunks)
313
+ orig.clear('#t1')
314
+
315
+ const resumeLedger = new BackstopDeliveryLedger()
316
+ let deliverCalls = 0
317
+ const oblLedger = new ObligationLedger(2)
318
+ oblLedger.openIfAbsent(obligation({ capturedDelivery: snapshot }))
319
+ oblLedger.noteCapturedDelivery('#t1', snapshot)
320
+
321
+ const dispatcher = createCapturedResumeDispatcher({
322
+ deliverAnswer: async (a) => {
323
+ deliverCalls++
324
+ a.resume.hydrate(resumeLedger, a.turnId)
325
+ const res = await runBackstopDelivery(
326
+ resumeLedger, a.turnId, a.resume.snapshot.chunks, a.cardMessageId,
327
+ { sendChunk: async (i: number) => [7000 + i], readBack: async () => 'exists' as ReadBackResult },
328
+ 3,
329
+ )
330
+ return { delivered: res.delivered, sentIds: res.sentIds }
331
+ },
332
+ obligationLedger: oblLedger,
333
+ backstopDeliveryLedger: resumeLedger,
334
+ flushedTurnSupersede: { record: () => {} },
335
+ historyEnabled: false,
336
+ })
337
+ const o = oblLedger.list()[0]
338
+ dispatcher.dispatch(o)
339
+ dispatcher.dispatch(o) // second, concurrent — must be a no-op
340
+ await flush(dispatcher)
341
+ expect(deliverCalls).toBe(1)
342
+ })
343
+
344
+ it('no captured snapshot ⇒ dispatch is a no-op (caller falls through to fresh generation)', async () => {
345
+ let delivered = false
346
+ const dispatcher = createCapturedResumeDispatcher({
347
+ deliverAnswer: async () => { delivered = true; return { delivered: true, sentIds: [] } },
348
+ obligationLedger: new ObligationLedger(2),
349
+ backstopDeliveryLedger: new BackstopDeliveryLedger(),
350
+ flushedTurnSupersede: { record: () => {} },
351
+ historyEnabled: false,
352
+ })
353
+ dispatcher.dispatch(obligation()) // no capturedDelivery
354
+ dispatcher.dispatch(obligation({ capturedDelivery: { chunks: [], chunkStates: [] } })) // empty
355
+ await flush(dispatcher)
356
+ expect(delivered).toBe(false)
357
+ })
358
+ })
@@ -48,6 +48,19 @@ const gatewaySrc = readFileSync(
48
48
  resolve(__dirname, '..', 'gateway', 'gateway.ts'),
49
49
  'utf-8',
50
50
  )
51
+ // #2996 P2: executeReply's body (the over-ping block + the lever-2 finalize)
52
+ // moved VERBATIM to outbound-send-path.ts `sendReply()`. The façade call-site
53
+ // assertions that used to grep the executeReply window grep the sendReply
54
+ // window there instead — the wiring contract they pin is unchanged.
55
+ const sendPathSrc = readFileSync(
56
+ resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
57
+ 'utf-8',
58
+ )
59
+ /** The sendReply window: from the function decl to the next export. */
60
+ function sendReplyWindow(): string {
61
+ const after = sendPathSrc.split('export async function sendReply(')[1] ?? ''
62
+ return after.split('\nexport ')[0] ?? after
63
+ }
51
64
 
52
65
  /** Source of a named gateway function up to the next top-level definition. */
53
66
  function fnSrc(name: string): string {
@@ -243,15 +256,15 @@ describe('claimOrDowngradePing — PR-4c over-ping decision moves into the faça
243
256
  // The relocation must NOT split the #2562 pair across the façade boundary:
244
257
  // the façade decides; the call-site thunk sets firstPingAt AND
245
258
  // firstPingWasSubstantive on two adjacent lines, no await between.
246
- expect(gatewaySrc).toMatch(
259
+ expect(sendPathSrc).toMatch(
247
260
  /turn\.firstPingAt = now\s*\n\s*turn\.firstPingWasSubstantive = replySubstantive/,
248
261
  )
249
262
  // No await anywhere in the executeReply over-ping block (the decide→apply→
250
263
  // pair-set synchronous chain). Bound to the block, BEFORE the Telegraph
251
264
  // block below (which legitimately awaits).
252
- const blockStart = gatewaySrc.indexOf('const applyOverPingDecision')
253
- const telegraphIdx = gatewaySrc.indexOf('// Telegraph publish (#579)', blockStart)
254
- const block = gatewaySrc.slice(blockStart, telegraphIdx)
265
+ const blockStart = sendPathSrc.indexOf('const applyOverPingDecision')
266
+ const telegraphIdx = sendPathSrc.indexOf('// Telegraph publish (#579)', blockStart)
267
+ const block = sendPathSrc.slice(blockStart, telegraphIdx)
255
268
  const blockCode = block
256
269
  .split('\n')
257
270
  .filter((l) => {
@@ -267,17 +280,14 @@ describe('claimOrDowngradePing — PR-4c over-ping decision moves into the faça
267
280
  // The over-ping net exists ONLY in executeReply. The retired stream_reply
268
281
  // tool (executeStreamReply) had no decideOverPing / firstPingAt /
269
282
  // wasOverPingSuppressed and never called claimOrDowngradePing.
270
- const calls = [...gatewaySrc.matchAll(/\.claimOrDowngradePing\(/g)]
283
+ // #2996 P2: the single call now lives in the extracted sendReply body;
284
+ // gateway.ts must have ZERO (the wrapper delegates).
285
+ const gwCalls = [...gatewaySrc.matchAll(/\.claimOrDowngradePing\(/g)]
286
+ expect(gwCalls).toHaveLength(0)
287
+ const calls = [...sendPathSrc.matchAll(/\.claimOrDowngradePing\(/g)]
271
288
  expect(calls).toHaveLength(1)
272
- const callIdx = gatewaySrc.indexOf('.claimOrDowngradePing(')
273
- const execReplyIdx = gatewaySrc.indexOf('async function executeReply(')
274
- // Upper bound: the next top-level function after executeReply.
275
- const nextFnIdx = gatewaySrc.indexOf('\nasync function ', execReplyIdx + 1)
276
- expect(execReplyIdx).toBeGreaterThan(-1)
277
- expect(nextFnIdx).toBeGreaterThan(execReplyIdx)
278
- // The single call is inside executeReply.
279
- expect(callIdx).toBeGreaterThan(execReplyIdx)
280
- expect(callIdx).toBeLessThan(nextFnIdx)
289
+ // The single call is inside the sendReply window.
290
+ expect(sendReplyWindow()).toMatch(/\.claimOrDowngradePing\(/)
281
291
  })
282
292
  })
283
293
 
@@ -299,7 +309,8 @@ describe('façade delegates to the existing emission primitives (call-site liter
299
309
  it('finalizeCard wraps clearActivitySummary at the call sites (not CALLED inside the façade)', () => {
300
310
  expect(facadeSrc).not.toMatch(/import .*clearActivitySummary/)
301
311
  expect(facadeCode).not.toMatch(/clearActivitySummary\(/)
302
- expect(gatewaySrc).toMatch(/finalizeCard\(\(\) => \{\s*\n\s*clearActivitySummary\(/)
312
+ // #2996 P2: the lever-2 call site moved (verbatim) into sendReply.
313
+ expect(sendPathSrc).toMatch(/finalizeCard\(\(\) => \{\s*\n\s*clearActivitySummary\(/)
303
314
  })
304
315
 
305
316
  it('claimOrDowngradePing — the call-site disabled thunk STILL contains a literal decideOverPing( (disabled-path proof)', () => {
@@ -308,9 +319,9 @@ describe('façade delegates to the existing emission primitives (call-site liter
308
319
  // its OWN literal `decideOverPing(` call inside the call-site thunk, VERBATIM
309
320
  // from PR-4b-base — so the disabled path is provably byte-identical (never
310
321
  // depends on the façade for the decision).
311
- const pingIdx = gatewaySrc.indexOf('claimOrDowngradePing(')
322
+ const pingIdx = sendPathSrc.indexOf('claimOrDowngradePing(')
312
323
  expect(pingIdx).toBeGreaterThan(-1)
313
- const window = gatewaySrc.slice(pingIdx, pingIdx + 1600)
324
+ const window = sendPathSrc.slice(pingIdx, pingIdx + 1600)
314
325
  expect(window).toMatch(/decideOverPing\(/)
315
326
  })
316
327
  })
@@ -385,8 +396,7 @@ describe('the drain sites route through the façade with producers preserved ver
385
396
 
386
397
  describe('the lever-2 finalize block routes through the façade', () => {
387
398
  it('executeReply finalize routes via markSubstantiveFinalDelivered + finalizeCard (latch + clear preserved)', () => {
388
- const after = gatewaySrc.split('async function executeReply(')[1] ?? ''
389
- const body = after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
399
+ const body = sendReplyWindow()
390
400
  expect(body).toMatch(/markSubstantiveFinalDelivered\(\(\) => \{\s*\n\s*finalizeTurn\.finalAnswerEverDelivered = true/)
391
401
  expect(body).toMatch(/finalizeCard\(\(\) => \{\s*\n\s*clearActivitySummary\(finalizeTurn\)/)
392
402
  })
@@ -354,8 +354,11 @@ describe('claimOrDowngradePing — no await in the synchronous decide→apply→
354
354
  resolve(__dirname, '..', 'gateway', 'emission-authority.ts'),
355
355
  'utf-8',
356
356
  )
357
+ // #2996 P2: the over-ping call site moved VERBATIM (with executeReply's
358
+ // body) into outbound-send-path.ts `sendReply()`; the await-free window
359
+ // assertion reads there. Contract unchanged.
357
360
  const gatewaySrc = readFileSync(
358
- resolve(__dirname, '..', 'gateway', 'gateway.ts'),
361
+ resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
359
362
  'utf-8',
360
363
  )
361
364
 
@@ -17,6 +17,13 @@ const gatewaySrc = readFileSync(
17
17
  resolve(__dirname, '..', 'gateway', 'gateway.ts'),
18
18
  'utf-8',
19
19
  )
20
+ // #2996 P2: executeReply's body (incl. the lever-2 finalize + the latch sets)
21
+ // moved VERBATIM to outbound-send-path.ts `sendReply()`; the assertions that
22
+ // pinned that body now read it there. The wiring contract is unchanged.
23
+ const sendPathSrc = readFileSync(
24
+ resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
25
+ 'utf-8',
26
+ )
20
27
 
21
28
  /** Source of `drainActivitySummary` up to the next top-level function. */
22
29
  function drainSrc(): string {
@@ -51,20 +58,23 @@ describe('sticky finalAnswerEverDelivered latch (lever 1 precondition / R0)', ()
51
58
  it('every site that sets the sticky latch true gates on finalAnswerSubstantive', () => {
52
59
  // The latch is set true only at the points that set finalAnswerDelivered=true,
53
60
  // and only when the reply was substantive — so an ack never latches it.
54
- const setTrue = [...gatewaySrc.matchAll(/finalAnswerEverDelivered\s*=\s*true/g)]
55
- // executeReply, silent-anchor merge, + the lever-2 finalize block
61
+ // #2996 P2: all three latch-set sites live in the extracted sendReply body.
62
+ const setTrue = [...sendPathSrc.matchAll(/finalAnswerEverDelivered\s*=\s*true/g)]
63
+ // sendReply post-send, silent-anchor merge, + the lever-2 finalize block
56
64
  // (which is itself substantive-gated).
57
65
  expect(setTrue.length).toBeGreaterThanOrEqual(3)
66
+ // And gateway.ts has none left (the wrapper delegates).
67
+ expect(gatewaySrc).not.toMatch(/finalAnswerEverDelivered\s*=\s*true/)
58
68
  // Each `finalAnswerEverDelivered = true` must sit in a substantive context:
59
69
  // either guarded by `if (turn.finalAnswerSubstantive)` or inside an
60
70
  // `isSubstantiveFinalReply(...)` branch. Assert the substantive-gating
61
71
  // token co-occurs (no bare unconditional latch set).
62
72
  const bareUnconditional = [
63
- ...gatewaySrc.matchAll(/\n\s*(?:turn|finalizeTurn)\.finalAnswerEverDelivered\s*=\s*true/g),
73
+ ...sendPathSrc.matchAll(/\n\s*(?:turn|finalizeTurn)\.finalAnswerEverDelivered\s*=\s*true/g),
64
74
  ]
65
75
  for (const m of bareUnconditional) {
66
76
  const idx = m.index ?? 0
67
- const window = gatewaySrc.slice(Math.max(0, idx - 600), idx)
77
+ const window = sendPathSrc.slice(Math.max(0, idx - 600), idx)
68
78
  expect(
69
79
  /finalAnswerSubstantive/.test(window) || /isSubstantiveFinalReply/.test(window),
70
80
  ).toBe(true)
@@ -125,10 +135,11 @@ describe('drain producers — narrative may not OPEN, liveness + tool may', () =
125
135
  })
126
136
 
127
137
  describe('lever 2 — finalize the card BEFORE a substantive reply send', () => {
128
- /** executeReply body up to the next top-level function. */
138
+ /** The extracted reply-orchestration body (#2996 P2): the sendReply window
139
+ * in outbound-send-path.ts, up to the next export. */
129
140
  function executeReplySrc(): string {
130
- const after = gatewaySrc.split('async function executeReply(')[1] ?? ''
131
- return after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
141
+ const after = sendPathSrc.split('export async function sendReply(')[1] ?? ''
142
+ return after.split('\nexport ')[0] ?? after
132
143
  }
133
144
 
134
145
  // #2996 step 1: the chunk send loop was relocated verbatim from executeReply