switchroom 0.19.3 → 0.19.5
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/auth-broker/index.js +104 -11
- package/dist/cli/autoaccept-poll.js +8 -2
- package/dist/cli/switchroom.js +20 -5
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +67 -4
- package/telegram-plugin/dist/gateway/gateway.js +524 -293
- package/telegram-plugin/gateway/command-format.ts +253 -0
- package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
- package/telegram-plugin/gateway/gateway.ts +97 -255
- package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
- package/telegram-plugin/gateway/session-model-file.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +18 -1
- package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
- package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
- package/telegram-plugin/render/line-start-guard.ts +76 -4
- package/telegram-plugin/rich-send.ts +8 -1
- package/telegram-plugin/tests/command-format.test.ts +212 -0
- package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
- package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
- package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
- package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
- package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
- package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
- package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
- package/telegram-plugin/tests/silent-end.test.ts +60 -5
- package/telegram-plugin/tests/worker-feed-origin-race-defer.test.ts +321 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker-feed origin-race defer (DM-misrouted worker card).
|
|
3
|
+
*
|
|
4
|
+
* Root cause: the gateway picks a worker card's destination on the FIRST
|
|
5
|
+
* progress tick of a new sub-agent. That tick can beat the async
|
|
6
|
+
* `jsonl_agent_id` backfill that links the sub-agent's registry row to its
|
|
7
|
+
* origin turn (retried ~every 3s). Until linked, origin resolution returns
|
|
8
|
+
* null and the destination hard-falls back to the owner DM — CREATING the card
|
|
9
|
+
* there. The origin (supergroup + forum topic) resolves ~3s later, but the DM
|
|
10
|
+
* card already exists and stays the visible one.
|
|
11
|
+
*
|
|
12
|
+
* Fix (approach #1 — defer): when the agent is not yet linked AND no card
|
|
13
|
+
* exists yet, SKIP card creation for that tick. The watcher re-fires within
|
|
14
|
+
* seconds; once the backfill links the row the card is created in the correct
|
|
15
|
+
* chat+topic.
|
|
16
|
+
*
|
|
17
|
+
* These tests exercise the REAL exported decision function
|
|
18
|
+
* `decideWorkerFeedDestination` (worker-feed-dispatch.ts) — the SAME code the
|
|
19
|
+
* gateway's `onProgress` block calls (issue #3460) — plus, in the integration
|
|
20
|
+
* case, the REAL `createWorkerActivityFeed` with a mock Bot API that records
|
|
21
|
+
* every send. A regression in the gateway's defer/route decision now fails a
|
|
22
|
+
* test, because the test binds to the shared function the gateway runs, not to
|
|
23
|
+
* a hand-rolled replica.
|
|
24
|
+
*/
|
|
25
|
+
import { describe, it, expect } from 'vitest'
|
|
26
|
+
import { decideWorkerFeedDestination } from '../gateway/worker-feed-dispatch.js'
|
|
27
|
+
import {
|
|
28
|
+
createWorkerActivityFeed,
|
|
29
|
+
type BotApiForWorkerFeed,
|
|
30
|
+
type WorkerActivityView,
|
|
31
|
+
} from '../worker-activity-feed.js'
|
|
32
|
+
|
|
33
|
+
const MAX = 10
|
|
34
|
+
const OWNER_DM = '111111'
|
|
35
|
+
const ORIGIN_CHAT = '-1002000000000' // supergroup
|
|
36
|
+
const ORIGIN_THREAD = 42 // forum topic
|
|
37
|
+
|
|
38
|
+
// The gateway resolves the owner DM from access config; the extracted pure
|
|
39
|
+
// function takes it as an explicit input. All calls in this suite pass the
|
|
40
|
+
// same owner DM so a fall-through to it is observable.
|
|
41
|
+
function decide(overrides: Partial<Parameters<typeof decideWorkerFeedDestination>[0]>) {
|
|
42
|
+
return decideWorkerFeedDestination({
|
|
43
|
+
origin: null,
|
|
44
|
+
cardExists: false,
|
|
45
|
+
priorDeferrals: 0,
|
|
46
|
+
maxDeferrals: MAX,
|
|
47
|
+
fleetChatId: '',
|
|
48
|
+
stampChatId: undefined,
|
|
49
|
+
stampThreadId: undefined,
|
|
50
|
+
ownerDm: OWNER_DM,
|
|
51
|
+
...overrides,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('decideWorkerFeedDestination (origin-race defer + routing decision)', () => {
|
|
56
|
+
it('(a) unlinked + no card → DEFER (no paint)', () => {
|
|
57
|
+
const out = decide({ origin: null, cardExists: false, priorDeferrals: 0 })
|
|
58
|
+
expect(out.action).toBe('defer')
|
|
59
|
+
if (out.action === 'defer') expect(out.deferrals).toBe(1)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('(b) linked → PAINT to the origin chat + forum thread', () => {
|
|
63
|
+
const out = decide({
|
|
64
|
+
origin: { chatId: ORIGIN_CHAT, threadId: ORIGIN_THREAD },
|
|
65
|
+
cardExists: false,
|
|
66
|
+
priorDeferrals: 3,
|
|
67
|
+
})
|
|
68
|
+
expect(out.action).toBe('paint')
|
|
69
|
+
if (out.action === 'paint') {
|
|
70
|
+
expect(out.chatId).toBe(ORIGIN_CHAT)
|
|
71
|
+
expect(out.threadId).toBe(ORIGIN_THREAD)
|
|
72
|
+
expect(out.deferrals).toBe(0)
|
|
73
|
+
expect(out.exhausted).toBe(false)
|
|
74
|
+
expect(out.ownerDmFallback).toBe(false)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('(c) exhausted defer (never-backfill, forum) → PAINT to the stamp chat + stamp FORUM THREAD, not owner DM', () => {
|
|
79
|
+
// priorDeferrals = MAX-1 → this tick is the MAX-th: paint anyway. No fleet
|
|
80
|
+
// chat is configured, so the destination is the live turn's chat AND its
|
|
81
|
+
// forum topic (stamp-turn fallback, #3458) — NOT General, NOT the owner DM.
|
|
82
|
+
const out = decide({
|
|
83
|
+
origin: null,
|
|
84
|
+
cardExists: false,
|
|
85
|
+
priorDeferrals: MAX - 1,
|
|
86
|
+
fleetChatId: '',
|
|
87
|
+
stampChatId: ORIGIN_CHAT,
|
|
88
|
+
stampThreadId: ORIGIN_THREAD,
|
|
89
|
+
})
|
|
90
|
+
expect(out.action).toBe('paint')
|
|
91
|
+
if (out.action === 'paint') {
|
|
92
|
+
expect(out.chatId).toBe(ORIGIN_CHAT)
|
|
93
|
+
expect(out.threadId).toBe(ORIGIN_THREAD) // forum topic carried, not General
|
|
94
|
+
expect(out.chatId).not.toBe(OWNER_DM)
|
|
95
|
+
expect(out.exhausted).toBe(true)
|
|
96
|
+
expect(out.deferrals).toBe(MAX)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('exhausted defer with NO stamp chat → owner DM (durable floor), flagged as fallback', () => {
|
|
101
|
+
const out = decide({
|
|
102
|
+
origin: null,
|
|
103
|
+
cardExists: false,
|
|
104
|
+
priorDeferrals: MAX - 1,
|
|
105
|
+
fleetChatId: '',
|
|
106
|
+
stampChatId: undefined,
|
|
107
|
+
})
|
|
108
|
+
expect(out.action).toBe('paint')
|
|
109
|
+
if (out.action === 'paint') {
|
|
110
|
+
expect(out.chatId).toBe(OWNER_DM)
|
|
111
|
+
expect(out.ownerDmFallback).toBe(true)
|
|
112
|
+
expect(out.exhausted).toBe(true)
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('(d) existing card always PAINTS (updates proceed) even while unlinked', () => {
|
|
117
|
+
const out = decide({
|
|
118
|
+
origin: null,
|
|
119
|
+
cardExists: true,
|
|
120
|
+
priorDeferrals: 0,
|
|
121
|
+
fleetChatId: ORIGIN_CHAT,
|
|
122
|
+
})
|
|
123
|
+
expect(out.action).toBe('paint')
|
|
124
|
+
if (out.action === 'paint') {
|
|
125
|
+
expect(out.chatId).toBe(ORIGIN_CHAT)
|
|
126
|
+
expect(out.deferrals).toBe(0)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('fleet chat wins over the stamp fallback when configured', () => {
|
|
131
|
+
const out = decide({
|
|
132
|
+
origin: null,
|
|
133
|
+
cardExists: true, // paint (not defer) so we observe the route
|
|
134
|
+
fleetChatId: ORIGIN_CHAT,
|
|
135
|
+
stampChatId: '999999',
|
|
136
|
+
stampThreadId: 7,
|
|
137
|
+
})
|
|
138
|
+
expect(out.action).toBe('paint')
|
|
139
|
+
if (out.action === 'paint') {
|
|
140
|
+
expect(out.chatId).toBe(ORIGIN_CHAT)
|
|
141
|
+
// Fleet-chat route carries no stamp thread (usingStampFallback is false).
|
|
142
|
+
expect(out.threadId).toBeUndefined()
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// ─── Integration: the real feed, driven by the real decision ──────────────────
|
|
148
|
+
|
|
149
|
+
interface SentRecord {
|
|
150
|
+
chatId: string
|
|
151
|
+
threadId: number | undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function makeRecordingBot(sent: SentRecord[]): BotApiForWorkerFeed {
|
|
155
|
+
let nextId = 1000
|
|
156
|
+
return {
|
|
157
|
+
async sendMessage(chatId, _text, opts) {
|
|
158
|
+
sent.push({
|
|
159
|
+
chatId,
|
|
160
|
+
threadId: (opts?.message_thread_id as number | undefined) ?? undefined,
|
|
161
|
+
})
|
|
162
|
+
return { message_id: nextId++ }
|
|
163
|
+
},
|
|
164
|
+
async editMessageText() {
|
|
165
|
+
return {}
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Faithful model of the gateway `onProgress` worker-feed block: run the REAL
|
|
172
|
+
* `decideWorkerFeedDestination` and — only when it says paint — call the real
|
|
173
|
+
* feed with the resolved chat/thread. `resolveOrigin` mimics
|
|
174
|
+
* `resolveSubagentOriginChat` (null until the backfill links the row).
|
|
175
|
+
*/
|
|
176
|
+
function workerFeedTick(
|
|
177
|
+
feed: ReturnType<typeof createWorkerActivityFeed>,
|
|
178
|
+
deferrals: Map<string, number>,
|
|
179
|
+
agentId: string,
|
|
180
|
+
resolveOrigin: () => { chatId: string; threadId?: number } | null,
|
|
181
|
+
view: WorkerActivityView,
|
|
182
|
+
// The stamp-turn fallback used when no fleet chat is configured. Mirrors the
|
|
183
|
+
// gateway's exhausted-defer paint source (stampTurn.sessionChatId / thread).
|
|
184
|
+
stamp: { chatId?: string; threadId?: number } = { chatId: OWNER_DM, threadId: undefined },
|
|
185
|
+
): void {
|
|
186
|
+
const dest = decideWorkerFeedDestination({
|
|
187
|
+
origin: resolveOrigin(),
|
|
188
|
+
cardExists: feed.has(agentId),
|
|
189
|
+
priorDeferrals: deferrals.get(agentId) ?? 0,
|
|
190
|
+
maxDeferrals: MAX,
|
|
191
|
+
fleetChatId: '',
|
|
192
|
+
stampChatId: stamp.chatId,
|
|
193
|
+
stampThreadId: stamp.threadId,
|
|
194
|
+
ownerDm: OWNER_DM,
|
|
195
|
+
})
|
|
196
|
+
if (dest.action === 'defer') {
|
|
197
|
+
deferrals.set(agentId, dest.deferrals)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
deferrals.delete(agentId)
|
|
201
|
+
void feed.update(agentId, dest.chatId, view, dest.threadId)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
describe('worker-feed origin race — outcome: no owner-DM card', () => {
|
|
205
|
+
const runningView = (elapsedMs: number): WorkerActivityView => ({
|
|
206
|
+
description: 'racing worker',
|
|
207
|
+
lastTool: 'Read',
|
|
208
|
+
toolCount: 1,
|
|
209
|
+
latestSummary: 'reading files',
|
|
210
|
+
elapsedMs,
|
|
211
|
+
state: 'running',
|
|
212
|
+
model: 'sonnet',
|
|
213
|
+
totalTokens: 10,
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('defers the racing first tick, then paints the card in the ORIGIN topic — never the DM', async () => {
|
|
217
|
+
let now = 0
|
|
218
|
+
const sent: SentRecord[] = []
|
|
219
|
+
const feed = createWorkerActivityFeed({
|
|
220
|
+
bot: makeRecordingBot(sent),
|
|
221
|
+
now: () => now,
|
|
222
|
+
firstPaintMinMs: 0, // paint immediately once we do update
|
|
223
|
+
minEditIntervalMs: 0,
|
|
224
|
+
})
|
|
225
|
+
const deferrals = new Map<string, number>()
|
|
226
|
+
const agentId = 'a37ad7639ae61476c'
|
|
227
|
+
|
|
228
|
+
// The backfill has NOT linked the row yet: origin resolves to null.
|
|
229
|
+
let linked = false
|
|
230
|
+
const resolveOrigin = () =>
|
|
231
|
+
linked ? { chatId: ORIGIN_CHAT, threadId: ORIGIN_THREAD } : null
|
|
232
|
+
|
|
233
|
+
// First progress tick — races the backfill (origin unresolved).
|
|
234
|
+
workerFeedTick(feed, deferrals, agentId, resolveOrigin, runningView(1000))
|
|
235
|
+
await Promise.resolve()
|
|
236
|
+
|
|
237
|
+
// OUTCOME: nothing sent. In particular, no card in the owner DM.
|
|
238
|
+
expect(sent).toHaveLength(0)
|
|
239
|
+
expect(deferrals.get(agentId)).toBe(1)
|
|
240
|
+
expect(feed.has(agentId)).toBe(false)
|
|
241
|
+
|
|
242
|
+
// ~3s later the backfill completes and links the row → origin resolves.
|
|
243
|
+
linked = true
|
|
244
|
+
now = 4000
|
|
245
|
+
workerFeedTick(feed, deferrals, agentId, resolveOrigin, runningView(4000))
|
|
246
|
+
await Promise.resolve()
|
|
247
|
+
await Promise.resolve()
|
|
248
|
+
|
|
249
|
+
// OUTCOME: the card is created in the ORIGIN supergroup + forum topic,
|
|
250
|
+
// and NEVER in the owner DM.
|
|
251
|
+
expect(sent.length).toBeGreaterThanOrEqual(1)
|
|
252
|
+
for (const s of sent) {
|
|
253
|
+
expect(s.chatId).not.toBe(OWNER_DM)
|
|
254
|
+
}
|
|
255
|
+
expect(sent.some((s) => s.chatId === ORIGIN_CHAT && s.threadId === ORIGIN_THREAD)).toBe(true)
|
|
256
|
+
// Defer state cleared once linked.
|
|
257
|
+
expect(deferrals.has(agentId)).toBe(false)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('bounded fallback: paints anyway if the backfill never links (no infinite defer)', async () => {
|
|
261
|
+
let now = 0
|
|
262
|
+
const sent: SentRecord[] = []
|
|
263
|
+
const feed = createWorkerActivityFeed({
|
|
264
|
+
bot: makeRecordingBot(sent),
|
|
265
|
+
now: () => now,
|
|
266
|
+
firstPaintMinMs: 0,
|
|
267
|
+
minEditIntervalMs: 0,
|
|
268
|
+
})
|
|
269
|
+
const deferrals = new Map<string, number>()
|
|
270
|
+
const agentId = 'neverlinks00000'
|
|
271
|
+
const resolveOrigin = () => null // backfill permanently broken
|
|
272
|
+
|
|
273
|
+
for (let i = 0; i < MAX; i++) {
|
|
274
|
+
now = i * 1000
|
|
275
|
+
workerFeedTick(feed, deferrals, agentId, resolveOrigin, runningView(now))
|
|
276
|
+
await Promise.resolve()
|
|
277
|
+
}
|
|
278
|
+
await Promise.resolve()
|
|
279
|
+
|
|
280
|
+
// The first MAX-1 ticks deferred; the MAX-th painted. The universal-liveness
|
|
281
|
+
// contract wins over infinite silence, so a card IS eventually created —
|
|
282
|
+
// here to the owner DM (the only resort when origin never resolves and no
|
|
283
|
+
// stamp chat is set).
|
|
284
|
+
expect(sent.length).toBeGreaterThanOrEqual(1)
|
|
285
|
+
expect(sent.every((s) => s.chatId === OWNER_DM)).toBe(true)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('exhausted-defer (never-backfill) fallback lands in the origin FORUM THREAD, not General (#3458)', async () => {
|
|
289
|
+
// The backfill never links, so origin stays null through the bounded cap.
|
|
290
|
+
// In a forum supergroup the gateway paints using the live turn's chat AND
|
|
291
|
+
// its forum-topic id (stampTurn.sessionChatId / .sessionThreadId). Before
|
|
292
|
+
// the fix the thread was dropped and the card landed in General (thread
|
|
293
|
+
// undefined). Assert the thread id is carried to the painted card.
|
|
294
|
+
let now = 0
|
|
295
|
+
const sent: SentRecord[] = []
|
|
296
|
+
const feed = createWorkerActivityFeed({
|
|
297
|
+
bot: makeRecordingBot(sent),
|
|
298
|
+
now: () => now,
|
|
299
|
+
firstPaintMinMs: 0,
|
|
300
|
+
minEditIntervalMs: 0,
|
|
301
|
+
})
|
|
302
|
+
const deferrals = new Map<string, number>()
|
|
303
|
+
const agentId = 'neverlinks-forum'
|
|
304
|
+
const resolveOrigin = () => null // backfill permanently broken
|
|
305
|
+
// Live turn's chat + forum topic — the exhausted-defer fallback source.
|
|
306
|
+
const liveFallback = { chatId: ORIGIN_CHAT, threadId: ORIGIN_THREAD }
|
|
307
|
+
|
|
308
|
+
for (let i = 0; i < MAX; i++) {
|
|
309
|
+
now = i * 1000
|
|
310
|
+
workerFeedTick(feed, deferrals, agentId, resolveOrigin, runningView(now), liveFallback)
|
|
311
|
+
await Promise.resolve()
|
|
312
|
+
}
|
|
313
|
+
await Promise.resolve()
|
|
314
|
+
|
|
315
|
+
// OUTCOME: a card painted, in the origin supergroup AND its forum topic —
|
|
316
|
+
// the thread id survived the never-backfill fallback (not General).
|
|
317
|
+
expect(sent.length).toBeGreaterThanOrEqual(1)
|
|
318
|
+
expect(sent.every((s) => s.chatId === ORIGIN_CHAT)).toBe(true)
|
|
319
|
+
expect(sent.every((s) => s.threadId === ORIGIN_THREAD)).toBe(true)
|
|
320
|
+
})
|
|
321
|
+
})
|