switchroom 0.18.31 → 0.18.32

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.
Files changed (65) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +4 -2
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +693 -253
  5. package/dist/host-control/main.js +5 -3
  6. package/dist/vault/approvals/kernel-server.js +4 -2
  7. package/dist/vault/broker/server.js +4 -2
  8. package/package.json +3 -2
  9. package/profiles/_base/start.sh.hbs +43 -5
  10. package/telegram-plugin/dist/gateway/gateway.js +25868 -25038
  11. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  12. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  13. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  14. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  15. package/telegram-plugin/gateway/gateway.ts +7195 -8837
  16. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  17. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  18. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  19. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  20. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  21. package/telegram-plugin/pending-user-notice.ts +59 -13
  22. package/telegram-plugin/subagent-watcher.ts +111 -28
  23. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  24. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  25. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +16 -6
  26. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +3 -3
  27. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  28. package/telegram-plugin/tests/emission-authority-facade.test.ts +29 -19
  29. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  30. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +18 -7
  31. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +249 -0
  32. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  33. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  34. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +303 -0
  35. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -3
  36. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  37. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  38. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  39. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  40. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +4 -4
  41. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  42. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +9 -3
  43. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  44. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  45. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +2 -1
  46. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  47. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  48. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  49. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +3 -2
  50. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  51. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +291 -0
  52. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  53. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  54. package/telegram-plugin/tests/turn-flush-safety.test.ts +8 -6
  55. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +112 -0
  56. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  57. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +126 -0
  58. package/telegram-plugin/tool-activity-summary.ts +27 -3
  59. package/telegram-plugin/worker-activity-feed.ts +24 -0
  60. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  61. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  62. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  63. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  64. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  65. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Import-safety pin for the #2996 P0c move: the remaining module-load side
