switchroom 0.18.8 → 0.18.10
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/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +198 -13
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +28 -4
- package/dist/host-control/main.js +3 -2
- package/dist/vault/approvals/kernel-server.js +2 -1
- package/dist/vault/broker/server.js +2 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +119 -37
- package/profiles/_shared/dev-protocol.md.hbs +42 -0
- package/skills/dev-protocol/SKILL.md +131 -0
- package/telegram-plugin/README.md +2 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
- package/telegram-plugin/admin-commands/index.ts +6 -1
- package/telegram-plugin/bridge/bridge.ts +23 -1
- package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
- package/telegram-plugin/chat-lock.ts +13 -0
- package/telegram-plugin/dist/bridge/bridge.js +24 -1
- package/telegram-plugin/dist/gateway/gateway.js +80236 -77465
- package/telegram-plugin/dist/server.js +29 -2
- package/telegram-plugin/fallback-card-collapse.ts +131 -0
- 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/bridge-dead-watchdog.ts +546 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/effort-command.ts +47 -3
- package/telegram-plugin/gateway/gateway.ts +2051 -3180
- 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/model-command.ts +94 -8
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-session-command.ts +365 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
- package/telegram-plugin/gateway/session-model-file.ts +166 -23
- package/telegram-plugin/gateway/stop-command.ts +56 -0
- package/telegram-plugin/photo-precheck.ts +201 -0
- package/telegram-plugin/quota-watch.ts +141 -2
- package/telegram-plugin/registry/subagents-schema.ts +26 -3
- package/telegram-plugin/registry/subagents.test.ts +67 -0
- package/telegram-plugin/retry-api-call.ts +31 -0
- package/telegram-plugin/subagent-watcher.ts +392 -1
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
- package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
- package/telegram-plugin/tests/effort-command.test.ts +59 -2
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
- 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/gateway-pending-command-wiring.test.ts +124 -0
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
- 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/model-command.test.ts +46 -3
- 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-session-command.test.ts +322 -0
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
- package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
- package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
- package/telegram-plugin/tests/quota-watch.test.ts +225 -0
- package/telegram-plugin/tests/session-model-file.test.ts +101 -2
- package/telegram-plugin/tests/stop-command.test.ts +234 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -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
- package/telegram-plugin/tests/worker-activity-feed.test.ts +37 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
- package/telegram-plugin/welcome-text.ts +4 -3
- package/telegram-plugin/worker-activity-feed.ts +27 -0
|
@@ -114,14 +114,14 @@ describe('gateway: intent writers on the restart verbs', () => {
|
|
|
114
114
|
expect(clearIdx).toBeGreaterThan(markerIdx)
|
|
115
115
|
})
|
|
116
116
|
|
|
117
|
-
it('/restart stamps
|
|
117
|
+
it('/restart stamps a KEEP intent before dispatch (#3039: a restart is not "clear my model")', () => {
|
|
118
118
|
const idx = GATEWAY_SRC.indexOf("stampUserRestartReason('user: /restart from chat')")
|
|
119
119
|
expect(idx).toBeGreaterThan(0)
|
|
120
120
|
const win = GATEWAY_SRC.slice(idx, idx + 900)
|
|
121
|
-
const
|
|
121
|
+
const keepIdx = win.indexOf("writeRelaunchModelIntent(smDir, 'keep', 'user: /restart from chat')")
|
|
122
122
|
const dispatchIdx = win.indexOf("hostdRequestId('gw-restart')")
|
|
123
|
-
expect(
|
|
124
|
-
expect(dispatchIdx).toBeGreaterThan(
|
|
123
|
+
expect(keepIdx).toBeGreaterThan(0)
|
|
124
|
+
expect(dispatchIdx).toBeGreaterThan(keepIdx)
|
|
125
125
|
})
|
|
126
126
|
|
|
127
127
|
it('/new and /reset stamp keep-intent (fresh conversation, same model — contract row 7)', () => {
|
|
@@ -137,22 +137,28 @@ describe('gateway: intent writers on the restart verbs', () => {
|
|
|
137
137
|
|
|
138
138
|
describe('gateway: model-menu callback persists the sticky override', () => {
|
|
139
139
|
it('a confirmed selection persists the canonical token (selectedModelToken), never the display label', () => {
|
|
140
|
-
|
|
140
|
+
// Recording extracted into recordModelMenuSideEffects (#3017) — shared by the
|
|
141
|
+
// live dispatcher and the deferred (queued mid-turn) apply so both record
|
|
142
|
+
// identically.
|
|
143
|
+
const idx = GATEWAY_SRC.indexOf('function recordModelMenuSideEffects')
|
|
141
144
|
expect(idx).toBeGreaterThan(0)
|
|
142
|
-
const win = GATEWAY_SRC.slice(idx, idx +
|
|
145
|
+
const win = GATEWAY_SRC.slice(idx, idx + 2400)
|
|
143
146
|
expect(win).toContain('outcome.selectedModelToken')
|
|
144
147
|
expect(win).toMatch(/writeSessionModelFile\(\s*smDir,\s*outcome\.selectedModelToken/)
|
|
145
148
|
})
|
|
146
149
|
|
|
147
150
|
it('a confirmed "Default" selection CLEARS the sticky file', () => {
|
|
148
|
-
const idx = GATEWAY_SRC.indexOf('
|
|
149
|
-
const win = GATEWAY_SRC.slice(idx, idx +
|
|
151
|
+
const idx = GATEWAY_SRC.indexOf('function recordModelMenuSideEffects')
|
|
152
|
+
const win = GATEWAY_SRC.slice(idx, idx + 2400)
|
|
150
153
|
expect(win).toContain('outcome.clearedDefault')
|
|
151
154
|
expect(win).toContain('clearSessionModelFile(smDir)')
|
|
152
155
|
})
|
|
153
156
|
|
|
154
157
|
it('the sr-* callback branch calls scheduleModelRelaunch, not inject', () => {
|
|
155
|
-
|
|
158
|
+
// Anchor on the sr-* TARGET dispatcher branch specifically (a mid-turn
|
|
159
|
+
// busy-gate #3017 also matches `if (data.startsWith(MODEL_CALLBACK_SR))`, so
|
|
160
|
+
// anchor on the relaunch call that is unique to the idle apply branch).
|
|
161
|
+
const idx = GATEWAY_SRC.indexOf('const srLabel = escapeHtmlForTg(srFriendlyLabel(srName))')
|
|
156
162
|
expect(idx).toBeGreaterThan(0)
|
|
157
163
|
const win = GATEWAY_SRC.slice(idx, idx + 1400)
|
|
158
164
|
expect(win).toContain('modelDeps.scheduleModelRelaunch(srName')
|
|
@@ -172,7 +178,9 @@ describe('gateway: model-menu callback persists the sticky override', () => {
|
|
|
172
178
|
|
|
173
179
|
describe('gateway: typed /model persists the REQUESTED canonical token', () => {
|
|
174
180
|
it('persists expandSrAlias(parsed.model), and `/model default` clears file + in-memory override', () => {
|
|
175
|
-
|
|
181
|
+
// Recording extracted into recordTypedModelSwitch (#3017) — shared by the
|
|
182
|
+
// live `bot.command('model')` handler and the deferred (queued mid-turn) apply.
|
|
183
|
+
const idx = GATEWAY_SRC.indexOf('function recordTypedModelSwitch')
|
|
176
184
|
expect(idx).toBeGreaterThan(0)
|
|
177
185
|
const win = GATEWAY_SRC.slice(idx, idx + 2400)
|
|
178
186
|
expect(win).toContain("requested?.toLowerCase() === 'default'")
|
|
@@ -184,7 +192,7 @@ describe('gateway: typed /model persists the REQUESTED canonical token', () => {
|
|
|
184
192
|
})
|
|
185
193
|
|
|
186
194
|
it('`/model default` file-clear is NOT gated on a positive confirmation (silent-switch path must not resurrect)', () => {
|
|
187
|
-
const idx = GATEWAY_SRC.indexOf(
|
|
195
|
+
const idx = GATEWAY_SRC.indexOf('function recordTypedModelSwitch')
|
|
188
196
|
const win = GATEWAY_SRC.slice(idx, idx + 2400)
|
|
189
197
|
// The default branch clears the file unconditionally, and only the
|
|
190
198
|
// in-memory override change is confirmation-gated inside it.
|
|
@@ -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
|
+
})
|