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.
- package/dist/cli/switchroom.js +59 -11
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +26 -0
- package/telegram-plugin/dist/gateway/gateway.js +1608 -841
- package/telegram-plugin/dist/server.js +26 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
- package/telegram-plugin/gateway/gateway.ts +524 -16
- package/telegram-plugin/gateway/model-command.ts +188 -56
- package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
- package/telegram-plugin/history.ts +118 -0
- package/telegram-plugin/registry/turns-schema.ts +89 -1
- package/telegram-plugin/reply-owner-resolve.ts +160 -0
- package/telegram-plugin/session-tail.ts +185 -0
- package/telegram-plugin/subagent-watcher.ts +45 -0
- package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
- package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
- package/telegram-plugin/tests/history.test.ts +91 -0
- package/telegram-plugin/tests/model-command.test.ts +189 -12
- package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
- package/telegram-plugin/tests/registry-turns.test.ts +51 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +279 -0
- package/telegram-plugin/tests/session-model-source.test.ts +11 -0
- package/telegram-plugin/tests/session-tail.test.ts +145 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
- package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +117 -1
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
- package/telegram-plugin/tool-activity-summary.ts +54 -3
- package/telegram-plugin/worker-activity-feed.ts +222 -10
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
- package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
- package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
- package/vendor/hindsight-memory/scripts/retain.py +299 -143
- package/vendor/hindsight-memory/scripts/session_start.py +14 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
- 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
|
-
|
|
1020
|
-
|
|
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
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|