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
@@ -79,6 +79,15 @@ export interface StatusPinClaim {
79
79
  messageId: number
80
80
  /** Chat the pin lives in. Never empty — the reconcile refuses an empty chat. */
81
81
  chatId: string
82
+ /**
83
+ * Forum topic the pinned message lives in, when known.
84
+ *
85
+ * The claim is keyed by `(chat, thread)`. It is NOT needed to unpin this one
86
+ * message (`unpinChatMessage` takes no `message_thread_id`), but it is the
87
+ * only thing that lets a sweep aim `unpinAllForumTopicMessages` at the right
88
+ * topic when a crash leaves a chat-wide stack of orphans behind.
89
+ */
90
+ threadId?: number
82
91
  /** Wall-clock ms the claim was FIRST taken (survives a same-id re-pin). */
83
92
  pinnedAt: number
84
93
  }
@@ -86,6 +95,9 @@ export interface StatusPinClaim {
86
95
  export interface StatusPinReconcileArgs {
87
96
  pinKey: string
88
97
  chatId: string
98
+ /** Forum topic the pinned message lives in, when the caller knows it. Carried
99
+ * into both the in-memory claim and the durable row (see StatusPinClaim). */
100
+ threadId?: number
89
101
  /** The claim held for this key when the reconcile started. */
90
102
  prev: PinState | null
91
103
  /** What the caller wants pinned for this key now. */
@@ -116,7 +128,7 @@ export interface StatusPinReconcileArgs {
116
128
  * is read once by the caller and both legs run inside that critical section.
117
129
  */
118
130
  export async function runStatusPinReconcile(args: StatusPinReconcileArgs): Promise<void> {
119
- const { pinKey, chatId, prev, desired, persist, runPin, claims } = args
131
+ const { pinKey, chatId, threadId, prev, desired, persist, runPin, claims } = args
120
132
  const now = args.now ?? Date.now
121
133
 
122
134
  // Publish a leg's outcome into the single claim registry. `pinnedAt` is
@@ -128,7 +140,7 @@ export async function runStatusPinReconcile(args: StatusPinReconcileArgs): Promi
128
140
  return
129
141
  }
130
142
  const pinnedAt = claims.get(pinKey)?.pinnedAt ?? now()
131
- claims.set(pinKey, { messageId: next.messageId, chatId, pinnedAt })
143
+ claims.set(pinKey, { messageId: next.messageId, chatId, threadId, pinnedAt })
132
144
  }
133
145
 
134
146
  // One leg, with the persist ordering its action requires. Only a fresh `pin`
@@ -152,6 +164,7 @@ export async function runStatusPinReconcile(args: StatusPinReconcileArgs): Promi
152
164
  fs: persist.fs,
153
165
  pinKey,
154
166
  chatId,
167
+ threadId,
155
168
  op,
156
169
  applyPin: () => runPin(legAction, from),
157
170
  now: now(),
@@ -55,6 +55,23 @@ export interface StatusPinStoreFsSeam {
55
55
  export interface PersistedStatusPin {
56
56
  pinKey: string
57
57
  chatId: string
58
+ /**
59
+ * Forum topic the pinned message lives in, when the caller knows it.
60
+ *
61
+ * The durable claim is keyed by `(chat, thread)`, not by chat alone. Telegram's
62
+ * pin stack is CHAT-WIDE — a "topic pin" is a chat-level pin whose message
63
+ * happens to sit in that thread, and `pinChatMessage`/`unpinChatMessage` take
64
+ * no `message_thread_id` at all. So the thread is NOT needed to unpin one
65
+ * known message; it is needed to DRAIN a topic, because the only topic-scoped
66
+ * pin verb a bot has is `unpinAllForumTopicMessages(chat_id, message_thread_id)`.
67
+ *
68
+ * Without this field a row records "there were orphan pins in forum chat X"
69
+ * and nothing more, so a boot after a crash cannot aim the one verb that would
70
+ * clear them — which is how a chat-wide stack grows on every restart. Optional
71
+ * so a v1–v3 snapshot still loads; a row without it degrades to the
72
+ * chat-level path.
73
+ */
74
+ threadId?: number
58
75
  messageId: number
59
76
  /** True while the pin API call is in-flight / unconfirmed (see above). */
60
77
  pending?: boolean
@@ -102,16 +119,17 @@ export const BOOT_UNPIN_MAX_ATTEMPTS = 5
102
119
 
103
120
  /** Envelope version. v1 had no `pending` field; a v1 row loads as a confirmed
104
121
  * pin (pending undefined). v2 adds the optional `pending` flag. v3 adds the
105
- * optional `pinnedAt` claim timestamp (#3810). All load fail-open an
106
- * unknown/newer version yields []. Every field added since v1 is optional, so
107
- * the versions are mutually readable and a downgrade degrades rather than
108
- * breaks. */
122
+ * optional `pinnedAt` claim timestamp (#3810). v4 adds the optional `threadId`
123
+ * so the claim is keyed by (chat, thread) and a forum topic can be drained
124
+ * after a crash. All load fail-open an unknown/newer version yields [].
125
+ * Every field added since v1 is optional, so the versions are mutually
126
+ * readable and a downgrade degrades rather than breaks. */
109
127
  interface SnapshotEnvelope {
110
- v: 1 | 2 | 3
128
+ v: 1 | 2 | 3 | 4
111
129
  pins: PersistedStatusPin[]
112
130
  }
113
131
 
114
- const SNAPSHOT_VERSIONS = new Set([1, 2, 3])
132
+ const SNAPSHOT_VERSIONS = new Set([1, 2, 3, 4])
115
133
 
116
134
  function isPinRow(x: unknown): x is PersistedStatusPin {
117
135
  if (x == null || typeof x !== 'object') return false
@@ -121,6 +139,7 @@ function isPinRow(x: unknown): x is PersistedStatusPin {
121
139
  o.pinKey.length > 0 &&
122
140
  typeof o.chatId === 'string' &&
123
141
  o.chatId.length > 0 &&
142
+ (o.threadId === undefined || typeof o.threadId === 'number') &&
124
143
  typeof o.messageId === 'number' &&
125
144
  (o.pending === undefined || typeof o.pending === 'boolean') &&
126
145
  (o.expiresAt === undefined || typeof o.expiresAt === 'number') &&
@@ -170,7 +189,7 @@ export function persistStatusPins(
170
189
  snapshot: readonly PersistedStatusPin[],
171
190
  log: (line: string) => void = (l) => process.stderr.write(l),
172
191
  ): void {
173
- const env: SnapshotEnvelope = { v: 3, pins: [...snapshot] }
192
+ const env: SnapshotEnvelope = { v: 4, pins: [...snapshot] }
174
193
  const tmp = path + '.tmp'
175
194
  try {
176
195
  fs.writeFileSync(tmp, JSON.stringify(env))
@@ -450,6 +469,9 @@ export function reconcileAndPersistStatusPin(args: {
450
469
  fs: StatusPinStoreFsSeam
451
470
  pinKey: string
452
471
  chatId: string
472
+ /** Forum topic the pinned message lives in, when known — persisted so a boot
473
+ * after a crash can aim `unpinAllForumTopicMessages` at the right topic. */
474
+ threadId?: number
453
475
  op: StatusPinPersistOp
454
476
  /** Execute the real pin/unpin; returns the confirmed message id (pin) or
455
477
  * null (cleared). Must never throw — API errors are swallowed inside. */
@@ -458,7 +480,7 @@ export function reconcileAndPersistStatusPin(args: {
458
480
  now?: number
459
481
  log?: (line: string) => void
460
482
  }): Promise<{ messageId: number } | null> {
461
- const { path, fs, pinKey, chatId, op } = args
483
+ const { path, fs, pinKey, chatId, threadId, op } = args
462
484
  const log = args.log ?? ((l: string) => process.stderr.write(l))
463
485
  const now = args.now ?? Date.now()
464
486
 
@@ -489,7 +511,7 @@ export function reconcileAndPersistStatusPin(args: {
489
511
  path,
490
512
  fs,
491
513
  pinKey,
492
- { pinKey, chatId, messageId: op.messageId, pending: true, pinnedAt },
514
+ { pinKey, chatId, threadId, messageId: op.messageId, pending: true, pinnedAt },
493
515
  log,
494
516
  )
495
517
  const next = await args.applyPin()
@@ -504,7 +526,7 @@ export function reconcileAndPersistStatusPin(args: {
504
526
  path,
505
527
  fs,
506
528
  pinKey,
507
- { pinKey, chatId, messageId: next.messageId, pinnedAt: claimAge(next.messageId) },
529
+ { pinKey, chatId, threadId, messageId: next.messageId, pinnedAt: claimAge(next.messageId) },
508
530
  log,
509
531
  )
510
532
  return next
@@ -541,7 +563,7 @@ export function reconcileAndPersistStatusPin(args: {
541
563
  path,
542
564
  fs,
543
565
  pinKey,
544
- { pinKey, chatId, messageId: next.messageId, pinnedAt: claimAge(next.messageId) },
566
+ { pinKey, chatId, threadId, messageId: next.messageId, pinnedAt: claimAge(next.messageId) },
545
567
  log,
546
568
  )
547
569
  }