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,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4065 follow-up — the SECOND inertness layer: the retry policy's benign-400
|
|
3
|
+
* swallow.
|
|
4
|
+
*
|
|
5
|
+
* Why this file exists
|
|
6
|
+
* --------------------
|
|
7
|
+
* `rollout-status-edit.test.ts` drives `handleRolloutStatusEdit` with an
|
|
8
|
+
* injected `editMessage` that throws a PLAIN `Error`, so `robustApiCall` is
|
|
9
|
+
* never in the loop. Production wires that same `editMessage` through
|
|
10
|
+
* `robustApiCall` (gateway.ts, `onRolloutStatusEdit`), and the retry policy
|
|
11
|
+
* classifies `400 "message to edit not found"` as a BENIGN 400
|
|
12
|
+
* (`classifyBenignTelegram400` → `'message_not_found'`) and RESOLVES
|
|
13
|
+
* `undefined` instead of rethrowing.
|
|
14
|
+
*
|
|
15
|
+
* The handler reads success from the absence of a throw. So on the real path,
|
|
16
|
+
* for the exact scenario the feature was written for — a seeded-resume
|
|
17
|
+
* narrator holding a carried `narration_message_id` whose card the operator
|
|
18
|
+
* deleted — the handler replied `{ok:true}`, hostd recorded a frozen card as
|
|
19
|
+
* live, and no re-post ever happened. Worse than pre-fix: hostd positively
|
|
20
|
+
* believed the card was fine. The `gone` signal was unreachable for its
|
|
21
|
+
* primary trigger while every test for it was green.
|
|
22
|
+
*
|
|
23
|
+
* So this suite refuses the fake seam twice over:
|
|
24
|
+
*
|
|
25
|
+
* - the edit goes through a `robustApiCall` composed EXACTLY as gateway.ts
|
|
26
|
+
* composes it (real `createSendGate.gate` wrapping the real
|
|
27
|
+
* `createRetryApiCall`), with a REAL `GrammyError` carrying Telegram's
|
|
28
|
+
* real `description`;
|
|
29
|
+
* - and the outcome is asserted where the operator feels it — the REAL
|
|
30
|
+
* `LogTailRolloutNarrator`, which must answer a deleted card with exactly
|
|
31
|
+
* one fresh post.
|
|
32
|
+
*
|
|
33
|
+
* A test that only asserted `{ok:false, gone:true}` would not have failed on
|
|
34
|
+
* the original #4065 bug either, so this one asserts the re-post.
|
|
35
|
+
*/
|
|
36
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
37
|
+
import { GrammyError } from 'grammy'
|
|
38
|
+
import { createRetryApiCall, type RetryCallOpts } from '../retry-api-call.js'
|
|
39
|
+
import { createSendGate } from '../send-gate.js'
|
|
40
|
+
import { handleRolloutStatusEdit } from '../gateway/rollout-status-edit.js'
|
|
41
|
+
import type { RolloutStatusEditMessage } from '../gateway/ipc-protocol.js'
|
|
42
|
+
import {
|
|
43
|
+
LogTailRolloutNarrator,
|
|
44
|
+
type RolloutCardEscalation,
|
|
45
|
+
type RolloutEditOutcome,
|
|
46
|
+
type RolloutNarrationRelay,
|
|
47
|
+
} from '../../src/host-control/rollout-narrator.js'
|
|
48
|
+
import type { StatusEntry } from '../../src/host-control/server.js'
|
|
49
|
+
import type { RolloutPhase } from '../../src/cli/rollout.js'
|
|
50
|
+
|
|
51
|
+
/** A real grammy 400, byte-identical in shape to what Bot API returns. */
|
|
52
|
+
function telegram400(description: string): GrammyError {
|
|
53
|
+
return new GrammyError(
|
|
54
|
+
"Call to 'editMessageText' failed!",
|
|
55
|
+
{
|
|
56
|
+
ok: false,
|
|
57
|
+
error_code: 400,
|
|
58
|
+
description: `Bad Request: ${description}`,
|
|
59
|
+
} as never,
|
|
60
|
+
'editMessageText',
|
|
61
|
+
{} as never,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `robustApiCall` composed exactly as gateway.ts:~5740 composes it:
|
|
67
|
+
* `sendGate.gate(() => rawRetry(fn, opts), opts)`. No stubs on either layer —
|
|
68
|
+
* this is the production sandwich, and the bug lived between its two slices.
|
|
69
|
+
*/
|
|
70
|
+
function makeProductionRobustApiCall(): <T>(
|
|
71
|
+
fn: () => Promise<T>,
|
|
72
|
+
opts?: RetryCallOpts,
|
|
73
|
+
) => Promise<T> {
|
|
74
|
+
const rawRetry = createRetryApiCall({
|
|
75
|
+
// Only the sleep is injected (fake timers would otherwise stall the
|
|
76
|
+
// network-retry backoff); every classification branch is the real one.
|
|
77
|
+
sleep: async () => {},
|
|
78
|
+
})
|
|
79
|
+
const sendGate = createSendGate({ initialWindows: [], bootRamp: {} })
|
|
80
|
+
return <T>(fn: () => Promise<T>, opts?: RetryCallOpts): Promise<T> =>
|
|
81
|
+
sendGate.gate(() => rawRetry(fn, opts), opts) as Promise<T>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The gateway's `onRolloutStatusEdit` deps, reproduced with the SAME
|
|
86
|
+
* `robustApiCall` opts the production call site passes — including
|
|
87
|
+
* `rethrowBenign400: true`, the fix under test.
|
|
88
|
+
*/
|
|
89
|
+
function editThroughGateway(
|
|
90
|
+
fail: (() => never) | null,
|
|
91
|
+
seen: { edits: number },
|
|
92
|
+
): (chatId: string | number, messageId: number, text: string) => Promise<unknown> {
|
|
93
|
+
const robustApiCall = makeProductionRobustApiCall()
|
|
94
|
+
return (chatId, messageId, _text) =>
|
|
95
|
+
robustApiCall(
|
|
96
|
+
async () => {
|
|
97
|
+
seen.edits++
|
|
98
|
+
if (fail) fail()
|
|
99
|
+
return { message_id: messageId }
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
chat_id: String(chatId),
|
|
103
|
+
verb: 'rollout-status-edit',
|
|
104
|
+
priorityClass: 'critical',
|
|
105
|
+
rethrowBenign400: true,
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Run one `rollout_status_edit` end-to-end and return the reply hostd sees. */
|
|
111
|
+
async function relayOneEdit(
|
|
112
|
+
fail: (() => never) | null,
|
|
113
|
+
messageId = 4242,
|
|
114
|
+
): Promise<{ reply: Record<string, unknown> | undefined; edits: number }> {
|
|
115
|
+
const seen = { edits: 0 }
|
|
116
|
+
const sent: Record<string, unknown>[] = []
|
|
117
|
+
await handleRolloutStatusEdit(
|
|
118
|
+
{
|
|
119
|
+
selfAgentName: 'overlord',
|
|
120
|
+
operatorChatId: () => 12345,
|
|
121
|
+
editMessage: editThroughGateway(fail, seen),
|
|
122
|
+
log: () => {},
|
|
123
|
+
},
|
|
124
|
+
{ send: (m) => void sent.push(m as unknown as Record<string, unknown>) },
|
|
125
|
+
{
|
|
126
|
+
type: 'rollout_status_edit',
|
|
127
|
+
requestId: 'ro-1',
|
|
128
|
+
agentName: 'overlord',
|
|
129
|
+
messageId,
|
|
130
|
+
text: 'phase: self-bump-done',
|
|
131
|
+
} as RolloutStatusEditMessage,
|
|
132
|
+
)
|
|
133
|
+
return { reply: sent[0], edits: seen.edits }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
describe('rollout-status-edit through the REAL retry policy', () => {
|
|
137
|
+
it('a DELETED card surfaces gone:true (the benign-400 swallow is opted out of)', async () => {
|
|
138
|
+
// Before the fix this resolved `{ok:true}` — the swallow ate the 400 and
|
|
139
|
+
// the handler saw no throw. That is the whole bug.
|
|
140
|
+
const { reply, edits } = await relayOneEdit(() => {
|
|
141
|
+
throw telegram400('message to edit not found')
|
|
142
|
+
})
|
|
143
|
+
expect(edits).toBe(1) // not retried — a benign 400 is terminal
|
|
144
|
+
expect(reply).toMatchObject({
|
|
145
|
+
type: 'rollout_status_edited',
|
|
146
|
+
requestId: 'ro-1',
|
|
147
|
+
ok: false,
|
|
148
|
+
gone: true,
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('"message is not modified" is a SUCCESS, not a gone (the card is live and correct)', async () => {
|
|
153
|
+
// Also swallowed by default; rethrowing it puts ONE classifier in charge
|
|
154
|
+
// rather than leaving the right answer to a coincidence.
|
|
155
|
+
const { reply } = await relayOneEdit(() => {
|
|
156
|
+
throw telegram400('message is not modified')
|
|
157
|
+
})
|
|
158
|
+
expect(reply).toMatchObject({ type: 'rollout_status_edited', ok: true })
|
|
159
|
+
expect(reply).not.toHaveProperty('gone')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("\"message can't be edited\" is also gone", async () => {
|
|
163
|
+
const { reply } = await relayOneEdit(() => {
|
|
164
|
+
throw telegram400("message can't be edited")
|
|
165
|
+
})
|
|
166
|
+
expect(reply).toMatchObject({ ok: false, gone: true })
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('a NON-benign rejection stays transient (no duplicate card)', async () => {
|
|
170
|
+
const { reply } = await relayOneEdit(() => {
|
|
171
|
+
throw telegram400('chat not found')
|
|
172
|
+
})
|
|
173
|
+
expect(reply).toMatchObject({ ok: false, gone: false })
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('an applied edit still replies ok:true', async () => {
|
|
177
|
+
const { reply, edits } = await relayOneEdit(null)
|
|
178
|
+
expect(edits).toBe(1)
|
|
179
|
+
expect(reply).toEqual({ type: 'rollout_status_edited', requestId: 'ro-1', ok: true })
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('rethrowBenign400 is scoped — other callers keep the swallow', () => {
|
|
184
|
+
it('a call WITHOUT the opt-out still resolves undefined on a benign 400', async () => {
|
|
185
|
+
const robustApiCall = makeProductionRobustApiCall()
|
|
186
|
+
const res = await robustApiCall(
|
|
187
|
+
async () => {
|
|
188
|
+
throw telegram400('message to edit not found')
|
|
189
|
+
},
|
|
190
|
+
{ chat_id: '12345', verb: 'some-card-repaint' },
|
|
191
|
+
)
|
|
192
|
+
expect(res).toBeUndefined()
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('the benign observer still fires when the error is rethrown', async () => {
|
|
196
|
+
const kinds: string[] = []
|
|
197
|
+
const retry = createRetryApiCall({
|
|
198
|
+
sleep: async () => {},
|
|
199
|
+
observer: { onBenign: ({ kind }) => void kinds.push(kind) },
|
|
200
|
+
})
|
|
201
|
+
await expect(
|
|
202
|
+
retry(
|
|
203
|
+
async () => {
|
|
204
|
+
throw telegram400('message to edit not found')
|
|
205
|
+
},
|
|
206
|
+
{ rethrowBenign400: true },
|
|
207
|
+
),
|
|
208
|
+
).rejects.toBeInstanceOf(GrammyError)
|
|
209
|
+
expect(kinds).toEqual(['message_not_found'])
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// ── The operator-visible outcome: a deleted card gets a fresh one ────────────
|
|
214
|
+
//
|
|
215
|
+
// The reply shape above is a means, not the end. What the operator feels is
|
|
216
|
+
// whether a roll whose card was deleted still ends on a truthful ✅/❌. So the
|
|
217
|
+
// last test drives the REAL narrator whose relay is the REAL handler over the
|
|
218
|
+
// REAL retry policy, and asserts the re-post.
|
|
219
|
+
|
|
220
|
+
function makeEntry(over: Partial<StatusEntry> = {}): StatusEntry {
|
|
221
|
+
return {
|
|
222
|
+
request_id: 'ro-1',
|
|
223
|
+
caller: { kind: 'agent', name: 'overlord' },
|
|
224
|
+
op: 'rollout',
|
|
225
|
+
result: 'started',
|
|
226
|
+
exit_code: null,
|
|
227
|
+
started_at: Date.now(),
|
|
228
|
+
finished_at: null,
|
|
229
|
+
stdout_tail: '',
|
|
230
|
+
stderr_tail: '',
|
|
231
|
+
pin: 'v1.2.3',
|
|
232
|
+
...over,
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const phase = (p: string, over: Partial<RolloutPhase> = {}): RolloutPhase =>
|
|
237
|
+
({ phase: p as RolloutPhase['phase'], target: 'v1.2.3', ...over }) as RolloutPhase
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Drive the narrator's debounce to quiescence.
|
|
241
|
+
*
|
|
242
|
+
* This file is swept by BOTH runners (vitest and the `bun test` sweep over
|
|
243
|
+
* telegram-plugin/), so it must stay on the dual-run-safe timer API: SYNC
|
|
244
|
+
* `advanceTimersByTime` + a microtask flush, never `vi.runAllTimersAsync`
|
|
245
|
+
* (undefined under bun — same trap as tests/draft-stream.test.ts). Looped,
|
|
246
|
+
* because a debounced flush awaits an async relay round-trip that can arm the
|
|
247
|
+
* next timer.
|
|
248
|
+
*/
|
|
249
|
+
async function drainNarrator(): Promise<void> {
|
|
250
|
+
for (let i = 0; i < 12; i++) {
|
|
251
|
+
vi.advanceTimersByTime(2000)
|
|
252
|
+
for (let j = 0; j < 12; j++) await Promise.resolve()
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* A narration relay whose `edit()` runs the REAL gateway handler over the REAL
|
|
258
|
+
* retry policy and maps the reply exactly as `SocketRolloutNarrationRelay`
|
|
259
|
+
* does (`ok===true` → `{ok:true}`, else `{ok:false, gone: obj.gone===true}`).
|
|
260
|
+
* The socket transport between them is pinned separately by
|
|
261
|
+
* `rollout-narration-edit-socket.test.ts`; what is under test here is the
|
|
262
|
+
* classification, which has to happen where the error still exists.
|
|
263
|
+
*/
|
|
264
|
+
function makeRealPathRelay(
|
|
265
|
+
deadMessageIds: Set<number>,
|
|
266
|
+
nextMessageId: number | null,
|
|
267
|
+
): RolloutNarrationRelay & { posts: number[]; edits: { messageId: number; text: string }[] } {
|
|
268
|
+
const posts: number[] = []
|
|
269
|
+
const edits: { messageId: number; text: string }[] = []
|
|
270
|
+
return {
|
|
271
|
+
posts,
|
|
272
|
+
edits,
|
|
273
|
+
async post() {
|
|
274
|
+
posts.push(nextMessageId ?? -1)
|
|
275
|
+
return nextMessageId
|
|
276
|
+
},
|
|
277
|
+
async edit(args): Promise<RolloutEditOutcome> {
|
|
278
|
+
edits.push({ messageId: args.messageId, text: args.text })
|
|
279
|
+
const dead = deadMessageIds.has(args.messageId)
|
|
280
|
+
const seen = { edits: 0 }
|
|
281
|
+
const sent: Record<string, unknown>[] = []
|
|
282
|
+
await handleRolloutStatusEdit(
|
|
283
|
+
{
|
|
284
|
+
selfAgentName: args.agentName,
|
|
285
|
+
operatorChatId: () => 12345,
|
|
286
|
+
editMessage: editThroughGateway(
|
|
287
|
+
dead
|
|
288
|
+
? () => {
|
|
289
|
+
throw telegram400('message to edit not found')
|
|
290
|
+
}
|
|
291
|
+
: null,
|
|
292
|
+
seen,
|
|
293
|
+
),
|
|
294
|
+
log: () => {},
|
|
295
|
+
},
|
|
296
|
+
{ send: (m) => void sent.push(m as unknown as Record<string, unknown>) },
|
|
297
|
+
{
|
|
298
|
+
type: 'rollout_status_edit',
|
|
299
|
+
requestId: args.requestId,
|
|
300
|
+
agentName: args.agentName,
|
|
301
|
+
messageId: args.messageId,
|
|
302
|
+
text: args.text,
|
|
303
|
+
} as RolloutStatusEditMessage,
|
|
304
|
+
)
|
|
305
|
+
const reply = sent[0]
|
|
306
|
+
if (reply?.ok === true) return { ok: true }
|
|
307
|
+
return {
|
|
308
|
+
ok: false,
|
|
309
|
+
gone: reply?.gone === true,
|
|
310
|
+
...(typeof reply?.reason === 'string' ? { reason: reply.reason } : {}),
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
describe('a deleted rollout card produces a re-post, driven through the real retry policy', () => {
|
|
317
|
+
beforeEach(() => void vi.useFakeTimers())
|
|
318
|
+
afterEach(() => void vi.useRealTimers())
|
|
319
|
+
|
|
320
|
+
it('seeded narrator + operator-deleted card → exactly one fresh post, terminal lands on it', async () => {
|
|
321
|
+
// Card 4242 was carried across the self-bump and then deleted by the
|
|
322
|
+
// operator. 5555 (the re-post) is editable.
|
|
323
|
+
const relay = makeRealPathRelay(new Set([4242]), 5555)
|
|
324
|
+
const escalations: RolloutCardEscalation[] = []
|
|
325
|
+
const n = new LogTailRolloutNarrator(relay, {
|
|
326
|
+
debounceMs: 1000,
|
|
327
|
+
escalate: (e) => void escalations.push(e),
|
|
328
|
+
})
|
|
329
|
+
const entry = makeEntry()
|
|
330
|
+
|
|
331
|
+
n.seedPostedMessage('ro-1', 'overlord', 4242)
|
|
332
|
+
n.onPhase(entry, phase('self-bump-done'))
|
|
333
|
+
await drainNarrator()
|
|
334
|
+
|
|
335
|
+
// THE ASSERTION THIS WHOLE FILE EXISTS FOR. With the benign-400 swallow in
|
|
336
|
+
// place the handler replied ok:true, the narrator saw a healthy edit, and
|
|
337
|
+
// `posts` stayed empty forever.
|
|
338
|
+
expect(relay.posts).toEqual([5555])
|
|
339
|
+
|
|
340
|
+
n.onTerminal(makeEntry({ result: 'completed', rolled: ['a', 'b'] }))
|
|
341
|
+
await drainNarrator()
|
|
342
|
+
|
|
343
|
+
// Exactly one re-post (no storm), and the operator's live card carries the
|
|
344
|
+
// terminal outcome.
|
|
345
|
+
expect(relay.posts).toEqual([5555])
|
|
346
|
+
expect(relay.edits.at(-1)!.messageId).toBe(5555)
|
|
347
|
+
expect(relay.edits.at(-1)!.text).toContain('✅')
|
|
348
|
+
// A restored card is not a failure.
|
|
349
|
+
expect(escalations).toEqual([])
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('a live card is never re-posted (no duplicate on the happy path)', async () => {
|
|
353
|
+
const relay = makeRealPathRelay(new Set(), 5555)
|
|
354
|
+
const n = new LogTailRolloutNarrator(relay, { debounceMs: 1000 })
|
|
355
|
+
const entry = makeEntry()
|
|
356
|
+
|
|
357
|
+
n.seedPostedMessage('ro-1', 'overlord', 4242)
|
|
358
|
+
n.onPhase(entry, phase('self-bump-done'))
|
|
359
|
+
await drainNarrator()
|
|
360
|
+
n.onTerminal(makeEntry({ result: 'completed', rolled: ['a'] }))
|
|
361
|
+
await drainNarrator()
|
|
362
|
+
|
|
363
|
+
expect(relay.posts).toEqual([])
|
|
364
|
+
expect(relay.edits.at(-1)!.messageId).toBe(4242)
|
|
365
|
+
})
|
|
366
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4065 — the rollout card's edit relay must tell hostd the TRUTH about an
|
|
3
|
+
* edit, so a seeded-resume narrator can notice it is editing a card that no
|
|
4
|
+
* longer exists instead of leaving the operator with a frozen card.
|
|
5
|
+
*
|
|
6
|
+
* Outcome under test (gateway half): "message to edit not found" replies
|
|
7
|
+
* `gone:true` (the only class that may trigger a re-post), everything else
|
|
8
|
+
* replies `gone:false`, and "message is not modified" is a SUCCESS — the live
|
|
9
|
+
* card already carries that exact body, so re-posting it would duplicate a
|
|
10
|
+
* perfectly good card.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest'
|
|
14
|
+
import {
|
|
15
|
+
classifyRolloutEditError,
|
|
16
|
+
handleRolloutStatusEdit,
|
|
17
|
+
type RolloutStatusEditDeps,
|
|
18
|
+
} from '../gateway/rollout-status-edit.js'
|
|
19
|
+
import type {
|
|
20
|
+
RolloutStatusEditMessage,
|
|
21
|
+
RolloutStatusEditedEvent,
|
|
22
|
+
} from '../gateway/ipc-protocol.js'
|
|
23
|
+
|
|
24
|
+
const MSG: RolloutStatusEditMessage = {
|
|
25
|
+
type: 'rollout_status_edit',
|
|
26
|
+
requestId: 'ro-1',
|
|
27
|
+
agentName: 'overlord',
|
|
28
|
+
messageId: 4242,
|
|
29
|
+
text: 'rolling…',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function harness(
|
|
33
|
+
over: Partial<RolloutStatusEditDeps> = {},
|
|
34
|
+
): { deps: RolloutStatusEditDeps; sent: RolloutStatusEditedEvent[]; client: { send(m: RolloutStatusEditedEvent): void } } {
|
|
35
|
+
const sent: RolloutStatusEditedEvent[] = []
|
|
36
|
+
const deps: RolloutStatusEditDeps = {
|
|
37
|
+
selfAgentName: 'overlord',
|
|
38
|
+
operatorChatId: () => 12345,
|
|
39
|
+
editMessage: async () => ({ message_id: 4242 }),
|
|
40
|
+
log: () => {},
|
|
41
|
+
...over,
|
|
42
|
+
}
|
|
43
|
+
return { deps, sent, client: { send: (m) => sent.push(m) } }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A grammy-shaped API error (the real thing carries `description`). */
|
|
47
|
+
function tgError(description: string): Error & { description: string } {
|
|
48
|
+
const e = new Error(description) as Error & { description: string }
|
|
49
|
+
e.description = description
|
|
50
|
+
return e
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('classifyRolloutEditError', () => {
|
|
54
|
+
it('classifies a deleted / uneditable target as gone', () => {
|
|
55
|
+
expect(
|
|
56
|
+
classifyRolloutEditError(tgError('Bad Request: message to edit not found')),
|
|
57
|
+
).toBe('gone')
|
|
58
|
+
expect(
|
|
59
|
+
classifyRolloutEditError(tgError("Bad Request: message can't be edited")),
|
|
60
|
+
).toBe('gone')
|
|
61
|
+
expect(classifyRolloutEditError(tgError('Bad Request: MESSAGE_ID_INVALID'))).toBe(
|
|
62
|
+
'gone',
|
|
63
|
+
)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('classifies an unchanged body as not-modified (the card is live)', () => {
|
|
67
|
+
expect(
|
|
68
|
+
classifyRolloutEditError(
|
|
69
|
+
tgError('Bad Request: message is not modified: specified new message content...'),
|
|
70
|
+
),
|
|
71
|
+
).toBe('not-modified')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('classifies everything else as transient — a re-post would duplicate a live card', () => {
|
|
75
|
+
expect(classifyRolloutEditError(tgError('Too Many Requests: retry after 12'))).toBe(
|
|
76
|
+
'transient',
|
|
77
|
+
)
|
|
78
|
+
expect(classifyRolloutEditError(new Error('socket hang up'))).toBe('transient')
|
|
79
|
+
expect(classifyRolloutEditError(undefined)).toBe('transient')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('handleRolloutStatusEdit', () => {
|
|
84
|
+
it('replies ok on a successful edit', async () => {
|
|
85
|
+
const h = harness()
|
|
86
|
+
await handleRolloutStatusEdit(h.deps, h.client, MSG)
|
|
87
|
+
expect(h.sent).toEqual([{ type: 'rollout_status_edited', requestId: 'ro-1', ok: true }])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('replies gone:true when the card no longer exists (the #4065 signal)', async () => {
|
|
91
|
+
const h = harness({
|
|
92
|
+
editMessage: async () => {
|
|
93
|
+
throw tgError('Bad Request: message to edit not found')
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
await handleRolloutStatusEdit(h.deps, h.client, MSG)
|
|
97
|
+
expect(h.sent).toHaveLength(1)
|
|
98
|
+
expect(h.sent[0]).toMatchObject({ requestId: 'ro-1', ok: false, gone: true })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('replies gone:false on a transient failure', async () => {
|
|
102
|
+
const h = harness({
|
|
103
|
+
editMessage: async () => {
|
|
104
|
+
throw tgError('Too Many Requests: retry after 12')
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
await handleRolloutStatusEdit(h.deps, h.client, MSG)
|
|
108
|
+
expect(h.sent[0]).toMatchObject({ ok: false, gone: false })
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('treats "not modified" as success — the live card already says this', async () => {
|
|
112
|
+
const h = harness({
|
|
113
|
+
editMessage: async () => {
|
|
114
|
+
throw tgError('Bad Request: message is not modified')
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
await handleRolloutStatusEdit(h.deps, h.client, MSG)
|
|
118
|
+
expect(h.sent).toEqual([{ type: 'rollout_status_edited', requestId: 'ro-1', ok: true }])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('rejects an agent mismatch without editing, and never reports gone', async () => {
|
|
122
|
+
let edits = 0
|
|
123
|
+
const h = harness({
|
|
124
|
+
selfAgentName: 'someone-else',
|
|
125
|
+
editMessage: async () => {
|
|
126
|
+
edits++
|
|
127
|
+
return {}
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
await handleRolloutStatusEdit(h.deps, h.client, MSG)
|
|
131
|
+
expect(edits).toBe(0)
|
|
132
|
+
expect(h.sent[0]).toMatchObject({ ok: false, gone: false, reason: 'agent mismatch' })
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('replies (never throws) when there is no operator chat', async () => {
|
|
136
|
+
const h = harness({ operatorChatId: () => undefined })
|
|
137
|
+
await handleRolloutStatusEdit(h.deps, h.client, MSG)
|
|
138
|
+
expect(h.sent[0]).toMatchObject({ ok: false, gone: false, reason: 'no operator chat' })
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* OPERATOR chat (allowFrom[0]) as an ORDINARY message (no pin, no
|
|
11
11
|
* inline_keyboard card), and replies with the message_id.
|
|
12
12
|
* 3. the EDIT handler edits in place via the wrapped retry path
|
|
13
|
-
* (
|
|
13
|
+
* (robustApiCall) — never a raw bot.api call — and, since #4065, replies
|
|
14
|
+
* with the outcome so hostd can tell an applied edit from an edit into a
|
|
15
|
+
* deleted card. Its body lives in gateway/rollout-status-edit.ts.
|
|
14
16
|
* 4. NEITHER handler pins a message — this is in-chat narration, not the
|
|
15
17
|
* retired pinned card (chat-is-the-single-source-of-truth).
|
|
16
18
|
*/
|
|
@@ -19,6 +21,10 @@ import { readFileSync } from "node:fs";
|
|
|
19
21
|
|
|
20
22
|
const gw = readFileSync(new URL("../gateway/gateway.ts", import.meta.url), "utf8");
|
|
21
23
|
const ipcServer = readFileSync(new URL("../gateway/ipc-server.ts", import.meta.url), "utf8");
|
|
24
|
+
const editModule = readFileSync(
|
|
25
|
+
new URL("../gateway/rollout-status-edit.ts", import.meta.url),
|
|
26
|
+
"utf8",
|
|
27
|
+
);
|
|
22
28
|
|
|
23
29
|
describe("rollout_status_* — ipc-server routing", () => {
|
|
24
30
|
it("validates + dispatches rollout_status_post", () => {
|
|
@@ -61,21 +67,48 @@ describe("onRolloutStatusPost — gateway handler invariants", () => {
|
|
|
61
67
|
});
|
|
62
68
|
|
|
63
69
|
describe("onRolloutStatusEdit — gateway handler invariants", () => {
|
|
64
|
-
|
|
70
|
+
// #4065 moved the handler BODY into gateway/rollout-status-edit.ts (which has
|
|
71
|
+
// its own behavioural suite, rollout-status-edit.test.ts). gateway.ts keeps
|
|
72
|
+
// only the wiring, so the invariants are pinned across both files.
|
|
73
|
+
const start = gw.indexOf("onRolloutStatusEdit(client: IpcClient, msg: RolloutStatusEditMessage)");
|
|
65
74
|
const next = gw.indexOf("onInjectInbound(", start);
|
|
66
75
|
const handler = gw.slice(start, next > start ? next : start + 2000);
|
|
67
76
|
|
|
68
77
|
it("the handler exists", () => {
|
|
69
78
|
expect(start).toBeGreaterThan(0);
|
|
70
79
|
});
|
|
71
|
-
it("
|
|
72
|
-
expect(handler).toMatch(/
|
|
80
|
+
it("delegates to the extracted edit handler", () => {
|
|
81
|
+
expect(handler).toMatch(/handleRolloutStatusEdit\(/);
|
|
82
|
+
expect(gw).toMatch(
|
|
83
|
+
/import \{ handleRolloutStatusEdit \} from '\.\/rollout-status-edit\.js'/,
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
it("fences the agent (agentName must match this gateway's own)", () => {
|
|
87
|
+
expect(handler).toMatch(/selfAgentName: process\.env\.SWITCHROOM_AGENT_NAME/);
|
|
88
|
+
expect(editModule).toMatch(/msg\.agentName !== self/);
|
|
89
|
+
});
|
|
90
|
+
it("edits the OPERATOR chat in place via the wrapped retry path", () => {
|
|
91
|
+
expect(handler).toMatch(/loadAccess\(\)\.allowFrom\[0\]/);
|
|
92
|
+
expect(handler).toMatch(/robustApiCall\(/);
|
|
93
|
+
expect(handler).toMatch(/bot\.api\.editMessageText\(chatId, messageId/);
|
|
94
|
+
});
|
|
95
|
+
// Routing through robustApiCall is necessary but NOT sufficient, and pinning
|
|
96
|
+
// only that enshrines the bug it hides: the retry policy's benign-400 swallow
|
|
97
|
+
// resolves `400 "message to edit not found"` instead of throwing, and this
|
|
98
|
+
// handler reads success from the absence of a throw. Both opt-outs are what
|
|
99
|
+
// make the wrapped path safe here, so both are pinned. The BEHAVIOUR lives in
|
|
100
|
+
// tests/rollout-status-edit-retry-policy.test.ts (real retry policy, real
|
|
101
|
+
// GrammyError, asserts the re-post); this is the structural backstop.
|
|
102
|
+
it("opts out of BOTH inertness layers — send-gate shed and benign-400 swallow", () => {
|
|
103
|
+
expect(handler).toMatch(/priorityClass: 'critical'/);
|
|
104
|
+
expect(handler).toMatch(/rethrowBenign400: true/);
|
|
73
105
|
});
|
|
74
|
-
it("
|
|
75
|
-
expect(
|
|
76
|
-
expect(
|
|
106
|
+
it("reports the edit outcome back to hostd (#4065 — no more edit-into-void)", () => {
|
|
107
|
+
expect(editModule).toMatch(/rollout_status_edited/);
|
|
108
|
+
expect(editModule).toMatch(/gone: cls === 'gone'/);
|
|
77
109
|
});
|
|
78
110
|
it("does NOT pin (edits an ordinary message)", () => {
|
|
79
111
|
expect(handler).not.toMatch(/pinChatMessage/);
|
|
112
|
+
expect(editModule).not.toMatch(/pinChatMessage/);
|
|
80
113
|
});
|
|
81
114
|
});
|
|
@@ -77,6 +77,10 @@ afterEach(() => {
|
|
|
77
77
|
})
|
|
78
78
|
|
|
79
79
|
describe('tg-post logger — resolved ok:false responses (#3927)', () => {
|
|
80
|
+
// These calls are made directly on `bot.api.*` with NO enclosing retry
|
|
81
|
+
// policy, so every rejection here IS the outcome and stays `status=err`.
|
|
82
|
+
// The `status=retry` tier (#3931) only applies inside `createRetryApiCall`
|
|
83
|
+
// — see `tg-post-retry-attribution.test.ts`.
|
|
80
84
|
it('logs a 429 flood-wait as status=err code=429 (FAILS before the fix: logged status=ok)', async () => {
|
|
81
85
|
const lines = await tgPostLinesFor({
|
|
82
86
|
ok: false,
|