switchroom 0.12.18 → 0.12.19

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.
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { decideInboundDelivery } from '../gateway/inbound-delivery-gate.js'
4
+
5
+ /**
6
+ * Regression coverage for #1556 — the lawgpt composer wedge.
7
+ *
8
+ * Before this gate, the gateway sent every inbound to the bridge
9
+ * immediately, buffering only when the bridge was offline. A
10
+ * non-steering message that arrived mid-turn was typed into claude's
11
+ * TUI composer and stranded when the auto-submit raced
12
+ * turn-completion. The deterministic invariant the gate enforces:
13
+ *
14
+ * a non-steering inbound is delivered ONLY when no turn is in flight.
15
+ *
16
+ * Steering (/steer, /s) is the sole exemption — reaching claude
17
+ * mid-turn is the entire point of that feature.
18
+ */
19
+ describe('decideInboundDelivery', () => {
20
+ it('delivers immediately when claude is idle (no turn in flight)', () => {
21
+ expect(
22
+ decideInboundDelivery({ turnInFlight: false, isSteering: false }),
23
+ ).toBe('deliver')
24
+ })
25
+
26
+ it('BUFFERS a non-steering message that arrives mid-turn (the wedge fix)', () => {
27
+ expect(
28
+ decideInboundDelivery({ turnInFlight: true, isSteering: false }),
29
+ ).toBe('buffer-until-idle')
30
+ })
31
+
32
+ it('delivers a steering message mid-turn (steering is intentionally exempt)', () => {
33
+ expect(
34
+ decideInboundDelivery({ turnInFlight: true, isSteering: true }),
35
+ ).toBe('deliver')
36
+ })
37
+
38
+ it('delivers a steering message when idle (steer with no active turn)', () => {
39
+ expect(
40
+ decideInboundDelivery({ turnInFlight: false, isSteering: true }),
41
+ ).toBe('deliver')
42
+ })
43
+
44
+ it('is total: the ONLY deferral path is mid-turn AND not steering', () => {
45
+ for (const turnInFlight of [true, false]) {
46
+ for (const isSteering of [true, false]) {
47
+ const decision = decideInboundDelivery({ turnInFlight, isSteering })
48
+ const expectBuffer = turnInFlight && !isSteering
49
+ expect(decision).toBe(expectBuffer ? 'buffer-until-idle' : 'deliver')
50
+ }
51
+ }
52
+ })
53
+ })
@@ -0,0 +1,229 @@
1
+ /**
2
+ * inbound-spool — durable, crash-tolerant inbound spool.
3
+ *
4
+ * Pins the determinism guarantee: a buffered inbound survives a
5
+ * gateway/container restart (it's on the persistent volume), is
6
+ * replayed un-acked, acked only on confirmed delivery, deduped by a
7
+ * stable id, and escalated-then-dropped if undeliverable past its
8
+ * bound — so the "your message is queued" promise is ALWAYS resolved
9
+ * (delivered or visibly retracted), never silently lost (the
10
+ * finn/carrie lost-on-restart incident class, 2026-05-19).
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest'
14
+ import {
15
+ createInboundSpool,
16
+ spoolId,
17
+ type InboundSpoolFsSeam,
18
+ } from '../gateway/inbound-spool.js'
19
+ import type { InboundMessage } from '../gateway/ipc-protocol.js'
20
+
21
+ function msg(over: Partial<InboundMessage> = {}): InboundMessage {
22
+ return {
23
+ type: 'inbound',
24
+ chatId: 'c1',
25
+ messageId: 1001,
26
+ user: 'ken',
27
+ userId: 42,
28
+ ts: 1000,
29
+ text: 'hello',
30
+ meta: {},
31
+ ...over,
32
+ } as InboundMessage
33
+ }
34
+
35
+ /** In-memory fake fs keyed by path. Models append, full rewrite, and
36
+ * atomic rename (so the tmp→rename compaction path is exercised). */
37
+ function fakeFs(): InboundSpoolFsSeam & { dump(p?: string): string } {
38
+ const files = new Map<string, string>()
39
+ return {
40
+ appendFileSync: (p, d) => files.set(p, (files.get(p) ?? '') + d),
41
+ readFileSync: (p) => files.get(p) ?? '',
42
+ writeFileSync: (p, d) => files.set(p, d),
43
+ renameSync: (from, to) => {
44
+ files.set(to, files.get(from) ?? '')
45
+ files.delete(from)
46
+ },
47
+ existsSync: (p) => files.has(p),
48
+ statSizeSync: (p) => Buffer.byteLength(files.get(p) ?? ''),
49
+ dump: (p = PATH) => files.get(p) ?? '',
50
+ }
51
+ }
52
+
53
+ const PATH = '/state/agent/telegram/inbound-spool.jsonl'
54
+
55
+ describe('spoolId — stable dedup key', () => {
56
+ it('real Telegram message → m:chat:msgId', () => {
57
+ expect(spoolId(msg({ chatId: 'c9', messageId: 55 }))).toBe('m:c9:55')
58
+ })
59
+ it('synthetic (messageId 0) → s:chat:source:ts (distinct events do not collapse)', () => {
60
+ const a = spoolId(msg({ messageId: 0, meta: { source: 'cron' }, ts: 100 }))
61
+ const b = spoolId(msg({ messageId: 0, meta: { source: 'cron' }, ts: 200 }))
62
+ expect(a).toBe('s:c1:cron:100')
63
+ expect(a).not.toBe(b) // different ts = different logical event
64
+ })
65
+ it('same logical synthetic retried (same ts) dedups to the same id', () => {
66
+ const a = spoolId(msg({ messageId: 0, meta: { source: 'cron' }, ts: 100 }))
67
+ const b = spoolId(msg({ messageId: 0, meta: { source: 'cron' }, ts: 100 }))
68
+ expect(a).toBe(b)
69
+ })
70
+ })
71
+
72
+ describe('inbound-spool — put / ack / dedup', () => {
73
+ it('put records a live entry; dedups a re-put of the same id', () => {
74
+ const fs = fakeFs()
75
+ const s = createInboundSpool({ path: PATH, fs })
76
+ expect(s.put('carrie', msg({ messageId: 7 }))).toBe(true)
77
+ expect(s.put('carrie', msg({ messageId: 7 }))).toBe(false) // dedup
78
+ expect(s.liveCount()).toBe(1)
79
+ expect(s.liveEntries()).toHaveLength(1)
80
+ expect(s.liveEntries()[0].agent).toBe('carrie')
81
+ })
82
+
83
+ it('ack tombstones the entry; ack is idempotent / unknown-id safe', () => {
84
+ const fs = fakeFs()
85
+ const s = createInboundSpool({ path: PATH, fs })
86
+ const m = msg({ messageId: 7 })
87
+ s.put('carrie', m)
88
+ s.ack(m)
89
+ expect(s.liveCount()).toBe(0)
90
+ s.ack(m) // idempotent
91
+ s.ack(msg({ messageId: 999 })) // unknown id, no throw
92
+ expect(s.liveCount()).toBe(0)
93
+ })
94
+
95
+ it('liveEntries is oldest-first (replay order)', () => {
96
+ const fs = fakeFs()
97
+ const s = createInboundSpool({ path: PATH, fs })
98
+ s.put('a', msg({ messageId: 1 }))
99
+ s.put('a', msg({ messageId: 2 }))
100
+ s.put('a', msg({ messageId: 3 }))
101
+ expect(s.liveEntries().map((e) => e.msg.messageId)).toEqual([1, 2, 3])
102
+ })
103
+ })
104
+
105
+ describe('inbound-spool — crash-survivable replay (the core guarantee)', () => {
106
+ it('a fresh spool over an existing file rebuilds live state (survives restart)', () => {
107
+ const fs = fakeFs()
108
+ const s1 = createInboundSpool({ path: PATH, fs })
109
+ s1.put('carrie', msg({ messageId: 7, text: 'the craft message' }))
110
+ s1.put('carrie', msg({ messageId: 8 }))
111
+ s1.ack(msg({ messageId: 8 })) // 8 delivered before the "crash"
112
+ // Simulate gateway/container restart: brand-new spool, SAME file.
113
+ const s2 = createInboundSpool({ path: PATH, fs })
114
+ expect(s2.liveCount()).toBe(1)
115
+ const live = s2.liveEntries()
116
+ expect(live[0].msg.messageId).toBe(7)
117
+ expect(live[0].msg.text).toBe('the craft message') // full payload survived
118
+ expect(live[0].agent).toBe('carrie')
119
+ })
120
+
121
+ it('tolerates a torn final line (crash mid-append) — skips it, keeps the rest', () => {
122
+ const fs = fakeFs()
123
+ const s1 = createInboundSpool({ path: PATH, fs })
124
+ s1.put('carrie', msg({ messageId: 7 }))
125
+ // Append a half-written record (no newline, invalid JSON tail).
126
+ fs.appendFileSync(PATH, '{"t":"put","id":"m:c1:8","agen')
127
+ const s2 = createInboundSpool({ path: PATH, fs })
128
+ expect(s2.liveCount()).toBe(1) // the torn line is ignored, 7 survives
129
+ expect(s2.liveEntries()[0].msg.messageId).toBe(7)
130
+ })
131
+
132
+ it('ignores any line that does not pass the shape check', () => {
133
+ const fs = fakeFs()
134
+ fs.appendFileSync(PATH, 'not json\n')
135
+ fs.appendFileSync(PATH, '{"t":"put"}\n') // missing id/msg/agent
136
+ fs.appendFileSync(PATH, '{"t":"weird","id":"x"}\n')
137
+ fs.appendFileSync(
138
+ PATH,
139
+ JSON.stringify({ t: 'put', id: 'm:c1:7', agent: 'a', firstAt: 1, msg: msg({ messageId: 7 }) }) + '\n',
140
+ )
141
+ const s = createInboundSpool({ path: PATH, fs })
142
+ expect(s.liveCount()).toBe(1)
143
+ expect(s.liveEntries()[0].msg.messageId).toBe(7)
144
+ })
145
+ })
146
+
147
+ describe('inbound-spool — bounded escalation (promise always resolved)', () => {
148
+ it('escalates+drops only entries older than the bound; younger untouched', () => {
149
+ const fs = fakeFs()
150
+ let t = 1_000_000
151
+ const s = createInboundSpool({
152
+ path: PATH,
153
+ fs,
154
+ now: () => t,
155
+ escalateAfterMs: 10_000,
156
+ })
157
+ s.put('carrie', msg({ messageId: 1 })) // firstAt = 1_000_000
158
+ t = 1_005_000
159
+ s.put('carrie', msg({ messageId: 2 })) // firstAt = 1_005_000
160
+ t = 1_012_000 // msg1 is 12s old (>10s bound), msg2 is 7s old
161
+ const escalated: number[] = []
162
+ const n = s.sweepEscalations((e) => escalated.push(e.msg.messageId as number))
163
+ expect(n).toBe(1)
164
+ expect(escalated).toEqual([1])
165
+ expect(s.liveCount()).toBe(1) // msg2 still live
166
+ expect(s.liveEntries()[0].msg.messageId).toBe(2)
167
+ })
168
+
169
+ it('an escalated id stays dropped across a restart (tombstoned, not replayed)', () => {
170
+ const fs = fakeFs()
171
+ let t = 0
172
+ const s1 = createInboundSpool({ path: PATH, fs, now: () => t, escalateAfterMs: 100 })
173
+ s1.put('a', msg({ messageId: 1 }))
174
+ t = 1000
175
+ expect(s1.sweepEscalations(() => {})).toBe(1)
176
+ const s2 = createInboundSpool({ path: PATH, fs })
177
+ expect(s2.liveCount()).toBe(0) // not resurrected on replay
178
+ })
179
+ })
180
+
181
+ describe('inbound-spool — robustness', () => {
182
+ it('a failing appendFileSync does not throw and keeps in-memory live state', () => {
183
+ const fs = fakeFs()
184
+ fs.appendFileSync = () => {
185
+ throw new Error('ENOSPC')
186
+ }
187
+ const logs: string[] = []
188
+ const s = createInboundSpool({ path: PATH, fs, log: (l) => logs.push(l) })
189
+ expect(() => s.put('a', msg({ messageId: 1 }))).not.toThrow()
190
+ expect(s.liveCount()).toBe(1) // live delivery still works (degraded durability)
191
+ expect(logs.join('')).toContain('durability degraded')
192
+ })
193
+
194
+ it('compacts once past the size bound, dropping acked ids', () => {
195
+ const fs = fakeFs()
196
+ const s = createInboundSpool({ path: PATH, fs, compactAtBytes: 200 })
197
+ for (let i = 1; i <= 20; i++) {
198
+ s.put('a', msg({ messageId: i, text: 'x'.repeat(50) }))
199
+ s.ack(msg({ messageId: i }))
200
+ }
201
+ s.put('a', msg({ messageId: 999 }))
202
+ // After compaction the file holds only the one live id, not the
203
+ // 20 acked put+ack pairs.
204
+ const s2 = createInboundSpool({ path: PATH, fs })
205
+ expect(s2.liveCount()).toBe(1)
206
+ expect(s2.liveEntries()[0].msg.messageId).toBe(999)
207
+ expect(fs.dump().split('\n').filter(Boolean).length).toBeLessThan(5)
208
+ })
209
+
210
+ it('atomic compaction: a rename crash leaves the ORIGINAL log intact (no loss)', () => {
211
+ const fs = fakeFs()
212
+ const s = createInboundSpool({ path: PATH, fs, compactAtBytes: 200 })
213
+ for (let i = 1; i <= 10; i++) {
214
+ s.put('a', msg({ messageId: i, text: 'y'.repeat(50) }))
215
+ }
216
+ // Simulate a crash AFTER the tmp write but BEFORE/at the rename.
217
+ fs.renameSync = () => {
218
+ throw new Error('crash mid-compact')
219
+ }
220
+ s.put('a', msg({ messageId: 11, text: 'y'.repeat(50) })) // triggers maybeCompact → rename throws
221
+ // Original append-only log must still hold every live entry — the
222
+ // failed compaction is a no-op, never a truncation.
223
+ const s2 = createInboundSpool({ path: PATH, fs })
224
+ expect(s2.liveCount()).toBe(11)
225
+ expect(s2.liveEntries().map((e) => e.msg.messageId)).toEqual([
226
+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11,
227
+ ])
228
+ })
229
+ })
@@ -247,3 +247,69 @@ describe('idleDrainTick — the 3rd drain trigger (finn orphan gap, 2026-05-19)'
247
247
  expect(probed).toBe(false) // cheap path: Map.get only, no bridge probe, no log
248
248
  })
