switchroom 0.19.30 → 0.19.32

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 (51) hide show
  1. package/dist/cli/switchroom.js +1121 -584
  2. package/dist/host-control/main.js +151 -73
  3. package/package.json +1 -1
  4. package/profiles/_base/cron-session.sh.hbs +5 -1
  5. package/profiles/_base/start.sh.hbs +15 -1
  6. package/telegram-plugin/dist/bridge/bridge.js +3 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +1660 -677
  8. package/telegram-plugin/dist/server.js +3 -0
  9. package/telegram-plugin/edit-flood-fuse.ts +70 -20
  10. package/telegram-plugin/gateway/boot-beacon.ts +364 -0
  11. package/telegram-plugin/gateway/boot-sweep-gate.ts +20 -15
  12. package/telegram-plugin/gateway/gateway.ts +87 -88
  13. package/telegram-plugin/gateway/inbound-spool.ts +39 -0
  14. package/telegram-plugin/gateway/narrative-lane.ts +12 -0
  15. package/telegram-plugin/gateway/obligation-store.ts +28 -0
  16. package/telegram-plugin/gateway/stale-pin-sweep-store.ts +221 -0
  17. package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +211 -0
  18. package/telegram-plugin/gateway/stale-pin-sweep.test.ts +804 -0
  19. package/telegram-plugin/gateway/stale-pin-sweep.ts +1146 -0
  20. package/telegram-plugin/gateway/status-pin-retarget.ts +15 -2
  21. package/telegram-plugin/gateway/status-pin-store.ts +33 -11
  22. package/telegram-plugin/gateway/stream-render.ts +578 -321
  23. package/telegram-plugin/registry/turns-schema.ts +21 -1
  24. package/telegram-plugin/retry-api-call.ts +46 -21
  25. package/telegram-plugin/session-tail.ts +13 -0
  26. package/telegram-plugin/shared/bot-runtime.ts +61 -17
  27. package/telegram-plugin/shared/gw-trace-gate.ts +18 -2
  28. package/telegram-plugin/tests/activity-card-wiring.test.ts +7 -7
  29. package/telegram-plugin/tests/activity-drain-fuse-drop-not-failure.test.ts +324 -0
  30. package/telegram-plugin/tests/agent-card-result-footer.test.ts +193 -0
  31. package/telegram-plugin/tests/boot-beacon.test.ts +462 -0
  32. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +6 -6
  33. package/telegram-plugin/tests/boot-sweep-gate.test.ts +42 -31
  34. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +2 -0
  35. package/telegram-plugin/tests/inbound-spool-progress.test.ts +2 -0
  36. package/telegram-plugin/tests/inbound-spool.test.ts +134 -5
  37. package/telegram-plugin/tests/narrative-lane-golden.test.ts +28 -0
  38. package/telegram-plugin/tests/obligation-determinism.test.ts +2 -0
  39. package/telegram-plugin/tests/obligation-store.test.ts +67 -1
  40. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  41. package/telegram-plugin/tests/status-pin-store.test.ts +26 -5
  42. package/telegram-plugin/tests/stream-render-golden.test.ts +20 -5
  43. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +161 -0
  44. package/telegram-plugin/tests/turn-mint-defers-until-dequeue.test.ts +196 -0
  45. package/telegram-plugin/tests/turn-mint-harness.ts +155 -0
  46. package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +124 -0
  47. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +30 -0
  48. package/telegram-plugin/tool-activity-summary.ts +104 -38
  49. package/telegram-plugin/worker-activity-feed.ts +33 -16
  50. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +0 -251
  51. package/telegram-plugin/gateway/dm-pin-sweep.ts +0 -178