3
+ * effects of gateway.ts are gated behind `isGatewayMain` so importing the
4
+ * module performs NO boot side effects, and the boot sequence is the guarded
5
+ * `startGateway()` at the bottom of the file.
6
+ *
7
+ * WHY this asserts on the parsed SOURCE, not a real `import()`:
8
+ * gateway.ts statically reaches modules whose bun:sqlite access + the still-
9
+ * ungated P0d process-handler / reaction-sweep registrations make a hermetic
10
+ * `await import()` under vitest impractical without heavy mocking (see the P0e
11
+ * note in the PR). The repo's oracle for "pin gateway structure without booting
12
+ * it" is source-parsing via `ts.createSourceFile` — the same pattern as
13
+ * gateway-bot-construction-deferral.test.ts (P0b) and
14
+ * gateway-handler-registration-wiring.test.ts (P0a). This test follows it and
15
+ * asserts OUTCOMES:
16
+ *
17
+ * 1. `isGatewayMain` is a module-scope const whose value is the argv[1]
18
+ * main-check (true only when this module is the process entrypoint).
19
+ * 2. The boot sequence is `async function startGateway()`, invoked EXACTLY
20
+ * once, and that sole invocation is guarded by `isGatewayMain`. The old
21
+ * top-level boot IIFE (`void (async () => …)()`) is gone.
22
+ * 3. No UNGUARDED module-scope P0c side effect remains: every top-level
23
+ * `setInterval` / `setTimeout` / `<x>.startTimer(…)` / `mkdirSync` /
24
+ * `runHistoryReaperNow` / `openTurnsDb` / `initHistory` /
25
+ * `acquireStartupLock` / `startWebhookIngestServer` /
26
+ * `createPollHealthCheck` / `loadObligations` call, and every module-scope
27
+ * `await`, sits inside an `isGatewayMain` guard (an `if (isGatewayMain …)`
28
+ * or an `isGatewayMain ? … : …` ternary) — or inside a function body.
29
+ *
30
+ * A regression that re-hoists a timer, the startup-lock await, the boot IIFE,
31
+ * or the turn-registry open back to unguarded module scope fails (3).
32
+ *
33
+ * OUT OF SCOPE (P0d, deliberately still unguarded — see the PR/issue): the
34
+ * process signal/error handlers (`process.on('SIGTERM'|'SIGINT'|'beforeExit'|
35
+ * 'unhandledRejection'|'uncaughtException')`, `installPosthogErrorHandlers()`)
36
+ * and the startup reaction sweep (`sweepActiveReactions`). Those are NOT in the
37
+ * sentinel set below.
38
+ */
39
+
40
+ import { describe, it, expect } from 'vitest'
41
+ import { readFileSync } from 'node:fs'
42
+ import { fileURLToPath } from 'node:url'
43
+ import { dirname, resolve } from 'node:path'
44
+ import ts from 'typescript'
45
+
46
+ const __dirname = dirname(fileURLToPath(import.meta.url))
47
+ const GATEWAY_PATH = resolve(__dirname, '..', 'gateway', 'gateway.ts')
48
+ const GATEWAY_SRC = readFileSync(GATEWAY_PATH, 'utf8')
49
+
50
+ const sourceFile = ts.createSourceFile(
51
+ GATEWAY_PATH,
52
+ GATEWAY_SRC,
53
+ ts.ScriptTarget.Latest,
54
+ true,
55
+ ts.ScriptKind.TS,
56
+ )
57
+
58
+ function isFunctionLike(n: ts.Node): boolean {
59
+ return (
60
+ ts.isFunctionDeclaration(n) ||
61
+ ts.isFunctionExpression(n) ||
62
+ ts.isArrowFunction(n) ||
63
+ ts.isMethodDeclaration(n) ||
64
+ ts.isConstructorDeclaration(n) ||
65
+ ts.isGetAccessorDeclaration(n) ||
66
+ ts.isSetAccessorDeclaration(n)
67
+ )
68
+ }
69
+
70
+ function lineOf(n: ts.Node): number {
71
+ return sourceFile.getLineAndCharacterOfPosition(n.getStart(sourceFile)).line + 1
72
+ }
73
+
74
+ // The P0c-scoped side-effect sentinels. Each of these, at module scope, MUST be
75
+ // isGatewayMain-guarded after P0c.
76
+ const CALL_SENTINELS = new Set([
77
+ 'setInterval',
78
+ 'setTimeout',
79
+ 'mkdirSync',
80
+ 'runHistoryReaperNow',
81
+ 'openTurnsDb',
82
+ 'initHistory',
83
+ 'acquireStartupLock',
84
+ 'startWebhookIngestServer',
85
+ 'createPollHealthCheck',
86
+ 'loadObligations',
87
+ ])
88
+
89
+ function sentinelName(n: ts.Node): string | null {
90
+ if (ts.isCallExpression(n)) {
91
+ const e = n.expression
92
+ if (ts.isIdentifier(e) && CALL_SENTINELS.has(e.text)) return e.text
93
+ // `<obj>.startTimer(...)` — the silencePoke / pendingProgress timers.
94
+ if (ts.isPropertyAccessExpression(e) && e.name.text === 'startTimer') {
95
+ return `${e.expression.getText(sourceFile)}.startTimer`
96
+ }
97
+ }
98
+ if (n.kind === ts.SyntaxKind.AwaitExpression) return 'await'
99
+ return null
100
+ }
101
+
102
+ function guardsOnMain(cond: ts.Expression): boolean {
103
+ return cond.getText(sourceFile).includes('isGatewayMain')
104
+ }
105
+
106
+ /**
107
+ * Walk the module top level (never descending into function bodies) collecting
108
+ * unguarded sentinels. `guarded` becomes true beneath an isGatewayMain
109
+ * if/ternary branch.
110
+ */
111
+ function collectUnguarded(): string[] {
112
+ const findings: string[] = []
113
+ function walk(node: ts.Node, guarded: boolean): void {
114
+ if (isFunctionLike(node)) return
115
+ if (ts.isIfStatement(node) && guardsOnMain(node.expression)) {
116
+ walk(node.thenStatement, true)
117
+ if (node.elseStatement) walk(node.elseStatement, guarded)
118
+ return
119
+ }
120
+ if (ts.isConditionalExpression(node) && guardsOnMain(node.condition)) {
121
+ walk(node.whenTrue, true)
122
+ walk(node.whenFalse, guarded)
123
+ return
124
+ }
125
+ const s = sentinelName(node)
126
+ if (s && !guarded) findings.push(`${s}@${lineOf(node)}`)
127
+ ts.forEachChild(node, c => walk(c, guarded))
128
+ }
129
+ for (const stmt of sourceFile.statements) walk(stmt, false)
130
+ return findings
131
+ }
132
+
133
+ describe('gateway #2996 P0c: module-load boot side effects are gated behind isGatewayMain', () => {
134
+ it('declares `isGatewayMain` as a module-scope const with the argv[1] main-check', () => {
135
+ let found = false
136
+ for (const stmt of sourceFile.statements) {
137
+ if (!ts.isVariableStatement(stmt)) continue
138
+ for (const decl of stmt.declarationList.declarations) {
139
+ if (ts.isIdentifier(decl.name) && decl.name.text === 'isGatewayMain') {
140
+ found = true
141
+ const init = decl.initializer?.getText(sourceFile) ?? ''
142
+ // Guards on argv[1] ending in the source or the built bundle name.
143
+ expect(init).toContain('process.argv[1]')
144
+ expect(init).toContain("endsWith('gateway.ts')")
145
+ expect(init).toContain("endsWith('gateway.js')")
146
+ }
147
+ }
148
+ }
149
+ expect(found).toBe(true)
150
+ })
151
+
152
+ it('defines an async `startGateway()` boot function and NO leftover boot IIFE', () => {
153
+ let startGatewayFn: ts.FunctionDeclaration | undefined
154
+ for (const stmt of sourceFile.statements) {
155
+ if (ts.isFunctionDeclaration(stmt) && stmt.name?.text === 'startGateway') {
156
+ startGatewayFn = stmt
157
+ }
158
+ }
159
+ expect(startGatewayFn).toBeDefined()
160
+ const isAsync = (startGatewayFn!.modifiers ?? []).some(
161
+ m => m.kind === ts.SyntaxKind.AsyncKeyword,
162
+ )
163
+ expect(isAsync).toBe(true)
164
+
165
+ // The old top-level boot IIFE `void (async () => {…})()` must be gone: no
166
+ // module-scope immediately-invoked async arrow remains.
167
+ let bootIifes = 0
168
+ for (const stmt of sourceFile.statements) {
169
+ if (!ts.isExpressionStatement(stmt)) continue
170
+ let expr = stmt.expression
171
+ if (ts.isVoidExpression(expr)) expr = expr.expression
172
+ if (
173
+ ts.isCallExpression(expr) &&
174
+ (ts.isArrowFunction(expr.expression) || ts.isFunctionExpression(expr.expression))
175
+ ) {
176
+ const inner = expr.expression
177
+ const asyncArrow =
178
+ (inner.modifiers ?? []).some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
179
+ if (asyncArrow) bootIifes++
180
+ }
181
+ }
182
+ expect(bootIifes).toBe(0)
183
+ })
184
+
185
+ it('invokes startGateway() EXACTLY once, guarded by isGatewayMain', () => {
186
+ let total = 0
187
+ let guardedCalls = 0
188
+ function walk(node: ts.Node, guarded: boolean): void {
189
+ if (isFunctionLike(node)) return // only the module-scope invocation
190
+ if (ts.isIfStatement(node) && guardsOnMain(node.expression)) {
191
+ walk(node.thenStatement, true)
192
+ if (node.elseStatement) walk(node.elseStatement, guarded)
193
+ return
194
+ }
195
+ if (
196
+ ts.isCallExpression(node) &&
197
+ ts.isIdentifier(node.expression) &&
198
+ node.expression.text === 'startGateway'
199
+ ) {
200
+ total++
201
+ if (guarded) guardedCalls++
202
+ }
203
+ ts.forEachChild(node, c => walk(c, guarded))
204
+ }
205
+ for (const stmt of sourceFile.statements) walk(stmt, false)
206
+ expect(total).toBe(1)
207
+ expect(guardedCalls).toBe(1)
208
+ })
209
+
210
+ it('leaves NO unguarded module-scope timer / boot-I/O / startup-lock side effect', () => {
211
+ // If this fails it prints exactly which sentinel (e.g. `setInterval@1234`,
212
+ // `await@9750`, `openTurnsDb@1700`) escaped the isGatewayMain guard.
213
+ expect(collectUnguarded()).toEqual([])
214
+ })
215
+
216
+ it('gates every module-scope timer handle (the 4 reaper/tick consts)', () => {
217
+ // pendingStateReaper / midSessionCardReaper / _deliveryMachineTick /
218
+ // _deliveryConfirmSweep are `const X = isGatewayMain ? setInterval(…) :
219
+ // undefined` — their setInterval must be inside an isGatewayMain ternary.
220
+ const timerConsts = new Set([
221
+ 'pendingStateReaper',
222
+ 'midSessionCardReaper',
223
+ '_deliveryMachineTick',
224
+ '_deliveryConfirmSweep',
225
+ ])
226
+ const seen = new Set<string>()
227
+ for (const stmt of sourceFile.statements) {
228
+ if (!ts.isVariableStatement(stmt)) continue
229
+ for (const decl of stmt.declarationList.declarations) {
230
+ if (
231
+ ts.isIdentifier(decl.name) &&
232
+ timerConsts.has(decl.name.text) &&
233
+ decl.initializer
234
+ ) {
235
+ seen.add(decl.name.text)
236
+ const init = decl.initializer
237
+ // Must be a conditional whose condition mentions isGatewayMain and
238
+ // whose truthy branch is the setInterval call.
239
+ expect(ts.isConditionalExpression(init)).toBe(true)
240
+ if (ts.isConditionalExpression(init)) {
241
+ expect(guardsOnMain(init.condition)).toBe(true)
242
+ expect(init.whenTrue.getText(sourceFile)).toContain('setInterval')
243
+ }
244
+ }
245
+ }
246
+ }
247
+ expect([...seen].sort()).toEqual([...timerConsts].sort())
248
+ })
249
+ })
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Import-safety pin for the #2996 P0b move: token materialization + `new Bot`
3
+ * construction are DEFERRED out of module import into the boot function
4
+ * `initGatewayBot()`.
5
+ *
6
+ * WHY this asserts on the parsed SOURCE, not a real `import()`:
7
+ * gateway.ts is NOT import-safe under vitest yet — the boot IIFE (`void (async
8
+ * () => { … })()`) still fires at module evaluation and P0b deliberately does
9
+ * NOT touch it (that guard is P0c/P0d scope). So a real `await import()` would
10
+ * still trigger the boot path. The oracle for "how the repo pins gateway
11
+ * structure without booting it" is source-parsing via `ts.createSourceFile`
12
+ * (see gateway-handler-registration-wiring.test.ts /
13
+ * gateway-pending-command-wiring.test.ts). This test follows that pattern and
14
+ * asserts OUTCOMES about where construction lives:
15
+ *
16
+ * 1. Module import (top-level, outside any function body) performs NO
17
+ * `new Bot(...)`, NO dynamic import of the materialize-bot-token helper,
18
+ * NO `materializeBotToken(...)` call, and has NO reachable `process.exit`
19
+ * and NO top-level `await`. i.e. importing gateway.ts constructs no bot,
20
+ * reads no token, and cannot exit the process.
21
+ * 2. `bot` is a deferred module-scope `let` (definite-assignment), NOT a
22
+ * `const bot = new Bot(...)`.
23
+ * 3. The boot function `initGatewayBot()` DOES all of the above: it is an
24
+ * async function whose body contains the `new Bot(...)`, the materialize
25
+ * dynamic import, the `materializeBotToken(...)` call, and the single
26
+ * `registerGatewayHandlers(bot, …)` wiring call.
27
+ *
28
+ * A count/handler-theater test would not catch a regression that re-hoists
29
+ * `new Bot` or the token read back to module scope; these node-location
30
+ * assertions do.
31
+ */
32
+
33
+ import { describe, it, expect } from 'vitest'
34
+ import { readFileSync } from 'node:fs'
35
+ import { fileURLToPath } from 'node:url'
36
+ import { dirname, resolve } from 'node:path'
37
+ import ts from 'typescript'
38
+
39
+ const __dirname = dirname(fileURLToPath(import.meta.url))
40
+ const GATEWAY_PATH = resolve(__dirname, '..', 'gateway', 'gateway.ts')
41
+ const GATEWAY_SRC = readFileSync(GATEWAY_PATH, 'utf8')
42
+
43
+ const sourceFile = ts.createSourceFile(
44
+ GATEWAY_PATH,
45
+ GATEWAY_SRC,
46
+ ts.ScriptTarget.Latest,
47
+ true,
48
+ ts.ScriptKind.TS,
49
+ )
50
+
51
+ const MATERIALIZE_MODULE_HINT = 'materialize-bot-token'
52
+
53
+ function isFunctionLike(n: ts.Node): boolean {
54
+ return (
55
+ ts.isFunctionDeclaration(n) ||
56
+ ts.isFunctionExpression(n) ||
57
+ ts.isArrowFunction(n) ||
58
+ ts.isMethodDeclaration(n) ||
59
+ ts.isConstructorDeclaration(n) ||
60
+ ts.isGetAccessorDeclaration(n) ||
61
+ ts.isSetAccessorDeclaration(n)
62
+ )
63
+ }
64
+
65
+ /**
66
+ * Walk every node that executes at MODULE SCOPE — i.e. descend through the
67
+ * source file but STOP at any function-like boundary (function/arrow/method
68
+ * bodies run later, when called, not at import). A `new Bot` or token read
69
+ * found here would fire at import; the whole point of P0b is that none do.
70
+ */
71
+ function walkModuleScope(visitor: (n: ts.Node) => void): void {
72
+ const recur = (node: ts.Node): void => {
73
+ ts.forEachChild(node, child => {
74
+ if (isFunctionLike(child)) return // do not descend into function bodies
75
+ visitor(child)
76
+ recur(child)
77
+ })
78
+ }
79
+ recur(sourceFile)
80
+ }
81
+
82
+ function isNewBot(n: ts.Node): boolean {
83
+ return ts.isNewExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === 'Bot'
84
+ }
85
+
86
+ function isMaterializeDynamicImport(n: ts.Node): boolean {
87
+ // `import('…materialize-bot-token…')`
88
+ if (!ts.isCallExpression(n)) return false
89
+ if (n.expression.kind !== ts.SyntaxKind.ImportKeyword) return false
90
+ const arg = n.arguments[0]
91
+ return arg != null && ts.isStringLiteralLike(arg) && arg.text.includes(MATERIALIZE_MODULE_HINT)
92
+ }
93
+
94
+ function isMaterializeBotTokenCall(n: ts.Node): boolean {
95
+ return ts.isCallExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === 'materializeBotToken'
96
+ }
97
+
98
+ function isProcessExitCall(n: ts.Node): boolean {
99
+ return (
100
+ ts.isCallExpression(n) &&
101
+ ts.isPropertyAccessExpression(n.expression) &&
102
+ ts.isIdentifier(n.expression.expression) &&
103
+ n.expression.expression.text === 'process' &&
104
+ n.expression.name.text === 'exit'
105
+ )
106
+ }
107
+
108
+ function findFunction(name: string): ts.FunctionDeclaration | undefined {
109
+ for (const s of sourceFile.statements) {
110
+ if (ts.isFunctionDeclaration(s) && s.name?.text === name) return s
111
+ }
112
+ return undefined
113
+ }
114
+
115
+ /** Count nodes matching a predicate anywhere within a subtree. */
116
+ function countIn(root: ts.Node, pred: (n: ts.Node) => boolean): number {
117
+ let count = 0
118
+ const visit = (node: ts.Node): void => {
119
+ if (pred(node)) count++
120
+ ts.forEachChild(node, visit)
121
+ }
122
+ visit(root)
123
+ return count
124
+ }
125
+
126
+ describe('gateway #2996 P0b: token materialization + new Bot are deferred to boot, import is construction-clean', () => {
127
+ it('constructs NO Bot at module import (no top-level `new Bot`)', () => {
128
+ let count = 0
129
+ walkModuleScope(n => {
130
+ if (isNewBot(n)) count++
131
+ })
132
+ expect(count).toBe(0)
133
+ })
134
+
135
+ it('reads NO token at module import (no top-level materialize import or materializeBotToken call)', () => {
136
+ let dynImports = 0
137
+ let materializeCalls = 0
138
+ walkModuleScope(n => {
139
+ if (isMaterializeDynamicImport(n)) dynImports++
140
+ if (isMaterializeBotTokenCall(n)) materializeCalls++
141
+ })
142
+ expect(dynImports).toBe(0)
143
+ expect(materializeCalls).toBe(0)
144
+ })
145
+
146
+ it('performs NO token-refusal exit at module import (no top-level process.exit for token materialization)', () => {
147
+ // NOTE: the pre-existing startup-lock mutex (#…) still has one top-level
148
+ // process.exit + top-level await — that is P0c/P0d scope, deliberately NOT
149
+ // touched here. This asserts specifically that the TOKEN-materialization
150
+ // exit paths (exit(1) on materialize failure / missing token) are gone from
151
+ // module scope: there is at most the single startup-lock exit, and none of
152
+ // them sit in a materialize/`new Bot` context (covered by the tests above).
153
+ let exits = 0
154
+ walkModuleScope(n => {
155
+ if (isProcessExitCall(n)) exits++
156
+ })
157
+ // The two token-materialization exit(1) sites moved into initGatewayBot;
158
+ // only the pre-existing startup-lock exit remains at module scope.
159
+ expect(exits).toBeLessThanOrEqual(1)
160
+ })
161
+
162
+ it('leaves NO module-scope reference to `bot` or `lockedBot` outside their declarations (all consumers deferred)', () => {
163
+ // Completeness pin: after P0b, the ONLY module-scope occurrences of `bot` /
164
+ // `lockedBot` are their `let` declarations. Every consumer — the api-tap /
165
+ // heartbeat wiring, checklist probes, chatLock.wrapBot, the /approvals
166
+ // registration, the callback-query factory, and registerGatewayHandlers —
167
+ // must live inside a function (initGatewayBot or a post-boot handler). A
168
+ // regression that re-hoists any consumer to import scope fails here.
169
+ const declLines = new Set<number>()
170
+ for (const stmt of sourceFile.statements) {
171
+ if (!ts.isVariableStatement(stmt)) continue
172
+ for (const decl of stmt.declarationList.declarations) {
173
+ if (ts.isIdentifier(decl.name) && (decl.name.text === 'bot' || decl.name.text === 'lockedBot')) {
174
+ declLines.add(sourceFile.getLineAndCharacterOfPosition(decl.name.getStart(sourceFile)).line)
175
+ }
176
+ }
177
+ }
178
+ const stray: string[] = []
179
+ walkModuleScope(n => {
180
+ if (ts.isIdentifier(n) && (n.text === 'bot' || n.text === 'lockedBot')) {
181
+ const line = sourceFile.getLineAndCharacterOfPosition(n.getStart(sourceFile)).line
182
+ if (!declLines.has(line)) stray.push(`${n.text}@${line + 1}`)
183
+ }
184
+ })
185
+ expect(stray).toEqual([])
186
+ })
187
+
188
+ it('declares `bot` as a deferred module-scope `let`, not `const bot = new Bot(...)`', () => {
189
+ let botDecls = 0
190
+ let botHasInitializer = false
191
+ for (const stmt of sourceFile.statements) {
192
+ if (!ts.isVariableStatement(stmt)) continue
193
+ for (const decl of stmt.declarationList.declarations) {
194
+ if (ts.isIdentifier(decl.name) && decl.name.text === 'bot') {
195
+ botDecls++
196
+ if (decl.initializer != null) botHasInitializer = true
197
+ // `let`, not `const`
198
+ expect(stmt.declarationList.flags & ts.NodeFlags.Const).toBe(0)
199
+ }
200
+ }
201
+ }
202
+ expect(botDecls).toBe(1)
203
+ expect(botHasInitializer).toBe(false)
204
+ })
205
+
206
+ it('defines an async initGatewayBot() boot function', () => {
207
+ const fn = findFunction('initGatewayBot')
208
+ expect(fn).toBeDefined()
209
+ const isAsync = (fn!.modifiers ?? []).some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
210
+ expect(isAsync).toBe(true)
211
+ })
212
+
213
+ it('initGatewayBot() performs the construction: new Bot, token materialization, and the registration wiring call', () => {
214
+ const fn = findFunction('initGatewayBot')
215
+ expect(fn?.body).toBeDefined()
216
+ const body = fn!.body!
217
+
218
+ // Exactly one `new Bot(...)` — the single constructed instance.
219
+ expect(countIn(body, isNewBot)).toBe(1)
220
+
221
+ // Token materialization lives here (dynamic import + the resolve call).
222
+ expect(countIn(body, isMaterializeDynamicImport)).toBe(1)
223
+ expect(countIn(body, isMaterializeBotTokenCall)).toBe(1)
224
+
225
+ // The two P0a-missed import-time bot consumers also moved here (P0b): the
226
+ // callback-query factory and the /approvals registration.
227
+ const isNamedCall = (name: string) => (n: ts.Node) =>
228
+ ts.isCallExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === name
229
+ expect(countIn(body, isNamedCall('createCallbackQueryHandlers'))).toBe(1)
230
+ expect(countIn(body, isNamedCall('registerApprovalsCommands'))).toBe(1)
231
+
232
+ // The refuse-to-boot exits (process.exit(1)) live here, not at import.
233
+ expect(countIn(body, isProcessExitCall)).toBeGreaterThanOrEqual(1)
234
+
235
+ // The single registerGatewayHandlers(bot, …) wiring call moved here (P0b).
236
+ let regCalls = 0
237
+ const visit = (node: ts.Node): void => {
238
+ if (
239
+ ts.isCallExpression(node) &&
240
+ ts.isIdentifier(node.expression) &&
241
+ node.expression.text === 'registerGatewayHandlers'
242
+ ) {
243
+ regCalls++
244
+ expect(node.arguments[0]?.getText(sourceFile)).toBe('bot')
245
+ }
246
+ ts.forEachChild(node, visit)
247
+ }
248
+ visit(body)
249
+ expect(regCalls).toBe(1)
250
+ })
251
+ })
@@ -51,19 +51,6 @@ function makeDeps(agentName: string | null) {
51
51
  ['chat1:thr1:msg1', 100],
52
52
  ['chat2:thr2:msg2', 200],
53
53
  ])