249
249
  })
250
+
251
+ describe('durable-spool integration (finn/carrie lost-on-restart fix)', () => {
252
+ function spySpool() {
253
+ const puts: string[] = []
254
+ const acks: string[] = []
255
+ return {
256
+ puts,
257
+ acks,
258
+ spool: {
259
+ put: (_a: string, m: InboundMessage) => {
260
+ puts.push(String(m.messageId))
261
+ return true
262
+ },
263
+ ack: (m: InboundMessage) => {
264
+ acks.push(String(m.messageId))
265
+ },
266
+ liveEntries: () => [],
267
+ sweepEscalations: () => 0,
268
+ liveCount: () => 0,
269
+ },
270
+ }
271
+ }
272
+
273
+ it('every push is durably spooled (chokepoint for all ~10 push sites)', () => {
274
+ const sp = spySpool()
275
+ const buf = createPendingInboundBuffer({ log: () => {}, spool: sp.spool as never })
276
+ buf.push('carrie', inbound('user', 7))
277
+ buf.push('carrie', inbound('user', 8))
278
+ expect(sp.puts).toEqual(['7', '8'])
279
+ })
280
+
281
+ it('a CONFIRMED delivery acks the spool; a miss does NOT (stays durable)', () => {
282
+ const sp = spySpool()
283
+ const buf = createPendingInboundBuffer({ log: () => {}, spool: sp.spool as never })
284
+ buf.push('carrie', inbound('user', 7))
285
+ // delivery succeeds → spool acked
286
+ redeliverBufferedInbound(buf, 'carrie', () => true, sp.spool as never)
287
+ expect(sp.acks).toEqual(['7'])
288
+
289
+ // delivery misses → NOT acked, re-buffered + still spooled
290
+ const sp2 = spySpool()
291
+ const buf2 = createPendingInboundBuffer({ log: () => {}, spool: sp2.spool as never })
292
+ buf2.push('carrie', inbound('user', 9))
293
+ redeliverBufferedInbound(buf2, 'carrie', () => false, sp2.spool as never)
294
+ expect(sp2.acks).toEqual([]) // never acked on a miss → survives for retry/escalation
295
+ expect(buf2.depth('carrie')).toBe(1) // re-buffered in memory too
296
+ })
297
+
298
+ it('idleDrainTick threads the spool through (ack only on delivered)', () => {
299
+ const sp = spySpool()
300
+ const buf = createPendingInboundBuffer({ log: () => {}, spool: sp.spool as never })
301
+ buf.push('carrie', inbound('user', 7))
302
+ idleDrainTick(buf, 'carrie', () => true, () => true, sp.spool as never)
303
+ expect(sp.acks).toEqual(['7'])
304
+ })
305
+
306
+ it('works with no spool (back-compat: undefined spool is a no-op)', () => {
307
+ const buf = createPendingInboundBuffer({ log: () => {} })
308
+ buf.push('carrie', inbound('user', 7))
309
+ expect(redeliverBufferedInbound(buf, 'carrie', () => true)).toEqual({
310
+ drained: 1,
311
+ redelivered: 1,
312
+ rebuffered: 0,
313
+ })
314
+ })
315
+ })