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.
Files changed (32) hide show
  1. package/dist/auth-broker/index.js +104 -11
  2. package/dist/cli/autoaccept-poll.js +8 -2
  3. package/dist/cli/switchroom.js +20 -5
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +1 -1
  6. package/profiles/_base/start.sh.hbs +67 -4
  7. package/telegram-plugin/dist/gateway/gateway.js +524 -293
  8. package/telegram-plugin/gateway/command-format.ts +253 -0
  9. package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
  10. package/telegram-plugin/gateway/gateway.ts +97 -255
  11. package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
  12. package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
  13. package/telegram-plugin/gateway/session-model-file.ts +13 -0
  14. package/telegram-plugin/gateway/stream-render.ts +18 -1
  15. package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
  16. package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
  17. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
  18. package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
  19. package/telegram-plugin/render/line-start-guard.ts +76 -4
  20. package/telegram-plugin/rich-send.ts +8 -1
  21. package/telegram-plugin/tests/command-format.test.ts +212 -0
  22. package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
  23. package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
  24. package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
  25. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
  26. package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
  27. package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
  28. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
  29. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
  30. package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
  31. package/telegram-plugin/tests/silent-end.test.ts +60 -5
  32. 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
+ })