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.
- package/bin/run-hook.sh +148 -0
- package/bin/workspace-dynamic-hook.sh +147 -38
- package/dist/agent-scheduler/index.js +11 -3
- package/dist/auth-broker/index.js +29 -4
- package/dist/cli/notion-write-pretool.mjs +11 -3
- package/dist/cli/switchroom.js +8307 -7620
- package/dist/host-control/main.js +626 -36
- package/dist/vault/approvals/kernel-server.js +30 -5
- package/dist/vault/broker/server.js +71 -18
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +8 -4
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +3 -3
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +8 -6
- package/telegram-plugin/bridge/bridge.ts +11 -19
- package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
- package/telegram-plugin/dist/bridge/bridge.js +15 -20
- package/telegram-plugin/dist/gateway/gateway.js +763 -373
- package/telegram-plugin/dist/server.js +19 -20
- package/telegram-plugin/gateway/boot-card.ts +5 -1
- package/telegram-plugin/gateway/boot-probes.ts +113 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
- package/telegram-plugin/gateway/gateway.ts +43 -1
- package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
- package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
- package/telegram-plugin/gateway/ipc-server.ts +13 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
- package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
- package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
- package/telegram-plugin/gateway/stream-render.ts +24 -5
- package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
- package/telegram-plugin/registry/turns-schema.test.ts +8 -3
- package/telegram-plugin/registry/turns-schema.ts +40 -12
- package/telegram-plugin/runtime-metrics.ts +14 -0
- package/telegram-plugin/silence-poke.ts +138 -0
- package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
- package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
- package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
- package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
- package/telegram-plugin/tests/registry-turns.test.ts +13 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
- package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
- package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
- package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
- package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
- package/telegram-plugin/tts-normalize.ts +6 -4
- package/telegram-plugin/voice-normalize-text.ts +168 -11
- package/vendor/hindsight-memory/CHANGELOG.md +73 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
- package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
- package/vendor/hindsight-memory/scripts/recall.py +257 -12
- package/vendor/hindsight-memory/scripts/retain.py +12 -6
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
- 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
|
+
})
|