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.
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +123 -16
- package/telegram-plugin/gateway/gateway.ts +210 -13
- package/telegram-plugin/reply-owner-resolve.ts +160 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +279 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +117 -1
- package/telegram-plugin/worker-activity-feed.ts +118 -10
|
@@ -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
|
-
|
|
1020
|
-
|
|
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
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|