switchroom 0.20.2 → 0.20.4

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 (27) hide show
  1. package/bin/handoff-briefing.sh +41 -1
  2. package/dist/cli/switchroom.js +10259 -1232
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +3 -3
  5. package/profiles/default/CLAUDE.md.hbs +13 -11
  6. package/telegram-plugin/dist/gateway/gateway.js +429 -98
  7. package/telegram-plugin/edit-flood-fuse.ts +332 -14
  8. package/telegram-plugin/gateway/feed-open-gate.ts +28 -8
  9. package/telegram-plugin/gateway/feed-reopen-gate.ts +88 -6
  10. package/telegram-plugin/gateway/gateway.ts +128 -104
  11. package/telegram-plugin/gateway/narrative-lane.ts +4 -0
  12. package/telegram-plugin/gateway/progress-fallback-cap.ts +195 -0
  13. package/telegram-plugin/gateway/stream-render.ts +67 -12
  14. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +13 -0
  15. package/telegram-plugin/gateway/subagent-origin-surface.ts +181 -0
  16. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +7 -0
  17. package/telegram-plugin/registry/subagents-schema.ts +61 -0
  18. package/telegram-plugin/registry/turns-schema.ts +26 -0
  19. package/telegram-plugin/tests/edit-flood-fuse-cosmetic-fairness.test.ts +229 -0
  20. package/telegram-plugin/tests/feed-open-gate.test.ts +42 -0
  21. package/telegram-plugin/tests/feed-reopen-gate.test.ts +114 -0
  22. package/telegram-plugin/tests/progress-cap.test.ts +182 -0
  23. package/telegram-plugin/tests/progress-fallback-cap.test.ts +91 -0
  24. package/telegram-plugin/tests/progress-update.test.ts +108 -12
  25. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +46 -0
  26. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +26 -0
  27. package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +304 -0
@@ -353,3 +353,29 @@ describe('decideSubagentProgress', () => {
353
353
  }
354
354
  })
355
355
  })
