switchroom 0.19.16 → 0.19.18

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 (66) hide show
  1. package/bin/run-hook.sh +148 -0
  2. package/bin/workspace-dynamic-hook.sh +147 -38
  3. package/dist/agent-scheduler/index.js +11 -3
  4. package/dist/auth-broker/index.js +29 -4
  5. package/dist/cli/notion-write-pretool.mjs +11 -3
  6. package/dist/cli/switchroom.js +8307 -7620
  7. package/dist/host-control/main.js +626 -36
  8. package/dist/vault/approvals/kernel-server.js +30 -5
  9. package/dist/vault/broker/server.js +71 -18
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +8 -4
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md.hbs +3 -3
  14. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  15. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  16. package/skills/mental-model-curator/SKILL.md +8 -6
  17. package/telegram-plugin/bridge/bridge.ts +11 -19
  18. package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +15 -20
  20. package/telegram-plugin/dist/gateway/gateway.js +763 -373
  21. package/telegram-plugin/dist/server.js +19 -20
  22. package/telegram-plugin/gateway/boot-card.ts +5 -1
  23. package/telegram-plugin/gateway/boot-probes.ts +113 -0
  24. package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
  25. package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
  26. package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
  27. package/telegram-plugin/gateway/gateway.ts +43 -1
  28. package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
  29. package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
  30. package/telegram-plugin/gateway/ipc-server.ts +13 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
  32. package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
  33. package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
  34. package/telegram-plugin/gateway/stream-render.ts +24 -5
  35. package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
  36. package/telegram-plugin/registry/turns-schema.test.ts +8 -3
  37. package/telegram-plugin/registry/turns-schema.ts +40 -12
  38. package/telegram-plugin/runtime-metrics.ts +14 -0
  39. package/telegram-plugin/silence-poke.ts +138 -0
  40. package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
  41. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
  42. package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
  43. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
  44. package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
  45. package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
  46. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
  47. package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
  48. package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
  49. package/telegram-plugin/tests/registry-turns.test.ts +13 -0
  50. package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
  51. package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
  52. package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
  53. package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
  54. package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
  55. package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
  56. package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
  57. package/telegram-plugin/tts-normalize.ts +6 -4
  58. package/telegram-plugin/voice-normalize-text.ts +168 -11
  59. package/vendor/hindsight-memory/CHANGELOG.md +73 -0
  60. package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
  61. package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
  62. package/vendor/hindsight-memory/scripts/recall.py +257 -12
  63. package/vendor/hindsight-memory/scripts/retain.py +12 -6
  64. package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
  65. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
  66. package/vendor/hindsight-memory/settings.json +1 -1
