switchroom 0.15.45 → 0.16.4
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/dist/agent-scheduler/index.js +122 -88
- package/dist/auth-broker/index.js +463 -177
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +17 -14
- package/dist/cli/notion-write-pretool.mjs +117 -86
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +3158 -1178
- package/dist/host-control/main.js +2833 -355
- package/dist/vault/approvals/kernel-server.js +7479 -7439
- package/dist/vault/broker/server.js +11312 -11272
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +88 -1
- package/profiles/_shared/execution-discipline.md.hbs +18 -0
- package/profiles/default/CLAUDE.md.hbs +0 -19
- package/telegram-plugin/.claude-plugin/plugin.json +2 -2
- package/telegram-plugin/answer-stream-flag.ts +12 -49
- package/telegram-plugin/answer-stream.ts +5 -150
- package/telegram-plugin/auth-snapshot-format.ts +280 -48
- package/telegram-plugin/auto-fallback-fleet.ts +44 -1
- package/telegram-plugin/context-exhaustion.ts +12 -0
- package/telegram-plugin/demo-mask.ts +154 -0
- package/telegram-plugin/dist/bridge/bridge.js +167 -124
- package/telegram-plugin/dist/gateway/gateway.js +3039 -1159
- package/telegram-plugin/dist/server.js +215 -172
- package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
- package/telegram-plugin/draft-stream.ts +47 -410
- package/telegram-plugin/final-answer-detect.ts +17 -12
- package/telegram-plugin/fleet-fallback-resume.ts +131 -0
- package/telegram-plugin/format.ts +56 -19
- package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
- package/telegram-plugin/gateway/auth-command.ts +70 -14
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
- package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
- package/telegram-plugin/gateway/current-turn-map.ts +188 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
- package/telegram-plugin/gateway/effort-command.ts +8 -3
- package/telegram-plugin/gateway/emission-authority.ts +369 -0
- package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
- package/telegram-plugin/gateway/gateway.ts +1837 -291
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
- package/telegram-plugin/gateway/represent-guard.ts +72 -0
- package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
- package/telegram-plugin/gateway/status-surface-log.ts +14 -3
- package/telegram-plugin/history.ts +33 -11
- package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
- package/telegram-plugin/issues-card.ts +4 -0
- package/telegram-plugin/model-unavailable.ts +124 -0
- package/telegram-plugin/narrative-dedup.ts +69 -0
- package/telegram-plugin/over-ping-safety-net.ts +70 -4
- package/telegram-plugin/package.json +3 -3
- package/telegram-plugin/pending-work-progress.ts +12 -0
- package/telegram-plugin/permission-rule.ts +32 -5
- package/telegram-plugin/permission-title.ts +152 -9
- package/telegram-plugin/quota-check.ts +13 -0
- package/telegram-plugin/quota-watch.ts +135 -7
- package/telegram-plugin/registry/turns-schema.test.ts +24 -0
- package/telegram-plugin/registry/turns-schema.ts +9 -0
- package/telegram-plugin/runtime-metrics.ts +13 -0
- package/telegram-plugin/session-tail.ts +96 -11
- package/telegram-plugin/silence-poke.ts +170 -24
- package/telegram-plugin/slot-banner-driver.ts +3 -0
- package/telegram-plugin/status-no-truncate.ts +44 -0
- package/telegram-plugin/status-reactions.ts +20 -3
- package/telegram-plugin/stream-controller.ts +4 -23
- package/telegram-plugin/stream-reply-handler.ts +6 -24
- package/telegram-plugin/streaming-metrics.ts +91 -0
- package/telegram-plugin/subagent-watcher.ts +212 -66
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
- package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
- package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
- package/telegram-plugin/tests/answer-stream.test.ts +2 -411
- package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
- package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
- package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
- package/telegram-plugin/tests/demo-mask.test.ts +127 -0
- package/telegram-plugin/tests/draft-stream.test.ts +0 -827
- package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
- package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
- package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
- package/telegram-plugin/tests/feed-survival.test.ts +526 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
- package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
- package/telegram-plugin/tests/history.test.ts +60 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
- package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
- package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
- package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
- package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
- package/telegram-plugin/tests/permission-rule.test.ts +17 -0
- package/telegram-plugin/tests/permission-title.test.ts +206 -17
- package/telegram-plugin/tests/quota-watch.test.ts +252 -9
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
- package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
- package/telegram-plugin/tests/represent-guard.test.ts +162 -0
- package/telegram-plugin/tests/session-tail.test.ts +147 -3
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
- package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
- package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
- package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
- package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
- package/telegram-plugin/tests/telegram-format.test.ts +101 -6
- package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
- package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
- package/telegram-plugin/tests/tool-labels.test.ts +67 -0
- package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
- package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
- package/telegram-plugin/tests/welcome-text.test.ts +32 -3
- package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
- package/telegram-plugin/tool-activity-summary.ts +375 -58
- package/telegram-plugin/turn-liveness-floor.ts +240 -0
- package/telegram-plugin/uat/assertions.ts +115 -0
- package/telegram-plugin/uat/driver.ts +68 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
- package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
- package/telegram-plugin/welcome-text.ts +13 -1
- package/telegram-plugin/worker-activity-feed.ts +157 -82
- package/telegram-plugin/draft-transport.ts +0 -122
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
- package/telegram-plugin/tests/draft-transport.test.ts +0 -211
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
2
|
+
import { mayDrainBufferedInbound } from '../gateway/serialize-drain-gate.js'
|
|
3
|
+
import type { EmissionTurnView, CardDrainGateCtx } from '../gateway/emission-authority.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PR-4d flag-parity proof — the card-drain gate at the façade layer.
|
|
7
|
+
*
|
|
8
|
+
* The emission-authority façade's NEW `mayDrainCardNow` unifies the card-drain
|
|
9
|
+
* single-flight read with the #2137 deliver-before-drain serialization gate,
|
|
10
|
+
* behind the `SWITCHROOM_EMISSION_AUTHORITY` kill-switch. The defining
|
|
11
|
+
* correctness property: flag-OFF ≡ flag-ON ≡ base — the verdict must equal the
|
|
12
|
+
* base single-flight read (`mayDrain(turn)`) on the live foreground card path,
|
|
13
|
+
* for BOTH flag states, across the full input cross-product.
|
|
14
|
+
*
|
|
15
|
+
* §5 modeling decision (the highest-risk call): the gateway threads
|
|
16
|
+
* `endingTurnFinalAnswerDelivered: null` for the CARD path, so
|
|
17
|
+
* `mayDrainBufferedInbound` degenerates to `!turnInFlight`. The card
|
|
18
|
+
* single-flight is governed by `activityInFlight` (via `mayDrain`), NOT by an
|
|
19
|
+
* ending turn's delivery state — so a synthetic represent turn
|
|
20
|
+
* (finalAnswerDelivered=false) can never wedge the live foreground card path.
|
|
21
|
+
*
|
|
22
|
+
* The flag is read ONCE at module top (the asserted read-once invariant). We
|
|
23
|
+
* flip it per flag state by dynamically re-importing the façade module under a
|
|
24
|
+
* unique query string (bun re-evaluates the module, so the read-once const is
|
|
25
|
+
* recomputed against the chosen env). This is a TEST-ONLY seam — it does not
|
|
26
|
+
* touch the production read-once path. Mirrors emission-authority-open-gate.test.ts.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
delete process.env.SWITCHROOM_EMISSION_AUTHORITY
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
let reimportSeq = 0
|
|
34
|
+
/**
|
|
35
|
+
* Re-import the façade with the flag set as requested. Bun re-evaluates a module
|
|
36
|
+
* imported under a fresh query string, so the read-once
|
|
37
|
+
* `EMISSION_AUTHORITY_ENABLED` const is recomputed against the env we set here —
|
|
38
|
+
* a test-only seam that leaves the production read-once path untouched.
|
|
39
|
+
*/
|
|
40
|
+
async function loadFacade(enabled: boolean): Promise<typeof import('../gateway/emission-authority.js')> {
|
|
41
|
+
if (enabled) process.env.SWITCHROOM_EMISSION_AUTHORITY = '1'
|
|
42
|
+
else delete process.env.SWITCHROOM_EMISSION_AUTHORITY
|
|
43
|
+
return import(`../gateway/emission-authority.js?carddraincase=${reimportSeq++}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The full cross-product of card-drain-gate inputs.
|
|
47
|
+
type Case = {
|
|
48
|
+
/** Single-flight: a drain Promise is in flight (non-null) ⇒ may NOT drain. */
|
|
49
|
+
activityInFlight: boolean
|
|
50
|
+
/** turnInFlightForGate() — claude busy. */
|
|
51
|
+
turnInFlight: boolean
|
|
52
|
+
/** The ending turn's finalAnswerDelivered, as the gate would receive it. */
|
|
53
|
+
endingTurnFinalAnswerDelivered: boolean | null | undefined
|
|
54
|
+
/** SERIALIZE_UNTIL_REPLIED_ENABLED. */
|
|
55
|
+
enabled: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function* cases(): Generator<Case> {
|
|
59
|
+
for (const activityInFlight of [false, true]) {
|
|
60
|
+
for (const turnInFlight of [false, true]) {
|
|
61
|
+
for (const endingTurnFinalAnswerDelivered of [true, false, null, undefined] as (
|
|
62
|
+
| boolean
|
|
63
|
+
| null
|
|
64
|
+
| undefined
|
|
65
|
+
)[]) {
|
|
66
|
+
for (const enabled of [false, true]) {
|
|
67
|
+
yield { activityInFlight, turnInFlight, endingTurnFinalAnswerDelivered, enabled }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function turnFor(c: Case): EmissionTurnView {
|
|
75
|
+
return { activityInFlight: c.activityInFlight ? Promise.resolve() : null }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The CARD-path ctx the gateway threads. The §5 decision: the card path always
|
|
80
|
+
* passes `endingTurnFinalAnswerDelivered: null` (NOT the case's raw value — the
|
|
81
|
+
* raw value is swept only to prove the card path is insensitive to it). The
|
|
82
|
+
* single-flight is governed by `activityInFlight`, not delivery state.
|
|
83
|
+
*/
|
|
84
|
+
function cardCtxFor(c: Case): CardDrainGateCtx {
|
|
85
|
+
return {
|
|
86
|
+
turnInFlight: c.turnInFlight,
|
|
87
|
+
endingTurnFinalAnswerDelivered: null,
|
|
88
|
+
enabled: c.enabled,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The base oracle: the single-flight read every drain site already performs. */
|
|
93
|
+
function baseMayDrain(c: Case): boolean {
|
|
94
|
+
return turnFor(c).activityInFlight == null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function keyOf(c: Case): string {
|
|
98
|
+
return `inflight=${c.activityInFlight} turnInFlight=${c.turnInFlight} delivered=${String(
|
|
99
|
+
c.endingTurnFinalAnswerDelivered,
|
|
100
|
+
)} serialize=${c.enabled}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe('mayDrainCardNow flag-parity — flag OFF ≡ flag ON ≡ base on the foreground card path', () => {
|
|
104
|
+
it('flag OFF: verdict == base single-flight read (mayDrain) for EVERY input', async () => {
|
|
105
|
+
const { EmissionAuthority } = await loadFacade(false)
|
|
106
|
+
for (const c of cases()) {
|
|
107
|
+
const ea = new EmissionAuthority('k')
|
|
108
|
+
const verdict = ea.mayDrainCardNow(turnFor(c), cardCtxFor(c))
|
|
109
|
+
expect(verdict, keyOf(c)).toBe(baseMayDrain(c))
|
|
110
|
+
// OFF is byte-equivalent to plain mayDrain (no deliver-before-drain term).
|
|
111
|
+
expect(verdict, keyOf(c)).toBe(ea.mayDrain(turnFor(c)))
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('flag ON: verdict == base on the foreground card path for EVERY input (endingTurnFinalAnswerDelivered:null degenerates the gate to !turnInFlight, subsumed by activityInFlight)', async () => {
|
|
116
|
+
const { EmissionAuthority } = await loadFacade(true)
|
|
117
|
+
for (const c of cases()) {
|
|
118
|
+
const ea = new EmissionAuthority('k')
|
|
119
|
+
const verdict = ea.mayDrainCardNow(turnFor(c), cardCtxFor(c))
|
|
120
|
+
|
|
121
|
+
// The card path threads endingTurnFinalAnswerDelivered:null, so under the
|
|
122
|
+
// flag the verdict is mayDrain(turn) && mayDrainBufferedInbound({
|
|
123
|
+
// turnInFlight, endingTurnFinalAnswerDelivered:null, enabled }). With
|
|
124
|
+
// delivered=null the predicate degenerates to !turnInFlight.
|
|
125
|
+
const expectedOnVerdict =
|
|
126
|
+
baseMayDrain(c) &&
|
|
127
|
+
mayDrainBufferedInbound({
|
|
128
|
+
turnInFlight: c.turnInFlight,
|
|
129
|
+
endingTurnFinalAnswerDelivered: null,
|
|
130
|
+
enabled: c.enabled,
|
|
131
|
+
})
|
|
132
|
+
expect(verdict, keyOf(c)).toBe(expectedOnVerdict)
|
|
133
|
+
|
|
134
|
+
// The DEFINING parity property: when claude is NOT mid-turn (the live
|
|
135
|
+
// foreground card path — the only path a card drain actually fires on,
|
|
136
|
+
// since a drain runs WITHIN the foreground turn), the flag-ON verdict
|
|
137
|
+
// equals base. turnInFlight just short-circuits to false, which the
|
|
138
|
+
// single-flight already covers when activityInFlight is set.
|
|
139
|
+
if (!c.turnInFlight) {
|
|
140
|
+
expect(verdict, keyOf(c)).toBe(baseMayDrain(c))
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('the synthetic-represent-turn row cannot wedge the card path (finalAnswerDelivered=false + activityInFlight=null ⇒ verdict matches base)', async () => {
|
|
146
|
+
const { EmissionAuthority } = await loadFacade(true)
|
|
147
|
+
// A synthetic represent turn starts finalAnswerDelivered=false. On the
|
|
148
|
+
// buffer-drain path that would BLOCK the gate forever. On the CARD path the
|
|
149
|
+
// gateway threads endingTurnFinalAnswerDelivered:null, so the false-delivery
|
|
150
|
+
// state is NEVER consulted — the card drains exactly as base would.
|
|
151
|
+
const represent: Case = {
|
|
152
|
+
activityInFlight: false, // free single-flight slot
|
|
153
|
+
turnInFlight: false, // the live foreground card path
|
|
154
|
+
endingTurnFinalAnswerDelivered: false, // the represent turn's raw state
|
|
155
|
+
enabled: true, // serialize-until-replied ON
|
|
156
|
+
}
|
|
157
|
+
const ea = new EmissionAuthority('k')
|
|
158
|
+
const verdict = ea.mayDrainCardNow(turnFor(represent), cardCtxFor(represent))
|
|
159
|
+
// base = mayDrain = true (slot free). The represent turn does NOT wedge it.
|
|
160
|
+
expect(verdict).toBe(true)
|
|
161
|
+
expect(verdict).toBe(baseMayDrain(represent))
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('flag-OFF set == flag-ON set on the foreground card path (cross-product set equality)', async () => {
|
|
165
|
+
const off = await loadFacade(false)
|
|
166
|
+
const on = await loadFacade(true)
|
|
167
|
+
const offVerdicts: string[] = []
|
|
168
|
+
const onVerdicts: string[] = []
|
|
169
|
+
for (const c of cases()) {
|
|
170
|
+
if (c.turnInFlight) continue // not the live foreground card path
|
|
171
|
+
const eaOff = new off.EmissionAuthority('k')
|
|
172
|
+
const eaOn = new on.EmissionAuthority('k')
|
|
173
|
+
offVerdicts.push(`${keyOf(c)} => ${eaOff.mayDrainCardNow(turnFor(c), cardCtxFor(c))}`)
|
|
174
|
+
onVerdicts.push(`${keyOf(c)} => ${eaOn.mayDrainCardNow(turnFor(c), cardCtxFor(c))}`)
|
|
175
|
+
}
|
|
176
|
+
expect(onVerdicts).toEqual(offVerdicts)
|
|
177
|
+
expect(offVerdicts.length).toBeGreaterThan(0)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe('mayDrainCardNow — no-deadlock interleaving (the gateway-acquired lock serializes FIFO; mayDrain stays lock-free)', () => {
|
|
182
|
+
/**
|
|
183
|
+
* The gateway acquires chatLock AROUND the gate; mayDrainCardNow itself is a
|
|
184
|
+
* pure read. This proves two concurrent gate decisions on the same chatKey
|
|
185
|
+
* serialize FIFO under a simple promise-chain lock (the chat-lock contract)
|
|
186
|
+
* and that neither blocks on the pure mayDrain read.
|
|
187
|
+
*/
|
|
188
|
+
function makeChainLock() {
|
|
189
|
+
const chains = new Map<string, Promise<unknown>>()
|
|
190
|
+
function run<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
191
|
+
const prior = chains.get(key) ?? Promise.resolve()
|
|
192
|
+
const next = prior.then(fn, fn)
|
|
193
|
+
const tracked = next.finally(() => {
|
|
194
|
+
if (chains.get(key) === tracked) chains.delete(key)
|
|
195
|
+
})
|
|
196
|
+
chains.set(key, tracked)
|
|
197
|
+
return next
|
|
198
|
+
}
|
|
199
|
+
return { run }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
it('two concurrent gate calls on the same chatKey serialize FIFO; neither blocks on the pure mayDrain', async () => {
|
|
203
|
+
const { EmissionAuthority } = await loadFacade(true)
|
|
204
|
+
const ea = new EmissionAuthority('k')
|
|
205
|
+
const turn: EmissionTurnView = { activityInFlight: null }
|
|
206
|
+
const ctx: CardDrainGateCtx = {
|
|
207
|
+
turnInFlight: false,
|
|
208
|
+
endingTurnFinalAnswerDelivered: null,
|
|
209
|
+
enabled: true,
|
|
210
|
+
}
|
|
211
|
+
const lock = makeChainLock()
|
|
212
|
+
const order: string[] = []
|
|
213
|
+
|
|
214
|
+
// The pure read never touches the lock — callable freely, returns instantly.
|
|
215
|
+
expect(ea.mayDrainCardNow(turn, ctx)).toBe(true)
|
|
216
|
+
|
|
217
|
+
const gate = (id: string) =>
|
|
218
|
+
lock.run('k', async () => {
|
|
219
|
+
order.push(`enter-${id}`)
|
|
220
|
+
// The gate decision (pure) runs inside the lock.
|
|
221
|
+
const verdict = ea.mayDrainCardNow(turn, ctx)
|
|
222
|
+
// Yield a microtask to expose any interleaving.
|
|
223
|
+
await Promise.resolve()
|
|
224
|
+
order.push(`exit-${id}`)
|
|
225
|
+
return verdict
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
const [v1, v2] = await Promise.all([gate('A'), gate('B')])
|
|
229
|
+
// FIFO: A fully completes before B enters (no interleave).
|
|
230
|
+
expect(order).toEqual(['enter-A', 'exit-A', 'enter-B', 'exit-B'])
|
|
231
|
+
expect(v1).toBe(true)
|
|
232
|
+
expect(v2).toBe(true)
|
|
233
|
+
// The pure read still works AFTER the serialized section — lock-free.
|
|
234
|
+
expect(ea.mayDrainCardNow(turn, ctx)).toBe(true)
|
|
235
|
+
})
|
|
236
|
+
})
|