switchroom 0.19.41 → 0.19.43
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 +23 -3
- package/dist/auth-broker/index.js +54 -9
- package/dist/cli/notion-write-pretool.mjs +23 -3
- package/dist/cli/switchroom.js +1956 -900
- package/dist/host-control/main.js +227 -30
- package/dist/vault/approvals/kernel-server.js +54 -9
- package/dist/vault/broker/server.js +54 -9
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +82 -4
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-layout.ts +63 -1
- package/telegram-plugin/dist/bridge/bridge.js +4 -1
- package/telegram-plugin/dist/gateway/gateway.js +1318 -693
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/flood-429-ledger.ts +5 -3
- package/telegram-plugin/flood-circuit-breaker.ts +11 -3
- package/telegram-plugin/format.ts +223 -13
- package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +56 -58
- package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
- package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
- package/telegram-plugin/gateway/ipc-server.ts +13 -4
- package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
- package/telegram-plugin/gateway/model-command.ts +21 -109
- package/telegram-plugin/gateway/obligation-store.ts +55 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
- package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
- package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
- package/telegram-plugin/gateway/stream-render.ts +62 -0
- package/telegram-plugin/gateway/update-announce.ts +11 -4
- package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
- package/telegram-plugin/hooks/audience-classify.mjs +364 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
- package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
- package/telegram-plugin/outbox.ts +103 -2
- package/telegram-plugin/render/rich-render.ts +10 -2
- package/telegram-plugin/reply-quote.ts +150 -0
- package/telegram-plugin/retry-api-call.ts +254 -11
- package/telegram-plugin/shared/bot-runtime.ts +69 -34
- package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
- package/telegram-plugin/silent-end.ts +34 -2
- package/telegram-plugin/status-no-truncate.ts +31 -16
- package/telegram-plugin/stream-controller.ts +44 -9
- package/telegram-plugin/stream-reply-handler.ts +5 -3
- package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
- package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
- package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
- package/telegram-plugin/tests/card-variants.golden.txt +9 -9
- package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
- package/telegram-plugin/tests/format-consistency.test.ts +160 -14
- package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
- package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
- package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
- package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
- package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
- package/telegram-plugin/tests/obligation-store.test.ts +51 -0
- package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
- package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
- package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
- package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
- package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
- package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
- package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
- package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
- package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
- package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
- package/telegram-plugin/tool-activity-summary.ts +58 -30
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
- package/vendor/hindsight-memory/CHANGELOG.md +28 -0
- package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
- package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +18 -3
- package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
- package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
- package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome test for the transient-network retry class, driven through the REAL
|
|
3
|
+
* seam that carries the bug: a real grammy `Bot` whose injected `fetch` throws
|
|
4
|
+
* a realistic transport error, wrapped by the real `createRetryApiCall` loop.
|
|
5
|
+
*
|
|
6
|
+
* Why not a hand-rolled `throw new Error('fetch failed')`: grammy never lets
|
|
7
|
+
* that shape reach the retry loop. `Api.callMethod` catches every fetch
|
|
8
|
+
* rejection and rethrows `toHttpError(method, sensitiveLogs, err)`
|
|
9
|
+
* (grammy 1.44.0, out/core/client.js:58 → out/core/error.js:76-83), which
|
|
10
|
+
* produces an `HttpError` whose message is `Network request for '<method>'
|
|
11
|
+
* failed!` — the original `fetch failed` / `ECONNRESET` text is DROPPED unless
|
|
12
|
+
* `client.sensitiveLogs` is on (it is off by default and we do not enable it,
|
|
13
|
+
* because it leaks the bot token URL into logs).
|
|
14
|
+
*
|
|
15
|
+
* So the only honest test of "does a transport blip get retried" has to go
|
|
16
|
+
* through grammy's own wrapping. Faking the error shape is what let the dead
|
|
17
|
+
* branch ship green (#4117).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
21
|
+
import { Bot, HttpError, GrammyError } from 'grammy'
|
|
22
|
+
import {
|
|
23
|
+
createRetryApiCall,
|
|
24
|
+
willRetryTelegramFailure,
|
|
25
|
+
isTransientNetworkMessage,
|
|
26
|
+
isTransientTransportError,
|
|
27
|
+
type RetryObserver,
|
|
28
|
+
} from '../retry-api-call.js'
|
|
29
|
+
import { installTgPostLogger } from '../shared/bot-runtime.js'
|
|
30
|
+
import { detectGatewayFindings, scanAgent } from '../../src/fleet-health/detect.js'
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.restoreAllMocks()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const TOKEN = '111111:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
|
|
37
|
+
|
|
38
|
+
function okResponse(result: unknown): Response {
|
|
39
|
+
return new Response(JSON.stringify({ ok: true, result }), {
|
|
40
|
+
status: 200,
|
|
41
|
+
headers: { 'content-type': 'application/json' },
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A real grammy Bot whose transport fails `failures` times with `err`, then
|
|
47
|
+
* succeeds. Returns the bot plus the live call count.
|
|
48
|
+
*/
|
|
49
|
+
function makeBot(err: unknown, failures: number) {
|
|
50
|
+
const calls = { n: 0 }
|
|
51
|
+
const bot = new Bot(TOKEN, {
|
|
52
|
+
botInfo: {
|
|
53
|
+
id: 1,
|
|
54
|
+
is_bot: true,
|
|
55
|
+
first_name: 'test',
|
|
56
|
+
username: 'test_bot',
|
|
57
|
+
can_join_groups: true,
|
|
58
|
+
can_read_all_group_messages: false,
|
|
59
|
+
supports_inline_queries: false,
|
|
60
|
+
can_connect_to_business: false,
|
|
61
|
+
has_main_web_app: false,
|
|
62
|
+
},
|
|
63
|
+
client: {
|
|
64
|
+
fetch: (async () => {
|
|
65
|
+
calls.n += 1
|
|
66
|
+
if (calls.n <= failures) throw err
|
|
67
|
+
return okResponse({ message_id: 42, date: 0, chat: { id: 7, type: 'private' } })
|
|
68
|
+
}) as unknown as typeof fetch,
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
return { bot, calls }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('grammy transport failures reach the retry loop as HttpError', () => {
|
|
75
|
+
it('grammy rewrites the transport message so the substring predicate cannot see it', async () => {
|
|
76
|
+
const { bot } = makeBot(new TypeError('fetch failed'), 99)
|
|
77
|
+
|
|
78
|
+
const caught = await bot.api.sendMessage(7, 'hi').then(
|
|
79
|
+
() => null,
|
|
80
|
+
(e: unknown) => e,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
// The shape the retry loop actually receives.
|
|
84
|
+
expect(caught).toBeInstanceOf(HttpError)
|
|
85
|
+
expect(caught).not.toBeInstanceOf(GrammyError)
|
|
86
|
+
expect((caught as HttpError).message).toBe("Network request for 'sendMessage' failed!")
|
|
87
|
+
|
|
88
|
+
// ...and the original transport text is gone, so the substring classifier
|
|
89
|
+
// used by the retry loop returns false on it. This is the defect.
|
|
90
|
+
expect(isTransientNetworkMessage((caught as HttpError).message)).toBe(false)
|
|
91
|
+
|
|
92
|
+
// The cause is preserved on `.error`, which is where the class lives.
|
|
93
|
+
expect((caught as HttpError).error).toBeInstanceOf(TypeError)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('retryApiCall retries a real grammy transport failure', () => {
|
|
98
|
+
for (const [label, cause] of [
|
|
99
|
+
['fetch failed (undici)', new TypeError('fetch failed')],
|
|
100
|
+
['ECONNRESET', Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' })],
|
|
101
|
+
['ETIMEDOUT', Object.assign(new Error('connect ETIMEDOUT'), { code: 'ETIMEDOUT' })],
|
|
102
|
+
['ENOTFOUND', Object.assign(new Error('getaddrinfo ENOTFOUND api.telegram.org'), {
|
|
103
|
+
code: 'ENOTFOUND',
|
|
104
|
+
})],
|
|
105
|
+
] as const) {
|
|
106
|
+
it(`delivers the message after a transient ${label}`, async () => {
|
|
107
|
+
const { bot, calls } = makeBot(cause, 1)
|
|
108
|
+
const retries: string[] = []
|
|
109
|
+
const observer: RetryObserver = {
|
|
110
|
+
onRetry: ({ reason }) => {
|
|
111
|
+
retries.push(reason)
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
const retryApiCall = createRetryApiCall({
|
|
115
|
+
sleep: async () => {},
|
|
116
|
+
observer,
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// OUTCOME: the send actually lands, and the loop actually retried.
|
|
120
|
+
const res = await retryApiCall(() => bot.api.sendMessage(7, 'hi'), {
|
|
121
|
+
verb: 'sendMessage',
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
expect(res).toMatchObject({ message_id: 42 })
|
|
125
|
+
expect(calls.n).toBe(2)
|
|
126
|
+
expect(retries).toEqual(['network'])
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
it('does NOT retry a caller-initiated abort — shutdown must not stall', async () => {
|
|
131
|
+
const aborted = Object.assign(new Error('The operation was aborted'), {
|
|
132
|
+
name: 'AbortError',
|
|
133
|
+
code: 'ABORT_ERR',
|
|
134
|
+
})
|
|
135
|
+
const { bot, calls } = makeBot(aborted, 99)
|
|
136
|
+
const sleeps: number[] = []
|
|
137
|
+
const retryApiCall = createRetryApiCall({
|
|
138
|
+
sleep: async (ms: number) => {
|
|
139
|
+
sleeps.push(ms)
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
await expect(
|
|
144
|
+
retryApiCall(() => bot.api.sendMessage(7, 'hi'), { verb: 'sendMessage' }),
|
|
145
|
+
).rejects.toBeInstanceOf(HttpError)
|
|
146
|
+
|
|
147
|
+
// Terminal on the first attempt, and no backoff was slept.
|
|
148
|
+
expect(calls.n).toBe(1)
|
|
149
|
+
expect(sleeps).toEqual([])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('DOES retry a grammy request timeout', async () => {
|
|
153
|
+
const timedOut = new Error("Request to 'sendMessage' timed out after 500 seconds")
|
|
154
|
+
const { bot, calls } = makeBot(timedOut, 1)
|
|
155
|
+
const retryApiCall = createRetryApiCall({ sleep: async () => {} })
|
|
156
|
+
|
|
157
|
+
const res = await retryApiCall(() => bot.api.sendMessage(7, 'hi'), {
|
|
158
|
+
verb: 'sendMessage',
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
expect(res).toMatchObject({ message_id: 42 })
|
|
162
|
+
expect(calls.n).toBe(2)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('gives up after maxRetries when the transport never recovers', async () => {
|
|
166
|
+
const { bot, calls } = makeBot(new TypeError('fetch failed'), 99)
|
|
167
|
+
const retries: string[] = []
|
|
168
|
+
const giveUps: number[] = []
|
|
169
|
+
const retryApiCall = createRetryApiCall({
|
|
170
|
+
maxRetries: 3,
|
|
171
|
+
sleep: async () => {},
|
|
172
|
+
observer: {
|
|
173
|
+
onRetry: ({ reason }) => retries.push(reason),
|
|
174
|
+
onGiveUp: ({ attempts }) => giveUps.push(attempts),
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
await expect(
|
|
179
|
+
retryApiCall(() => bot.api.sendMessage(7, 'hi'), { verb: 'sendMessage' }),
|
|
180
|
+
).rejects.toBeInstanceOf(HttpError)
|
|
181
|
+
|
|
182
|
+
expect(calls.n).toBe(3)
|
|
183
|
+
expect(retries).toEqual(['network', 'network'])
|
|
184
|
+
expect(giveUps).toEqual([3])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('does NOT retry a real GrammyError 403 that arrives over the same seam', async () => {
|
|
188
|
+
const calls = { n: 0 }
|
|
189
|
+
const bot = new Bot(TOKEN, {
|
|
190
|
+
botInfo: {
|
|
191
|
+
id: 1,
|
|
192
|
+
is_bot: true,
|
|
193
|
+
first_name: 'test',
|
|
194
|
+
username: 'test_bot',
|
|
195
|
+
can_join_groups: true,
|
|
196
|
+
can_read_all_group_messages: false,
|
|
197
|
+
supports_inline_queries: false,
|
|
198
|
+
can_connect_to_business: false,
|
|
199
|
+
has_main_web_app: false,
|
|
200
|
+
},
|
|
201
|
+
client: {
|
|
202
|
+
fetch: (async () => {
|
|
203
|
+
calls.n += 1
|
|
204
|
+
return new Response(
|
|
205
|
+
JSON.stringify({
|
|
206
|
+
ok: false,
|
|
207
|
+
error_code: 403,
|
|
208
|
+
description: 'Forbidden: bot was blocked by the user',
|
|
209
|
+
}),
|
|
210
|
+
{ status: 403, headers: { 'content-type': 'application/json' } },
|
|
211
|
+
)
|
|
212
|
+
}) as unknown as typeof fetch,
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
const retryApiCall = createRetryApiCall({ sleep: async () => {} })
|
|
217
|
+
|
|
218
|
+
await expect(
|
|
219
|
+
retryApiCall(() => bot.api.sendMessage(7, 'hi'), { verb: 'sendMessage' }),
|
|
220
|
+
).rejects.toBeInstanceOf(GrammyError)
|
|
221
|
+
|
|
222
|
+
// Terminal on the first attempt — a 403 must never be retried.
|
|
223
|
+
expect(calls.n).toBe(1)
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('the log tier and the retry policy agree about a transport blip', () => {
|
|
228
|
+
/**
|
|
229
|
+
* Full production composition: retry policy → tg-post transformer → real
|
|
230
|
+
* grammy client → a fetch that blips once. Returns the gateway log lines
|
|
231
|
+
* shaped exactly as `gateway-supervisor.log` carries them, so the REAL
|
|
232
|
+
* fleet-health detector can be run over them.
|
|
233
|
+
*/
|
|
234
|
+
async function gatewayLinesForBlip(failures: number): Promise<{
|
|
235
|
+
lines: string[]
|
|
236
|
+
threw: boolean
|
|
237
|
+
}> {
|
|
238
|
+
const written: string[] = []
|
|
239
|
+
const spy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
|
|
240
|
+
written.push(String(chunk))
|
|
241
|
+
return true
|
|
242
|
+
}) as unknown as typeof process.stderr.write)
|
|
243
|
+
let threw = false
|
|
244
|
+
try {
|
|
245
|
+
const { bot } = makeBot(new TypeError('fetch failed'), failures)
|
|
246
|
+
installTgPostLogger(bot)
|
|
247
|
+
const retryApiCall = createRetryApiCall({ sleep: async () => {} })
|
|
248
|
+
// `sendRichMessage` is the user-facing reply verb the fleet-health
|
|
249
|
+
// `reply-delivery-failure` signature is scoped to (detect.ts:169) — using
|
|
250
|
+
// any other method would make the detector assertions below vacuous.
|
|
251
|
+
await retryApiCall(() =>
|
|
252
|
+
bot.api.sendRichMessage(4242, { markdown: 'the answer' }),
|
|
253
|
+
).catch(() => {
|
|
254
|
+
threw = true
|
|
255
|
+
})
|
|
256
|
+
} finally {
|
|
257
|
+
spy.mockRestore()
|
|
258
|
+
}
|
|
259
|
+
const lines = written
|
|
260
|
+
.filter((l) => l.startsWith('tg-post '))
|
|
261
|
+
.map((l) => `2026-08-01T12:00:00Z gateway: ${l.trimEnd()}`)
|
|
262
|
+
return { lines, threw }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
it('blip-then-ok: the reply lands, the blip logs status=retry, nothing escalates', async () => {
|
|
266
|
+
const { lines, threw } = await gatewayLinesForBlip(1)
|
|
267
|
+
|
|
268
|
+
// Ground truth: the reply WAS delivered. Without this the rest is vacuous.
|
|
269
|
+
expect(threw).toBe(false)
|
|
270
|
+
expect(lines).toHaveLength(2)
|
|
271
|
+
expect(lines[1]).toContain('status=ok')
|
|
272
|
+
|
|
273
|
+
// The blip is on the log, honestly labelled as an ATTEMPT — not an outcome.
|
|
274
|
+
expect(lines[0]).toContain('status=retry')
|
|
275
|
+
expect(lines[0]).toContain('err=HttpError')
|
|
276
|
+
|
|
277
|
+
// THE outcome: fleet-health raises nothing for a delivered reply.
|
|
278
|
+
const joined = lines.join('\n')
|
|
279
|
+
expect(detectGatewayFindings('alpha', joined).gw_hits['reply-delivery-failure']).toBe(0)
|
|
280
|
+
expect(scanAgent('alpha', '', joined).escalate).toBe(false)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('transport down for every attempt: exactly one delivery-failure alert', async () => {
|
|
284
|
+
const { lines, threw } = await gatewayLinesForBlip(99)
|
|
285
|
+
|
|
286
|
+
expect(threw).toBe(true)
|
|
287
|
+
// Three attempts: two survivable, the last one terminal.
|
|
288
|
+
expect(lines).toHaveLength(3)
|
|
289
|
+
expect(lines.filter((l) => l.includes('status=retry'))).toHaveLength(2)
|
|
290
|
+
expect(lines.filter((l) => l.includes('status=err'))).toHaveLength(1)
|
|
291
|
+
|
|
292
|
+
// One alert for one lost answer — not one per attempt, not zero.
|
|
293
|
+
const joined = lines.join('\n')
|
|
294
|
+
expect(detectGatewayFindings('alpha', joined).gw_hits['reply-delivery-failure']).toBe(1)
|
|
295
|
+
expect(scanAgent('alpha', '', joined).escalate).toBe(true)
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
describe('isTransientTransportError keeps the non-retryable classes out', () => {
|
|
300
|
+
it('excludes local resource exhaustion even when grammy wrapped it', () => {
|
|
301
|
+
const enospc = Object.assign(new Error('ENOSPC: no space left on device'), {
|
|
302
|
+
code: 'ENOSPC',
|
|
303
|
+
})
|
|
304
|
+
expect(isTransientTransportError(enospc)).toBe(false)
|
|
305
|
+
// #2923: retrying a local disk failure in a tight loop is what tripped the
|
|
306
|
+
// per-bot flood ban. Wrapping it in an HttpError must not make it retryable.
|
|
307
|
+
expect(
|
|
308
|
+
isTransientTransportError(new HttpError("Network request for 'sendMessage' failed!", enospc)),
|
|
309
|
+
).toBe(false)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('excludes every GrammyError (Telegram answered — that IS the outcome)', () => {
|
|
313
|
+
for (const code of [400, 403, 404]) {
|
|
314
|
+
const err = new GrammyError(
|
|
315
|
+
'Call to sendMessage failed!',
|
|
316
|
+
{ ok: false, error_code: code, description: 'nope' },
|
|
317
|
+
'sendMessage',
|
|
318
|
+
{},
|
|
319
|
+
)
|
|
320
|
+
expect(isTransientTransportError(err)).toBe(false)
|
|
321
|
+
}
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('excludes a caller-initiated abort, but NOT a grammy request timeout', () => {
|
|
325
|
+
// `bot.stop()` / an explicit AbortSignal aborts the fetch. Retrying that
|
|
326
|
+
// three times just re-aborts and adds backoff sleeps to shutdown.
|
|
327
|
+
const aborted = Object.assign(new Error('The operation was aborted'), {
|
|
328
|
+
name: 'AbortError',
|
|
329
|
+
code: 'ABORT_ERR',
|
|
330
|
+
})
|
|
331
|
+
expect(
|
|
332
|
+
isTransientTransportError(
|
|
333
|
+
new HttpError("Network request for 'sendMessage' failed!", aborted),
|
|
334
|
+
),
|
|
335
|
+
).toBe(false)
|
|
336
|
+
|
|
337
|
+
// grammy's OWN timeout is a different object: `createTimeout` rejects the
|
|
338
|
+
// race with a plain Error BEFORE aborting the controller
|
|
339
|
+
// (grammy 1.44.0, out/core/client.js:168-178). That IS transient.
|
|
340
|
+
expect(
|
|
341
|
+
isTransientTransportError(
|
|
342
|
+
new HttpError(
|
|
343
|
+
"Network request for 'sendMessage' failed!",
|
|
344
|
+
new Error("Request to 'sendMessage' timed out after 500 seconds"),
|
|
345
|
+
),
|
|
346
|
+
),
|
|
347
|
+
).toBe(true)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('excludes an unrelated programming error', () => {
|
|
351
|
+
expect(isTransientTransportError(new TypeError('x is not a function'))).toBe(false)
|
|
352
|
+
expect(isTransientTransportError(undefined)).toBe(false)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('accepts a transport error preserved through standard cause-chaining', () => {
|
|
356
|
+
expect(
|
|
357
|
+
isTransientTransportError(
|
|
358
|
+
new Error('send failed', { cause: new TypeError('fetch failed') }),
|
|
359
|
+
),
|
|
360
|
+
).toBe(true)
|
|
361
|
+
})
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
describe('willRetryTelegramFailure agrees with the loop for HttpError', () => {
|
|
365
|
+
it('reports a mid-policy grammy transport failure as retryable', async () => {
|
|
366
|
+
const { bot } = makeBot(new TypeError('fetch failed'), 99)
|
|
367
|
+
const err = (await bot.api.sendMessage(7, 'hi').catch((e: unknown) => e)) as HttpError
|
|
368
|
+
|
|
369
|
+
// Same fact the loop uses: attempt 0 of 3 ⇒ another attempt follows.
|
|
370
|
+
expect(
|
|
371
|
+
willRetryTelegramFailure(
|
|
372
|
+
{ errorCode: null, message: err.message, cause: err },
|
|
373
|
+
{ attempt: 0, maxRetries: 3, maxFloodSleepMs: 120_000 },
|
|
374
|
+
),
|
|
375
|
+
).toBe(true)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('reports the LAST attempt as terminal', async () => {
|
|
379
|
+
const { bot } = makeBot(new TypeError('fetch failed'), 99)
|
|
380
|
+
const err = (await bot.api.sendMessage(7, 'hi').catch((e: unknown) => e)) as HttpError
|
|
381
|
+
|
|
382
|
+
expect(
|
|
383
|
+
willRetryTelegramFailure(
|
|
384
|
+
{ errorCode: null, message: err.message, cause: err },
|
|
385
|
+
{ attempt: 2, maxRetries: 3, maxFloodSleepMs: 120_000 },
|
|
386
|
+
),
|
|
387
|
+
).toBe(false)
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('reports a real GrammyError 403 as terminal', async () => {
|
|
391
|
+
const err = new GrammyError(
|
|
392
|
+
'Call to sendMessage failed!',
|
|
393
|
+
{ ok: false, error_code: 403, description: 'Forbidden' },
|
|
394
|
+
'sendMessage',
|
|
395
|
+
{},
|
|
396
|
+
)
|
|
397
|
+
expect(
|
|
398
|
+
willRetryTelegramFailure(
|
|
399
|
+
{ errorCode: err.error_code, message: err.message, cause: err },
|
|
400
|
+
{ attempt: 0, maxRetries: 3, maxFloodSleepMs: 120_000 },
|
|
401
|
+
),
|
|
402
|
+
).toBe(false)
|
|
403
|
+
})
|
|
404
|
+
})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4065 — the SOCKET-LEVEL proof that hostd actually RECEIVES the
|
|
3
|
+
* `rollout_status_edited` reply over the real transport.
|
|
4
|
+
*
|
|
5
|
+
* Why this file exists
|
|
6
|
+
* --------------------
|
|
7
|
+
* Every other test for this feature fakes the seam that carries the bug:
|
|
8
|
+
* `rollout-status-edit.test.ts` drives `handleRolloutStatusEdit` against an
|
|
9
|
+
* in-process `{ send }` sink, and `rollout-narrator.test.ts` drives the
|
|
10
|
+
* narrator against a fake relay. Both were green while the feature was
|
|
11
|
+
* COMPLETELY INERT in production, because the relay's `edit()` half-closed the
|
|
12
|
+
* socket (`client.end()`) right after writing the request, and the gateway's
|
|
13
|
+
* IPC server is `Bun.listen`, which tears the connection down on the peer FIN
|
|
14
|
+
* (~5ms) instead of keeping it readable. The real reply — which only arrives
|
|
15
|
+
* AFTER a Telegram API round-trip, i.e. hundreds of ms later — was written to a
|
|
16
|
+
* dead socket (`socket.write` returning -1, unchecked by `IpcClientImpl.send`)
|
|
17
|
+
* and never reached hostd. hostd then always resolved
|
|
18
|
+
* `{ok:false, gone:false, reason:"no edit reply"}`, which by design never
|
|
19
|
+
* re-posts: identical behaviour to no fix at all.
|
|
20
|
+
*
|
|
21
|
+
* So this test wires the REAL `createIpcServer` (Bun.listen) to the REAL
|
|
22
|
+
* `SocketRolloutNarrationRelay` over a real unix socket, and makes the reply
|
|
23
|
+
* land only AFTER a delay at least as long as a Telegram round-trip. It fails
|
|
24
|
+
* on the half-close implementation and passes on the hold-open one.
|
|
25
|
+
*
|
|
26
|
+
* Runs under bun (`Bun.listen` is a bun built-in) — vitest-excluded, named by
|
|
27
|
+
* telegram-plugin/scripts/bun-test-ci.sh via the `tests/` target.
|
|
28
|
+
*/
|
|
29
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
30
|
+
import { mkdtempSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { tmpdir } from "node:os";
|
|
33
|
+
import { createIpcServer, type IpcServer, type IpcClient } from "../gateway/ipc-server.js";
|
|
34
|
+
import { handleRolloutStatusEdit } from "../gateway/rollout-status-edit.js";
|
|
35
|
+
import type { RolloutStatusEditMessage } from "../gateway/ipc-protocol.js";
|
|
36
|
+
import { SocketRolloutNarrationRelay } from "../../src/host-control/rollout-narration-relay.js";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The load-bearing constant. A real `editMessageText` costs a network
|
|
40
|
+
* round-trip to api.telegram.org — on the order of 100-500ms, and more under
|
|
41
|
+
* `robustApiCall`'s 429 retry policy. The reply CANNOT be delivered
|
|
42
|
+
* synchronously with the request, which is exactly why a half-closed socket
|
|
43
|
+
* loses it. Anything comfortably above one event-loop turn reproduces it; 300ms
|
|
44
|
+
* is a conservative stand-in for the round-trip while keeping the suite fast.
|
|
45
|
+
*/
|
|
46
|
+
const TELEGRAM_ROUND_TRIP_MS = 300;
|
|
47
|
+
|
|
48
|
+
function tmpSocket(): string {
|
|
49
|
+
const dir = mkdtempSync(join(tmpdir(), "rollout-edit-sock-"));
|
|
50
|
+
return join(dir, "gateway.sock");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function wait(ms: number): Promise<void> {
|
|
54
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Stand up the real IPC server with the real edit handler behind a delayed
|
|
59
|
+
* `editMessage`, and return a real relay pointed at it.
|
|
60
|
+
*/
|
|
61
|
+
function harness(editMessage: () => Promise<unknown>): {
|
|
62
|
+
server: IpcServer;
|
|
63
|
+
relay: SocketRolloutNarrationRelay;
|
|
64
|
+
} {
|
|
65
|
+
const socketPath = tmpSocket();
|
|
66
|
+
const server = createIpcServer({
|
|
67
|
+
socketPath,
|
|
68
|
+
onClientRegistered: () => {},
|
|
69
|
+
onClientDisconnected: () => {},
|
|
70
|
+
onToolCall: async () => ({ type: "tool_call_result", id: "x", success: true }),
|
|
71
|
+
onSessionEvent: () => {},
|
|
72
|
+
onPermissionRequest: () => {},
|
|
73
|
+
onHeartbeat: () => {},
|
|
74
|
+
onScheduleRestart: () => {},
|
|
75
|
+
onRolloutStatusEdit: (client: IpcClient, msg: RolloutStatusEditMessage) =>
|
|
76
|
+
handleRolloutStatusEdit(
|
|
77
|
+
{
|
|
78
|
+
selfAgentName: "carrie",
|
|
79
|
+
operatorChatId: () => "12345",
|
|
80
|
+
editMessage,
|
|
81
|
+
log: () => {},
|
|
82
|
+
},
|
|
83
|
+
client,
|
|
84
|
+
msg,
|
|
85
|
+
),
|
|
86
|
+
});
|
|
87
|
+
const relay = new SocketRolloutNarrationRelay({
|
|
88
|
+
resolveGatewaySocket: () => socketPath,
|
|
89
|
+
editReplyTimeoutMs: 5000,
|
|
90
|
+
});
|
|
91
|
+
return { server, relay };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe("rollout narration edit — real socket, delayed reply (#4065)", () => {
|
|
95
|
+
const servers: IpcServer[] = [];
|
|
96
|
+
|
|
97
|
+
afterEach(async () => {
|
|
98
|
+
for (const s of servers) await s.close();
|
|
99
|
+
servers.length = 0;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("receives an ok reply that arrives a Telegram round-trip after the request", async () => {
|
|
103
|
+
const { server, relay } = harness(async () => {
|
|
104
|
+
await wait(TELEGRAM_ROUND_TRIP_MS);
|
|
105
|
+
return { message_id: 77 };
|
|
106
|
+
});
|
|
107
|
+
servers.push(server);
|
|
108
|
+
await wait(50);
|
|
109
|
+
|
|
110
|
+
const started = Date.now();
|
|
111
|
+
const outcome = await relay.edit({
|
|
112
|
+
requestId: "ro-ok-1",
|
|
113
|
+
agentName: "carrie",
|
|
114
|
+
messageId: 77,
|
|
115
|
+
text: "phase 2/4 — restarting carrie",
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// The outcome must be the gateway's real answer, not the connection-closed
|
|
119
|
+
// fallback the half-close produced.
|
|
120
|
+
expect(outcome).toEqual({ ok: true });
|
|
121
|
+
// And it must genuinely have waited for the round-trip, i.e. the socket
|
|
122
|
+
// stayed alive across it rather than resolving early on close.
|
|
123
|
+
expect(Date.now() - started).toBeGreaterThanOrEqual(TELEGRAM_ROUND_TRIP_MS);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("receives a gone:true classification that arrives after the round-trip", async () => {
|
|
127
|
+
const { server, relay } = harness(async () => {
|
|
128
|
+
await wait(TELEGRAM_ROUND_TRIP_MS);
|
|
129
|
+
throw new Error("Bad Request: message to edit not found");
|
|
130
|
+
});
|
|
131
|
+
servers.push(server);
|
|
132
|
+
await wait(50);
|
|
133
|
+
|
|
134
|
+
const outcome = await relay.edit({
|
|
135
|
+
requestId: "ro-gone-1",
|
|
136
|
+
agentName: "carrie",
|
|
137
|
+
messageId: 77,
|
|
138
|
+
text: "phase 3/4 — verifying carrie",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// This is the whole point of #4065: only an explicit `gone` may trigger the
|
|
142
|
+
// narrator's one-shot re-post. A lost reply degrades to gone:false and the
|
|
143
|
+
// operator keeps staring at a frozen card.
|
|
144
|
+
expect(outcome.ok).toBe(false);
|
|
145
|
+
expect(outcome.gone).toBe(true);
|
|
146
|
+
expect(outcome.reason).toMatch(/message to edit not found/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("receives a transient classification (gone:false) after the round-trip", async () => {
|
|
150
|
+
const { server, relay } = harness(async () => {
|
|
151
|
+
await wait(TELEGRAM_ROUND_TRIP_MS);
|
|
152
|
+
throw new Error("Bad Gateway");
|
|
153
|
+
});
|
|
154
|
+
servers.push(server);
|
|
155
|
+
await wait(50);
|
|
156
|
+
|
|
157
|
+
const outcome = await relay.edit({
|
|
158
|
+
requestId: "ro-transient-1",
|
|
159
|
+
agentName: "carrie",
|
|
160
|
+
messageId: 77,
|
|
161
|
+
text: "phase 4/4 — carrie back up",
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(outcome.ok).toBe(false);
|
|
165
|
+
expect(outcome.gone).toBe(false);
|
|
166
|
+
// A REAL gateway answer, distinguishable from the "no edit reply" the
|
|
167
|
+
// dropped-reply bug produced.
|
|
168
|
+
expect(outcome.reason).toMatch(/Bad Gateway/);
|
|
169
|
+
});
|
|
170
|
+
});
|