switchroom 0.18.23 → 0.18.24

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.
@@ -298,6 +298,36 @@ export interface WorkerActivityFeedOpts {
298
298
  * Tests inject a small value.
299
299
  */
300
300
  staleWorkerTtlMs?: number
301
+ /**
302
+ * ABSOLUTE row-lifetime cap (ms), measured from a row's creation — NOT from
303
+ * its last `update()`. The heartbeat force-terminates (and, when it was the
304
+ * last live worker, unpins) ANY row this old, whether or not it is marked
305
+ * finished and no matter how recently it updated.
306
+ *
307
+ * Why this exists AND is distinct from `staleWorkerTtlMs` (Carrie 5h zombie
308
+ * pin, v0.18.23): the `staleWorkerTtlMs` backstop is keyed off `lastUpdateAt`,
309
+ * so it only bites a row that goes SILENT. A leaked row that never transitions
310
+ * to finished AND keeps receiving `update()` cues every heartbeat (~6s) resets
311
+ * `lastUpdateAt` on every tick, so the silence sweep can NEVER match — the row
312
+ * (and its group pin) lives forever. This cap is immune to that reset: it is
313
+ * anchored to `createdAtMs` (immutable), so an immortal-but-updating row is
314
+ * still reaped past an absolute age. The primary fix (the watcher's
315
+ * `onTerminalCleanup` → `terminate` wiring) drives the clean path; this is the
316
+ * deterministic last-resort guarantee that no card can outlive the cap even if
317
+ * that signal is missed or the row is spuriously re-driven.
318
+ *
319
+ * The gateway DERIVES this from the watcher's effective in-flight terminal cap
320
+ * (`resolveInflightTerminalCapMs()`) times a multiple, matching the
321
+ * `staleWorkerTtlMs` derivation pattern (never a bare magic number). It is set
322
+ * comfortably above any legitimate single worker's lifetime so it can only
323
+ * bite a genuine leak; if a real long worker ever hit it, its cosmetic feed
324
+ * card collapses to `incomplete` and unpins — its actual result still reaches
325
+ * the user via the separate handback reply.
326
+ *
327
+ * Fallback default (this module, for direct / non-gateway callers) 6 h.
328
+ * Tests inject a small value.
329
+ */
330
+ absoluteRowLifetimeCapMs?: number
301
331
  /**
302
332
  * Group-level status-pin reconcile hook (#3207 review). Because workers now
303
333
  * COALESCE into one shared message, the pin MUST follow the GROUP lifecycle,
@@ -359,6 +389,19 @@ interface WorkerRow {
359
389
  * and on every `update()`.
360
390
  */
361
391
  lastUpdateAt: number
392
+ /**
393
+ * Wall-clock ms this row was first CREATED (its first `update()` cue).
394
+ * IMMUTABLE after creation — unlike `lastUpdateAt`, it is NEVER re-stamped by
395
+ * later updates. The heartbeat's ABSOLUTE row-lifetime cap force-terminates a
396
+ * row whose `createdAtMs` is older than `absoluteRowLifetimeCapMs` regardless
397
+ * of how recently it updated. This closes the immortal-card leak the
398
+ * `lastUpdateAt` backstop cannot: a row that keeps receiving `update()` cues
399
+ * (a zombie whose watcher entry re-registers, or any spurious re-drive) resets
400
+ * `lastUpdateAt` every tick, so the silence-TTL sweep NEVER fires — but the
401
+ * absolute cap is immune to that reset (Carrie 5h zombie pin, re-edited 3000+
402
+ * times, only cleared by the restart-time dm-pin-sweep).
403
+ */
404
+ createdAtMs: number
362
405
  /**
363
406
  * Wall-clock ms the CURRENT step started — stamped whenever a NEW narrative
364
407
  * line lands (the `→` line changes). The heartbeat's step suffix shows the
@@ -523,6 +566,22 @@ export interface WorkerActivityFeed {
523
566
  * stays visible. Idempotent; a no-op if the worker was never finalized.
524
567
  */
525
568
  resurrect(agentId: string): void
569
+ /**
570
+ * Boot / reconnect purge — reconcile the ENTIRE feed to empty and release
571
+ * EVERY group's pin, unconditionally.
572
+ *
573
+ * Why this is a hard invariant, not a TTL: every tracked worker row is a live
574
+ * sub-agent that was a CHILD PROCESS of the gateway. It cannot outlive a
575
+ * gateway restart, and it cannot survive a bridge reconnect that reconstructs
576
+ * the feed either — so any row still present when the feed is (re)initialised
577
+ * is definitionally dead. Left alone, the OLD feed instance's `wk:group:` pins
578
+ * are orphaned: the replacement feed is empty and never knew those groups, so
579
+ * it never unpins them (the reconnect-orphaned-pin leak — no full-boot pin
580
+ * sweep runs on a bare reconnect). This finalizes+removes every row and drives
581
+ * `reconcilePin(messageId: null)` for every group so the coalesced card is
582
+ * unpinned at once. Idempotent; a no-op on an already-empty feed.
583
+ */
584
+ purgeAllOnBoot(): void
526
585
  /** Clear the heartbeat interval (gateway shutdown). Idempotent. */
527
586
  stop(): void
528
587
  /** Manually fire one heartbeat tick (test hook). */
@@ -540,6 +599,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
540
599
  const heartbeatTickMs = opts.heartbeatTickMs ?? 6000
541
600
  const maxRows = Math.max(1, Math.floor(opts.maxRows ?? 8))
542
601
  const staleWorkerTtlMs = Math.max(1, Math.floor(opts.staleWorkerTtlMs ?? 50 * 60_000))
602
+ const absoluteRowLifetimeCapMs = Math.max(1, Math.floor(opts.absoluteRowLifetimeCapMs ?? 6 * 60 * 60_000))
543
603
  const reconcilePinFn = opts.reconcilePin ?? (() => {})
544
604
  const setIntervalFn =
545
605
  opts.setInterval ??
@@ -1016,14 +1076,29 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1016
1076
  // `onTerminalCleanup` sweep. Collect the stale agent ids first (terminate
1017
1077
  // mutates the group's worker map), then terminate each through its chain so
1018
1078
  // the render/unpin happens under the normal cooldown/flood guards.
1019
- const staleAgentIds: string[] = []
1020
- const staleFinished: Array<{ g: FeedGroup; agentId: string }> = []
1079
+ //
1080
+ // TWO independent triggers, OR'd per row:
1081
+ // 1. SILENCE TTL (`staleWorkerTtlMs`, keyed off `lastUpdateAt`) — a row
1082
+ // that stopped receiving cues.
1083
+ // 2. ABSOLUTE row-lifetime cap (`absoluteRowLifetimeCapMs`, keyed off the
1084
+ // IMMUTABLE `createdAtMs`) — a row past an absolute age NO MATTER how
1085
+ // recently it updated. This is the durable guard against the immortal-
1086
+ // but-updating card the silence sweep can never catch: a leaked row
1087
+ // that keeps getting `update()` cues every heartbeat resets
1088
+ // `lastUpdateAt` forever, so only an age anchor immune to that reset can
1089
+ // reap it (Carrie 5h zombie pin, re-edited 3000+ times).
1090
+ const staleAgentIds: Array<{ agentId: string; reason: 'silence' | 'absolute' }> = []
1091
+ const staleFinished: Array<{ g: FeedGroup; agentId: string; reason: 'silence' | 'absolute' }> = []
1021
1092
  for (const g of groups.values()) {
1022
1093
  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
- }
1094
+ const silent = now - row.lastUpdateAt >= staleWorkerTtlMs
1095
+ const tooOld = now - row.createdAtMs >= absoluteRowLifetimeCapMs
1096
+ if (!silent && !tooOld) continue
1097
+ // Attribute the reap to the absolute cap only when silence alone would
1098
+ // NOT have fired — so the log names the trigger that actually caught it.
1099
+ const reason: 'silence' | 'absolute' = silent ? 'silence' : 'absolute'
1100
+ if (row.finished) staleFinished.push({ g, agentId: row.agentId, reason })
1101
+ else staleAgentIds.push({ agentId: row.agentId, reason })
1027
1102
  }