@@ -0,0 +1,392 @@
1
+ /**
2
+ * switchroom#3552 — the silence-poke timer leak.
3
+ *
4
+ * `startTurn(key)` arms per-turn state that only `endTurn(key)` drops. Several
5
+ * turn-end paths never reached an `endTurn` call (bridge death via
6
+ * `disconnect-flush`, keyed teardowns that bypass the `turn_end` handler), so
7
+ * the state outlived its turn and was disarmed only when the 300s fallback
8
+ * eventually fired against the dead key, recognised the late fire and logged
9
+ * `turn_ended_cleanly_during_window` — 504 such skips vs 110 real fires in 14
10
+ * days on the fleet.
11
+ *
12
+ * The fix is the `isTurnLive` reaper predicate consulted on every poll tick.
13
+ * These tests assert the OUTCOME (state gone, no fallback delivered, real fires
14
+ * unaffected), not merely that the code path runs.
15
+ */
16
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
17
+ import { readFileSync } from 'fs'
18
+ import { join } from 'path'
19
+ import {
20
+ startTurn,
21
+ noteOutbound,
22
+ __tickForTests,
23
+ __setDepsForTests,
24
+ __getStateForTests,
25
+ __resetAllForTests,
26
+ DEFAULT_THRESHOLDS,
27
+ type SilencePokeMetric,
28
+ type FrameworkFallbackContext,
29
+ } from '../silence-poke.js'
30
+ import { buildSilencePokeOptions } from '../gateway/liveness-wiring.js'
31
+ import { makeLivenessFixture, type TurnMapLike } from './helpers/liveness-wiring-fixture.js'
32
+
33
+ const ORIGINAL_KILL_SWITCH = process.env.SWITCHROOM_DISABLE_SILENCE_POKE
34
+
35
+ interface Fixtures {
36
+ emitted: SilencePokeMetric[]
37
+ fallbacks: FrameworkFallbackContext[]
38
+ live: Set<string>
39
+ }
40
+
41
+ function setup(opts?: { wireReaper?: boolean }): Fixtures {
42
+ const f: Fixtures = { emitted: [], fallbacks: [], live: new Set<string>() }
43
+ __setDepsForTests({
44
+ emitMetric: (e) => f.emitted.push(e),
45
+ onFrameworkFallback: (ctx) => { f.fallbacks.push(ctx) },
46
+ thresholdsMs: { ...DEFAULT_THRESHOLDS },
47
+ ...(opts?.wireReaper === false ? {} : { isTurnLive: (key: string) => f.live.has(key) }),
48
+ })
49
+ return f
50
+ }
51
+
52
+ beforeEach(() => {
53
+ __resetAllForTests()
54
+ delete process.env.SWITCHROOM_DISABLE_SILENCE_POKE
55
+ })
56
+
57
+ afterEach(() => {
58
+ __resetAllForTests()
59
+ if (ORIGINAL_KILL_SWITCH == null) delete process.env.SWITCHROOM_DISABLE_SILENCE_POKE
60
+ else process.env.SWITCHROOM_DISABLE_SILENCE_POKE = ORIGINAL_KILL_SWITCH
61
+ })
62
+
63
+ describe('#3552 silence-poke orphan reap', () => {
64
+ const KEY = '-100999:_'
65
+
66
+ it('drops state on the next tick once the turn is no longer live — no 300s wait', () => {
67
+ const f = setup()
68
+ f.live.add(KEY)
69
+ startTurn(KEY, 0)
70
+ __tickForTests(5_000)
71
+ expect(__getStateForTests(KEY)).toBeDefined()
72
+
73
+ // The turn ends via a path that forgot to call endTurn().
74
+ f.live.delete(KEY)
75
+ __tickForTests(10_000)
76
+
77
+ expect(__getStateForTests(KEY)).toBeUndefined()
78
+ expect(f.emitted.filter((e) => e.kind === 'silence_poke_orphan_reaped')).toHaveLength(1)
79
+ // The outcome that matters: the 300s fallback can never fire for this key.
80
+ __tickForTests(400_000)
81
+ expect(f.fallbacks).toHaveLength(0)
82
+ })
83
+
84
+ it('does NOT reap a turn that is still live but silent — the real fire still happens', () => {
85
+ const f = setup()
86
+ f.live.add(KEY)
87
+ startTurn(KEY, 0)
88
+
89
+ __tickForTests(299_000)
90
+ expect(f.fallbacks).toHaveLength(0)
91
+ expect(__getStateForTests(KEY)).toBeDefined()
92
+
93
+ __tickForTests(300_000)
94
+ expect(f.fallbacks).toHaveLength(1)
95
+ expect(f.fallbacks[0]!.key).toBe(KEY)
96
+ expect(f.emitted.some((e) => e.kind === 'silence_poke_orphan_reaped')).toBe(false)
97
+ })
98
+
99
+ it('the stubbed-live case: state survives while the predicate reports live (rule only, NOT the predicate)', () => {
100
+ // NAMING IS DELIBERATE. This test stubs `isTurnLive`, so it asserts the
101
+ // silence-poke RULE ("never reap a key the predicate calls live") and
102
+ // nothing about the production predicate itself — it would pass identically
103
+ // against a wrong predicate. The predicate is covered separately below, in
104
+ // '#3552 — the production isTurnLive predicate'; do not read this one as
105
+ // coverage of the buffer-gate near-miss.
106
+ const f = setup()
107
+ f.live.add(KEY)
108
+ startTurn(KEY, 0)
109
+ noteOutbound(KEY, 1_000)
110
+ __tickForTests(60_000)
111
+ expect(__getStateForTests(KEY)).toBeDefined()
112
+ expect(f.emitted.some((e) => e.kind === 'silence_poke_orphan_reaped')).toBe(false)
113
+ })
114
+
115
+ it('reaps only the dead key, leaving a live sibling topic armed', () => {
116
+ const f = setup()
117
+ const DEAD = '-100999:11'
118
+ const LIVE = '-100999:22'
119
+ f.live.add(DEAD)
120
+ f.live.add(LIVE)
121
+ startTurn(DEAD, 0)
122
+ startTurn(LIVE, 0)
123
+
124
+ f.live.delete(DEAD)
125
+ __tickForTests(10_000)
126
+
127
+ expect(__getStateForTests(DEAD)).toBeUndefined()
128
+ expect(__getStateForTests(LIVE)).toBeDefined()
129
+ })
130
+
131
+ it('is inert when the predicate is not wired (back-compat harnesses)', () => {
132
+ const f = setup({ wireReaper: false })
133
+ startTurn(KEY, 0)
134
+ __tickForTests(10_000)
135
+ expect(__getStateForTests(KEY)).toBeDefined()
136
+ expect(f.emitted.some((e) => e.kind === 'silence_poke_orphan_reaped')).toBe(false)
137
+ })
138
+ })
139
+
140
+ describe('#3552 — the production isTurnLive predicate', () => {
141
+ // The highest-blast-radius line in this PR is the predicate wired at
142
+ // liveness-wiring.ts, and every behavioural test above STUBS it. A false reap
143
+ // is permanent and silent — nothing re-arms until the next `startTurn` — so
144
+ // the predicate gets its own coverage: a structural pin on the real source
145
+ // (the technique this repo already uses for gateway-resident callbacks that
146
+ // cannot be instantiated in a unit test), plus a behavioural model of the two
147
+ // gateway maps it reads.
148
+ const src = readFileSync(join(__dirname, '..', 'gateway', 'liveness-wiring.ts'), 'utf8')
149
+ const KEY = '-100999:11'
150
+
151
+ /** Source lines with comments stripped — a canonical string in a comment must never pay for a code site. */
152
+ const codeLines = src.split('\n').filter((l) => {
153
+ const t = l.trim()
154
+ return !(t.startsWith('//') || t.startsWith('*') || t.startsWith('/*'))
155
+ })
156
+
157
+ it('is wired with BOTH clauses ANDed — the near-miss this fix turns on', () => {
158
+ const line = codeLines.find((l) => l.includes('isTurnLive:'))
159
+ expect(line, 'isTurnLive must be wired in liveness-wiring.ts').toBeDefined()
160
+ // EXACT text, not a bag of fragments. #3575 review: the previous form
161
+ // asserted four independent regexes that each had to match SOMEWHERE on the
162
+ // line, with nothing anchoring the end of the arrow body and nothing
163
+ // forbidding extra conjuncts — so mutants SURVIVED it, including
164
+ // `!(A == null && B == null) && activeTurnStartedAt.get(key) != null`
165
+ // which boolean-reduces to exactly the naive `activeTurnStartedAt`-only
166
+ // predicate this test is NAMED after, plus `&& false` (reaper disabled for
167
+ // every key) and an inverted `=== false`. Both halves must be present,
168
+ // negated TOGETHER, and nothing else may be ANDed on:
169
+ // `activeTurnStartedAt` ALONE is the wrong predicate, because
170
+ // `releaseTurnBufferGate` clears it mid-turn on every final-answer reply,
171
+ // so a predicate missing (or effectively cancelling) the `getCurrentTurn()`
172
+ // clause silently reaps live turns doing post-answer work.
173
+ //
174
+ // #3580: the second clause is the KEYED read. `getCurrentTurn()` is the
175
+ // most-recent-set MIRROR under SWITCHROOM_EMISSION_AUTHORITY=1, so an
176
+ // unkeyed read answers about whichever topic started a turn LAST — not
177
+ // about `key` — and false-reaps a live turn whose buffer gate was released.
178
+ expect(line!.trim()).toBe(
179
+ 'isTurnLive: (key) => !(activeTurnStartedAt.get(key) == null && getCurrentTurnForKey(key) == null),',
180
+ )
181
+ // Belt and braces on the mutation the exact-equality pin exists for: the
182
+ // reaper must not read the unkeyed singleton at all.
183
+ expect(line!).not.toMatch(/getCurrentTurn\(\)/)
184
+ })
185
+
186
+ it('the two late-fire guards read keyed too — exact lines, so no site can drift back', () => {
187
+ // #3580 M1: all three sites change TOGETHER. One keyed + two unkeyed leaves
188
+ // the reaper and the guards disagreeing about liveness, which is worse than
189
+ // the consistent-but-wrong state it replaces. Exact trimmed equality (not
190
+ // fragment regexes) is the only form that forbids an extra conjunct.
191
+ const guards = codeLines
192
+ .map((l) => l.trim())
193
+ .filter((t) => t.includes('activeTurnStartedAt.get(ctx.key)'))
194
+ expect(guards).toEqual([
195
+ // onMidTurnFloor
196
+ 'if (activeTurnStartedAt.get(ctx.key) == null && getCurrentTurnForKey(ctx.key) == null) return',
197
+ // onFrameworkFallback
198
+ 'if (activeTurnStartedAt.get(ctx.key) == null && getCurrentTurnForKey(ctx.key) == null) {',
199
+ ])
200
+ })
201
+
202
+ it('matches the late-fire guard it was deliberately copied from', () => {
203
+ // Same condition, so a mid-turn buffer-gate release can never be reaped by
204
+ // one and treated as live by the other. If someone tightens/loosens one
205
+ // site only, the two drift and the reaper starts disagreeing with the guard.
206
+ //
207
+ // #3575 review: scan CODE only. Counting occurrences across the raw file let
208
+ // an OR-for-AND swap survive by leaving the canonical text behind in a stale
209
+ // comment — the comment paid for the missing code site.
210
+ const code = codeLines.join('\n')
211
+ const predicate = /activeTurnStartedAt\.get\(([\w.]+)\) == null && getCurrentTurnForKey\(\1\) == null/g
212
+ expect((code.match(predicate) ?? []).length).toBe(3)
213
+ // ...and no site may drift BACK to the unkeyed singleton read (#3580).
214
+ const unkeyed = /activeTurnStartedAt\.get\([\w.]+\) == null && getCurrentTurn\(\) == null/g
215
+ expect(code.match(unkeyed) ?? []).toEqual([])
216
+ })
217
+
218
+ // #3575 review B3 — EXECUTE the real predicate. The previous version of this
219
+ // block re-typed the expression by hand into a local `productionPredicate`,
220
+ // which by construction cannot detect divergence from the wiring it claims to
221
+ // cover. `buildSilencePokeOptions` is a pure function of its deps (its only
222
+ // gateway import is `import type`), so the real options object — including the
223
+ // real `isTurnLive` closure — can be built here.
224
+ function realPredicate(activeTurnStartedAt: Map<string, number>, currentTurn: object | null) {
225
+ const fx = makeLivenessFixture()
226
+ for (const [k, v] of activeTurnStartedAt) fx.activeTurnStartedAt.set(k, v)
227
+ fx.setCurrentTurn(currentTurn)
228
+ const isTurnLive = buildSilencePokeOptions(fx.deps).isTurnLive
229
+ expect(isTurnLive, 'the wiring must supply isTurnLive').toBeTypeOf('function')
230
+ return isTurnLive!
231
+ }
232
+
233
+ it('calls a mid-turn buffer-gate release LIVE (the near-miss), where the naive predicate would not', () => {
234
+ const m = new Map<string, number>()
235
+ m.set(KEY, 0)
236
+ const liveTurn = { turnId: 'c:3#1' }
237
+ // `releaseTurnBufferGate` / `finalizeStatusReaction('done')` fire mid-turn
238
+ // on every final-answer reply and clear this entry while the turn runs on.
239
+ m.delete(KEY)
240
+ expect(realPredicate(m, liveTurn)(KEY)).toBe(true)
241
+ // The predicate I nearly shipped — activeTurnStartedAt alone — would have
242
+ // reaped this live turn, permanently and silently.
243
+ const naive = (key: string) => m.get(key) != null
244
+ expect(naive(KEY)).toBe(false)
245
+ })
246
+
247
+ it('calls a genuinely finished turn DEAD (so the reap still happens)', () => {
248
+ expect(realPredicate(new Map(), null)(KEY)).toBe(false)
249
+ })
250
+
251
+ it('calls a turn with its gate still set LIVE even when the singleton is null', () => {
252
+ const m = new Map<string, number>([[KEY, 0]])
253
+ expect(realPredicate(m, null)(KEY)).toBe(true)
254
+ })
255
+
256
+ // #3580 — the case #3575 could only DOCUMENT (as "with BOTH signals dead the
257
+ // tick reaps, even mid-turn") is now FIXED, so this asserts the opposite
258
+ // outcome. `releaseTurnBufferGate` (turn-end.ts) deletes the
259
+ // `activeTurnStartedAt` entry on EVERY reply finalize — including a mid-turn
260
+ // interim ack — while the turn keeps running. Under
261
+ // SWITCHROOM_EMISSION_AUTHORITY=1 the singleton is a most-recent-set MIRROR,
262
+ // so once topic B started and ended a turn the UNKEYED read said null for
263
+ // topic A too: A read dead on BOTH clauses and was false-reaped — permanent
264
+ // and silent (nothing re-arms until the next `startTurn`), costing A its
265
+ // mid-turn beat and its 300 s #1122 unwedge. The keyed read resolves A's OWN
266
+ // entry, so A stays armed.
267
+ //
268
+ // The `CurrentTurnMap` here is the REAL production class, re-imported with
269
+ // the flag ON through its read-once seam (mirroring per-topic-current-turn's
270
+ // `loadMap`) — not a hand-modelled stand-in of the mirror semantics.
271
+ // The re-import-per-flag seam is the query-string idiom from
272
+ // per-topic-current-turn.test.ts's `loadMap`: the flag is read ONCE at module
273
+ // top, and a fresh query string forces a fresh evaluation against the env we
274
+ // set here. Deliberately NOT `vi.resetModules()` — this suite runs under BOTH
275
+ // vitest and `bun test` in CI, and bun ignores it (the flag-ON tests then
276
+ // silently ran flag-OFF and failed there while passing locally).
277
+ async function loadFlagOnTurnMap(): Promise<TurnMapLike> {
278
+ const prev = process.env.SWITCHROOM_EMISSION_AUTHORITY
279
+ process.env.SWITCHROOM_EMISSION_AUTHORITY = '1'
280
+ try {
281
+ // A STATIC specifier with a distinguishing query — the module is
282
+ // evaluated once, under the flag we just set, and both flag-ON tests
283
+ // share that evaluation (each still builds its own map instance). A
284
+ // template-literal specifier is not statically analyzable and Vite
285
+ // rejects it ("Unknown variable dynamic import"); `vi.resetModules()`
286
+ // is a no-op under bun. This form works under both runners.
287
+ const mod = await import('../gateway/current-turn-map.js?emissionAuthorityOn')
288
+ expect(mod.EMISSION_AUTHORITY_ENABLED, 'the re-import seam must observe the flag').toBe(true)
289
+ return new mod.CurrentTurnMap() as TurnMapLike
290
+ } finally {
291
+ if (prev == null) delete process.env.SWITCHROOM_EMISSION_AUTHORITY
292
+ else process.env.SWITCHROOM_EMISSION_AUTHORITY = prev
293
+ }
294
+ }
295
+
296
+ const KEY_B = '-100999:22'
297
+
298
+ it('flag-ON: a mid-turn topic A survives topic B starting AND ending a turn (the false reap #3580 closes)', async () => {
299
+ const emitted: SilencePokeMetric[] = []
300
+ const fallbacks: FrameworkFallbackContext[] = []
301
+ const turnMap = await loadFlagOnTurnMap()
302
+ const fx = makeLivenessFixture({}, turnMap)
303
+ const turnA = { turnId: 'a#1', sessionChatId: '-100999', sessionThreadId: 11 }
304
+ const turnB = { turnId: 'b#1', sessionChatId: '-100999', sessionThreadId: 22 }
305
+
306
+ // Topic A is live for THIS key.
307
+ fx.activeTurnStartedAt.set(KEY, 0)
308
+ fx.setCurrentTurnForKey(KEY, turnA)
309
+ __setDepsForTests({
310
+ ...buildSilencePokeOptions(fx.deps),
311
+ emitMetric: (e) => emitted.push(e),
312
+ onFrameworkFallback: async (ctx) => { fallbacks.push(ctx) },
313
+ })
314
+ startTurn(KEY, 0)
315
+ __tickForTests(5_000)
316
+ expect(__getStateForTests(KEY)).toBeDefined()
317
+
318
+ // A's final answer lands: releaseTurnBufferGate drops A's activeTurnStartedAt
319
+ // entry while A keeps doing post-answer work.
320
+ fx.activeTurnStartedAt.delete(KEY)
321
+ // Then topic B starts (flipping the most-recent-set mirror off A) and ends.
322
+ fx.setCurrentTurnForKey(KEY_B, turnB)
323
+ fx.deps.endCurrentTurnForKey(turnB as never, KEY_B)
324
+
325
+ // Ground truth of the trap: BOTH signals a naive/unkeyed reader consults
326
+ // now say "dead" for A, even though A is running.
327
+ expect(fx.activeTurnStartedAt.get(KEY) == null).toBe(true)
328
+ expect(fx.deps.getCurrentTurn() == null).toBe(true)
329
+ // ...but the keyed read still resolves A's own topic.
330
+ expect(fx.deps.getCurrentTurnForKey(KEY)).toBe(turnA)
331
+
332
+ __tickForTests(10_000)
333
+
334
+ // The outcome that matters: A is NOT reaped, so it keeps its mid-turn beat.
335
+ expect(__getStateForTests(KEY)).toBeDefined()
336
+ expect(emitted.some((e) => e.kind === 'silence_poke_orphan_reaped')).toBe(false)
337
+ // ...and its 300 s unwedge still fires — the #1122 protection survives.
338
+ __tickForTests(310_000)
339
+ expect(fallbacks.map((f) => f.key)).toEqual([KEY])
340
+ })
341
+
342
+ it('flag-ON: a topic whose OWN turn ended is still reaped (the keyed read did not disable the reaper)', async () => {
343
+ const emitted: SilencePokeMetric[] = []
344
+ const turnMap = await loadFlagOnTurnMap()
345
+ const fx = makeLivenessFixture({}, turnMap)
346
+ const turnA = { turnId: 'a#1', sessionChatId: '-100999', sessionThreadId: 11 }
347
+ const turnB = { turnId: 'b#1', sessionChatId: '-100999', sessionThreadId: 22 }
348
+
349
+ fx.activeTurnStartedAt.set(KEY, 0)
350
+ fx.setCurrentTurnForKey(KEY, turnA)
351
+ // A live sibling B exists the whole time — under an unkeyed read its
352
+ // presence would have kept the dead A armed forever.
353
+ fx.setCurrentTurnForKey(KEY_B, turnB)
354
+ __setDepsForTests({
355
+ ...buildSilencePokeOptions(fx.deps),
356
+ emitMetric: (e) => emitted.push(e),
357
+ onFrameworkFallback: async () => {},
358
+ })
359
+ startTurn(KEY, 0)
360
+ __tickForTests(5_000)
361
+ expect(__getStateForTests(KEY)).toBeDefined()
362
+
363
+ // A ends via a path that forgot endTurn(): gate cleared, keyed entry gone.
364
+ fx.activeTurnStartedAt.delete(KEY)
365
+ fx.deps.endCurrentTurnForKey(turnA as never, KEY)
366
+ __tickForTests(10_000)
367
+
368
+ expect(__getStateForTests(KEY)).toBeUndefined()
369
+ expect(emitted.filter((e) => e.kind === 'silence_poke_orphan_reaped')).toHaveLength(1)
370
+ })
371
+
372
+ it('keeps a live turn armed whenever EITHER liveness signal survives (the conjunction is load-bearing)', () => {
373
+ // The two survivable shapes of the buffer-gate window, through the real
374
+ // predicate on the real tick. Either signal alone must hold the state.
375
+ for (const shape of ['gate-only', 'singleton-only'] as const) {
376
+ __resetAllForTests()
377
+ const emitted: SilencePokeMetric[] = []
378
+ const fx = makeLivenessFixture()
379
+ if (shape === 'gate-only') fx.activeTurnStartedAt.set(KEY, 0)
380
+ else fx.setCurrentTurn({ turnId: 'a#1' })
381
+ __setDepsForTests({
382
+ ...buildSilencePokeOptions(fx.deps),
383
+ emitMetric: (e) => emitted.push(e),
384
+ onFrameworkFallback: async () => {},
385
+ })
386
+ startTurn(KEY, 0)
387
+ __tickForTests(10_000)
388
+ expect(__getStateForTests(KEY), shape).toBeDefined()
389
+ expect(emitted.some((e) => e.kind === 'silence_poke_orphan_reaped'), shape).toBe(false)
390
+ }
391
+ })
392
+ })