switchroom 0.18.23 → 0.18.25

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 (45) hide show
  1. package/dist/cli/switchroom.js +59 -11
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/telegram-plugin/dist/bridge/bridge.js +26 -0
  5. package/telegram-plugin/dist/gateway/gateway.js +1608 -841
  6. package/telegram-plugin/dist/server.js +26 -0
  7. package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
  8. package/telegram-plugin/gateway/gateway.ts +524 -16
  9. package/telegram-plugin/gateway/model-command.ts +188 -56
  10. package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
  11. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
  12. package/telegram-plugin/history.ts +118 -0
  13. package/telegram-plugin/registry/turns-schema.ts +89 -1
  14. package/telegram-plugin/reply-owner-resolve.ts +160 -0
  15. package/telegram-plugin/session-tail.ts +185 -0
  16. package/telegram-plugin/subagent-watcher.ts +45 -0
  17. package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
  18. package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
  19. package/telegram-plugin/tests/history.test.ts +91 -0
  20. package/telegram-plugin/tests/model-command.test.ts +189 -12
  21. package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
  22. package/telegram-plugin/tests/registry-turns.test.ts +51 -0
  23. package/telegram-plugin/tests/reply-owner-resolve.test.ts +279 -0
  24. package/telegram-plugin/tests/session-model-source.test.ts +11 -0
  25. package/telegram-plugin/tests/session-tail.test.ts +145 -0
  26. package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
  27. package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
  28. package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
  29. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
  30. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +117 -1
  31. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
  32. package/telegram-plugin/tool-activity-summary.ts +54 -3
  33. package/telegram-plugin/worker-activity-feed.ts +222 -10
  34. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
  35. package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
  36. package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
  37. package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
  38. package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
  39. package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
  40. package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
  41. package/vendor/hindsight-memory/scripts/retain.py +299 -143
  42. package/vendor/hindsight-memory/scripts/session_start.py +14 -0
  43. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
  44. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
  45. package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
