switchroom 0.18.8 → 0.18.9
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/README.md +2 -2
- package/dist/cli/switchroom.js +2 -2
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +78648 -77445
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/gateway.ts +527 -2880
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
- package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
|
@@ -131,10 +131,17 @@ describe('lever 2 — finalize the card BEFORE a substantive reply send', () =>
|
|
|
131
131
|
return after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
// #2996 step 1: the chunk send loop was relocated verbatim from executeReply
|
|
135
|
+
// into outbound-send-path.ts's `sendReplyChunks`, invoked here as
|
|
136
|
+
// `await sendReplyChunks(chunkSendDeps, …)`. This guard's INTENT — the
|
|
137
|
+
// lever-2 card finalize runs BEFORE the reply send — is unchanged; the send
|
|
138
|
+
// marker is now the delegation call rather than the inline `for` loop.
|
|
139
|
+
const SEND_MARKER = 'sendReplyChunks('
|
|
140
|
+
|
|
141
|
+
it('executeReply finalizes (clearActivitySummary) before the reply send, gated on substantive', () => {
|
|
135
142
|
const src = executeReplySrc()
|
|
136
143
|
const clearIdx = src.indexOf('clearActivitySummary(')
|
|
137
|
-
const loopIdx = src.indexOf(
|
|
144
|
+
const loopIdx = src.indexOf(SEND_MARKER)
|
|
138
145
|
expect(clearIdx).toBeGreaterThan(-1)
|
|
139
146
|
expect(loopIdx).toBeGreaterThan(-1)
|
|
140
147
|
expect(clearIdx).toBeLessThan(loopIdx)
|
|
@@ -148,8 +155,8 @@ describe('lever 2 — finalize the card BEFORE a substantive reply send', () =>
|
|
|
148
155
|
// An ack (non-substantive) falls through and never finalizes early, so the
|
|
149
156
|
// reopen path keeps owning the card (the #2141 ack-then-work feed).
|
|
150
157
|
const replySrc = executeReplySrc()
|
|
151
|
-
// The pre-
|
|
152
|
-
const preLoop = replySrc.split(
|
|
158
|
+
// The pre-send clearActivitySummary must be the substantive-gated one.
|
|
159
|
+
const preLoop = replySrc.split(SEND_MARKER)[0] ?? ''
|
|
153
160
|
const clears = [...preLoop.matchAll(/clearActivitySummary\(/g)]
|
|
154
161
|
expect(clears).toHaveLength(1)
|
|
155
162
|
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subprocess probe for the SWITCHROOM_DELIVERY_MACHINE_CUTOVER kill switch.
|
|
3
|
+
*
|
|
4
|
+
* The cutover env var is read at MODULE LOAD in both
|
|
5
|
+
* `inbound-delivery-machine-dispatch.ts` and
|
|
6
|
+
* `inbound-delivery-machine-shadow.ts`, so an in-process test can't flip it
|
|
7
|
+
* after import (and bun's `vi` shim has no resetModules/stubEnv). The
|
|
8
|
+
* cutover-flip test spawns this script under `bun` with the env it wants
|
|
9
|
+
* and asserts the JSON verdict printed on stdout.
|
|
10
|
+
*
|
|
11
|
+
* Output: one JSON line —
|
|
12
|
+
* {
|
|
13
|
+
* dispatchEnabled, cutoverEnabled, shadowEnabled,
|
|
14
|
+
* sendCalls, buffered, setTurnStartedCalls, deliverResults
|
|
15
|
+
* }
|
|
16
|
+
* after driving a fresh-turn inbound effect set through dispatchEffects
|
|
17
|
+
* against a recording fake ctx.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { dispatchEffects, isDispatchEnabled } from '../../gateway/inbound-delivery-machine-dispatch'
|
|
21
|
+
import {
|
|
22
|
+
isDeliveryCutoverEnabled,
|
|
23
|
+
__shadowEnabledForTests,
|
|
24
|
+
} from '../../gateway/inbound-delivery-machine-shadow'
|
|
25
|
+
import { createPendingInboundBuffer } from '../../gateway/pending-inbound-buffer'
|
|
26
|
+
import { createPendingPermissionBuffer } from '../../gateway/pending-permission-decisions'
|
|
27
|
+
import { initialState, transition, type ChatKey } from '../../gateway/inbound-delivery-machine'
|
|
28
|
+
|
|
29
|
+
const KEY = '111:_' as ChatKey
|
|
30
|
+
const msg = { type: 'inbound', chatId: '111', messageId: 42, text: 'hello' }
|
|
31
|
+
|
|
32
|
+
const alive = transition(initialState(), { kind: 'bridgeUp', at: 1000 }).state
|
|
33
|
+
const { effects } = transition(alive, {
|
|
34
|
+
kind: 'inbound',
|
|
35
|
+
key: KEY,
|
|
36
|
+
msg: { msgId: 42, isSteering: false, payload: msg },
|
|
37
|
+
at: 2000,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
let sendCalls = 0
|
|
41
|
+
const buffer = createPendingInboundBuffer()
|
|
42
|
+
let setTurnStartedCalls = 0
|
|
43
|
+
const deliverResults: boolean[] = []
|
|
44
|
+
|
|
45
|
+
dispatchEffects(effects, {
|
|
46
|
+
selfAgent: 'probe-agent',
|
|
47
|
+
ipcServer: {
|
|
48
|
+
sendToAgent: () => {
|
|
49
|
+
sendCalls++
|
|
50
|
+
return true
|
|
51
|
+
},
|
|
52
|
+
} as never,
|
|
53
|
+
pendingInboundBuffer: buffer,
|
|
54
|
+
inboundSpool: null,
|
|
55
|
+
pendingPermissionBuffer: createPendingPermissionBuffer(),
|
|
56
|
+
log: () => {},
|
|
57
|
+
onSetTurnStarted: () => {
|
|
58
|
+
setTurnStartedCalls++
|
|
59
|
+
},
|
|
60
|
+
onDeliverResult: (_k, ok) => {
|
|
61
|
+
deliverResults.push(ok)
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
process.stdout.write(
|
|
66
|
+
JSON.stringify({
|
|
67
|
+
dispatchEnabled: isDispatchEnabled(),
|
|
68
|
+
cutoverEnabled: isDeliveryCutoverEnabled(),
|
|
69
|
+
shadowEnabled: __shadowEnabledForTests(),
|
|
70
|
+
sendCalls,
|
|
71
|
+
buffered: buffer.drain('probe-agent').length,
|
|
72
|
+
setTurnStartedCalls,
|
|
73
|
+
deliverResults,
|
|
74
|
+
}) + '\n',
|
|
75
|
+
)
|
|
@@ -35,8 +35,12 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
|
|
|
35
35
|
})
|
|
36
36
|
|
|
37
37
|
it('reply: scrubs at entry, before the stderr preview log', () => {
|
|
38
|
+
// #2996: the entry pipeline (normalize → redact → punctuation/bold →
|
|
39
|
+
// voice-scrub) is extracted into outbound-send-path.ts. The reply path now
|
|
40
|
+
// delegates via `normalizeOutboundBody(rawText, 'reply', redactOutboundText)`
|
|
41
|
+
// — the injected redactor still runs at entry, before the stderr preview.
|
|
38
42
|
const start = src.indexOf('async function executeReply(')
|
|
39
|
-
const redactIdx = src.indexOf(`
|
|
43
|
+
const redactIdx = src.indexOf(`normalizeOutboundBody(rawText, 'reply', redactOutboundText)`, start)
|
|
40
44
|
const previewIdx = src.indexOf('reply: invoked chatId=', start)
|
|
41
45
|
expect(start).toBeGreaterThan(0)
|
|
42
46
|
expect(redactIdx).toBeGreaterThan(start)
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR3c cutover flip (#2794 / #2996 item 1) — the inbound-routing flip.
|
|
3
|
+
*
|
|
4
|
+
* `handleInbound` now dispatches the machine's captured `inbound` effects
|
|
5
|
+
* through `dispatchEffects` as the AUTHORITATIVE deliver-vs-buffer routing.
|
|
6
|
+
* These tests pin the two contract halves the flip rests on:
|
|
7
|
+
*
|
|
8
|
+
* 1. DEFAULT ON — with no env override, both the cutover gate
|
|
9
|
+
* (`isDeliveryCutoverEnabled`) and the dispatcher (`isDispatchEnabled`)
|
|
10
|
+
* are enabled, and the machine's effect sequences execute real I/O
|
|
11
|
+
* (fresh turn → setTurnStarted + deliverToBridge; mid-turn → buffer).
|
|
12
|
+
*
|
|
13
|
+
* 2. KILL SWITCH — `SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0` restores
|
|
14
|
+
* legacy behavior: the gate reads legacy claudeBusyKeys (gate returns
|
|
15
|
+
* false from `isDeliveryCutoverEnabled`) and `dispatchEffects` is a
|
|
16
|
+
* total no-op, so the imperative twin in gateway.ts is the only
|
|
17
|
+
* executor. Env is read at module load, so the kill-switch tests use
|
|
18
|
+
* vi.resetModules + a fresh dynamic import.
|
|
19
|
+
*
|
|
20
|
+
* Plus the `onDeliverResult` observer added for the flip: the gateway's
|
|
21
|
+
* machine-deliver path branches on the send outcome (delivered → busy-mark
|
|
22
|
+
* + delivery-confirm tracking; miss → release + durable-buffer + restart
|
|
23
|
+
* notice), so the callback contract (fires once per deliverToBridge, with
|
|
24
|
+
* the real ok, and never breaks delivery when the observer throws) is
|
|
25
|
+
* load-bearing.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
29
|
+
import { spawnSync } from 'node:child_process'
|
|
30
|
+
import { join } from 'node:path'
|
|
31
|
+
import {
|
|
32
|
+
dispatchEffects,
|
|
33
|
+
isDispatchEnabled,
|
|
34
|
+
} from '../gateway/inbound-delivery-machine-dispatch'
|
|
35
|
+
import type { DispatchCtx } from '../gateway/inbound-delivery-machine-dispatch'
|
|
36
|
+
import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer'
|
|
37
|
+
import { createPendingPermissionBuffer } from '../gateway/pending-permission-decisions'
|
|
38
|
+
import {
|
|
39
|
+
initialState,
|
|
40
|
+
transition,
|
|
41
|
+
type ChatKey,
|
|
42
|
+
type State,
|
|
43
|
+
} from '../gateway/inbound-delivery-machine'
|
|
44
|
+
import {
|
|
45
|
+
isDeliveryCutoverEnabled,
|
|
46
|
+
} from '../gateway/inbound-delivery-machine-shadow'
|
|
47
|
+
|
|
48
|
+
const KEY = '111:_' as ChatKey
|
|
49
|
+
|
|
50
|
+
function machineMsg(payload: unknown, isSteering = false) {
|
|
51
|
+
return { msgId: 42, isSteering, payload }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ipcMsg(): Record<string, unknown> {
|
|
55
|
+
return {
|
|
56
|
+
type: 'inbound',
|
|
57
|
+
chatId: '111',
|
|
58
|
+
messageId: 42,
|
|
59
|
+
text: 'hello',
|
|
60
|
+
meta: undefined,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function makeCtx(overrides?: Partial<DispatchCtx>): {
|
|
65
|
+
ctx: DispatchCtx
|
|
66
|
+
logs: string[]
|
|
67
|
+
sendToAgent: ReturnType<typeof vi.fn>
|
|
68
|
+
inbound: ReturnType<typeof createPendingInboundBuffer>
|
|
69
|
+
} {
|
|
70
|
+
const logs: string[] = []
|
|
71
|
+
const sendToAgent = vi.fn(() => true)
|
|
72
|
+
const inbound = createPendingInboundBuffer()
|
|
73
|
+
const ctx: DispatchCtx = {
|
|
74
|
+
selfAgent: 'test-agent',
|
|
75
|
+
ipcServer: { sendToAgent } as never,
|
|
76
|
+
pendingInboundBuffer: inbound,
|
|
77
|
+
inboundSpool: null,
|
|
78
|
+
pendingPermissionBuffer: createPendingPermissionBuffer(),
|
|
79
|
+
log: (line: string) => logs.push(line),
|
|
80
|
+
...overrides,
|
|
81
|
+
}
|
|
82
|
+
return { ctx, logs, sendToAgent, inbound }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Drive the pure machine to bridge-alive-idle. */
|
|
86
|
+
function aliveIdle(): State {
|
|
87
|
+
return transition(initialState(), { kind: 'bridgeUp', at: 1000 }).state
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe('cutover default — ON without env override', () => {
|
|
91
|
+
it('dispatcher and gate are enabled by default (no kill-switch in test env)', () => {
|
|
92
|
+
expect(process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER).toBeUndefined()
|
|
93
|
+
expect(isDispatchEnabled()).toBe(true)
|
|
94
|
+
expect(isDeliveryCutoverEnabled()).toBe(true)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('fresh-turn inbound: machine effects execute setTurnStarted + deliverToBridge', () => {
|
|
98
|
+
const state = aliveIdle()
|
|
99
|
+
const msg = ipcMsg()
|
|
100
|
+
const { effects } = transition(state, {
|
|
101
|
+
kind: 'inbound',
|
|
102
|
+
key: KEY,
|
|
103
|
+
msg: machineMsg(msg),
|
|
104
|
+
at: 2000,
|
|
105
|
+
})
|
|
106
|
+
expect(effects.map((e) => e.kind)).toEqual([
|
|
107
|
+
'setTurnStarted',
|
|
108
|
+
'deliverToBridge',
|
|
109
|
+
'logTrace',
|
|
110
|
+
])
|
|
111
|
+
|
|
112
|
+
const { ctx, sendToAgent } = makeCtx()
|
|
113
|
+
const marked: Array<[ChatKey, number]> = []
|
|
114
|
+
let deliveredOk: boolean | null = null
|
|
115
|
+
dispatchEffects(effects, {
|
|
116
|
+
...ctx,
|
|
117
|
+
onSetTurnStarted: (k, at) => marked.push([k, at]),
|
|
118
|
+
onDeliverResult: (_k, ok) => {
|
|
119
|
+
deliveredOk = ok
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
// Busy-key mirror stamped BEFORE the send (machine effect order).
|
|
123
|
+
expect(marked).toEqual([[KEY, 2000]])
|
|
124
|
+
expect(sendToAgent).toHaveBeenCalledTimes(1)
|
|
125
|
+
expect(sendToAgent).toHaveBeenCalledWith('test-agent', msg)
|
|
126
|
+
expect(deliveredOk).toBe(true)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('mid-turn non-steering inbound: machine effects buffer (no bridge send)', () => {
|
|
130
|
+
// idle → fresh turn for KEY, then a second non-steering inbound mid-turn.
|
|
131
|
+
const s1 = transition(aliveIdle(), {
|
|
132
|
+
kind: 'inbound',
|
|
133
|
+
key: KEY,
|
|
134
|
+
msg: machineMsg(ipcMsg()),
|
|
135
|
+
at: 2000,
|
|
136
|
+
}).state
|
|
137
|
+
const held = ipcMsg()
|
|
138
|
+
const { effects } = transition(s1, {
|
|
139
|
+
kind: 'inbound',
|
|
140
|
+
key: KEY,
|
|
141
|
+
msg: machineMsg(held),
|
|
142
|
+
at: 3000,
|
|
143
|
+
})
|
|
144
|
+
expect(effects.map((e) => e.kind)).toEqual([
|
|
145
|
+
'bufferInbound',
|
|
146
|
+
'persistInbound',
|
|
147
|
+
'logTrace',
|
|
148
|
+
])
|
|
149
|
+
|
|
150
|
+
const { ctx, sendToAgent, inbound } = makeCtx()
|
|
151
|
+
dispatchEffects(effects, ctx)
|
|
152
|
+
expect(sendToAgent).not.toHaveBeenCalled()
|
|
153
|
+
const drained = inbound.drain('test-agent')
|
|
154
|
+
expect(drained).toHaveLength(1)
|
|
155
|
+
expect(drained[0]).toBe(held)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('steering inbound mid-turn: delivered to bridge, NO setTurnStarted', () => {
|
|
159
|
+
const s1 = transition(aliveIdle(), {
|
|
160
|
+
kind: 'inbound',
|
|
161
|
+
key: KEY,
|
|
162
|
+
msg: machineMsg(ipcMsg()),
|
|
163
|
+
at: 2000,
|
|
164
|
+
}).state
|
|
165
|
+
const steer = ipcMsg()
|
|
166
|
+
const { effects } = transition(s1, {
|
|
167
|
+
kind: 'inbound',
|
|
168
|
+
key: KEY,
|
|
169
|
+
msg: machineMsg(steer, true),
|
|
170
|
+
at: 3000,
|
|
171
|
+
})
|
|
172
|
+
expect(effects.map((e) => e.kind)).toEqual(['deliverToBridge', 'logTrace'])
|
|
173
|
+
|
|
174
|
+
const { ctx, sendToAgent } = makeCtx()
|
|
175
|
+
const marked: unknown[] = []
|
|
176
|
+
dispatchEffects(effects, { ...ctx, onSetTurnStarted: (k) => marked.push(k) })
|
|
177
|
+
expect(sendToAgent).toHaveBeenCalledWith('test-agent', steer)
|
|
178
|
+
expect(marked).toEqual([])
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('onDeliverResult observer contract (the flip’s post-send branch source)', () => {
|
|
183
|
+
it('reports ok=false when sendToAgent returns false (send-miss branch)', () => {
|
|
184
|
+
const state = aliveIdle()
|
|
185
|
+
const { effects } = transition(state, {
|
|
186
|
+
kind: 'inbound',
|
|
187
|
+
key: KEY,
|
|
188
|
+
msg: machineMsg(ipcMsg()),
|
|
189
|
+
at: 2000,
|
|
190
|
+
})
|
|
191
|
+
const { ctx } = makeCtx({ ipcServer: { sendToAgent: vi.fn(() => false) } as never })
|
|
192
|
+
const results: boolean[] = []
|
|
193
|
+
dispatchEffects(effects, { ...ctx, onDeliverResult: (_k, ok) => results.push(ok) })
|
|
194
|
+
expect(results).toEqual([false])
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('reports ok=false when the send throws', () => {
|
|
198
|
+
const state = aliveIdle()
|
|
199
|
+
const { effects } = transition(state, {
|
|
200
|
+
kind: 'inbound',
|
|
201
|
+
key: KEY,
|
|
202
|
+
msg: machineMsg(ipcMsg()),
|
|
203
|
+
at: 2000,
|
|
204
|
+
})
|
|
205
|
+
const { ctx } = makeCtx({
|
|
206
|
+
ipcServer: {
|
|
207
|
+
sendToAgent: vi.fn(() => {
|
|
208
|
+
throw new Error('socket gone')
|
|
209
|
+
}),
|
|
210
|
+
} as never,
|
|
211
|
+
})
|
|
212
|
+
const results: boolean[] = []
|
|
213
|
+
dispatchEffects(effects, { ...ctx, onDeliverResult: (_k, ok) => results.push(ok) })
|
|
214
|
+
expect(results).toEqual([false])
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('a throwing observer never breaks delivery or enrolment', () => {
|
|
218
|
+
const state = aliveIdle()
|
|
219
|
+
const msg = ipcMsg()
|
|
220
|
+
const { effects } = transition(state, {
|
|
221
|
+
kind: 'inbound',
|
|
222
|
+
key: KEY,
|
|
223
|
+
msg: machineMsg(msg),
|
|
224
|
+
at: 2000,
|
|
225
|
+
})
|
|
226
|
+
const enrolled: unknown[] = []
|
|
227
|
+
const { ctx, sendToAgent } = makeCtx()
|
|
228
|
+
expect(() =>
|
|
229
|
+
dispatchEffects(effects, {
|
|
230
|
+
...ctx,
|
|
231
|
+
onDeliverResult: () => {
|
|
232
|
+
throw new Error('observer bug')
|
|
233
|
+
},
|
|
234
|
+
onUserInboundDelivered: (m) => enrolled.push(m),
|
|
235
|
+
}),
|
|
236
|
+
).not.toThrow()
|
|
237
|
+
expect(sendToAgent).toHaveBeenCalledTimes(1)
|
|
238
|
+
// Enrolment still happened after the observer threw.
|
|
239
|
+
expect(enrolled).toEqual([msg])
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
describe('machine deliver path — send-miss busy-key handling (review fix, PR #3012)', () => {
|
|
244
|
+
// Mirrors the gateway's machine-deliver wiring: onSetTurnStarted stamps
|
|
245
|
+
// the busy mirror AND records the key; the miss branch releases ONLY a
|
|
246
|
+
// key THIS dispatch stamped. A steer-miss must keep the original turn's
|
|
247
|
+
// key (the twin never reserves for steers), else the wiped key fires a
|
|
248
|
+
// false machine_over_holds parity drift and the pending-restart gate
|
|
249
|
+
// (claudeBusyKeys.size) could green-light a mid-turn restart.
|
|
250
|
+
function runMissHarness(
|
|
251
|
+
effects: ReturnType<typeof transition>['effects'],
|
|
252
|
+
busyKeys: Set<string>,
|
|
253
|
+
): { stamped: string | null } {
|
|
254
|
+
let stamped: string | null = null
|
|
255
|
+
let delivered = false
|
|
256
|
+
const { ctx } = makeCtx({ ipcServer: { sendToAgent: vi.fn(() => false) } as never })
|
|
257
|
+
dispatchEffects(effects, {
|
|
258
|
+
...ctx,
|
|
259
|
+
onSetTurnStarted: (k) => {
|
|
260
|
+
stamped = k
|
|
261
|
+
busyKeys.add(k)
|
|
262
|
+
},
|
|
263
|
+
onDeliverResult: (_k, ok) => {
|
|
264
|
+
delivered = ok
|
|
265
|
+
},
|
|
266
|
+
})
|
|
267
|
+
expect(delivered).toBe(false)
|
|
268
|
+
// Gateway miss branch (guarded release):
|
|
269
|
+
if (stamped != null) busyKeys.delete(stamped)
|
|
270
|
+
return { stamped }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
it('fresh-turn miss: the key THIS dispatch stamped is released', () => {
|
|
274
|
+
const busyKeys = new Set<string>()
|
|
275
|
+
const { effects } = transition(aliveIdle(), {
|
|
276
|
+
kind: 'inbound',
|
|
277
|
+
key: KEY,
|
|
278
|
+
msg: machineMsg(ipcMsg()),
|
|
279
|
+
at: 2000,
|
|
280
|
+
})
|
|
281
|
+
const { stamped } = runMissHarness(effects, busyKeys)
|
|
282
|
+
expect(stamped).toBe(KEY)
|
|
283
|
+
expect(busyKeys.has(KEY)).toBe(false)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('steer miss: no setTurnStarted fired, the ORIGINAL turn\'s key survives', () => {
|
|
287
|
+
// Original fresh turn holds the busy key.
|
|
288
|
+
const busyKeys = new Set<string>([KEY])
|
|
289
|
+
const s1 = transition(aliveIdle(), {
|
|
290
|
+
kind: 'inbound',
|
|
291
|
+
key: KEY,
|
|
292
|
+
msg: machineMsg(ipcMsg()),
|
|
293
|
+
at: 2000,
|
|
294
|
+
}).state
|
|
295
|
+
const { effects } = transition(s1, {
|
|
296
|
+
kind: 'inbound',
|
|
297
|
+
key: KEY,
|
|
298
|
+
msg: machineMsg(ipcMsg(), true),
|
|
299
|
+
at: 3000,
|
|
300
|
+
})
|
|
301
|
+
expect(effects.some((e) => e.kind === 'setTurnStarted')).toBe(false)
|
|
302
|
+
const { stamped } = runMissHarness(effects, busyKeys)
|
|
303
|
+
expect(stamped).toBeNull()
|
|
304
|
+
// The in-flight turn's key was NOT wiped by the steer's send-miss.
|
|
305
|
+
expect(busyKeys.has(KEY)).toBe(true)
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
describe('carve-out routing anchors (gateway keys off these machine facts)', () => {
|
|
310
|
+
it('PIN: bridge-dead inbound emits the exact `inbound_bridge_dead_buffer` trace stage', () => {
|
|
311
|
+
// gateway.ts's machineBridgeDead carve-out matches this string verbatim
|
|
312
|
+
// to route bridge-dead inbounds to the imperative twin. A rename here
|
|
313
|
+
// silently reroutes them to the machine's buffer+persist (losing the
|
|
314
|
+
// twin's shouldTrackDelivery drop semantics + restart notice) — this
|
|
315
|
+
// pin makes the rename loud. Deleted in PR4 with the twin.
|
|
316
|
+
const dead = initialState() // bridge_dead is the initial global state
|
|
317
|
+
const { effects } = transition(dead, {
|
|
318
|
+
kind: 'inbound',
|
|
319
|
+
key: KEY,
|
|
320
|
+
msg: machineMsg(ipcMsg()),
|
|
321
|
+
at: 2000,
|
|
322
|
+
})
|
|
323
|
+
expect(effects.map((e) => e.kind)).toEqual(['bufferInbound', 'persistInbound', 'logTrace'])
|
|
324
|
+
expect(effects.find((e) => e.kind === 'logTrace')).toMatchObject({
|
|
325
|
+
stage: 'inbound_bridge_dead_buffer',
|
|
326
|
+
})
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('interrupt-while-in-turn carve-out: a mid-turn non-steering inbound buffers (gateway must reroute interrupts to the twin)', () => {
|
|
330
|
+
// The machine has no interrupt event — a `!`-interrupt body looks like a
|
|
331
|
+
// plain mid-turn inbound and would BUFFER (stranding it: the SIGINT'd
|
|
332
|
+
// turn may never emit turn_complete). gateway.ts's machineAuthoritative
|
|
333
|
+
// predicate therefore excludes `machineBuffers && isInterrupt` and falls
|
|
334
|
+
// back to the twin's deliver carve-out. This pins the machine fact the
|
|
335
|
+
// predicate rests on.
|
|
336
|
+
const s1 = transition(aliveIdle(), {
|
|
337
|
+
kind: 'inbound',
|
|
338
|
+
key: KEY,
|
|
339
|
+
msg: machineMsg(ipcMsg()),
|
|
340
|
+
at: 2000,
|
|
341
|
+
}).state
|
|
342
|
+
const { effects } = transition(s1, {
|
|
343
|
+
kind: 'inbound',
|
|
344
|
+
key: KEY,
|
|
345
|
+
msg: machineMsg(ipcMsg(), false),
|
|
346
|
+
at: 3000,
|
|
347
|
+
})
|
|
348
|
+
expect(effects.some((e) => e.kind === 'bufferInbound')).toBe(true)
|
|
349
|
+
expect(effects.some((e) => e.kind === 'deliverToBridge')).toBe(false)
|
|
350
|
+
})
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
// The env var is read at MODULE LOAD in the dispatch + shadow modules, and
|
|
354
|
+
// bun's `vi` shim has no resetModules/stubEnv — so the kill-switch/default
|
|
355
|
+
// assertions run the fixture probe in a SUBPROCESS with a controlled env
|
|
356
|
+
// (same pattern as bridge-anonymous-refuse.test.ts).
|
|
357
|
+
describe('kill switch — SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0 restores legacy (subprocess)', () => {
|
|
358
|
+
const PROBE = join(__dirname, 'fixtures', 'cutover-killswitch-probe.ts')
|
|
359
|
+
|
|
360
|
+
function bunBin(): string | null {
|
|
361
|
+
const r = spawnSync('which', ['bun'], { encoding: 'utf-8' })
|
|
362
|
+
const p = r.status === 0 ? r.stdout.trim() : ''
|
|
363
|
+
return p !== '' ? p : null
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function runProbe(env: Record<string, string | undefined>) {
|
|
367
|
+
const bun = bunBin()
|
|
368
|
+
if (bun == null) return null
|
|
369
|
+
const r = spawnSync(bun, ['run', PROBE], {
|
|
370
|
+
encoding: 'utf-8',
|
|
371
|
+
env: { ...process.env, ...env },
|
|
372
|
+
timeout: 30_000,
|
|
373
|
+
})
|
|
374
|
+
expect(r.status).toBe(0)
|
|
375
|
+
const line = r.stdout.trim().split('\n').pop() ?? ''
|
|
376
|
+
return JSON.parse(line) as {
|
|
377
|
+
dispatchEnabled: boolean
|
|
378
|
+
cutoverEnabled: boolean
|
|
379
|
+
shadowEnabled: boolean
|
|
380
|
+
sendCalls: number
|
|
381
|
+
buffered: number
|
|
382
|
+
setTurnStartedCalls: number
|
|
383
|
+
deliverResults: boolean[]
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
it('default (no env): machine authoritative — dispatcher executes the deliver', () => {
|
|
388
|
+
const v = runProbe({ SWITCHROOM_DELIVERY_MACHINE_CUTOVER: undefined })
|
|
389
|
+
if (v == null) return // bun not on PATH — covered by the in-process default tests above
|
|
390
|
+
expect(v.dispatchEnabled).toBe(true)
|
|
391
|
+
expect(v.cutoverEnabled).toBe(true)
|
|
392
|
+
expect(v.sendCalls).toBe(1)
|
|
393
|
+
expect(v.setTurnStartedCalls).toBe(1)
|
|
394
|
+
expect(v.deliverResults).toEqual([true])
|
|
395
|
+
expect(v.buffered).toBe(0)
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('=0: dispatcher is a total no-op and the gate reverts to legacy; shadow stays on', () => {
|
|
399
|
+
const v = runProbe({ SWITCHROOM_DELIVERY_MACHINE_CUTOVER: '0' })
|
|
400
|
+
if (v == null) return
|
|
401
|
+
expect(v.dispatchEnabled).toBe(false)
|
|
402
|
+
expect(v.cutoverEnabled).toBe(false)
|
|
403
|
+
// Shadow trace continues for the bake; only authoritative reads flip off.
|
|
404
|
+
expect(v.shadowEnabled).toBe(true)
|
|
405
|
+
expect(v.sendCalls).toBe(0)
|
|
406
|
+
expect(v.buffered).toBe(0)
|
|
407
|
+
expect(v.setTurnStartedCalls).toBe(0)
|
|
408
|
+
expect(v.deliverResults).toEqual([])
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('=1 (explicit on): identical to the default', () => {
|
|
412
|
+
const v = runProbe({ SWITCHROOM_DELIVERY_MACHINE_CUTOVER: '1' })
|
|
413
|
+
if (v == null) return
|
|
414
|
+
expect(v.dispatchEnabled).toBe(true)
|
|
415
|
+
expect(v.cutoverEnabled).toBe(true)
|
|
416
|
+
expect(v.sendCalls).toBe(1)
|
|
417
|
+
})
|
|
418
|
+
})
|