@yeaft/webchat-agent 0.1.526 → 0.1.527

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.526",
3
+ "version": "0.1.527",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -15,5 +15,7 @@ export {
15
15
  MAX_CHAIN_DEPTH,
16
16
  DEFAULT_WINDOW_MS,
17
17
  DEFAULT_MAX_HITS_PER_WINDOW,
18
+ DEFAULT_MAX_KEYS,
19
+ DEFAULT_TTL_MULTIPLIER,
18
20
  } from './loop-guard.js';
19
21
  export { createRouter } from './router.js';
@@ -18,6 +18,22 @@
18
18
  * The guard is a pure in-memory helper — no persistence — because the
19
19
  * threat model is one runaway turn storm within a single process tick.
20
20
  *
21
+ * Long-running process hygiene (N1, task-334d-followup):
22
+ * The `hits` Map is keyed by "groupId::vpId" and would otherwise grow
23
+ * unboundedly over a long session. Two complementary bounds:
24
+ * - TTL sweep: on each NEW key insert, drop entries whose most
25
+ * recent hit is older than `ttlMultiplier × windowMs` (default 2×).
26
+ * Those entries can never throttle anyone regardless — their rate
27
+ * window is already fully expired. Amortised O(n) but only on new
28
+ * keys, so normal hot-path cost stays O(1).
29
+ * - LRU cap: after the TTL sweep, if size > maxKeys (default 1000)
30
+ * the Map's insertion-order head (oldest-used key) is evicted until
31
+ * back under the cap. `check()` and `record()` both `touch()` a key
32
+ * (delete+re-set) so recency is refreshed on every access.
33
+ * Behavior invariants preserved: the `'all'` broadcast sentinel, the
34
+ * `now()` injection seam, and the chain-depth check are untouched — only
35
+ * the eviction path is new.
36
+ *
21
37
  * Integration contract (routing/router.js):
22
38
  * - router stamps envelope.meta.causedBy = [...prevChain, currentMsgId]
23
39
  * - router calls `guard.check({ groupId, targetVpId, chain })` BEFORE
@@ -30,6 +46,8 @@
30
46
  export const MAX_CHAIN_DEPTH = 10;
31
47
  export const DEFAULT_WINDOW_MS = 5_000;
32
48
  export const DEFAULT_MAX_HITS_PER_WINDOW = 8;
49
+ export const DEFAULT_MAX_KEYS = 1_000;
50
+ export const DEFAULT_TTL_MULTIPLIER = 2;
33
51
 
34
52
  /**
35
53
  * Build a new loop guard. Safe to share across a single web-bridge process.
@@ -38,17 +56,24 @@ export const DEFAULT_MAX_HITS_PER_WINDOW = 8;
38
56
  * maxChainDepth?: number,
39
57
  * windowMs?: number,
40
58
  * maxHitsPerWindow?: number,
41
- * now?: () => number, // injectable for tests
59
+ * maxKeys?: number, // N1: LRU cap (default 1000)
60
+ * ttlMultiplier?: number, // N1: evict keys idle > ttlMultiplier*windowMs
61
+ * now?: () => number, // injectable for tests
42
62
  * }} [options]
43
63
  */