@@ -92,6 +92,14 @@ export interface WorkerActivityView {
92
92
  lastTool: { name: string; sanitisedArg: string } | null
93
93
  /** Number of tool calls observed so far. */
94
94
  toolCount: number
95
+ /**
96
+ * Running TOTAL tokens across the worker's assistant messages so far
97
+ * (input + output + cache_creation, deduped by message.id in the watcher;
98
+ * cache_read is excluded — replayed cached context, not new work). Rendered
99
+ * as `· {N} tok` on the card's metrics line. Omitted /
100
+ * 0 → no token segment (a worker that emitted no usage).
101
+ */
102
+ totalTokens?: number
95
103
  /** The worker's latest narrative line, if any (already capped upstream). */
96
104
  latestSummary: string
97
105
  /**
@@ -178,6 +186,7 @@ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): st
178
186
  toolCount: v.toolCount,
179
187
  state: v.state,
180
188
  model: v.model,
189
+ totalTokens: v.totalTokens,
181
190
  }
182
191
 
183
192
  // Terminal: latestSummary carries the worker's final result text (gateway
@@ -298,6 +307,59 @@ export interface WorkerActivityFeedOpts {
298
307
  * Tests inject a small value.
299
308
  */
300
309
  staleWorkerTtlMs?: number
310
+ /**
311
+ * ABSOLUTE row-lifetime cap (ms), measured from a row's creation — NOT from
312
+ * its last `update()`. The heartbeat force-terminates (and, when it was the
313
+ * last live worker, unpins) ANY row this old, whether or not it is marked
314
+ * finished and no matter how recently it updated.
315
+ *
316
+ * Why this exists AND is distinct from `staleWorkerTtlMs` (Carrie 5h zombie
317
+ * pin, v0.18.23): the `staleWorkerTtlMs` backstop is keyed off `lastUpdateAt`,
318
+ * so it only bites a row that goes SILENT. A leaked row that never transitions
319
+ * to finished AND keeps receiving `update()` cues every heartbeat (~6s) resets
320
+ * `lastUpdateAt` on every tick, so the silence sweep can NEVER match — the row
321
+ * (and its group pin) lives forever. This cap is immune to that reset: it is
322
+ * anchored to `createdAtMs` (immutable), so an immortal-but-updating row is
323
+ * still reaped past an absolute age. The primary fix (the watcher's
324
+ * `onTerminalCleanup` → `terminate` wiring) drives the clean path; this is the
325
+ * deterministic last-resort guarantee that no card can outlive the cap even if
326
+ * that signal is missed or the row is spuriously re-driven.
327
+ *
328
+ * The gateway DERIVES this from the watcher's effective in-flight terminal cap
329
+ * (`resolveInflightTerminalCapMs()`) times a multiple, matching the
330
+ * `staleWorkerTtlMs` derivation pattern (never a bare magic number). It is set
331
+ * comfortably above any legitimate single worker's lifetime so it can only
332
+ * bite a genuine leak; if a real long worker ever hit it, its cosmetic feed
333
+ * card collapses to `incomplete` and unpins — its actual result still reaches
334
+ * the user via the separate handback reply.
335
+ *
336
+ * Fallback default (this module, for direct / non-gateway callers) 6 h.
337
+ * Tests inject a small value.
338
+ */
339
+ absoluteRowLifetimeCapMs?: number
340
+ /**
341
+ * ABSOLUTE reused-group-MESSAGE lifetime cap (ms), measured from the moment
342
+ * the shared message was posted (`messageCreatedAtMs`), NOT from a worker
343
+ * row's age. When workers overlap continuously the group is NEVER reset to a
344
+ * fresh message (the only messageId reset is the group-reuse-after-terminal
345
+ * branch, gated on `!hasLiveWorker`), so ONE message can live for hours. If
346
+ * that message loses its pin out-of-band (a raw unpin somewhere else, or
347
+ * Telegram pin-stacking burying it), the steady-state edit path re-pins via
348
+ * `syncPin`; but a STALE in-memory pin claim (the claim still names this id, so
349
+ * `decidePinAction` no-ops on equal id) can only be cleared by rotating to a
350
+ * NEW message id. This cap force-rotates the shared message past an absolute
351
+ * age WHILE live workers remain, so the pin surface is periodically re-
352
+ * established through the first-paint `syncPin` path — the deterministic
353
+ * defense-in-depth that bounds how long a buried card can stay invisible
354
+ * (invisible-worker-cards incident, 2026-07-15; mirrors the immutable-anchor
355
+ * absolute-cap pattern of `absoluteRowLifetimeCapMs` / #3239's row cap).
356
+ *
357
+ * Conservative by design: set well above a normal edit cadence so a healthy
358
+ * pinned card is not needlessly churned. Rotation costs ONE unpin (old claim)
359
+ * + ONE pin (fresh message) per cap interval — never a burst. Fallback default
360
+ * (this module) 60 min; tests inject a small value.
361
+ */
362
+ groupMessageLifetimeCapMs?: number
301
363
  /**
302
364
  * Group-level status-pin reconcile hook (#3207 review). Because workers now
303
365
  * COALESCE into one shared message, the pin MUST follow the GROUP lifecycle,
@@ -359,6 +421,19 @@ interface WorkerRow {
359
421
  * and on every `update()`.
360
422
  */
361
423
  lastUpdateAt: number
424
+ /**
425
+ * Wall-clock ms this row was first CREATED (its first `update()` cue).
426
+ * IMMUTABLE after creation — unlike `lastUpdateAt`, it is NEVER re-stamped by
427
+ * later updates. The heartbeat's ABSOLUTE row-lifetime cap force-terminates a
428
+ * row whose `createdAtMs` is older than `absoluteRowLifetimeCapMs` regardless
429
+ * of how recently it updated. This closes the immortal-card leak the
430
+ * `lastUpdateAt` backstop cannot: a row that keeps receiving `update()` cues
431
+ * (a zombie whose watcher entry re-registers, or any spurious re-drive) resets
432
+ * `lastUpdateAt` every tick, so the silence-TTL sweep NEVER fires — but the
433
+ * absolute cap is immune to that reset (Carrie 5h zombie pin, re-edited 3000+
434
+ * times, only cleared by the restart-time dm-pin-sweep).
435
+ */
436
+ createdAtMs: number
362
437
  /**
363
438
  * Wall-clock ms the CURRENT step started — stamped whenever a NEW narrative
364
439
  * line lands (the `→` line changes). The heartbeat's step suffix shows the
@@ -382,6 +457,15 @@ interface FeedGroup {
382
457
  chatId: string
383
458
  threadId?: number
384
459
  messageId: number | null
460
+ /**
461
+ * Wall-clock ms the CURRENT shared message was posted (first paint / re-paint).
462
+ * IMMUTABLE for the lifetime of a given `messageId`: stamped when a message id
463
+ * is assigned and NEVER re-stamped by later edits, so the group-message
464
+ * lifetime cap (`groupMessageLifetimeCapMs`) measures the reused message's true
465
+ * absolute age and cannot be reset by a continuous edit stream. Reset to 0
466
+ * whenever `messageId` goes null (rotation / stale-message drop).
467
+ */
468
+ messageCreatedAtMs: number
385
469
  lastBody: string | null
386
470
  lastEditAt: number
387
471
  cooldownUntil: number
@@ -417,6 +501,17 @@ interface FeedGroup {
417
501
 
418
502
  const COOLDOWN_JITTER_MS = 500
419
503
 
504
+ /**
505
+ * Static body the retired shared message is finalized to when the group-message
506
+ * lifetime cap rotates to a fresh card (invisible-worker-cards review, FIX 1).
507
+ * Without this the abandoned message stays frozen showing live-styled worker
508
+ * rows and reads like a stuck worker. A single best-effort edit collapses it to
509
+ * an honest "moved" note — issued once per rotation (≥ cap interval), never per
510
+ * tick, so it adds no edit churn / pin storm. Plain voice, no em dash.
511
+ */
512
+ const WORKER_CARD_SUPERSEDED_BODY =
513
+ '🛠 **Worker** · _continued_\n\n_Live progress moved to a fresh card to stay pinned._'
514
+
420
515
  function extractRetryAfterSecs(err: unknown): number | null {
421
516
  if (err == null || typeof err !== 'object') return null
422
517
  const e = err as { error_code?: unknown; parameters?: { retry_after?: unknown } }
@@ -523,6 +618,22 @@ export interface WorkerActivityFeed {
523
618
  * stays visible. Idempotent; a no-op if the worker was never finalized.
524
619
  */
525
620
  resurrect(agentId: string): void
621
+ /**
622
+ * Boot / reconnect purge — reconcile the ENTIRE feed to empty and release
623
+ * EVERY group's pin, unconditionally.
624
+ *
625
+ * Why this is a hard invariant, not a TTL: every tracked worker row is a live
626
+ * sub-agent that was a CHILD PROCESS of the gateway. It cannot outlive a
627
+ * gateway restart, and it cannot survive a bridge reconnect that reconstructs
628
+ * the feed either — so any row still present when the feed is (re)initialised
629
+ * is definitionally dead. Left alone, the OLD feed instance's `wk:group:` pins
630
+ * are orphaned: the replacement feed is empty and never knew those groups, so
631
+ * it never unpins them (the reconnect-orphaned-pin leak — no full-boot pin
632
+ * sweep runs on a bare reconnect). This finalizes+removes every row and drives
633
+ * `reconcilePin(messageId: null)` for every group so the coalesced card is
634
+ * unpinned at once. Idempotent; a no-op on an already-empty feed.
635
+ */
636
+ purgeAllOnBoot(): void
526
637
  /** Clear the heartbeat interval (gateway shutdown). Idempotent. */
527
638
  stop(): void
528
639
  /** Manually fire one heartbeat tick (test hook). */
@@ -540,6 +651,8 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
540
651
  const heartbeatTickMs = opts.heartbeatTickMs ?? 6000
541
652
  const maxRows = Math.max(1, Math.floor(opts.maxRows ?? 8))
542
653
  const staleWorkerTtlMs = Math.max(1, Math.floor(opts.staleWorkerTtlMs ?? 50 * 60_000))
654
+ const absoluteRowLifetimeCapMs = Math.max(1, Math.floor(opts.absoluteRowLifetimeCapMs ?? 6 * 60 * 60_000))
655
+ const groupMessageLifetimeCapMs = Math.max(1, Math.floor(opts.groupMessageLifetimeCapMs ?? 60 * 60_000))
543
656
  const reconcilePinFn = opts.reconcilePin ?? (() => {})
544
657
  const setIntervalFn =
545
658
  opts.setInterval ??
@@ -694,6 +807,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
694
807
  description: v.description,
695
808
  elapsedMs: elapsedFor(r),
696
809
  toolCount: v.toolCount,
810
+ totalTokens: v.totalTokens,
697
811
  currentStep,
698
812
  // Full per-worker rolling history (oldest→newest) so the combined feed
699
813
  // can paint an adaptive-depth ✓/→ trail, not just the latest line. The
@@ -843,6 +957,9 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
843
957
  return
844
958
  }
845
959
  g.messageId = sent.message_id
960
+ // Stamp the reused-message birth time (immutable for this id) so the
961
+ // group-message lifetime cap measures the message's true absolute age.
962
+ g.messageCreatedAtMs = now
846
963
  g.lastBody = body
847
964
  g.lastEditAt = now
848
965
  // A fresh message is a live running paint, never a terminal recap.
@@ -891,6 +1008,16 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
891
1008
  `worker-feed: edit feed=${g.feedKey} chat=${g.chatId} ` +
892
1009
  `thread=${g.threadId ?? '-'} msgId=${g.messageId} workers=${g.workers.size} bytes=${body.length}`,
893
1010
  )
1011
+ // Re-assert the group pin on the steady-state edit path (invisible-
1012
+ // worker-cards fix). `syncPin` was previously called ONLY at lifecycle
1013
+ // edges (first-paint / terminal / GC), so once a long-lived reused
1014
+ // message lost its pin out-of-band it was edited live forever but never
1015
+ // re-pinned → scroll-buried. This closes that gap deterministically. It
1016
+ // is a NO-OP when the pin is already correct: `decidePinAction` returns
1017
+ // `noop` on equal claim id (status-pin.ts), so this makes ZERO Telegram
1018
+ // calls on the common path and cannot create a pin storm — when the
1019
+ // claim was dropped (variant 1) it re-pins the current message.
1020
+ syncPin(g)
894
1021
  }
895
1022
  if (isTerminal) clearStaged()
896
1023
  } catch (err) {
@@ -911,6 +1038,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
911
1038
  // stale message id; a fresh first-paint re-establishes one if workers
912
1039
  // are still live. On a terminal finalize, clear the staged repaint.
913
1040
  g.messageId = null
1041
+ g.messageCreatedAtMs = 0
914
1042
  g.lastBody = null
915
1043
  // The pinned message no longer exists → release the group pin claim
916
1044
  // (clearStaged already re-syncs on the terminal path).
@@ -994,6 +1122,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
994
1122
  description: lv?.description ?? 'background task',
995
1123
  lastTool: null,
996
1124
  toolCount: lv?.toolCount ?? 0,
1125
+ totalTokens: lv?.totalTokens,
997
1126
  // No fabricated result paragraph — an authoritative sweep can't know what
998
1127
  // the worker returned; the terminal card shows the header struck-through
999
1128
  // as `incomplete`, and the handback (if it ran) carries the actual result.
@@ -1016,14 +1145,29 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1016
1145
  // `onTerminalCleanup` sweep. Collect the stale agent ids first (terminate
1017
1146
  // mutates the group's worker map), then terminate each through its chain so
1018
1147
  // the render/unpin happens under the normal cooldown/flood guards.
1019
- const staleAgentIds: string[] = []
1020
- const staleFinished: Array<{ g: FeedGroup; agentId: string }> = []
1148
+ //
1149
+ // TWO independent triggers, OR'd per row:
1150
+ // 1. SILENCE TTL (`staleWorkerTtlMs`, keyed off `lastUpdateAt`) — a row
1151
+ // that stopped receiving cues.
1152
+ // 2. ABSOLUTE row-lifetime cap (`absoluteRowLifetimeCapMs`, keyed off the
1153
+ // IMMUTABLE `createdAtMs`) — a row past an absolute age NO MATTER how
1154
+ // recently it updated. This is the durable guard against the immortal-
1155
+ // but-updating card the silence sweep can never catch: a leaked row
1156
+ // that keeps getting `update()` cues every heartbeat resets
1157
+ // `lastUpdateAt` forever, so only an age anchor immune to that reset can
1158
+ // reap it (Carrie 5h zombie pin, re-edited 3000+ times).
1159
+ const staleAgentIds: Array<{ agentId: string; reason: 'silence' | 'absolute' }> = []
1160
+ const staleFinished: Array<{ g: FeedGroup; agentId: string; reason: 'silence' | 'absolute' }> = []
1021
1161
  for (const g of groups.values()) {
1022
1162
  for (const row of g.workers.values()) {
1023
- if (now - row.lastUpdateAt >= staleWorkerTtlMs) {
1024
- if (row.finished) staleFinished.push({ g, agentId: row.agentId })
1025
- else staleAgentIds.push(row.agentId)
1026
- }
1163
+ const silent = now - row.lastUpdateAt >= staleWorkerTtlMs
1164
+ const tooOld = now - row.createdAtMs >= absoluteRowLifetimeCapMs
1165
+ if (!silent && !tooOld) continue
1166
+ // Attribute the reap to the absolute cap only when silence alone would
1167
+ // NOT have fired — so the log names the trigger that actually caught it.
1168
+ const reason: 'silence' | 'absolute' = silent ? 'silence' : 'absolute'
1169
+ if (row.finished) staleFinished.push({ g, agentId: row.agentId, reason })
1170
+ else staleAgentIds.push({ agentId: row.agentId, reason })
1027
1171
  }
1028
1172
  }
1029
1173
  // GC any FINISHED row still lingering past the TTL (#3207: finished rows
@@ -1032,14 +1176,25 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1032
1176
  // normal finalize path drops the row immediately now, but reap directly
1033
1177
  // here as a durable backstop: `terminate()` no-ops on a finished row, so
1034
1178
  // remove it and release the pin without routing through it.
1035
- for (const { g, agentId } of staleFinished) {
1036
- log(`worker-feed: TTL GC finished row agent=${agentId} feed=${g.feedKey} — reaping leaked finished row`)
1179
+ for (const { g, agentId, reason } of staleFinished) {
1180
+ if (reason === 'absolute') {
1181
+ const age = Math.floor((now - (g.workers.get(agentId)?.createdAtMs ?? now)) / 1000)
1182
+ log(`worker-feed: ABSOLUTE cap GC finished row agent=${agentId} feed=${g.feedKey} — age ${age}s (>= ${Math.floor(absoluteRowLifetimeCapMs / 1000)}s); reaping immortal finished row`)
1183
+ } else {
1184
+ log(`worker-feed: TTL GC finished row agent=${agentId} feed=${g.feedKey} — reaping leaked finished row`)
1185
+ }
1037
1186
  g.pendingFinalize.delete(agentId)
1038
1187
  removeWorker(g, agentId)
1039
1188
  syncPin(g)
1040
1189
  }
1041
- for (const agentId of staleAgentIds) {
1042
- log(`worker-feed: TTL reap agent=${agentId} — no update in ${Math.floor((now - (groupOfAgent(agentId)?.workers.get(agentId)?.lastUpdateAt ?? now)) / 1000)}s (>= ${Math.floor(staleWorkerTtlMs / 1000)}s); force-terminating leaked row`)
1190
+ for (const { agentId, reason } of staleAgentIds) {
1191
+ const row = groupOfAgent(agentId)?.workers.get(agentId)
1192
+ if (reason === 'absolute') {
1193
+ const age = Math.floor((now - (row?.createdAtMs ?? now)) / 1000)
1194
+ log(`worker-feed: ABSOLUTE cap reap agent=${agentId} — row age ${age}s (>= ${Math.floor(absoluteRowLifetimeCapMs / 1000)}s); force-terminating immortal row (survives lastUpdateAt reset)`)
1195
+ } else {
1196
+ log(`worker-feed: TTL reap agent=${agentId} — no update in ${Math.floor((now - (row?.lastUpdateAt ?? now)) / 1000)}s (>= ${Math.floor(staleWorkerTtlMs / 1000)}s); force-terminating leaked row`)
1197
+ }
1043
1198
  void terminateWorker(agentId)
1044
1199
  }
1045
1200
  for (const g of [...groups.values()]) {
@@ -1066,6 +1221,39 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1066
1221
  const running = runningRows(g)
1067
1222
  if (running.length === 0) continue
1068
1223
 
1224
+ // ABSOLUTE group-message lifetime cap (invisible-worker-cards fix, defense
1225
+ // in depth). While workers overlap continuously the shared message is
1226
+ // never reset, so one message can live for hours; if its pin was lost
1227
+ // out-of-band and the in-memory claim went STALE (claim still names this
1228
+ // id → `decidePinAction` no-ops on equal id), `syncPin` alone can never
1229
+ // re-pin it. Force-rotate to a fresh message past an absolute age WHILE
1230
+ // live workers remain: drop the id (unpinning the stale claim via
1231
+ // `syncPin`, which clears it to null), then fall through to the first-
1232
+ // paint path, whose `syncPin` re-pins the NEW message from a null claim.
1233
+ // Costs exactly one unpin + one pin per cap interval — never a burst.
1234
+ if (g.messageId != null && now - g.messageCreatedAtMs >= groupMessageLifetimeCapMs) {
1235
+ const age = Math.floor((now - g.messageCreatedAtMs) / 1000)
1236
+ const retiredId = g.messageId
1237
+ log(
1238
+ `worker-feed: group-message lifetime cap rotate feed=${g.feedKey} ` +
1239
+ `msgId=${retiredId} — age ${age}s (>= ${Math.floor(groupMessageLifetimeCapMs / 1000)}s); ` +
1240
+ `rotating to a fresh message to re-establish the pin surface`,
1241
+ )
1242
+ g.messageId = null
1243
+ g.messageCreatedAtMs = 0
1244
+ g.lastBody = null
1245
+ // Clear the (possibly stale) pin claim for the retired message; the
1246
+ // fresh first-paint below re-pins the new one from a null claim.
1247
+ syncPin(g)
1248
+ // FIX 1: collapse the retired message to an honest "moved" note so it
1249
+ // doesn't sit frozen showing live-styled rows (mistakable for a stuck
1250
+ // worker). ONE best-effort edit per rotation (≥ cap interval), fired
1251
+ // off-chain and swallowing errors — never per-tick, never a burst.
1252
+ void opts.bot
1253
+ .editMessageText(g.chatId, retiredId, WORKER_CARD_SUPERSEDED_BODY, sendOptsFor(g))
1254
+ .catch(() => {})
1255
+ }
1256
+
1069
1257
  // First-paint path: no message yet and some worker has now crossed
1070
1258
  // firstPaintMin (a prose-silent worker's single early tick was held).
1071
1259
  if (g.messageId == null) {
@@ -1132,6 +1320,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1132
1320
  chatId,
1133
1321
  threadId,
1134
1322
  messageId: null,
1323
+ messageCreatedAtMs: 0,
1135
1324
  lastBody: null,
1136
1325
  lastEditAt: 0,
1137
1326
  cooldownUntil: 0,
@@ -1151,6 +1340,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1151
1340
  // live worker remains AND the group last went terminal.
1152
1341
  if (!g.workers.has(agentId) && g.terminalPainted && !hasLiveWorker(g)) {
1153
1342
  g.messageId = null
1343
+ g.messageCreatedAtMs = 0
1154
1344
  g.lastBody = null
1155
1345
  g.pendingFinalize.clear()
1156
1346
  g.terminalPainted = false
@@ -1166,6 +1356,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1166
1356
  state: 'running',
1167
1357
  finished: false,
1168
1358
  lastUpdateAt: nowFn(),
1359
+ createdAtMs: nowFn(),
1169
1360
  dispatchAtMs: null,
1170
1361
  stepStartedAtMs: null,
1171
1362
  }
@@ -1240,6 +1431,27 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1240
1431
  log(`worker-feed: resurrect agent=${agentId} — cleared finalized gate; card will repaint on next running cue`)
1241
1432
  }
1242
1433
  },
1434
+ purgeAllOnBoot(): void {
1435
+ for (const g of [...groups.values()]) {
1436
+ // Release the group pin unconditionally: the shared card is orphaned
1437
+ // after a restart/reconnect (its workers are dead child processes), so
1438
+ // it must be unpinned regardless of `hasLiveWorker` — NOT gated like
1439
+ // `syncPin`, which would keep the pin for a row we are about to drop.
1440
+ if (g.messageId != null) {
1441
+ reconcilePinFn({ feedKey: g.feedKey, chatId: g.chatId, threadId: g.threadId, messageId: null })
1442
+ }
1443
+ for (const agentId of [...g.workers.keys()]) {
1444
+ // Latch finalized so a late/inflight cue on the OLD chain can't
1445
+ // resurrect a row on a feed we are tearing down.
1446
+ markFinalized(agentId)
1447
+ agentIndex.delete(agentId)
1448
+ }
1449
+ g.workers.clear()
1450
+ g.pendingFinalize.clear()
1451
+ groups.delete(g.feedKey)
1452
+ }
1453
+ log('worker-feed: purgeAllOnBoot — reconciled feed to empty and released all group pins')
1454
+ },
1243
1455
  heartbeatTick,
1244
1456
  stop() {
1245
1457
  if (heartbeatTimer != null) {