switchroom 0.12.25 → 0.12.27
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 +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +357 -357
- package/dist/host-control/main.js +99 -99
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +335 -211
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/gateway.ts +50 -40
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +188 -0
- package/telegram-plugin/gateway/ipc-server.ts +40 -2
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +240 -0
|
@@ -261,6 +261,7 @@ import { chatKey, chatKeyWithSuffix } from './chat-key.js'
|
|
|
261
261
|
// effects.
|
|
262
262
|
import { shadowEmit } from './inbound-delivery-machine-shadow.js'
|
|
263
263
|
import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
|
|
264
|
+
import { dispatchEffects, isDispatchEnabled } from './inbound-delivery-machine-dispatch.js'
|
|
264
265
|
import {
|
|
265
266
|
buildVaultGrantApprovedInbound,
|
|
266
267
|
buildVaultGrantDeniedInbound,
|
|
@@ -3204,50 +3205,59 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
3204
3205
|
// causing the shadow state to read `bridge_dead` even when the
|
|
3205
3206
|
// real bridge was healthy, because every recall.py connect+disconnect
|
|
3206
3207
|
// would flip the state.
|
|
3207
|
-
|
|
3208
|
-
shadowEmit({ kind: 'bridgeUp', at: Date.now() })
|
|
3209
|
-
|
|
3208
|
+
const bridgeUpEffects = client.agentName != null
|
|
3209
|
+
? shadowEmit({ kind: 'bridgeUp', at: Date.now() })
|
|
3210
|
+
: []
|
|
3210
3211
|
client.send({ type: 'status', status: 'agent_connected' })
|
|
3211
3212
|
|
|
3212
|
-
//
|
|
3213
|
-
//
|
|
3214
|
-
//
|
|
3215
|
-
//
|
|
3216
|
-
//
|
|
3217
|
-
//
|
|
3218
|
-
//
|
|
3213
|
+
// Phase 2b PR 3a — bridgeUp cutover. The state machine's `bridgeUp`
|
|
3214
|
+
// transition returns `redeliverPersistedPermVerdicts`, `drainBuffer`,
|
|
3215
|
+
// and `logTrace` effects. The dispatcher executes them in order:
|
|
3216
|
+
// the perm-verdict drain unblocks any claude turn suspended inside
|
|
3217
|
+
// an MCP permission call; the inbound drain flushes synthetic
|
|
3218
|
+
// inbounds queued while the bridge was offline (#1150) so the agent
|
|
3219
|
+
// wakes up to missed wake-ups before the boot-card path below runs.
|
|
3220
|
+
// Lossless: `redeliverBufferedInbound` re-buffers per-message misses
|
|
3221
|
+
// and `spool.ack` tombstones confirmed deliveries. Skipped when
|
|
3222
|
+
// agentName is null (pre-handshake / anonymous client — those
|
|
3223
|
+
// bridges never registered an identity and can't have accumulated
|
|
3224
|
+
// buffered inbounds keyed by name).
|
|
3219
3225
|
if (client.agentName != null) {
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3226
|
+
if (isDispatchEnabled()) {
|
|
3227
|
+
dispatchEffects(bridgeUpEffects, {
|
|
3228
|
+
selfAgent: client.agentName,
|
|
3229
|
+
ipcServer,
|
|
3230
|
+
pendingInboundBuffer,
|
|
3231
|
+
inboundSpool: inboundSpool ?? null,
|
|
3232
|
+
pendingPermissionBuffer,
|
|
3233
|
+
client,
|
|
3234
|
+
})
|
|
3235
|
+
} else {
|
|
3236
|
+
// Kill-switch fallback: imperative drain (parity with pre-cutover
|
|
3237
|
+
// behavior). Kept for SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0
|
|
3238
|
+
// rollback safety; deleted in PR 4 once the cutover bakes.
|
|
3239
|
+
const pending = pendingInboundBuffer.drain(client.agentName)
|
|
3240
|
+
for (const msg of pending) {
|
|
3241
|
+
try {
|
|
3242
|
+
client.send(msg)
|
|
3243
|
+
inboundSpool?.ack(msg)
|
|
3244
|
+
} catch (err) {
|
|
3245
|
+
process.stderr.write(
|
|
3246
|
+
`telegram gateway: pending-inbound drain failed agent=${client.agentName} ` +
|
|
3247
|
+
`source=${msg.meta?.source ?? '-'}: ${(err as Error).message}\n`,
|
|
3248
|
+
)
|
|
3249
|
+
}
|
|
3235
3250
|
}
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
} catch (err) {
|
|
3247
|
-
process.stderr.write(
|
|
3248
|
-
`telegram gateway: pending-permission drain failed agent=${client.agentName} ` +
|
|
3249
|
-
`request=${ev.requestId} behavior=${ev.behavior}: ${(err as Error).message}\n`,
|
|
3250
|
-
)
|
|
3251
|
+
const pendingVerdicts = pendingPermissionBuffer.drain(client.agentName)
|
|
3252
|
+
for (const ev of pendingVerdicts) {
|
|
3253
|
+
try {
|
|
3254
|
+
client.send(ev)
|
|
3255
|
+
} catch (err) {
|
|
3256
|
+
process.stderr.write(
|
|
3257
|
+
`telegram gateway: pending-permission drain failed agent=${client.agentName} ` +
|
|
3258
|
+
`request=${ev.requestId} behavior=${ev.behavior}: ${(err as Error).message}\n`,
|
|
3259
|
+
)
|
|
3260
|
+
}
|
|
3251
3261
|
}
|
|
3252
3262
|
}
|
|
3253
3263
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InboundDeliveryStateMachine — DISPATCH (Phase 2b PR 3a, bridgeUp cutover).
|
|
3
|
+
*
|
|
4
|
+
* Per RFC `docs/rfcs/inbound-delivery-state-machine.md`, the state
|
|
5
|
+
* machine is pure: `transition(state, event) → { state', effects[] }`.
|
|
6
|
+
* The gateway's job is to (a) emit events at the right moments and
|
|
7
|
+
* (b) execute the returned effects against real I/O. This module owns
|
|
8
|
+
* step (b) for the cutover.
|
|
9
|
+
*
|
|
10
|
+
* Scope of THIS PR — bridgeUp only:
|
|
11
|
+
* - drainBuffer → executed
|
|
12
|
+
* - redeliverPersistedPermVerdicts → executed
|
|
13
|
+
* - logTrace → executed
|
|
14
|
+
*
|
|
15
|
+
* Other effects (deliverToBridge, bufferInbound, persistInbound,
|
|
16
|
+
* setTurnStarted, clearTurnStarted, noteOutbound, firePoke,
|
|
17
|
+
* deliverPermVerdict, persistPermVerdict) still flow through their
|
|
18
|
+
* existing imperative paths in `gateway.ts`. The dispatcher logs them
|
|
19
|
+
* as `not-yet-cutover` so a future PR can wire them without grep-and-
|
|
20
|
+
* pray. NEVER silently no-op: the trace is the gate.
|
|
21
|
+
*
|
|
22
|
+
* Kill switch: `SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0` disables
|
|
23
|
+
* dispatcher execution and the gateway falls back to imperative-only.
|
|
24
|
+
* Default is ON — this PR is the cutover.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type {
|
|
28
|
+
Effect,
|
|
29
|
+
InboundMessage as MachineInboundMessage,
|
|
30
|
+
} from './inbound-delivery-machine.js'
|
|
31
|
+
import type { IpcServer, IpcClient } from './ipc-server.js'
|
|
32
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
33
|
+
import type { PendingInboundBuffer } from './pending-inbound-buffer.js'
|
|
34
|
+
import { redeliverBufferedInbound } from './pending-inbound-buffer.js'
|
|
35
|
+
import type { InboundSpool } from './inbound-spool.js'
|
|
36
|
+
import type { PendingPermissionBuffer } from './pending-permission-decisions.js'
|
|
37
|
+
|
|
38
|
+
export interface DispatchCtx {
|
|
39
|
+
readonly selfAgent: string
|
|
40
|
+
readonly ipcServer: IpcServer
|
|
41
|
+
readonly pendingInboundBuffer: PendingInboundBuffer
|
|
42
|
+
readonly inboundSpool: InboundSpool | null
|
|
43
|
+
readonly pendingPermissionBuffer: PendingPermissionBuffer
|
|
44
|
+
/** Optional: when set, prefer sending direct to this client (bridgeUp path). */
|
|
45
|
+
readonly client?: IpcClient
|
|
46
|
+
/** Optional log sink — default stderr. Test hook. */
|
|
47
|
+
readonly log?: (line: string) => void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const enabled = process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== '0'
|
|
51
|
+
|
|
52
|
+
export function isDispatchEnabled(): boolean {
|
|
53
|
+
return enabled
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Execute the effects returned by `transition()`. Pure imperative
|
|
58
|
+
* driver: side-effects only, no machine state held here.
|
|
59
|
+
*
|
|
60
|
+
* Effects are dispatched in the order returned by the machine. The
|
|
61
|
+
* machine guarantees a sensible order (e.g., `setTurnStarted` before
|
|
62
|
+
* `deliverToBridge` so a downstream observer sees the turn marker
|
|
63
|
+
* already up).
|
|
64
|
+
*/
|
|
65
|
+
export function dispatchEffects(effects: readonly Effect[], ctx: DispatchCtx): void {
|
|
66
|
+
if (!enabled) return
|
|
67
|
+
for (const effect of effects) {
|
|
68
|
+
dispatchOne(effect, ctx)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function dispatchOne(effect: Effect, ctx: DispatchCtx): void {
|
|
73
|
+
const log = ctx.log ?? ((line: string) => process.stderr.write(line))
|
|
74
|
+
switch (effect.kind) {
|
|
75
|
+
case 'drainBuffer': {
|
|
76
|
+
// The bridgeUp drain: flush any synthetic inbounds queued for
|
|
77
|
+
// this agent while the bridge was offline. Mirrors what
|
|
78
|
+
// onClientRegistered did inline pre-cutover (gateway.ts:3219-3236).
|
|
79
|
+
// Lossless: redeliverBufferedInbound re-buffers any per-message
|
|
80
|
+
// miss and tombstones the durable spool entry on confirmed
|
|
81
|
+
// delivery.
|
|
82
|
+
const send = (msg: InboundMessage): boolean => {
|
|
83
|
+
// Prefer the just-registered client when present (bridgeUp
|
|
84
|
+
// path) — that's a direct send. Fall back to ipcServer
|
|
85
|
+
// lookup for non-client-bound drains (turn-complete flush
|
|
86
|
+
// from a future PR).
|
|
87
|
+
if (ctx.client) {
|
|
88
|
+
try {
|
|
89
|
+
ctx.client.send(msg)
|
|
90
|
+
return true
|
|
91
|
+
} catch (err) {
|
|
92
|
+
log(
|
|
93
|
+
`telegram gateway: dispatch drainBuffer client.send threw agent=${ctx.selfAgent} ` +
|
|
94
|
+
`source=${msg.meta?.source ?? '-'}: ${(err as Error).message}\n`,
|
|
95
|
+
)
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return ctx.ipcServer.sendToAgent(ctx.selfAgent, msg)
|
|
100
|
+
}
|
|
101
|
+
const result = redeliverBufferedInbound(
|
|
102
|
+
ctx.pendingInboundBuffer,
|
|
103
|
+
ctx.selfAgent,
|
|
104
|
+
send,
|
|
105
|
+
ctx.inboundSpool ?? undefined,
|
|
106
|
+
)
|
|
107
|
+
if (result.drained > 0) {
|
|
108
|
+
log(
|
|
109
|
+
`telegram gateway: dispatch drainBuffer agent=${ctx.selfAgent} ` +
|
|
110
|
+
`drained=${result.drained} redelivered=${result.redelivered} ` +
|
|
111
|
+
`rebuffered=${result.rebuffered}\n`,
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
case 'redeliverPersistedPermVerdicts': {
|
|
118
|
+
// Drain permission verdicts missed while the bridge was offline.
|
|
119
|
+
// A claude turn suspended inside the MCP permission call is
|
|
120
|
+
// unblocked the moment the reconnecting bridge relays the
|
|
121
|
+
// verdict. Mirrors the pre-cutover loop at gateway.ts:3242-3252.
|
|
122
|
+
const pending = ctx.pendingPermissionBuffer.drain(ctx.selfAgent)
|
|
123
|
+
let delivered = 0
|
|
124
|
+
let failed = 0
|
|
125
|
+
for (const ev of pending) {
|
|
126
|
+
try {
|
|
127
|
+
if (ctx.client) {
|
|
128
|
+
ctx.client.send(ev)
|
|
129
|
+
} else {
|
|
130
|
+
ctx.ipcServer.sendToAgent(ctx.selfAgent, ev)
|
|
131
|
+
}
|
|
132
|
+
delivered++
|
|
133
|
+
} catch (err) {
|
|
134
|
+
failed++
|
|
135
|
+
log(
|
|
136
|
+
`telegram gateway: dispatch redeliverPerm send threw agent=${ctx.selfAgent} ` +
|
|
137
|
+
`request=${ev.requestId} behavior=${ev.behavior}: ${(err as Error).message}\n`,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (pending.length > 0) {
|
|
142
|
+
log(
|
|
143
|
+
`telegram gateway: dispatch redeliverPerm agent=${ctx.selfAgent} ` +
|
|
144
|
+
`delivered=${delivered} failed=${failed}\n`,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
case 'logTrace': {
|
|
151
|
+
const keyPart = effect.key ? ` key=${effect.key}` : ''
|
|
152
|
+
const metaPart = effect.metadata
|
|
153
|
+
? ' ' + Object.entries(effect.metadata).map(([k, v]) => `${k}=${String(v)}`).join(' ')
|
|
154
|
+
: ''
|
|
155
|
+
log(`gw-trace dispatch stage=${effect.stage}${keyPart}${metaPart}\n`)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The cases below are KNOWN effect kinds that this PR does NOT
|
|
160
|
+
// cut over. The imperative paths still run for them; the
|
|
161
|
+
// dispatcher logs the event so future cutover PRs can grep for
|
|
162
|
+
// exactly the call sites to migrate.
|
|
163
|
+
case 'deliverToBridge':
|
|
164
|
+
case 'bufferInbound':
|
|
165
|
+
case 'persistInbound':
|
|
166
|
+
case 'setTurnStarted':
|
|
167
|
+
case 'clearTurnStarted':
|
|
168
|
+
case 'noteOutbound':
|
|
169
|
+
case 'firePoke':
|
|
170
|
+
case 'deliverPermVerdict':
|
|
171
|
+
case 'persistPermVerdict': {
|
|
172
|
+
log(`gw-trace dispatch not-yet-cutover effect=${effect.kind}\n`)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Test hook: invoke the per-effect dispatcher directly so unit tests
|
|
180
|
+
* can drive individual effects against mocks without constructing a
|
|
181
|
+
* full effects array.
|
|
182
|
+
*/
|
|
183
|
+
export function __dispatchOneForTests(effect: Effect, ctx: DispatchCtx): void {
|
|
184
|
+
dispatchOne(effect, ctx)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Type re-export — convenience for test fixtures. */
|
|
188
|
+
export type { MachineInboundMessage }
|
|
@@ -266,8 +266,24 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
266
266
|
|
|
267
267
|
function removeClient(client: IpcClient & { _socket: ReturnType<typeof Bun.listen> extends infer S ? any : never }) {
|
|
268
268
|
clients.delete(client);
|
|
269
|
-
|
|
270
|
-
if
|
|
269
|
+
// CRITICAL race fix (2026-05-20): only delete from agentIndex /
|
|
270
|
+
// topicIndex if the index still points to THIS client. A bridge
|
|
271
|
+
// that reconnects fast can have its NEW client overwrite
|
|
272
|
+
// agentIndex[name] (via handleRegister's replace-not-reject)
|
|
273
|
+
// BEFORE the OLD client's close+removeClient runs. Blindly
|
|
274
|
+
// deleting agentIndex[name] would remove the LIVE replacement
|
|
275
|
+
// client by accident → sendToAgent returns false → all subsequent
|
|
276
|
+
// inbound buffered until the bridge happens to reconnect in an
|
|
277
|
+
// ordering that works out. User-visible symptom was the chronic
|
|
278
|
+
// bridge-flap pattern (clerk + gymbro unresponsive 2026-05-20)
|
|
279
|
+
// where the gateway log showed "bridge registered" but messages
|
|
280
|
+
// were still getting buffered as if no bridge existed.
|
|
281
|
+
if (client.agentName && agentIndex.get(client.agentName) === client) {
|
|
282
|
+
agentIndex.delete(client.agentName);
|
|
283
|
+
}
|
|
284
|
+
if (client.topicId != null && topicIndex.get(client.topicId) === client) {
|
|
285
|
+
topicIndex.delete(client.topicId);
|
|
286
|
+
}
|
|
271
287
|
loggedLegacyUpdatePlaceholder.delete(client.id);
|
|
272
288
|
onClientDisconnected(client);
|
|
273
289
|
log(`client disconnected: ${client.id} (agent=${client.agentName})`);
|
|
@@ -407,6 +423,28 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
407
423
|
if (client.agentName) agentIndex.delete(client.agentName);
|
|
408
424
|
if (client.topicId != null) topicIndex.delete(client.topicId);
|
|
409
425
|
|
|
426
|
+
// 2026-05-20 race fix: if a PRIOR client is registered as this
|
|
427
|
+
// agent name (a stale/zombie connection that hasn't been evicted
|
|
428
|
+
// yet), explicitly close it before installing this new client.
|
|
429
|
+
// Without this, the prior client remains in `clients` set, its
|
|
430
|
+
// heartbeat watchdog still ticks, and its eventual close+removeClient
|
|
431
|
+
// can confuse routing. The removeClient identity-check fix above
|
|
432
|
+
// means the index won't be wrongly deleted, but two concurrent
|
|
433
|
+
// clients claiming the same agent name is still a routing hazard
|
|
434
|
+
// — close the zombie cleanly here.
|
|
435
|
+
const existingClient = agentIndex.get(msg.agentName);
|
|
436
|
+
if (existingClient && existingClient !== client) {
|
|
437
|
+
log(
|
|
438
|
+
`register: closing prior client for agent=${msg.agentName} ` +
|
|
439
|
+
`(prior_id=${existingClient.id} new_id=${client.id}) — bridge reconnect race`,
|
|
440
|
+
);
|
|
441
|
+
try {
|
|
442
|
+
(existingClient as IpcClientImpl).close();
|
|
443
|
+
} catch {
|
|
444
|
+
/* nothing to do */
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
410
448
|
client.agentName = msg.agentName;
|
|
411
449
|
client.topicId = msg.topicId ?? null;
|
|
412
450
|
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `inbound-delivery-machine-dispatch.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Per RFC PR 3 cutover: the dispatcher executes effects against real
|
|
5
|
+
* primitives. These tests pin the contract per effect kind — which
|
|
6
|
+
* primitive gets called, with what args, exactly once. The gate that
|
|
7
|
+
* catches a double-drain regression at the imperative call sites.
|
|
8
|
+
*
|
|
9
|
+
* Scope of THIS file matches the PR's scope: bridgeUp's three effects
|
|
10
|
+
* (drainBuffer, redeliverPersistedPermVerdicts, logTrace) are wired
|
|
11
|
+
* and tested end-to-end. Other effect kinds are asserted to log a
|
|
12
|
+
* `not-yet-cutover` trace without throwing — those are the future
|
|
13
|
+
* cutover seam.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
|
17
|
+
import {
|
|
18
|
+
__dispatchOneForTests,
|
|
19
|
+
dispatchEffects,
|
|
20
|
+
isDispatchEnabled,
|
|
21
|
+
} from '../gateway/inbound-delivery-machine-dispatch'
|
|
22
|
+
import type { DispatchCtx } from '../gateway/inbound-delivery-machine-dispatch'
|
|
23
|
+
import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer'
|
|
24
|
+
import { createPendingPermissionBuffer } from '../gateway/pending-permission-decisions'
|
|
25
|
+
import type { Effect } from '../gateway/inbound-delivery-machine'
|
|
26
|
+
|
|
27
|
+
function makeCtx(overrides?: Partial<DispatchCtx>): {
|
|
28
|
+
ctx: DispatchCtx
|
|
29
|
+
logs: string[]
|
|
30
|
+
sendToAgent: ReturnType<typeof vi.fn>
|
|
31
|
+
clientSend: ReturnType<typeof vi.fn>
|
|
32
|
+
inbound: ReturnType<typeof createPendingInboundBuffer>
|
|
33
|
+
perm: ReturnType<typeof createPendingPermissionBuffer>
|
|
34
|
+
} {
|
|
35
|
+
const logs: string[] = []
|
|
36
|
+
const sendToAgent = vi.fn(() => true)
|
|
37
|
+
const clientSend = vi.fn()
|
|
38
|
+
const inbound = createPendingInboundBuffer()
|
|
39
|
+
const perm = createPendingPermissionBuffer()
|
|
40
|
+
const ctx: DispatchCtx = {
|
|
41
|
+
selfAgent: 'test-agent',
|
|
42
|
+
ipcServer: { sendToAgent } as never,
|
|
43
|
+
pendingInboundBuffer: inbound,
|
|
44
|
+
inboundSpool: null,
|
|
45
|
+
pendingPermissionBuffer: perm,
|
|
46
|
+
client: { send: clientSend, agentName: 'test-agent', id: 'c1' } as never,
|
|
47
|
+
log: (line: string) => logs.push(line),
|
|
48
|
+
...overrides,
|
|
49
|
+
}
|
|
50
|
+
return { ctx, logs, sendToAgent, clientSend, inbound, perm }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('isDispatchEnabled', () => {
|
|
54
|
+
it('defaults to true when the kill-switch env-var is unset', () => {
|
|
55
|
+
expect(isDispatchEnabled()).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('dispatchEffects — drainBuffer', () => {
|
|
60
|
+
it('redelivers buffered inbound to the registered client', () => {
|
|
61
|
+
const { ctx, clientSend, inbound } = makeCtx()
|
|
62
|
+
const msg1 = { type: 'inbound' as const, id: 'm1', chat_id: '1', text: 'hello', meta: { source: 'telegram' } }
|
|
63
|
+
const msg2 = { type: 'inbound' as const, id: 'm2', chat_id: '1', text: 'world', meta: { source: 'cron' } }
|
|
64
|
+
inbound.push('test-agent', msg1 as never)
|
|
65
|
+
inbound.push('test-agent', msg2 as never)
|
|
66
|
+
expect(inbound.depth('test-agent')).toBe(2)
|
|
67
|
+
|
|
68
|
+
dispatchEffects([{ kind: 'drainBuffer' }], ctx)
|
|
69
|
+
|
|
70
|
+
expect(clientSend).toHaveBeenCalledTimes(2)
|
|
71
|
+
expect(clientSend).toHaveBeenNthCalledWith(1, msg1)
|
|
72
|
+
expect(clientSend).toHaveBeenNthCalledWith(2, msg2)
|
|
73
|
+
expect(inbound.depth('test-agent')).toBe(0)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('no-ops on empty buffer (no log, no send)', () => {
|
|
77
|
+
const { ctx, clientSend, logs, inbound } = makeCtx()
|
|
78
|
+
expect(inbound.depth('test-agent')).toBe(0)
|
|
79
|
+
|
|
80
|
+
dispatchEffects([{ kind: 'drainBuffer' }], ctx)
|
|
81
|
+
|
|
82
|
+
expect(clientSend).not.toHaveBeenCalled()
|
|
83
|
+
expect(logs.filter((l) => l.includes('drainBuffer'))).toHaveLength(0)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('falls back to ipcServer.sendToAgent when no client is set', () => {
|
|
87
|
+
const { ctx, sendToAgent, clientSend, inbound } = makeCtx({ client: undefined })
|
|
88
|
+
const msg = { type: 'inbound' as const, id: 'm1', chat_id: '1', text: 'hi', meta: { source: 'cron' } }
|
|
89
|
+
inbound.push('test-agent', msg as never)
|
|
90
|
+
|
|
91
|
+
dispatchEffects([{ kind: 'drainBuffer' }], ctx)
|
|
92
|
+
|
|
93
|
+
expect(clientSend).not.toHaveBeenCalled()
|
|
94
|
+
expect(sendToAgent).toHaveBeenCalledTimes(1)
|
|
95
|
+
expect(sendToAgent).toHaveBeenCalledWith('test-agent', msg)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('dispatchEffects — redeliverPersistedPermVerdicts', () => {
|
|
100
|
+
it('drains pendingPermissionBuffer to the client', () => {
|
|
101
|
+
const { ctx, clientSend, perm } = makeCtx()
|
|
102
|
+
const v1 = {
|
|
103
|
+
type: 'permission_decision' as const,
|
|
104
|
+
requestId: 'r1',
|
|
105
|
+
behavior: 'allow' as const,
|
|
106
|
+
updatedInput: {},
|
|
107
|
+
timestamp: 0,
|
|
108
|
+
}
|
|
109
|
+
const v2 = {
|
|
110
|
+
type: 'permission_decision' as const,
|
|
111
|
+
requestId: 'r2',
|
|
112
|
+
behavior: 'deny' as const,
|
|
113
|
+
updatedInput: {},
|
|
114
|
+
timestamp: 0,
|
|
115
|
+
}
|
|
116
|
+
perm.push('test-agent', v1 as never)
|
|
117
|
+
perm.push('test-agent', v2 as never)
|
|
118
|
+
|
|
119
|
+
dispatchEffects([{ kind: 'redeliverPersistedPermVerdicts' }], ctx)
|
|
120
|
+
|
|
121
|
+
expect(clientSend).toHaveBeenCalledTimes(2)
|
|
122
|
+
expect(clientSend).toHaveBeenNthCalledWith(1, v1)
|
|
123
|
+
expect(clientSend).toHaveBeenNthCalledWith(2, v2)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('no-ops on empty perm buffer', () => {
|
|
127
|
+
const { ctx, clientSend, logs } = makeCtx()
|
|
128
|
+
dispatchEffects([{ kind: 'redeliverPersistedPermVerdicts' }], ctx)
|
|
129
|
+
expect(clientSend).not.toHaveBeenCalled()
|
|
130
|
+
expect(logs.filter((l) => l.includes('redeliverPerm'))).toHaveLength(0)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('dispatchEffects — logTrace', () => {
|
|
135
|
+
it('writes a single grep-friendly line', () => {
|
|
136
|
+
const { ctx, logs } = makeCtx()
|
|
137
|
+
dispatchEffects(
|
|
138
|
+
[{ kind: 'logTrace', stage: 'bridge_recover', metadata: { foo: 'bar' } }],
|
|
139
|
+
ctx,
|
|
140
|
+
)
|
|
141
|
+
expect(logs).toHaveLength(1)
|
|
142
|
+
expect(logs[0]).toContain('gw-trace dispatch stage=bridge_recover')
|
|
143
|
+
expect(logs[0]).toContain('foo=bar')
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('dispatchEffects — bridgeUp composite (all three effects in order)', () => {
|
|
148
|
+
it('drains perm verdicts THEN inbound buffer THEN logs', () => {
|
|
149
|
+
const { ctx, clientSend, inbound, perm, logs } = makeCtx()
|
|
150
|
+
const verdict = {
|
|
151
|
+
type: 'permission_decision' as const,
|
|
152
|
+
requestId: 'rA',
|
|
153
|
+
behavior: 'allow' as const,
|
|
154
|
+
updatedInput: {},
|
|
155
|
+
timestamp: 0,
|
|
156
|
+
}
|
|
157
|
+
const inMsg = { type: 'inbound' as const, id: 'mB', chat_id: '1', text: 't', meta: { source: 'cron' } }
|
|
158
|
+
perm.push('test-agent', verdict as never)
|
|
159
|
+
inbound.push('test-agent', inMsg as never)
|
|
160
|
+
|
|
161
|
+
// Effects in the order the machine returns them for bridgeUp.
|
|
162
|
+
dispatchEffects(
|
|
163
|
+
[
|
|
164
|
+
{ kind: 'redeliverPersistedPermVerdicts' },
|
|
165
|
+
{ kind: 'drainBuffer' },
|
|
166
|
+
{ kind: 'logTrace', stage: 'bridge_recover' },
|
|
167
|
+
],
|
|
168
|
+
ctx,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
expect(clientSend).toHaveBeenCalledTimes(2)
|
|
172
|
+
expect(clientSend).toHaveBeenNthCalledWith(1, verdict)
|
|
173
|
+
expect(clientSend).toHaveBeenNthCalledWith(2, inMsg)
|
|
174
|
+
expect(logs.some((l) => l.includes('stage=bridge_recover'))).toBe(true)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe('dispatchEffects — kill switch', () => {
|
|
179
|
+
// The kill switch is checked at module-load time so we can't toggle
|
|
180
|
+
// it inside the test process easily. We validate the contract that
|
|
181
|
+
// the helper exists and respects the env-var — see isDispatchEnabled.
|
|
182
|
+
it('exposes the kill-switch check', () => {
|
|
183
|
+
expect(typeof isDispatchEnabled()).toBe('boolean')
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe('dispatchOne — not-yet-cutover effects log without throwing', () => {
|
|
188
|
+
const notYet: Effect['kind'][] = [
|
|
189
|
+
'deliverToBridge',
|
|
190
|
+
'bufferInbound',
|
|
191
|
+
'persistInbound',
|
|
192
|
+
'setTurnStarted',
|
|
193
|
+
'clearTurnStarted',
|
|
194
|
+
'noteOutbound',
|
|
195
|
+
'firePoke',
|
|
196
|
+
'deliverPermVerdict',
|
|
197
|
+
'persistPermVerdict',
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
for (const kind of notYet) {
|
|
201
|
+
it(`'${kind}' logs not-yet-cutover and does NOT touch primitives`, () => {
|
|
202
|
+
const { ctx, sendToAgent, clientSend, logs } = makeCtx()
|
|
203
|
+
// Construct a minimal valid effect for each kind. Most carry a
|
|
204
|
+
// payload that the dispatcher ignores in this PR.
|
|
205
|
+
const effect = synthesizeNotYetCutoverEffect(kind)
|
|
206
|
+
expect(() => __dispatchOneForTests(effect, ctx)).not.toThrow()
|
|
207
|
+
expect(sendToAgent).not.toHaveBeenCalled()
|
|
208
|
+
expect(clientSend).not.toHaveBeenCalled()
|
|
209
|
+
expect(logs.some((l) => l.includes(`not-yet-cutover effect=${kind}`))).toBe(true)
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
function synthesizeNotYetCutoverEffect(kind: Effect['kind']): Effect {
|
|
215
|
+
const k = 'c1:_' as never
|
|
216
|
+
const msg = { msgId: 1, isSteering: false, payload: null } as never
|
|
217
|
+
const verdict = { requestId: 'r', behavior: 'allow' as const, payload: null } as never
|
|
218
|
+
switch (kind) {
|
|
219
|
+
case 'deliverToBridge':
|
|
220
|
+
return { kind, key: k, msg }
|
|
221
|
+
case 'bufferInbound':
|
|
222
|
+
return { kind, key: k, msg }
|
|
223
|
+
case 'persistInbound':
|
|
224
|
+
return { kind, key: k, msg }
|
|
225
|
+
case 'setTurnStarted':
|
|
226
|
+
return { kind, key: k, at: 0 }
|
|
227
|
+
case 'clearTurnStarted':
|
|
228
|
+
return { kind, key: k }
|
|
229
|
+
case 'noteOutbound':
|
|
230
|
+
return { kind, key: k, at: 0 }
|
|
231
|
+
case 'firePoke':
|
|
232
|
+
return { kind, key: k, level: 'fallback' }
|
|
233
|
+
case 'deliverPermVerdict':
|
|
234
|
+
return { kind, verdict }
|
|
235
|
+
case 'persistPermVerdict':
|
|
236
|
+
return { kind, verdict }
|
|
237
|
+
default:
|
|
238
|
+
throw new Error(`unreachable: ${kind}`)
|
|
239
|
+
}
|
|
240
|
+
}
|