44
64
  export function createLoopGuard(options = {}) {
45
65
  const maxChainDepth = options.maxChainDepth ?? MAX_CHAIN_DEPTH;
46
66
  const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
47
67
  const maxHits = options.maxHitsPerWindow ?? DEFAULT_MAX_HITS_PER_WINDOW;
68
+ const maxKeys = options.maxKeys ?? DEFAULT_MAX_KEYS;
69
+ const ttlMultiplier = options.ttlMultiplier ?? DEFAULT_TTL_MULTIPLIER;
48
70
  const now = typeof options.now === 'function' ? options.now : Date.now;
49
71
 
50
- /** Map<"groupId::vpId", number[]> — sorted ascending timestamps. */
72
+ /** Map<"groupId::vpId", number[]> — sorted ascending timestamps.
73
+ * Map insertion order doubles as LRU recency: touching (delete+set) on
74
+ * every access keeps the oldest-used entry at the front for eviction. */
51
75
  const hits = new Map();
76
+ let evictions = 0;
52
77
 
53
78
  function key(groupId, vpId) { return `${groupId}::${vpId}`; }
54
79
 
@@ -58,6 +83,47 @@ export function createLoopGuard(options = {}) {
58
83
  if (i > 0) arr.splice(0, i);
59
84
  }
60
85
 
86
+ /**
87
+ * Touch a key → move to the Map's insertion tail (most-recently-used).
88
+ * Used on BOTH check() and record() paths so a blocked-but-checked
89
+ * target is kept warm as long as something keeps referencing it.
90
+ */
91
+ function touch(k, arr) {
92
+ hits.delete(k);
93
+ hits.set(k, arr);
94
+ }
95
+
96
+ /**
97
+ * Opportunistic TTL sweep: drop keys whose last hit is older than
98
+ * ttlMultiplier × windowMs (i.e. their rate window is fully expired and
99
+ * stale). Called on insert to amortise cleanup across normal traffic,
100
+ * so we never scan on the hot read path.
101
+ */
102
+ function sweepExpired() {
103
+ const ttlCutoff = now() - ttlMultiplier * windowMs;
104
+ for (const [k, arr] of hits) {
105
+ // arr is sorted ascending; the tail is the most-recent hit.
106
+ const last = arr.length > 0 ? arr[arr.length - 1] : -Infinity;
107
+ if (last < ttlCutoff) {
108
+ hits.delete(k);
109
+ evictions += 1;
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Enforce the hard LRU cap. Called on insert AFTER sweepExpired so
116
+ * only genuinely hot but stale-enough entries get evicted.
117
+ */
118
+ function enforceCap() {
119
+ while (hits.size > maxKeys) {
120
+ const oldest = hits.keys().next().value;
121
+ if (oldest === undefined) break;
122
+ hits.delete(oldest);
123
+ evictions += 1;
124
+ }
125
+ }
126
+
61
127
  return {
62
128
  /**
63
129
  * Check whether a forward to (groupId, vpId) with the supplied causedBy
@@ -83,6 +149,9 @@ export function createLoopGuard(options = {}) {
83
149
  if (arr) {
84
150
  const cutoff = now() - windowMs;
85
151
  trim(arr, cutoff);
152
+ // Refresh LRU recency — a repeatedly-probed hot target should not
153
+ // be evicted just because it never crosses into record().
154
+ touch(k, arr);
86
155
  if (arr.length >= maxHits) {
87
156
  return {
88
157
  ok: false,
@@ -99,6 +168,7 @@ export function createLoopGuard(options = {}) {
99
168
  if (!groupId || !targetVpId) return;
100
169
  const k = key(groupId, targetVpId);
101
170
  let arr = hits.get(k);
171
+ const creating = !arr;
102
172
  if (!arr) {
103
173
  arr = [];
104
174
  hits.set(k, arr);
@@ -106,17 +176,36 @@ export function createLoopGuard(options = {}) {
106
176
  const cutoff = now() - windowMs;
107
177
  trim(arr, cutoff);
108
178
  arr.push(now());
179
+ // Refresh LRU recency for both existing and new keys.
180
+ touch(k, arr);
181
+ // On *new* key creation, opportunistically clean up: first drop
182
+ // fully-expired entries (cheap, bounds unbounded growth), then
183
+ // enforce the hard cap. Skip on the update path to keep hot-loop
184
+ // cost O(1).
185
+ if (creating) {
186
+ sweepExpired();
187
+ enforceCap();
188
+ }
109
189
  },
110
190
 
111
191
  /** Snapshot for tests / debug. */
112
192
  snapshot() {
113
193
  const out = {};
114
194
  for (const [k, arr] of hits) out[k] = arr.slice();
115
- return { hits: out, maxChainDepth, windowMs, maxHits };
195
+ return {
196
+ hits: out,
197
+ maxChainDepth,
198
+ windowMs,
199
+ maxHits,
200
+ maxKeys,
201
+ ttlMultiplier,
202
+ size: hits.size,
203
+ evictions,
204
+ };
116
205
  },
117
206
 
118
207
  /** Wipe all counters (tests). */
119
- reset() { hits.clear(); },
208
+ reset() { hits.clear(); evictions = 0; },
120
209
  };
121
210
  }
122
211