1028
1103
  }
1029
1104
  // GC any FINISHED row still lingering past the TTL (#3207: finished rows
@@ -1032,14 +1107,25 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1032
1107
  // normal finalize path drops the row immediately now, but reap directly
1033
1108
  // here as a durable backstop: `terminate()` no-ops on a finished row, so
1034
1109
  // 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`)
1110
+ for (const { g, agentId, reason } of staleFinished) {
1111
+ if (reason === 'absolute') {
1112
+ const age = Math.floor((now - (g.workers.get(agentId)?.createdAtMs ?? now)) / 1000)
1113
+ 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`)
1114
+ } else {
1115
+ log(`worker-feed: TTL GC finished row agent=${agentId} feed=${g.feedKey} — reaping leaked finished row`)
1116
+ }
1037
1117
  g.pendingFinalize.delete(agentId)
1038
1118
  removeWorker(g, agentId)
1039
1119
  syncPin(g)
1040
1120
  }
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`)
1121
+ for (const { agentId, reason } of staleAgentIds) {
1122
+ const row = groupOfAgent(agentId)?.workers.get(agentId)
1123
+ if (reason === 'absolute') {
1124
+ const age = Math.floor((now - (row?.createdAtMs ?? now)) / 1000)
1125
+ log(`worker-feed: ABSOLUTE cap reap agent=${agentId} — row age ${age}s (>= ${Math.floor(absoluteRowLifetimeCapMs / 1000)}s); force-terminating immortal row (survives lastUpdateAt reset)`)
1126
+ } else {
1127
+ 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`)
1128
+ }
1043
1129
  void terminateWorker(agentId)
1044
1130
  }
1045
1131
  for (const g of [...groups.values()]) {
@@ -1166,6 +1252,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1166
1252
  state: 'running',
1167
1253
  finished: false,
1168
1254
  lastUpdateAt: nowFn(),
1255
+ createdAtMs: nowFn(),
1169
1256
  dispatchAtMs: null,
1170
1257
  stepStartedAtMs: null,
1171
1258
  }
@@ -1240,6 +1327,27 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1240
1327
  log(`worker-feed: resurrect agent=${agentId} — cleared finalized gate; card will repaint on next running cue`)
1241
1328
  }
1242
1329
  },
1330
+ purgeAllOnBoot(): void {
1331
+ for (const g of [...groups.values()]) {
1332
+ // Release the group pin unconditionally: the shared card is orphaned
1333
+ // after a restart/reconnect (its workers are dead child processes), so
1334
+ // it must be unpinned regardless of `hasLiveWorker` — NOT gated like
1335
+ // `syncPin`, which would keep the pin for a row we are about to drop.
1336
+ if (g.messageId != null) {
1337
+ reconcilePinFn({ feedKey: g.feedKey, chatId: g.chatId, threadId: g.threadId, messageId: null })
1338
+ }
1339
+ for (const agentId of [...g.workers.keys()]) {
1340
+ // Latch finalized so a late/inflight cue on the OLD chain can't
1341
+ // resurrect a row on a feed we are tearing down.
1342
+ markFinalized(agentId)
1343
+ agentIndex.delete(agentId)
1344
+ }
1345
+ g.workers.clear()
1346
+ g.pendingFinalize.clear()
1347
+ groups.delete(g.feedKey)
1348
+ }
1349
+ log('worker-feed: purgeAllOnBoot — reconciled feed to empty and released all group pins')
1350
+ },
1243
1351
  heartbeatTick,
1244
1352
  stop() {
1245
1353
  if (heartbeatTimer != null) {