54
- // PR3b: claudeBusyKeys tracks turns actually handed to claude. In a
55
- // healthy registered-disconnect scenario both maps would carry the
56
- // same keys (delivery succeeded); the dangling-sweep tests below
57
- // override individual deps to exercise the orphaned-key path.
58
- const claudeBusyKeys = new Set<string>([
59
- 'chat1:thr1:msg1',
60
- 'chat2:thr2:msg2',
61
- ])
62
- // #2787: shadow timestamp map, kept in lockstep with claudeBusyKeys.
63
- const claudeBusyKeySince = new Map<string, number>([
64
- ['chat1:thr1:msg1', 100],
65
- ['chat2:thr2:msg2', 200],
66
- ])
67
54
  const activeDraftStreams = new Map<string, FakeStream>([
68
55
  ['chat1:thr1:r1', { isFinal: () => false, finalize: finalizeA }],
69
56
  ['chat2:thr2:r2', { isFinal: () => true, finalize: finalizeB }],
@@ -79,8 +66,6 @@ function makeDeps(agentName: string | null) {
79
66
  activeStatusReactions,
80
67
  activeReactionMsgIds,
81
68
  activeTurnStartedAt,
82
- claudeBusyKeys,
83
- claudeBusyKeySince,
84
69
  activeDraftStreams,
85
70
  clearActiveReactions,
86
71
  disposeProgressDriver,
@@ -202,8 +187,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
202
187
  ['ghost:thr:msg', { chatId: 'ghost', messageId: 42 }],
203
188
  ]),
204
189
  activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
205
- claudeBusyKeys: new Set<string>(['ghost:thr:msg']),
206
- claudeBusyKeySince: new Map<string, number>([['ghost:thr:msg', 100]]),
207
190
  activeDraftStreams: new Map<string, FakeStream>(),
208
191
  clearActiveReactions,
209
192
  disposeProgressDriver,
@@ -214,10 +197,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
214
197
 
215
198
  flushOnAgentDisconnect(deps)
216
199
 
217
- // PR3b: claudeBusyKeys swept alongside the activeTurnStartedAt
218
- // dangling entry — both maps mirror each other on registered
219
- // disconnects, so a key in one is always a key in the other.
220
- expect(deps.claudeBusyKeys.size).toBe(0)
221
200
  // The sweep fired and cleared the dangling entry.
222
201
  expect(deps.activeTurnStartedAt.size).toBe(0)
223
202
  expect(deps.activeReactionMsgIds.size).toBe(0)
@@ -261,8 +240,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
261
240
  activeStatusReactions: new Map<string, FakeCtrl>(),
262
241
  activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
263
242
  activeTurnStartedAt: new Map<string, number>([['real-turn:thr:msg', 100]]),
264
- claudeBusyKeys: new Set<string>(['real-turn:thr:msg']),
265
- claudeBusyKeySince: new Map<string, number>([['real-turn:thr:msg', 100]]),
266
243
  activeDraftStreams: new Map<string, FakeStream>(),
267
244
  clearActiveReactions: vi.fn(),
268
245
  disposeProgressDriver: vi.fn(),
@@ -278,109 +255,11 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
278
255
  expect(onDanglingTurnsSwept).not.toHaveBeenCalled()
279
256
  })
