poltertab 1.7.0 → 1.8.0-beta.1
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/README.md +5 -2
- package/mcp-server/bridge.js +233 -27
- package/mcp-server/config.js +27 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -244,8 +244,11 @@ themselves into **secondary** mode, proxying their commands through the primary,
|
|
|
244
244
|
so concurrent agents share one browser with no extra configuration. The
|
|
245
245
|
primary tags each secondary's commands with its id, and the extension keeps a
|
|
246
246
|
separate current tab per agent, so agents get their own tabs. Up to
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
50 secondaries are accepted (set `POLTERTAB_MAX_SECONDARIES` to change the
|
|
248
|
+
limit). A secondary refused for capacity is told why and retries with backoff
|
|
249
|
+
(2s doubling to 60s) instead of looping. If the primary exits, exactly one
|
|
250
|
+
secondary promotes itself to replace it and the others reconnect to it. The
|
|
251
|
+
extension popup lists whichever agents are connected.
|
|
249
252
|
|
|
250
253
|
**Changing the WebSocket Port:**
|
|
251
254
|
Only needed when something that *isn't* PolterTab already holds `7822` —
|
package/mcp-server/bridge.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
const WebSocket = require("ws");
|
|
14
14
|
const crypto = require("crypto");
|
|
15
|
+
const net = require("net");
|
|
15
16
|
const {
|
|
16
17
|
resolvePort,
|
|
17
18
|
DEFAULT_WS_PORT,
|
|
@@ -23,6 +24,9 @@ const {
|
|
|
23
24
|
NETWORK_MAX_BODY_BYTES,
|
|
24
25
|
PROMOTION_DELAY_MS,
|
|
25
26
|
PROMOTION_JITTER_MS,
|
|
27
|
+
PROMOTION_BACKOFF_INITIAL_MS,
|
|
28
|
+
PROMOTION_BACKOFF_MAX_MS,
|
|
29
|
+
PROMOTION_BACKOFF_JITTER,
|
|
26
30
|
MAX_SECONDARIES,
|
|
27
31
|
POLTERTAB_HOME,
|
|
28
32
|
} = require("./config.js");
|
|
@@ -47,6 +51,15 @@ let nodeId = null;
|
|
|
47
51
|
let wss = null;
|
|
48
52
|
let httpServer = null;
|
|
49
53
|
|
|
54
|
+
// Secondary-side retry state. One counter for the whole reconnect/probe/promote
|
|
55
|
+
// cycle, reset only by a Primary's secondary_accepted; `refusal` and
|
|
56
|
+
// `unreachable` say why this node is between connections, so a command sent
|
|
57
|
+
// meanwhile can name the cause instead of a generic "not connected".
|
|
58
|
+
let backoffAttempt = 0;
|
|
59
|
+
let retryTimer = null;
|
|
60
|
+
let refusal = null;
|
|
61
|
+
let unreachable = false;
|
|
62
|
+
|
|
50
63
|
function broadcastSessionState() {
|
|
51
64
|
if (extensionSocket && extensionSocket.readyState === WebSocket.OPEN) {
|
|
52
65
|
const sessions = [];
|
|
@@ -107,6 +120,10 @@ function setupPrimaryServer() {
|
|
|
107
120
|
console.error(
|
|
108
121
|
`[PolterTab MCP] Port ${WS_PORT} in use, switching to Secondary Mode...`,
|
|
109
122
|
);
|
|
123
|
+
// A server that never bound is still a handle; every lost promotion race
|
|
124
|
+
// used to leave one behind.
|
|
125
|
+
httpServer.close();
|
|
126
|
+
httpServer = null;
|
|
110
127
|
startSecondaryMode();
|
|
111
128
|
} else {
|
|
112
129
|
console.error(`[PolterTab MCP] HTTP server error: ${e.message}`);
|
|
@@ -174,7 +191,10 @@ function setupPrimaryServer() {
|
|
|
174
191
|
|
|
175
192
|
function startSecondaryMode() {
|
|
176
193
|
isSecondary = true;
|
|
177
|
-
|
|
194
|
+
// Once per process. The extension keys this agent's current tab by nodeId,
|
|
195
|
+
// so a fresh one after a lost promotion race orphaned that pointer and the
|
|
196
|
+
// agent's next untargeted command landed on the user's focused tab.
|
|
197
|
+
if (!nodeId) nodeId = crypto.randomUUID();
|
|
178
198
|
console.error(
|
|
179
199
|
`[PolterTab MCP] Node ${nodeId} running as Secondary. Connecting to Primary...`,
|
|
180
200
|
);
|
|
@@ -182,19 +202,93 @@ function startSecondaryMode() {
|
|
|
182
202
|
connectToPrimary();
|
|
183
203
|
}
|
|
184
204
|
|
|
205
|
+
// The retry schedule: INITIAL × 2^attempt, capped, ±JITTER so a crowd of
|
|
206
|
+
// refused Secondaries does not come back in step. `rand` is injectable for
|
|
207
|
+
// the tests.
|
|
208
|
+
function _backoffDelay(attempt, rand = Math.random) {
|
|
209
|
+
const base = Math.min(
|
|
210
|
+
PROMOTION_BACKOFF_INITIAL_MS * 2 ** attempt,
|
|
211
|
+
PROMOTION_BACKOFF_MAX_MS,
|
|
212
|
+
);
|
|
213
|
+
const jitter = 1 + PROMOTION_BACKOFF_JITTER * (2 * rand() - 1);
|
|
214
|
+
return Math.min(Math.round(base * jitter), PROMOTION_BACKOFF_MAX_MS);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function scheduleRetry(fn, delay) {
|
|
218
|
+
clearTimeout(retryTimer);
|
|
219
|
+
retryTimer = setTimeout(fn, delay);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Is anything listening on the port? Binding to find out is what leaked a
|
|
223
|
+
// server per attempt; a refused TCP connect is the only answer that means the
|
|
224
|
+
// Primary is gone.
|
|
225
|
+
function probePrimary() {
|
|
226
|
+
return new Promise((resolve) => {
|
|
227
|
+
const sock = net.connect(WS_PORT, "localhost");
|
|
228
|
+
// A listener with a full accept backlog answers with neither connect nor
|
|
229
|
+
// error, which would park this node with no retry scheduled. Treat silence
|
|
230
|
+
// as "something is there": connectToPrimary's handshake timeout then sorts
|
|
231
|
+
// a hung Primary from a live one and keeps the backoff running.
|
|
232
|
+
sock.setTimeout(1000, () => {
|
|
233
|
+
sock.destroy();
|
|
234
|
+
resolve(true);
|
|
235
|
+
});
|
|
236
|
+
sock.once("connect", () => {
|
|
237
|
+
sock.destroy();
|
|
238
|
+
resolve(true);
|
|
239
|
+
});
|
|
240
|
+
sock.once("error", (err) => resolve(err.code !== "ECONNREFUSED"));
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function promoteOrRejoin() {
|
|
245
|
+
if (await probePrimary()) {
|
|
246
|
+
connectToPrimary();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
isSecondary = false;
|
|
250
|
+
setupPrimaryServer();
|
|
251
|
+
}
|
|
252
|
+
|
|
185
253
|
function connectToPrimary() {
|
|
186
|
-
|
|
254
|
+
const ws = new WebSocket(`ws://localhost:${WS_PORT}`, {
|
|
187
255
|
perMessageDeflate: true,
|
|
256
|
+
// A Primary that accepts TCP but never answers the upgrade (event loop
|
|
257
|
+
// blocked, SIGSTOP) would otherwise hold this node forever.
|
|
258
|
+
handshakeTimeout: 5000,
|
|
188
259
|
});
|
|
260
|
+
extensionSocket = ws;
|
|
261
|
+
let opened = false;
|
|
262
|
+
let accepted = false;
|
|
263
|
+
let errCode = null;
|
|
189
264
|
|
|
190
|
-
|
|
265
|
+
ws.on("open", () => {
|
|
266
|
+
opened = true;
|
|
191
267
|
console.error("[PolterTab MCP] Connected to Primary MCP Server.");
|
|
192
|
-
|
|
268
|
+
// The version is what tells a Primary this node understands 4xxx closes.
|
|
269
|
+
ws.send(
|
|
270
|
+
JSON.stringify({
|
|
271
|
+
type: "secondary_mcp",
|
|
272
|
+
nodeId,
|
|
273
|
+
version: OWN_VERSION,
|
|
274
|
+
pid: process.pid,
|
|
275
|
+
parentPid: process.ppid,
|
|
276
|
+
}),
|
|
277
|
+
);
|
|
193
278
|
});
|
|
194
279
|
|
|
195
|
-
|
|
280
|
+
ws.on("message", (message) => {
|
|
196
281
|
try {
|
|
197
282
|
const msg = JSON.parse(message);
|
|
283
|
+
// Frames about this connection carry a type, never a command id, and
|
|
284
|
+
// are handled before the id branch so none can settle a pending command.
|
|
285
|
+
if (msg.type === "secondary_accepted") {
|
|
286
|
+
accepted = true;
|
|
287
|
+
backoffAttempt = 0;
|
|
288
|
+
refusal = null;
|
|
289
|
+
unreachable = false;
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
198
292
|
if (msg.id && pendingCommands.has(msg.id)) {
|
|
199
293
|
const { resolve, reject, timer } = pendingCommands.get(msg.id);
|
|
200
294
|
clearTimeout(timer);
|
|
@@ -214,30 +308,70 @@ function connectToPrimary() {
|
|
|
214
308
|
}
|
|
215
309
|
});
|
|
216
310
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
311
|
+
ws.on("close", (code, reason) => {
|
|
312
|
+
if (extensionSocket === ws) extensionSocket = null;
|
|
313
|
+
// Each branch below sets the one that applies; neither may outlive the
|
|
314
|
+
// close that caused it.
|
|
315
|
+
refusal = null;
|
|
316
|
+
unreachable = false;
|
|
317
|
+
|
|
318
|
+
const refused = code >= 4000 && code <= 4999;
|
|
319
|
+
let info = {};
|
|
320
|
+
if (refused) {
|
|
321
|
+
try {
|
|
322
|
+
info = JSON.parse(reason.toString());
|
|
323
|
+
} catch (_) {}
|
|
324
|
+
}
|
|
222
325
|
|
|
223
326
|
// Reject all pending commands
|
|
327
|
+
const aborted =
|
|
328
|
+
info.reason === "capacity_rejected"
|
|
329
|
+
? `[capacity_rejected limit=${info.limit}] Primary (pid ${info.pid}) refused this node. Command aborted.`
|
|
330
|
+
: "Primary node disconnected. Command aborted.";
|
|
224
331
|
for (const [id, { reject, timer }] of pendingCommands) {
|
|
225
332
|
clearTimeout(timer);
|
|
226
|
-
reject(new Error(
|
|
333
|
+
reject(new Error(aborted));
|
|
227
334
|
pendingCommands.delete(id);
|
|
228
335
|
}
|
|
229
336
|
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
337
|
+
// Refused by a live Primary: it is not dying, so promoting can only fail.
|
|
338
|
+
if (refused) {
|
|
339
|
+
const delay = _backoffDelay(backoffAttempt++);
|
|
340
|
+
refusal = { ...info, retryAt: Date.now() + delay };
|
|
341
|
+
console.error(
|
|
342
|
+
`[PolterTab MCP] Primary refused this node (${code} ${info.reason || "unknown"}). Retrying as Secondary in ${delay}ms.`,
|
|
343
|
+
);
|
|
344
|
+
scheduleRetry(connectToPrimary, delay);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Something holds the port but never completed the handshake: a hung
|
|
349
|
+
// Primary, or not a PolterTab one. Promoting would only hit EADDRINUSE.
|
|
350
|
+
if (!opened && errCode !== "ECONNREFUSED") {
|
|
351
|
+
const delay = _backoffDelay(backoffAttempt++);
|
|
352
|
+
unreachable = true;
|
|
353
|
+
console.error(
|
|
354
|
+
`[PolterTab MCP] Primary on port ${WS_PORT} is not responding (${errCode || code}). Retrying in ${delay}ms.`,
|
|
355
|
+
);
|
|
356
|
+
scheduleRetry(connectToPrimary, delay);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
233
359
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
360
|
+
// The Primary went away, or closed this node without saying why (as a
|
|
361
|
+
// pre-1.8 Primary does at its limit). Probe first; promote only if the port
|
|
362
|
+
// is free. A node that was never accepted stays on the backoff schedule, so
|
|
363
|
+
// even a codeless refusal cannot loop at full speed.
|
|
364
|
+
const delay = accepted
|
|
365
|
+
? PROMOTION_DELAY_MS + Math.floor(Math.random() * PROMOTION_JITTER_MS)
|
|
366
|
+
: _backoffDelay(backoffAttempt++);
|
|
367
|
+
console.error(
|
|
368
|
+
`[PolterTab MCP] Connection to Primary lost. Attempting to become Primary in ${delay}ms...`,
|
|
369
|
+
);
|
|
370
|
+
scheduleRetry(promoteOrRejoin, delay);
|
|
238
371
|
});
|
|
239
372
|
|
|
240
|
-
|
|
373
|
+
ws.on("error", (err) => {
|
|
374
|
+
errCode = err.code || null;
|
|
241
375
|
console.error(`[PolterTab MCP] Secondary WebSocket error: ${err.message}`);
|
|
242
376
|
});
|
|
243
377
|
}
|
|
@@ -270,6 +404,7 @@ async function readNetworkState(params = {}) {
|
|
|
270
404
|
function setupPrimaryWss(wss) {
|
|
271
405
|
wss.on("connection", (ws) => {
|
|
272
406
|
ws.isAlive = true;
|
|
407
|
+
ws.supportsNewCloseCodes = false;
|
|
273
408
|
ws.on("pong", () => {
|
|
274
409
|
ws.isAlive = true;
|
|
275
410
|
});
|
|
@@ -279,18 +414,55 @@ function setupPrimaryWss(wss) {
|
|
|
279
414
|
const msg = JSON.parse(message);
|
|
280
415
|
|
|
281
416
|
if (msg.type === "secondary_mcp") {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
417
|
+
// Only a Secondary that names its version understands a 4xxx close.
|
|
418
|
+
// An older one answers any close by trying to promote — the loop this
|
|
419
|
+
// limit used to trigger — so it is never refused and never counted.
|
|
420
|
+
ws.supportsNewCloseCodes =
|
|
421
|
+
typeof msg.version === "string" && msg.version !== "";
|
|
422
|
+
if (ws.supportsNewCloseCodes) {
|
|
423
|
+
let v2 = 0;
|
|
424
|
+
for (const [id, c] of secondaryClients) {
|
|
425
|
+
if (id !== msg.nodeId && c.supportsNewCloseCodes) v2++;
|
|
426
|
+
}
|
|
427
|
+
if (v2 >= MAX_SECONDARIES) {
|
|
428
|
+
console.error(
|
|
429
|
+
`[PolterTab MCP] Rejecting secondary node ${msg.nodeId} - limit reached.`,
|
|
430
|
+
);
|
|
431
|
+
ws.close(
|
|
432
|
+
4000,
|
|
433
|
+
JSON.stringify({
|
|
434
|
+
reason: "capacity_rejected",
|
|
435
|
+
limit: MAX_SECONDARIES,
|
|
436
|
+
retry_in_ms: PROMOTION_BACKOFF_INITIAL_MS,
|
|
437
|
+
pid: process.pid,
|
|
438
|
+
}),
|
|
439
|
+
);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// Same node, new socket: the old one is dead weight the client may
|
|
444
|
+
// never end, and the close guard below keeps its close harmless.
|
|
445
|
+
const displaced = secondaryClients.get(msg.nodeId);
|
|
446
|
+
if (displaced && displaced !== ws && displaced.readyState === WebSocket.OPEN) {
|
|
447
|
+
displaced.close(1000);
|
|
288
448
|
}
|
|
289
449
|
secondaryClients.set(msg.nodeId, ws);
|
|
290
450
|
ws.nodeId = msg.nodeId;
|
|
451
|
+
ws.version = msg.version || null;
|
|
452
|
+
ws.pid = msg.pid || null;
|
|
453
|
+
ws.parentPid = msg.parentPid || null;
|
|
291
454
|
console.error(
|
|
292
455
|
`[PolterTab MCP] Secondary node connected: ${msg.nodeId}. Total secondaries: ${secondaryClients.size}`,
|
|
293
456
|
);
|
|
457
|
+
if (ws.supportsNewCloseCodes) {
|
|
458
|
+
ws.send(
|
|
459
|
+
JSON.stringify({
|
|
460
|
+
type: "secondary_accepted",
|
|
461
|
+
nodeId: msg.nodeId,
|
|
462
|
+
primaryVersion: OWN_VERSION,
|
|
463
|
+
}),
|
|
464
|
+
);
|
|
465
|
+
}
|
|
294
466
|
broadcastSessionState();
|
|
295
467
|
return;
|
|
296
468
|
}
|
|
@@ -302,6 +474,23 @@ function setupPrimaryWss(wss) {
|
|
|
302
474
|
|
|
303
475
|
if (msg.type === "proxy_command") {
|
|
304
476
|
const { id, action } = msg;
|
|
477
|
+
// No nodeId means no secondary_mcp yet — or a socket refused with
|
|
478
|
+
// 4000 an instant ago. The extension would bucket its command as the
|
|
479
|
+
// Primary agent's and move that agent's current tab; a refused
|
|
480
|
+
// socket would not even get the reply. A well-behaved client's
|
|
481
|
+
// handshake always precedes its first command on the same socket.
|
|
482
|
+
if (!ws.nodeId) {
|
|
483
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
484
|
+
ws.send(
|
|
485
|
+
JSON.stringify({
|
|
486
|
+
id,
|
|
487
|
+
success: false,
|
|
488
|
+
error: "[not_registered] Send secondary_mcp before proxy_command.",
|
|
489
|
+
}),
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
305
494
|
// Stamp which agent is asking, from the socket rather than the
|
|
306
495
|
// message, so the extension keeps this Secondary's current tab apart
|
|
307
496
|
// from the Primary's and every other Secondary's.
|
|
@@ -481,8 +670,12 @@ function setupPrimaryWss(wss) {
|
|
|
481
670
|
console.error(
|
|
482
671
|
`[PolterTab MCP] Secondary node disconnected: ${ws.nodeId}`,
|
|
483
672
|
);
|
|
484
|
-
|
|
485
|
-
|
|
673
|
+
// A node now keeps its id across reconnects, so its new socket may
|
|
674
|
+
// already be registered by the time the old one's close arrives.
|
|
675
|
+
if (secondaryClients.get(ws.nodeId) === ws) {
|
|
676
|
+
secondaryClients.delete(ws.nodeId);
|
|
677
|
+
broadcastSessionState();
|
|
678
|
+
}
|
|
486
679
|
} else if (extensionSocket === ws) {
|
|
487
680
|
console.error("[PolterTab MCP] Chrome extension disconnected.");
|
|
488
681
|
extensionSocket = null;
|
|
@@ -560,6 +753,17 @@ async function sendCommand(action, params) {
|
|
|
560
753
|
|
|
561
754
|
if (isSecondary) {
|
|
562
755
|
if (!isConnected) {
|
|
756
|
+
if (refusal && refusal.reason === "capacity_rejected") {
|
|
757
|
+
const s = Math.max(0, Math.ceil((refusal.retryAt - Date.now()) / 1000));
|
|
758
|
+
throw new Error(
|
|
759
|
+
`[capacity_rejected limit=${refusal.limit}] Primary (pid ${refusal.pid}) has ${refusal.limit} secondaries. Retry in ${s}s.`,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
if (unreachable) {
|
|
763
|
+
throw new Error(
|
|
764
|
+
`[primary_unreachable port=${WS_PORT}] Primary is not responding. Backing off.`,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
563
767
|
throw new Error("Secondary node not connected to Primary MCP server.");
|
|
564
768
|
}
|
|
565
769
|
|
|
@@ -618,4 +822,6 @@ module.exports = {
|
|
|
618
822
|
isSecondary: () => isSecondary,
|
|
619
823
|
extensionVersion: () => extensionVersion,
|
|
620
824
|
_buildEnvelope,
|
|
825
|
+
_backoffDelay,
|
|
826
|
+
_probePrimary: probePrimary,
|
|
621
827
|
};
|
package/mcp-server/config.js
CHANGED
|
@@ -46,6 +46,18 @@ function resolvePort(argv = process.argv, env = process.env) {
|
|
|
46
46
|
return DEFAULT_WS_PORT;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// The v2 Secondary ceiling: POLTERTAB_MAX_SECONDARIES if it is a whole number
|
|
50
|
+
// ≥ 0, else 50. 0 is honoured — it refuses every versioned Secondary (old ones
|
|
51
|
+
// are still accepted, see MAX_SECONDARIES below) — so an explicit 0 must not
|
|
52
|
+
// fall through to the default the way `parseInt(x) || 50` let it.
|
|
53
|
+
const DEFAULT_MAX_SECONDARIES = 50;
|
|
54
|
+
function resolveMaxSecondaries(env = process.env) {
|
|
55
|
+
const raw = env.POLTERTAB_MAX_SECONDARIES;
|
|
56
|
+
if (raw === undefined || raw.trim() === "") return DEFAULT_MAX_SECONDARIES;
|
|
57
|
+
const n = Number(raw);
|
|
58
|
+
return Number.isInteger(n) && n >= 0 ? n : DEFAULT_MAX_SECONDARIES;
|
|
59
|
+
}
|
|
60
|
+
|
|
49
61
|
module.exports = {
|
|
50
62
|
POLTERTAB_HOME,
|
|
51
63
|
MEMORY_DIR: path.join(POLTERTAB_HOME, "navigation_memory"),
|
|
@@ -53,6 +65,7 @@ module.exports = {
|
|
|
53
65
|
|
|
54
66
|
DEFAULT_WS_PORT,
|
|
55
67
|
resolvePort,
|
|
68
|
+
resolveMaxSecondaries,
|
|
56
69
|
|
|
57
70
|
// See the budget above before changing this.
|
|
58
71
|
COMMAND_TIMEOUT_MS: 35000,
|
|
@@ -74,9 +87,20 @@ module.exports = {
|
|
|
74
87
|
PROMOTION_DELAY_MS: 500,
|
|
75
88
|
PROMOTION_JITTER_MS: 2000,
|
|
76
89
|
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
90
|
+
// A refused or unreachable Secondary retries on this schedule — 2 s, 4 s,
|
|
91
|
+
// 8 s … ±25%, capped — and the whole probe/connect/promote cycle runs under
|
|
92
|
+
// it, reset only when a Primary sends secondary_accepted. Without it a
|
|
93
|
+
// refusal was answered by an immediate reconnect, refused again, forever.
|
|
94
|
+
PROMOTION_BACKOFF_INITIAL_MS: 2000,
|
|
95
|
+
PROMOTION_BACKOFF_MAX_MS: 60000,
|
|
96
|
+
PROMOTION_BACKOFF_JITTER: 0.25,
|
|
97
|
+
|
|
98
|
+
// A safety net against runaway processes, not a usage limit: at 5 it was
|
|
99
|
+
// routinely hit by idle helpers, and every refusal fed the loop above. Only
|
|
100
|
+
// Secondaries that declare a version count; an old one is always accepted,
|
|
101
|
+
// because the only refusal it understands is a close it answers by trying to
|
|
102
|
+
// promote.
|
|
103
|
+
MAX_SECONDARIES: resolveMaxSecondaries(),
|
|
80
104
|
|
|
81
105
|
// Lazy-loaded content arrives after the scroll returns. Long enough for the
|
|
82
106
|
// XHR it triggered to land in the capture buffer, short enough that a
|
package/package.json
CHANGED