@@ -1,251 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest'
2
- import {
3
- isDmChatId,
4
- collectDmChatIdsFromStores,
5
- createDmPinSweeper,
6
- unexpiredStoreRepinIds,
7
- type DmPinSweeperDeps,
8
- } from './dm-pin-sweep'
9
-
10
- // Synthetic chat IDs only (check-no-pii-secrets): positive = DM, negative =
11
- // group/supergroup.
12
- const DM_A = '5000001'
13
- const DM_B = '5000002'
14
- const GROUP = '-1002000000001'
15
-
16
- describe('isDmChatId', () => {
17
- it('true for positive integer DM ids', () => {
18
- expect(isDmChatId(DM_A)).toBe(true)
19
- expect(isDmChatId('1')).toBe(true)
20
- })
21
- it('false for group/supergroup (negative), zero, non-integer, non-numeric', () => {
22
- expect(isDmChatId(GROUP)).toBe(false)
23
- expect(isDmChatId('-1')).toBe(false)
24
- expect(isDmChatId('0')).toBe(false)
25
- expect(isDmChatId('1.5')).toBe(false)
26
- expect(isDmChatId('abc')).toBe(false)
27
- expect(isDmChatId('')).toBe(false)
28
- expect(isDmChatId('Infinity')).toBe(false)
29
- })
30
- })
31
-
32
- describe('collectDmChatIdsFromStores', () => {
33
- it('collects distinct DM chat ids across all three stores and drops non-DMs', () => {
34
- const ids = collectDmChatIdsFromStores({
35
- statusPins: [{ chatId: DM_A }, { chatId: GROUP }, { chatId: DM_A }],
36
- activityCards: [{ chatId: DM_B }, { chatId: '0' }],
37
- queuedCards: [{ chatId: DM_A }, { chatId: '-77' }],
38
- })
39
- expect(new Set(ids)).toEqual(new Set([DM_A, DM_B]))
40
- expect(ids).toHaveLength(2)
41
- })
42
- it('handles missing store arrays', () => {
43
- expect(collectDmChatIdsFromStores({})).toEqual([])
44
- expect(collectDmChatIdsFromStores({ statusPins: [{ chatId: GROUP }] })).toEqual([])
45
- })
46
- })
47
-
48
- function makeDeps(overrides: Partial<DmPinSweeperDeps> = {}): {
49
- deps: DmPinSweeperDeps
50
- unpinAll: ReturnType<typeof vi.fn>
51
- pinSilent: ReturnType<typeof vi.fn>
52
- } {
53
- const unpinAll = vi.fn(async () => undefined)
54
- const pinSilent = vi.fn(async () => undefined)
55
- const deps: DmPinSweeperDeps = {
56
- unpinAll,
57
- pinSilent,
58
- liveTrackedMessageIds: () => [],
59
- eligible: () => true,
60
- log: () => {},
61
- ...overrides,
62
- }
63
- return { deps, unpinAll, pinSilent }
64
- }
65
-
66
- describe('createDmPinSweeper', () => {
67
- it('DM with tracked pins: unpin-all exactly once, then re-pin exactly the live ids', async () => {
68
- const { deps, unpinAll, pinSilent } = makeDeps({
69
- liveTrackedMessageIds: (chatId) => (chatId === DM_A ? [111, 222] : []),
70
- })
71
- const sweeper = createDmPinSweeper(deps)
72
- await sweeper.sweep(DM_A)
73
-
74
- expect(unpinAll).toHaveBeenCalledTimes(1)
75
- expect(unpinAll).toHaveBeenCalledWith(DM_A)
76
- expect(pinSilent.mock.calls).toEqual([
77
- [DM_A, 111],
78
- [DM_A, 222],
79
- ])
80
- expect(sweeper.hasSwept(DM_A)).toBe(true)
81
- })
82
-
83
- it('DM with no tracked pins: unpin-all once, no re-pin', async () => {
84
- const { deps, unpinAll, pinSilent } = makeDeps()
85
- const sweeper = createDmPinSweeper(deps)
86
- await sweeper.sweep(DM_A)
87
- expect(unpinAll).toHaveBeenCalledTimes(1)
88
- expect(pinSilent).not.toHaveBeenCalled()
89
- })
90
-
91
- it('group/supergroup id: never unpin-all (keeps human pins)', async () => {
92
- const { deps, unpinAll, pinSilent } = makeDeps()
93
- const sweeper = createDmPinSweeper(deps)
94
- await sweeper.sweep(GROUP)
95
- await sweeper.sweep('-1')
96
- expect(unpinAll).not.toHaveBeenCalled()
97
- expect(pinSilent).not.toHaveBeenCalled()
98
- })
99
-
100
- it('not eligible (lost startup mutex): does nothing', async () => {
101
- const { deps, unpinAll } = makeDeps({ eligible: () => false })
102
- const sweeper = createDmPinSweeper(deps)
103
- await sweeper.sweep(DM_A)
104
- expect(unpinAll).not.toHaveBeenCalled()
105
- expect(sweeper.hasSwept(DM_A)).toBe(false)
106
- })
107
-
108
- it('second sweep of same chat does not re-sweep', async () => {
109
- const { deps, unpinAll } = makeDeps()
110
- const sweeper = createDmPinSweeper(deps)
111
- await sweeper.sweep(DM_A)
112
- await sweeper.sweep(DM_A)
113
- expect(unpinAll).toHaveBeenCalledTimes(1)
114
- })
115
-
116
- it('concurrent triggers on the same chat fire unpin-all only once', async () => {
117
- let resolveUnpin: () => void = () => {}
118
- const unpinAll = vi.fn(
119
- () =>
120
- new Promise<void>((res) => {
121
- resolveUnpin = res
122
- }),
123
- )
124
- const { deps } = makeDeps({ unpinAll })
125
- const sweeper = createDmPinSweeper(deps)
126
- const p1 = sweeper.sweep(DM_A)
127
- const p2 = sweeper.sweep(DM_A)
128
- resolveUnpin()
129
- await Promise.all([p1, p2])
130
- expect(unpinAll).toHaveBeenCalledTimes(1)
131
- })
132
-
133
- it('429 / unpin-all rejects: degrades without throwing, still re-pins live ids', async () => {
134
- const unpinAll = vi.fn(async () => {
135
- throw new Error('429: Too Many Requests: retry after 30')
136
- })
137
- const { deps, pinSilent } = makeDeps({
138
- unpinAll,
139
- liveTrackedMessageIds: () => [333],
140
- })
141
- const sweeper = createDmPinSweeper(deps)
142
- // Must not reject.
143
- await expect(sweeper.sweep(DM_A)).resolves.toBeUndefined()
144
- expect(unpinAll).toHaveBeenCalledTimes(1)
145
- expect(pinSilent).toHaveBeenCalledWith(DM_A, 333)
146
- })
147
-
148
- it('re-pin failure is absorbed and does not reject', async () => {
149
- const pinSilent = vi.fn(async () => {
150
- throw new Error('pin failed')
151
- })
152
- const { deps } = makeDeps({
153
- pinSilent,
154
- liveTrackedMessageIds: () => [444],
155
- })
156
- const sweeper = createDmPinSweeper(deps)
157
- await expect(sweeper.sweep(DM_A)).resolves.toBeUndefined()
158
- })
159
-
160
- it('no unhandled rejection escapes a failing sweep (fire-and-forget safe)', async () => {
161
- const unhandled: unknown[] = []
162
- const onUnhandled = (r: unknown): void => {
163
- unhandled.push(r)
164
- }
165
- process.on('unhandledRejection', onUnhandled)
166
- try {
167
- const { deps } = makeDeps({
168
- unpinAll: async () => {
169
- throw new Error('boom')
170
- },
171
- liveTrackedMessageIds: () => [555],
172
- })
173
- const sweeper = createDmPinSweeper(deps)
174
- // Fire-and-forget, as the gateway does.
175
- void sweeper.sweep(DM_A)
176
- await new Promise((r) => setTimeout(r, 10))
177
- } finally {
178
- process.off('unhandledRejection', onUnhandled)
179
- }
180
- expect(unhandled).toEqual([])
181
- })
182
- })
183
-
184
- describe('tool-pin survival (#3001 contract, review blocker on #3074)', () => {
185
- const NOW = 1_752_000_000_000
186
-
187
- it('unexpiredStoreRepinIds keeps unexpired tool rows for the chat, drops expired / work-scoped / other-chat rows', () => {
188
- const rows = [
189
- // Unexpired tool pin in this chat — MUST survive.
190
- { chatId: DM_A, messageId: 10, expiresAt: NOW + 60_000 },
191
- // Expired tool pin — due for sweeping, NOT re-pinned.
192
- { chatId: DM_A, messageId: 11, expiresAt: NOW - 1 },
193
- // Work-scoped row (no expiresAt) — stale after restart, NOT re-pinned.
194
- { chatId: DM_A, messageId: 12 },
195
- // Unexpired tool pin in a DIFFERENT chat — not this chat's re-pin set.
196
- { chatId: DM_B, messageId: 13, expiresAt: NOW + 60_000 },
197
- ]
198
- expect(unexpiredStoreRepinIds(rows, DM_A, NOW)).toEqual([10])
199
- expect(unexpiredStoreRepinIds(rows, DM_B, NOW)).toEqual([13])
200
- })
201
-
202
- // Composition test mirroring the gateway wiring: liveTrackedMessageIds
203
- // unions in-memory claims with unexpired store rows.
204
- function gatewayStyleDeps(args: {
205
- inMemory: number[]
206
- storeRows: { chatId: string; messageId: number; expiresAt?: number }[]
207
- }): ReturnType<typeof makeDeps> {
208
- return makeDeps({
209
- liveTrackedMessageIds: (chatId) => [
210
- ...args.inMemory,
211
- ...unexpiredStoreRepinIds(args.storeRows, chatId, NOW),
212
- ],
213
- })
214
- }
215
-
216
- it('(a) boot sweep: DM with an unexpired tool: row → unpinAll once, then that messageId re-pinned', async () => {
217
- const { deps, unpinAll, pinSilent } = gatewayStyleDeps({
218
- inMemory: [], // fresh boot — in-memory claims empty
219
- storeRows: [{ chatId: DM_A, messageId: 777, expiresAt: NOW + 60_000 }],
220
- })
221
- const sweeper = createDmPinSweeper(deps)
222
- await sweeper.sweep(DM_A)
223
- expect(unpinAll).toHaveBeenCalledTimes(1)
224
- expect(pinSilent.mock.calls).toEqual([[DM_A, 777]])
225
- })
226
-
227
- it('(b) expired tool: row is NOT re-pinned', async () => {
228
- const { deps, unpinAll, pinSilent } = gatewayStyleDeps({
229
- inMemory: [],
230
- storeRows: [{ chatId: DM_A, messageId: 778, expiresAt: NOW - 1 }],
231
- })
232
- const sweeper = createDmPinSweeper(deps)
233
- await sweeper.sweep(DM_A)
234
- expect(unpinAll).toHaveBeenCalledTimes(1)
235
- expect(pinSilent).not.toHaveBeenCalled()
236
- })
237
-
238
- it('(c) first-inbound sweep preserves an unexpired tool pin alongside a live in-memory claim, deduped', async () => {
239
- const { deps, pinSilent } = gatewayStyleDeps({
240
- inMemory: [500, 777], // live fg: claim + the tool pin also claimed in-memory
241
- storeRows: [{ chatId: DM_A, messageId: 777, expiresAt: NOW + 60_000 }],
242
- })
243
- const sweeper = createDmPinSweeper(deps)
244
- await sweeper.sweep(DM_A)
245
- // 777 appears in BOTH sources but is re-pinned exactly once.
246
- expect(pinSilent.mock.calls).toEqual([
247
- [DM_A, 500],
248
- [DM_A, 777],
249
- ])
250
- })
251
- })
@@ -1,178 +0,0 @@
1
- /**
2
- * DM pin sweep (#3026) — durable clearing of stale bot-authored pins in
3
- * user DM chats.
4
- *
5
- * Why this exists (two independent gaps the probe-based boot sweep can't
6
- * close for DMs):
7
- *
8
- * 1. The boot pin sweep (`shouldSweepChatAtBoot`) deliberately SKIPS
9
- * positive (DM) chat IDs — the Bot API returns `400 chat not found`
10
- * for a user who never messaged the bot. So DM chats were never
11
- * boot-swept at all, and stale pins from a prior session lingered
12
- * across restarts (observed fleet-wide 2026-07-11).
13
- *
14
- * 2. Even when a DM chat IS reachable, `getChat()` exposes ONLY the
15
- * NEWEST pinned message. Telegram DMs STACK pins and the Bot API has
16
- * no list-pins method, so older orphan pins are invisible to a
17
- * probe-based (unpin-one-by-message-id) sweep forever.
18
- *
19
- * The durable fix, DM-only: call `unpinAllChatMessages` ONCE per DM chat
20
- * per gateway boot (clearing the whole stack — safe in a DM, where
21
- * virtually every pin is bot-authored), then immediately RE-PIN the live
22
- * tracked cards from the in-memory pin claims (silent, `disable_notification`)
23
- * so a genuinely in-flight status/worker pin survives the sweep.
24
- *
25
- * Groups/supergroups (negative IDs) are NOT touched here — an unpin-all
26
- * there would nuke human pins. They keep the existing probe-based path.
27
- *
28
- * This module is the PURE half (chat-id classification, store→DM-chat-id
29
- * collection, and a dependency-injected sweeper with the once-per-boot
30
- * guard). The gateway binds the live Bot API + in-memory claim map.
31
- */
32
-
33
- /**
34
- * True iff `chatId` is a user DM chat: a positive integer. Group and
35
- * supergroup IDs are negative; zero / non-numeric / non-integer are
36
- * malformed and never DMs.
37
- */
38
- export function isDmChatId(chatId: string): boolean {
39
- const n = Number(chatId)
40
- return Number.isFinite(n) && Number.isInteger(n) && n > 0
41
- }
42
-
43
- /**
44
- * Collect the DISTINCT DM chat IDs that appear in ANY of the persisted pin
45
- * stores — the set of DM chats the gateway has a record of having pinned
46
- * something in during a prior session. These are the DM chats worth
47
- * sweeping at boot (an unpin-all is wasted on a DM we never pinned in).
48
- *
49
- * Pure over its input records; callers pass the loaded store snapshots
50
- * (status pins, activity cards, queued/busy-ack cards). Non-DM chat IDs
51
- * are filtered out.
52
- */
53
- export function collectDmChatIdsFromStores(input: {
54
- statusPins?: readonly { chatId: string }[]
55
- activityCards?: readonly { chatId: string }[]
56
- queuedCards?: readonly { chatId: string }[]
57
- }): string[] {
58
- const out = new Set<string>()
59
- const add = (rows?: readonly { chatId: string }[]): void => {
60
- if (rows == null) return
61
- for (const r of rows) if (isDmChatId(r.chatId)) out.add(r.chatId)
62
- }
63
- add(input.statusPins)
64
- add(input.activityCards)
65
- add(input.queuedCards)
66
- return [...out]
67
- }
68
-
69
- /**
70
- * The messageIds in `chatId` that must SURVIVE a DM unpin-all because they
71
- * belong to deliberately-retained store rows: unexpired time-scoped `tool:`
72
- * pins (the `pin_message` MCP tool, #3001), which `runStatusPinBootCleanup`
73
- * intentionally KEEPS across restarts. Work-scoped rows (no `expiresAt`) are
74
- * stale by definition after a restart and are NOT re-pin candidates; expired
75
- * time-scoped rows are due for sweeping.
76
- *
77
- * Pure over a loaded store snapshot; the gateway binds a LIVE store read into
78
- * `liveTrackedMessageIds` so BOTH the boot sweep and a first-inbound sweep
79
- * see the kept rows (the in-memory claim maps are empty at boot).
80
- */
81
- export function unexpiredStoreRepinIds(
82
- rows: readonly { chatId: string; messageId: number; expiresAt?: number }[],
83
- chatId: string,
84
- now: number,
85
- ): number[] {
86
- const out: number[] = []
87
- for (const r of rows) {
88
- if (r.chatId !== chatId) continue
89
- if (r.expiresAt != null && r.expiresAt > now) out.push(r.messageId)
90
- }
91
- return out
92
- }
93
-
94
- export interface DmPinSweeperDeps {
95
- /** Clear EVERY pinned message in a chat. Bound to the gateway's
96
- * robust/retry API wrapper so a 429 flood-wait is retried, then
97
- * degrades (rejects) rather than crashing — the sweeper absorbs it. */
98
- unpinAll: (chatId: string) => Promise<unknown>
99
- /** Silently re-pin an EXISTING message (disable_notification). Bound to
100
- * the gateway's robust/retry API wrapper. */
101
- pinSilent: (chatId: string, messageId: number) => Promise<unknown>
102
- /** Pin message ids in this chat that must survive the unpin-all: the
103
- * in-memory status-pin claims still owned by an in-flight turn/worker,
104
- * UNIONED with the deliberately-retained store rows (unexpired `tool:`
105
- * pins, #3001 — see unexpiredStoreRepinIds). The gateway binds both
106
- * sources; duplicates are deduped by the sweeper before re-pinning. */
107
- liveTrackedMessageIds: (chatId: string) => number[]
108
- /** Gate: sweep ONLY when the gateway won the startup mutex. The stores
109
- * and the chat's pins are shared per-agent state; a LOSING double-boot
110
- * must never unpin the live holder's legitimate pins. */
111
- eligible: () => boolean
112
- /** Optional structured log sink. */
113
- log?: (line: string) => void
114
- }
115
-
116
- export interface DmPinSweeper {
117
- /**
118
- * Sweep a single DM chat: unpin-all then re-pin live tracked cards.
119
- * At most ONCE per chat per sweeper lifetime (one gateway boot). No-op
120
- * for non-DM chats, when not eligible, or when already swept. Never
121
- * throws — a failed unpin-all/re-pin degrades and is logged.
122
- */
123
- sweep(chatId: string): Promise<void>
124
- /** True if this chat has already been swept this boot (test/observability). */
125
- hasSwept(chatId: string): boolean
126
- }
127
-
128
- export function createDmPinSweeper(deps: DmPinSweeperDeps): DmPinSweeper {
129
- const swept = new Set<string>()
130
- return {
131
- hasSwept: (chatId) => swept.has(chatId),
132
- async sweep(chatId: string): Promise<void> {
133
- if (!isDmChatId(chatId)) return
134
- if (!deps.eligible()) return
135
- if (swept.has(chatId)) return
136
- // Claim the once-guard BEFORE the first await so two concurrent
137
- // triggers (boot + first-inbound landing in the same tick) can't
138
- // both fire the unpin-all.
139
- swept.add(chatId)
140
-
141
- // Snapshot live claims BEFORE the unpin-all so we know exactly which
142
- // messages to restore. Dedupe — the in-memory and store-row sources
143
- // can both report the same messageId.
144
- const liveIds = [...new Set(deps.liveTrackedMessageIds(chatId))]
145
-
146
- try {
147
- await deps.unpinAll(chatId)
148
- } catch (err) {
149
- const msg = err instanceof Error ? err.message : String(err)
150
- deps.log?.(
151
- `telegram gateway: dm-pin-sweep: unpin-all failed (chat=${chatId}): ${msg}\n`,
152
- )
153
- // Degrade: the stack may still hold stale pins, but we don't
154
- // crash and we don't retry this boot. Fall through to re-pin the
155
- // live claims (harmless if the unpin didn't land).
156
- }
157
-
158
- for (const messageId of liveIds) {
159
- try {
160
- await deps.pinSilent(chatId, messageId)
161
- } catch (err) {
162
- const msg = err instanceof Error ? err.message : String(err)
163
- deps.log?.(
164
- `telegram gateway: dm-pin-sweep: re-pin failed ` +
165
- `(chat=${chatId} msg=${messageId}): ${msg}\n`,
166
- )
167
- }
168
- }
169
-
170
- if (liveIds.length > 0) {
171
- deps.log?.(
172
- `telegram gateway: dm-pin-sweep: cleared stacked pins in DM ${chatId}, ` +
173
- `re-pinned ${liveIds.length} live card(s)\n`,
174
- )
175
- }
176
- },
177
- }
178
- }