280
257
 
281
- // PR3b orphan-sweep regression: synthetic-inbound deliveries
282
- // (cron, reactions, vault, button-callback) bypass handleInbound's
283
- // fresh-turn branch and so never stamp activeTurnStartedAt. They
284
- // DO mark claudeBusyKeys. If their turn dies without turn_end, the
285
- // activeTurnStartedAt-keyed dangling sweep misses them — orphan
286
- // persists in claudeBusyKeys → fleet gate wedges. This test pins
287
- // the post-sweep claudeBusyKeys.clear() fix.
288
- it('sweeps claudeBusyKeys orphans that have NO activeTurnStartedAt entry (PR3b follow-up)', () => {
289
- const onDanglingTurnsSwept = vi.fn()
290
- const log = vi.fn()
291
- const deps = {
292
- agentName: 'clerk',
293
- activeStatusReactions: new Map<string, FakeCtrl>(),
294
- activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
295
- activeTurnStartedAt: new Map<string, number>(),
296
- // The orphan scenario: claude was handed a turn (e.g. cron
297
- // synthetic delivered), so claudeBusyKeys has it, but
298
- // activeTurnStartedAt was never set because cron bypasses
299
- // handleInbound's fresh-turn branch.
300
- claudeBusyKeys: new Set<string>(['cron-only-key:_']),
301
- claudeBusyKeySince: new Map<string, number>([['cron-only-key:_', 100]]),
302
- activeDraftStreams: new Map<string, FakeStream>(),
303
- clearActiveReactions: vi.fn(),
304
- disposeProgressDriver: vi.fn(),
305
- stopTurnTypingLoops: vi.fn(),
306
- onDanglingTurnsSwept,
307
- log,
308
- }
309
-
310
- flushOnAgentDisconnect(deps)
311
-
312
- // The orphan is cleared even though it never had an
313
- // activeTurnStartedAt entry.
314
- expect(deps.claudeBusyKeys.size).toBe(0)
315
- // The activeTurnStartedAt-keyed sweep wasn't fired (nothing in
316
- // that map to sweep) — so onDanglingTurnsSwept shouldn't fire
317
- // either. The orphan sweep is a separate observation.
318
- expect(onDanglingTurnsSwept).not.toHaveBeenCalled()
319
- // But it logs the orphan-clear so operators can see it.
320
- expect(
321
- log.mock.calls.some((c: unknown[]) =>
322
- typeof c[0] === 'string' && /orphan claudeBusyKeys/.test(c[0]),
323
- ),
324
- ).toBe(true)
325
- })
326
-
327
- it('orphan-sweep singular vs plural log message agrees with count', () => {
328
- // Tiny grammar regression: "1 entry" vs "2 entries".
329
- const log = vi.fn()
330
- const baseDeps = {
331
- agentName: 'clerk',
332
- activeStatusReactions: new Map<string, FakeCtrl>(),
333
- activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
334
- activeTurnStartedAt: new Map<string, number>(),
335
- activeDraftStreams: new Map<string, FakeStream>(),
336
- clearActiveReactions: vi.fn(),
337
- disposeProgressDriver: vi.fn(),
338
- stopTurnTypingLoops: vi.fn(),
339
- }
340
- // Singular form.
341
- flushOnAgentDisconnect({
342
- ...baseDeps,
343
- claudeBusyKeys: new Set<string>(['k1:_']),
344
- claudeBusyKeySince: new Map<string, number>([['k1:_', 100]]),
345
- log,
346
- })
347
- expect(log.mock.calls.some((c: unknown[]) =>
348
- typeof c[0] === 'string' && / 1 orphan claudeBusyKeys entry /.test(c[0]),
349
- )).toBe(true)
350
- // Plural form.
351
- log.mockClear()
352
- flushOnAgentDisconnect({
353
- ...baseDeps,
354
- claudeBusyKeys: new Set<string>(['k1:_', 'k2:1']),
355
- claudeBusyKeySince: new Map<string, number>([['k1:_', 100], ['k2:1', 100]]),
356
- log,
357
- })
358
- expect(log.mock.calls.some((c: unknown[]) =>
359
- typeof c[0] === 'string' && / 2 orphan claudeBusyKeys entries /.test(c[0]),
360
- )).toBe(true)
361
- })
362
-
363
- it('does NOT fire orphan-sweep log when claudeBusyKeys is empty', () => {
364
- // Zero-noise discipline: every disconnect for a healthy idle
365
- // agent shouldn't produce a "0 orphan claudeBusyKeys" line.
366
- const log = vi.fn()
367
- flushOnAgentDisconnect({
368
- agentName: 'clerk',
369
- activeStatusReactions: new Map<string, FakeCtrl>(),
370
- activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
371
- activeTurnStartedAt: new Map<string, number>(),
372
- claudeBusyKeys: new Set<string>(),
373
- claudeBusyKeySince: new Map<string, number>(),
374
- activeDraftStreams: new Map<string, FakeStream>(),
375
- clearActiveReactions: vi.fn(),
376
- disposeProgressDriver: vi.fn(),
377
- stopTurnTypingLoops: vi.fn(),
378
- log,
379
- })
380
- expect(log.mock.calls.some((c: unknown[]) =>
381
- typeof c[0] === 'string' && /orphan claudeBusyKeys/.test(c[0]),
382
- )).toBe(false)
383
- })
258
+ // (#2996 P1) The PR3b claudeBusyKeys orphan-sweep tests that lived here
259
+ // were deleted with the set itself — the delivery machine's bridgeDown
260
+ // event (emitted by the gateway alongside this flush) structurally
261
+ // clears the turn-in-flight gate for synthetic-delivered turns that
262
+ // died without a turn_end.
384
263
 
385
264
  it('omitting onDanglingTurnsSwept is safe (optional callback)', () => {
386
265
  // Backward-compat guard — existing callers that don't pass the new
@@ -390,8 +269,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
390
269
  activeStatusReactions: new Map<string, FakeCtrl>(),
391
270
  activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
392
271
  activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
393
- claudeBusyKeys: new Set<string>(['ghost:thr:msg']),
394
- claudeBusyKeySince: new Map<string, number>([['ghost:thr:msg', 100]]),
395
272
  activeDraftStreams: new Map<string, FakeStream>(),
396
273
  clearActiveReactions: vi.fn(),
397
274
  disposeProgressDriver: vi.fn(),