356
+
357
+ // ─── msg-6897 misroute regression (2026-08-04): meta.chat_id is LOAD-BEARING ──
358
+ // Same turn-registration contract as the handback builder (see
359
+ // subagent-handback-inbound-builder.test.ts): without meta.chat_id the
360
+ // progress turn's channel XML has no chat_id, the gateway mints no turn atom
361
+ // (no `turns` row, no `turn-active.json`), and a worker dispatched from
362
+ // inside the progress turn can never be attributed to its chat/topic.
363
+ describe('buildSubagentProgressInbound — meta.chat_id turn registration (msg-6897)', () => {
364
+ it('carries the origin chat as meta.chat_id', () => {
365
+ const inbound = buildSubagentProgressInbound({
366
+ ctx: {
367
+ chatId: '-1004223464247',
368
+ threadId: 77,
369
+ subagentJsonlId: 'stem1',
370
+ taskDescription: 'Topic work',
371
+ latestSummary: 'still going',
372
+ elapsedMs: 6 * 60_000,
373
+ bucketIdx: 1,
374
+ progressIntervalMs: 5 * 60_000,
375
+ },
376
+ nowMs: 1_700_000_000_000,
377
+ })
378
+ expect(inbound.meta.chat_id).toBe('-1004223464247')
379
+ expect(inbound.meta.message_thread_id).toBe('77')
380
+ })
381
+ })
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Regression: the gap-dispatch worker misroute (Telegram msg 6897,
3
+ * 2026-08-04).
4
+ *
5
+ * A depth-1 background worker was dispatched ~6.6 min AFTER the last topic
6
+ * turn's row had ended, with NO `turns` row open at its `started_at` (the
7
+ * dispatching turn was a synthesized handback turn that never registered a
8
+ * turn surface). Consequences on the pre-fix code:
9
+ *
10
+ * - the pretool hook's #2085 dispatch-time stamp had no `turn-active.json`
11
+ * to read → `parent_turn_key` NULL at insert;
12
+ * - the watcher's started_at window backfill
13
+ * (`SELECT turn_key FROM turns WHERE started_at <= ? AND (ended_at IS
14
+ * NULL OR ended_at >= ?)`) matched nothing → NULL stays;
15
+ * - `resolveSubagentOriginChat` → null → BOTH the worker card
16
+ * (`resolveWorkerFeedChat`) and the handback decision
17
+ * (`decideSubagentHandback`) fell through `fleetChatId || allowFrom[0]`
18
+ * to the OWNER DM with the forum thread stripped.
19
+ *
20
+ * These tests pin the two closures:
21
+ * 1. `stampSubagentDispatchTurn` — the gateway-side dispatch-time stamp
22
+ * (marker-free, window-free) that attributes the row from the live
23
+ * turn key the gateway holds when it observes the Agent/Task tool_use.
24
+ * 2. `resolveWorkerSurfaceChat` — the safe-degradation floor: when the
25
+ * stamp still missed, resolution falls to the MOST-RECENT turn's
26
+ * chat+topic (the #3458 mechanism, DB-backed) BEFORE the owner DM —
27
+ * asserted end-to-end into `decideSubagentHandback`'s built inbound.
28
+ *
29
+ * bun:sqlite — run under Bun:
30
+ * bun test telegram-plugin/tests/worker-origin-gap-dispatch.test.ts
31
+ */
32
+
33
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
34
+ import { openTurnsDbInMemory } from '../registry/turns-schema.js'
35
+ import {
36
+ applySubagentsSchema,
37
+ stampSubagentDispatchTurn,
38
+ resolveSubagentOriginTurnKey,
39
+ getSubagent,
40
+ } from '../registry/subagents-schema.js'
41
+ import {
42
+ resolveWorkerSurfaceChat,
43
+ resolveSubagentOriginChatDb,
44
+ resolveRecentTurnFallbackChat,
45
+ } from '../gateway/subagent-origin-surface.js'
46
+ import { decideSubagentHandback } from '../gateway/subagent-handback-inbound-builder.js'
47
+
48
+ type Db = ReturnType<typeof openTurnsDbInMemory>
49
+
50
+ const TOPIC_CHAT = '-1004223464247'
51
+ const TOPIC_THREAD = '77'
52
+ const OWNER_DM = '555'
53
+
54
+ let db: Db
55
+
56
+ beforeEach(() => {
57
+ db = openTurnsDbInMemory()
58
+ applySubagentsSchema(db)
59
+ })
60
+
61
+ afterEach(() => {
62
+ try { db.close() } catch { /* ignore */ }
63
+ })
64
+
65
+ function insertTurn(args: {
66
+ turnKey: string
67
+ chatId: string
68
+ threadId?: string | null
69
+ startedAt: number
70
+ endedAt?: number | null
71
+ }) {
72
+ db.prepare(`
73
+ INSERT INTO turns (turn_key, chat_id, thread_id, started_at, ended_at, created_at, updated_at)
74
+ VALUES (?, ?, ?, ?, ?, ?, ?)
75
+ `).run(
76
+ args.turnKey,
77
+ args.chatId,
78
+ args.threadId ?? null,
79
+ args.startedAt,
80
+ args.endedAt ?? null,
81
+ args.startedAt,
82
+ args.startedAt,
83
+ )
84
+ }
85
+
86
+ /** A hook-inserted depth-1 row: parent_turn_key NULL (no marker at dispatch),
87
+ * jsonl already linked (the backfill linked the stem but had no containing
88
+ * turn window to attribute from — the exact msg-6897 row shape). */
89
+ function insertGapDispatchedWorker(args: { id: string; jsonlAgentId: string; startedAt: number }) {
90
+ db.prepare(`
91
+ INSERT INTO subagents
92
+ (id, parent_session_id, parent_turn_key, agent_type, description,
93
+ background, started_at, last_activity_at, status, jsonl_agent_id)
94
+ VALUES (?, 'sess-1', NULL, 'worker', 'Gap-dispatched task', 1, ?, ?, 'running', ?)
95
+ `).run(args.id, args.startedAt, args.startedAt, args.jsonlAgentId)
96
+ }
97
+
98
+ describe('gap dispatch — safe-degradation floor (msg-6897)', () => {
99
+ /** The incident fixture: topic turn [1_000_000, 1_600_000] (ended), worker
100
+ * dispatched ~6.6 min after end — no turns row open at started_at. */
101
+ function seedIncident() {
102
+ insertTurn({
103
+ turnKey: `${TOPIC_CHAT}:${TOPIC_THREAD}:1000000`,
104
+ chatId: TOPIC_CHAT,
105
+ threadId: TOPIC_THREAD,
106
+ startedAt: 1_000_000,
107
+ endedAt: 1_600_000,
108
+ })
109
+ const dispatchAt = 1_600_000 + Math.round(6.6 * 60_000)
110
+ insertGapDispatchedWorker({ id: 'toolu_gap', jsonlAgentId: 'wkr_gap', startedAt: dispatchAt })
111
+ }
112
+
113
+ it('pre-condition: origin resolution genuinely misses for the gap-dispatched row', () => {
114
+ seedIncident()
115
+ // The row is linked but unattributed — the DB-confirmed incident state.
116
+ expect(getSubagent(db, 'toolu_gap')?.parent_turn_key).toBeNull()
117
+ expect(resolveSubagentOriginTurnKey(db, 'wkr_gap')).toBeNull()
118
+ expect(resolveSubagentOriginChatDb(db, 'wkr_gap')).toBeNull()
119
+ })
120
+
121
+ it('worker card surface resolves to the origin topic, NOT the owner DM with thread stripped', () => {
122
+ seedIncident()
123
+ const dest = resolveWorkerSurfaceChat(db, 'wkr_gap', { fleetChatId: '', ownerDm: OWNER_DM })
124
+ // THE outcome assertion: {chat_id, thread_id} — the topic, not the DM.
125
+ expect(dest.chatId).toBe(TOPIC_CHAT)
126
+ expect(dest.threadId).toBe(Number(TOPIC_THREAD))
127
+ expect(dest.via).toBe('recent-turn')
128
+ })
129
+
130
+ it('handback inbound routes to the origin topic, NOT the owner DM with thread stripped', () => {
131
+ seedIncident()
132
+ // The gateway's onFinish ladder post-fix: origin (null here) → recent-turn
133
+ // floor, fed into the SAME pure decision the gateway runs.
134
+ const surface = resolveWorkerSurfaceChat(db, 'wkr_gap', { fleetChatId: '', ownerDm: OWNER_DM })
135
+ const decision = decideSubagentHandback({
136
+ handbackEnvValue: undefined,
137
+ outcome: 'completed',
138
+ isBackground: true,
139
+ fleetChatId: surface.via === 'owner-dm' ? '' : surface.chatId,
140
+ ...(surface.threadId != null ? { originThreadId: surface.threadId } : {}),
141
+ ownerChatId: OWNER_DM,
142
+ taskDescription: 'Gap-dispatched task',
143
+ resultText: 'done',
144
+ jsonlAgentId: 'wkr_gap',
145
+ nowMs: 1_700_000_000_000,
146
+ })
147
+ expect(decision.deliver).toBe(true)
148
+ if (decision.deliver) {
149
+ expect(decision.chatId).toBe(TOPIC_CHAT)
150
+ expect(decision.inbound.threadId).toBe(Number(TOPIC_THREAD))
151
+ expect(decision.inbound.meta.message_thread_id).toBe(TOPIC_THREAD)
152
+ // And the minted handback turn registers its own surface (fix 2).
153
+ expect(decision.inbound.meta.chat_id).toBe(TOPIC_CHAT)
154
+ }
155
+ })
156
+
157
+ it('a properly-attributed worker still resolves via origin — the floor never hijacks it', () => {
158
+ // Origin topic A; a NEWER turn ran in topic B. The attributed worker must
159
+ // keep routing to A (origin), not follow the recent-turn floor to B.
160
+ insertTurn({ turnKey: `${TOPIC_CHAT}:4:1000`, chatId: TOPIC_CHAT, threadId: '4', startedAt: 1000, endedAt: 2000 })
161
+ insertTurn({ turnKey: `${TOPIC_CHAT}:9:5000`, chatId: TOPIC_CHAT, threadId: '9', startedAt: 5000, endedAt: 6000 })
162
+ db.prepare(`
163
+ INSERT INTO subagents
164
+ (id, parent_session_id, parent_turn_key, agent_type, description,
165
+ background, started_at, last_activity_at, status, jsonl_agent_id)
166
+ VALUES ('toolu_ok', 'sess-1', ?, 'worker', 'Attributed', 1, 1500, 1500, 'running', 'wkr_ok')
167
+ `).run(`${TOPIC_CHAT}:4:1000`)
168
+
169
+ const dest = resolveWorkerSurfaceChat(db, 'wkr_ok', { fleetChatId: '', ownerDm: OWNER_DM })
170
+ expect(dest.via).toBe('origin')
171
+ expect(dest.chatId).toBe(TOPIC_CHAT)
172
+ expect(dest.threadId).toBe(4)
173
+ })
174
+
175
+ it('floor is bounded by the dispatch time — a NEWER unrelated chat turn never captures the surface', () => {
176
+ seedIncident()
177
+ // The operator later drives a turn in a DIFFERENT (possibly shared) group
178
+ // B, AFTER the worker's dispatch. The floor must resolve to the last turn
179
+ // BEFORE the dispatch (topic A) — never leak the worker's surface into B.
180
+ insertTurn({
181
+ turnKey: '-1009999999999:_:9000000',
182
+ chatId: '-1009999999999',
183
+ threadId: null,
184
+ startedAt: 9_000_000,
185
+ endedAt: 9_100_000,
186
+ })
187
+ const dest = resolveWorkerSurfaceChat(db, 'wkr_gap', { fleetChatId: '', ownerDm: OWNER_DM })
188
+ expect(dest.chatId).toBe(TOPIC_CHAT)
189
+ expect(dest.threadId).toBe(Number(TOPIC_THREAD))
190
+ expect(dest.via).toBe('recent-turn')
191
+ })
192
+
193
+ it('only post-dispatch turns exist — floor declines and falls to the owner DM, not the newer chat', () => {
194
+ insertGapDispatchedWorker({ id: 'toolu_late', jsonlAgentId: 'wkr_late', startedAt: 1000 })
195
+ insertTurn({ turnKey: '-1008888888888:_:5000', chatId: '-1008888888888', threadId: null, startedAt: 5000, endedAt: 6000 })
196
+ const dest = resolveWorkerSurfaceChat(db, 'wkr_late', { fleetChatId: '', ownerDm: OWNER_DM })
197
+ expect(dest.chatId).toBe(OWNER_DM)
198
+ expect(dest.via).toBe('owner-dm')
199
+ })
200
+
201
+ it('empty turns table still floors to the owner DM (durable last resort)', () => {
202
+ insertGapDispatchedWorker({ id: 'toolu_bare', jsonlAgentId: 'wkr_bare', startedAt: 1000 })
203
+ const dest = resolveWorkerSurfaceChat(db, 'wkr_bare', { fleetChatId: '', ownerDm: OWNER_DM })
204
+ expect(dest.chatId).toBe(OWNER_DM)
205
+ expect(dest.via).toBe('owner-dm')
206
+ })
207
+
208
+ it('null db (turn registry disabled) degrades to fleet → owner DM as before', () => {
209
+ const dest = resolveWorkerSurfaceChat(null, 'wkr_any', { fleetChatId: '', ownerDm: OWNER_DM })
210
+ expect(dest).toEqual({ chatId: OWNER_DM, via: 'owner-dm' })
211
+ })
212
+ })
213
+
214
+ describe('gateway dispatch-time stamp — stampSubagentDispatchTurn (msg-6897)', () => {
215
+ const TURN_KEY = `${TOPIC_CHAT}:${TOPIC_THREAD}:2000000`
216
+
217
+ function seedOpenTurn() {
218
+ insertTurn({
219
+ turnKey: TURN_KEY,
220
+ chatId: TOPIC_CHAT,
221
+ threadId: TOPIC_THREAD,
222
+ startedAt: 2_000_000,
223
+ endedAt: null,
224
+ })
225
+ }
226
+
227
+ it('UPDATE path: stamps a hook-inserted row whose parent_turn_key is NULL', () => {
228
+ seedOpenTurn()
229
+ insertGapDispatchedWorker({ id: 'toolu_stamp', jsonlAgentId: 'wkr_stamp', startedAt: 2_000_500 })
230
+
231
+ stampSubagentDispatchTurn(db, {
232
+ toolUseId: 'toolu_stamp',
233
+ parentTurnKey: TURN_KEY,
234
+ agentType: 'worker',
235
+ description: 'Gap-dispatched task',
236
+ background: true,
237
+ now: 2_000_500,
238
+ })
239
+
240
+ expect(getSubagent(db, 'toolu_stamp')?.parent_turn_key).toBe(TURN_KEY)
241
+ // End-to-end: the stamped row now resolves to the origin topic.
242
+ const origin = resolveSubagentOriginChatDb(db, 'wkr_stamp')
243
+ expect(origin?.chatId).toBe(TOPIC_CHAT)
244
+ expect(origin?.threadId).toBe(Number(TOPIC_THREAD))
245
+ })
246
+
247
+ it('INSERT path: the gateway stamp can land BEFORE the hook row (race) and the hook insert then no-ops', () => {
248
+ seedOpenTurn()
249
+ stampSubagentDispatchTurn(db, {
250
+ toolUseId: 'toolu_first',
251
+ parentTurnKey: TURN_KEY,
252
+ agentType: 'worker',
253
+ description: 'Raced dispatch',
254
+ background: true,
255
+ now: 2_000_600,
256
+ })
257
+ const row = getSubagent(db, 'toolu_first')
258
+ expect(row?.parent_turn_key).toBe(TURN_KEY)
259
+ expect(row?.background).toBe(true)
260
+ expect(row?.status).toBe('running')
261
+ })
262
+
263
+ it('never overwrites a hook-stamped parent_turn_key (normal in-turn dispatch unchanged)', () => {
264
+ seedOpenTurn()
265
+ db.prepare(`
266
+ INSERT INTO subagents
267
+ (id, parent_session_id, parent_turn_key, agent_type, description,
268
+ background, started_at, last_activity_at, status, jsonl_agent_id)
269
+ VALUES ('toolu_pre', 'sess-1', 'other:4:999', 'worker', 'Pre-stamped', 1, 2000700, 2000700, 'running', 'wkr_pre')
270
+ `).run()
271
+
272
+ stampSubagentDispatchTurn(db, {
273
+ toolUseId: 'toolu_pre',
274
+ parentTurnKey: TURN_KEY,
275
+ background: true,
276
+ now: 2_000_700,
277
+ })
278
+
279
+ expect(getSubagent(db, 'toolu_pre')?.parent_turn_key).toBe('other:4:999')
280
+ })
281
+ })
282
+
283
+ describe('recent-turn floor internals', () => {
284
+ it('resolveRecentTurnFallbackChat picks the newest PRE-DISPATCH turn and parses its thread', () => {
285
+ insertTurn({ turnKey: 'a:_:100', chatId: '111', threadId: null, startedAt: 100, endedAt: 200 })
286
+ insertTurn({ turnKey: `b:${TOPIC_THREAD}:300`, chatId: TOPIC_CHAT, threadId: TOPIC_THREAD, startedAt: 300, endedAt: null })
287
+ // A turn STARTED AFTER the worker's dispatch must never win the floor.
288
+ insertTurn({ turnKey: 'c:_:900', chatId: '-1007777777777', threadId: null, startedAt: 900, endedAt: 950 })
289
+ insertGapDispatchedWorker({ id: 'toolu_f', jsonlAgentId: 'wkr_f', startedAt: 500 })
290
+ const recent = resolveRecentTurnFallbackChat(db, 'wkr_f')
291
+ expect(recent?.chatId).toBe(TOPIC_CHAT)
292
+ expect(recent?.threadId).toBe(Number(TOPIC_THREAD))
293
+ })
294
+
295
+ it('returns null on an empty turns table', () => {
296
+ insertGapDispatchedWorker({ id: 'toolu_g', jsonlAgentId: 'wkr_g', startedAt: 500 })
297
+ expect(resolveRecentTurnFallbackChat(db, 'wkr_g')).toBeNull()
298
+ })
299
+
300
+ it('returns null when the worker has no subagents row (no dispatch time to bound by)', () => {
301
+ insertTurn({ turnKey: 'a:_:100', chatId: '111', threadId: null, startedAt: 100, endedAt: 200 })
302
+ expect(resolveRecentTurnFallbackChat(db, 'wkr_unknown')).toBeNull()
303
+ })
304
+ })