klypix-mcp 1.56.0 → 1.57.0
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 +1 -1
- package/src/mcp-supervisor.mjs +183 -1
- package/src/runtime-inspector.mjs +29 -2
package/package.json
CHANGED
package/src/mcp-supervisor.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
inspectAutoUpdate,
|
|
27
27
|
spawnAutoUpdateHelper,
|
|
28
28
|
} from './mcp-auto-update.mjs';
|
|
29
|
+
import { removeSession, upsertSession } from './agent-presence.mjs';
|
|
29
30
|
|
|
30
31
|
const INTERNAL_PREFIX = '__klypix_supervisor__';
|
|
31
32
|
const DEFAULT_POLL_MS = 1000;
|
|
@@ -286,6 +287,25 @@ class Supervisor {
|
|
|
286
287
|
this.recoveryAttempts = 0;
|
|
287
288
|
this.recoveryTimer = null;
|
|
288
289
|
this.lastFailedSignature = null;
|
|
290
|
+
// RAM Phase 2 — idle worker hibernation. An idle connection pays for a
|
|
291
|
+
// whole worker process it is not using (measured: 11 idle pairs = 1,445 MB
|
|
292
|
+
// with ZERO models resident, so this is process baseline, not semantics).
|
|
293
|
+
// After this much host silence the worker half is retired; the next host
|
|
294
|
+
// message wakes it through the SAME queue → candidate → commit → flush path
|
|
295
|
+
// recovery already uses, with the task scope replayed. Set 0 to disable
|
|
296
|
+
// (instant rollback to today's behavior; no data/format/protocol change).
|
|
297
|
+
const hibernateEnv = Number(process.env.KLYPIX_WORKER_HIBERNATE_MS);
|
|
298
|
+
this.hibernateIdleMs = Number.isFinite(hibernateEnv) ? Math.max(0, hibernateEnv) : 600_000;
|
|
299
|
+
this.hibernatedTarget = null;
|
|
300
|
+
this.hibernatedAt = null;
|
|
301
|
+
this.hibernations = 0;
|
|
302
|
+
this.hibernateProbeInFlight = false;
|
|
303
|
+
this.hibernateSkipReason = null;
|
|
304
|
+
// Presence identity of the hibernated connection. While the worker is gone
|
|
305
|
+
// the SUPERVISOR keeps its lane row fresh, so peers see exactly what they
|
|
306
|
+
// saw before — hibernation buys RAM without spending coordination.
|
|
307
|
+
this.presenceIdentity = null;
|
|
308
|
+
this.presenceHeartbeat = null;
|
|
289
309
|
}
|
|
290
310
|
|
|
291
311
|
writeState(extra = {}) {
|
|
@@ -297,6 +317,13 @@ class Supervisor {
|
|
|
297
317
|
parentPid: this.parentPid,
|
|
298
318
|
vault: this.vaultArg ? this.vaultArg.replace(/\\/g, '/') : null,
|
|
299
319
|
defaultRoot: this.defaultRoot,
|
|
320
|
+
hibernation: {
|
|
321
|
+
idleMs: this.hibernateIdleMs,
|
|
322
|
+
hibernated: this.status === 'hibernated',
|
|
323
|
+
since: this.status === 'hibernated' ? this.hibernatedAt : null,
|
|
324
|
+
count: this.hibernations,
|
|
325
|
+
skipReason: this.hibernateSkipReason || null,
|
|
326
|
+
},
|
|
300
327
|
cwd: process.cwd().replace(/\\/g, '/'),
|
|
301
328
|
bootedAt: this.bootedAt,
|
|
302
329
|
updatedAt: new Date().toISOString(),
|
|
@@ -327,6 +354,124 @@ class Supervisor {
|
|
|
327
354
|
} catch { /* diagnostics must never break the transport */ }
|
|
328
355
|
}
|
|
329
356
|
|
|
357
|
+
// Retire the worker half of an idle pair. Deliberately conservative: only a
|
|
358
|
+
// settled, fully-handshaked, request-free connection hibernates, and only
|
|
359
|
+
// when we can prove we are able to wake it (the host's initialize is what a
|
|
360
|
+
// respawned worker replays).
|
|
361
|
+
async maybeHibernate() {
|
|
362
|
+
if (this.closed || !this.hibernateIdleMs || this.hibernateProbeInFlight) return;
|
|
363
|
+
if (!this.active || this.candidate || this.standby) return;
|
|
364
|
+
if (this.status !== 'ready') return;
|
|
365
|
+
if (this.hostRequests.size || this.workerRequests.size || this.hostQueue.length) return;
|
|
366
|
+
if (!this.initializeRequest || !this.hostInitialized) return;
|
|
367
|
+
const last = Date.parse(this.lastHostMessageAt || this.bootedAt);
|
|
368
|
+
if (!Number.isFinite(last) || Date.now() - last < this.hibernateIdleMs) return;
|
|
369
|
+
|
|
370
|
+
// PRESENCE IS NON-NEGOTIABLE. A worker's graceful stop calls removeSession,
|
|
371
|
+
// so hibernating would delete a LIVE session from every peer's view unless
|
|
372
|
+
// something keeps its lane row fresh. Probe the worker for its presence
|
|
373
|
+
// identity; the supervisor then heartbeats that row itself while the worker
|
|
374
|
+
// sleeps, and pins the SAME session id into the respawned worker's env so
|
|
375
|
+
// the wake never mints a second row. Identity unavailable → never hibernate.
|
|
376
|
+
this.hibernateProbeInFlight = true;
|
|
377
|
+
let identity = null;
|
|
378
|
+
let probeFailed = false;
|
|
379
|
+
try {
|
|
380
|
+
const probe = await this.sendInternal(this.active, 'tools/call', {
|
|
381
|
+
name: 'brain_sync',
|
|
382
|
+
arguments: { phase: 'checkpoint', include_context: false },
|
|
383
|
+
}, 4000);
|
|
384
|
+
const structured = probe?.structuredContent || null;
|
|
385
|
+
if (!structured || structured.reason === 'no-project-brain') {
|
|
386
|
+
identity = null; // no lane row exists → nothing to keep alive
|
|
387
|
+
} else if (structured.brain && structured.self?.id) {
|
|
388
|
+
identity = {
|
|
389
|
+
brainPath: String(structured.brain),
|
|
390
|
+
id: String(structured.self.id),
|
|
391
|
+
client: structured.self.client || 'unknown',
|
|
392
|
+
surface: structured.self.surface || null,
|
|
393
|
+
branch: structured.self.branch || null,
|
|
394
|
+
};
|
|
395
|
+
} else {
|
|
396
|
+
probeFailed = true; // owns presence but unidentifiable → refuse
|
|
397
|
+
}
|
|
398
|
+
} catch {
|
|
399
|
+
probeFailed = true;
|
|
400
|
+
} finally {
|
|
401
|
+
this.hibernateProbeInFlight = false;
|
|
402
|
+
}
|
|
403
|
+
// Conditions can change across the await — re-verify before retiring.
|
|
404
|
+
if (this.closed || !this.active || this.candidate || this.standby) return;
|
|
405
|
+
if (this.hostRequests.size || this.workerRequests.size || this.hostQueue.length) return;
|
|
406
|
+
if (probeFailed) {
|
|
407
|
+
this.hibernateSkipReason = 'presence-identity-unavailable';
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
this.hibernateSkipReason = null;
|
|
411
|
+
this.presenceIdentity = identity;
|
|
412
|
+
const worker = this.active;
|
|
413
|
+
this.hibernatedTarget = worker.target;
|
|
414
|
+
this.hibernatedAt = new Date().toISOString();
|
|
415
|
+
this.hibernations++;
|
|
416
|
+
this.active = null;
|
|
417
|
+
this.status = 'hibernated';
|
|
418
|
+
// A connection that owns a row must NOT let the worker remove it on the way
|
|
419
|
+
// out; one without a row retires gracefully as usual.
|
|
420
|
+
this.retireWorker(worker, 350, { preservePresence: Boolean(this.presenceIdentity) });
|
|
421
|
+
this.startPresenceHeartbeat();
|
|
422
|
+
this.writeState();
|
|
423
|
+
log(`worker hibernated after ${Math.round((Date.now() - last) / 1000)}s idle — wakes on the next request${this.presenceIdentity ? ' (presence held by the supervisor)' : ''}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Re-register the sleeping connection's lane row on the SAME cadence the
|
|
427
|
+
// worker used, through the SAME shared upsertSession (one implementation,
|
|
428
|
+
// one lock). Fields not supplied are preserved by the merge, so a declared
|
|
429
|
+
// intent/file scope survives hibernation untouched.
|
|
430
|
+
startPresenceHeartbeat() {
|
|
431
|
+
this.stopPresenceHeartbeat();
|
|
432
|
+
const who = this.presenceIdentity;
|
|
433
|
+
if (!who) return;
|
|
434
|
+
const beat = () => {
|
|
435
|
+
try {
|
|
436
|
+
upsertSession({
|
|
437
|
+
brainPath: who.brainPath,
|
|
438
|
+
id: who.id,
|
|
439
|
+
client: who.client,
|
|
440
|
+
surface: who.surface,
|
|
441
|
+
branch: who.branch,
|
|
442
|
+
channel: 'mcp',
|
|
443
|
+
event: 'McpHibernated',
|
|
444
|
+
hostPid: this.parentPid,
|
|
445
|
+
});
|
|
446
|
+
} catch { /* presence upkeep is best-effort; TTL is the backstop */ }
|
|
447
|
+
};
|
|
448
|
+
// ORDER MATTERS (caught by real-worker measurement, not by the fixture):
|
|
449
|
+
// the retiring worker calls removeSession during its shutdown grace, so a
|
|
450
|
+
// single beat fired now is immediately UNDONE and the row would stay gone
|
|
451
|
+
// until the 60s tick — i.e. the session disappears from every peer for a
|
|
452
|
+
// minute. Re-assert across the whole grace window, then settle into the
|
|
453
|
+
// normal cadence.
|
|
454
|
+
beat();
|
|
455
|
+
for (const delay of [500, 1_200, 2_500, 5_000]) {
|
|
456
|
+
const t = setTimeout(() => { if (this.presenceHeartbeat) beat(); }, delay);
|
|
457
|
+
t.unref?.();
|
|
458
|
+
}
|
|
459
|
+
this.presenceHeartbeat = setInterval(beat, 60_000);
|
|
460
|
+
this.presenceHeartbeat.unref?.();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
stopPresenceHeartbeat() {
|
|
464
|
+
if (this.presenceHeartbeat) clearInterval(this.presenceHeartbeat);
|
|
465
|
+
this.presenceHeartbeat = null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
wake() {
|
|
469
|
+
if (this.closed || this.active || this.candidate) return;
|
|
470
|
+
const target = this.hibernatedTarget || this.selectInitialTarget();
|
|
471
|
+
log('waking hibernated worker');
|
|
472
|
+
this.startCandidate(target, { recovery: true });
|
|
473
|
+
}
|
|
474
|
+
|
|
330
475
|
selectInitialTarget() {
|
|
331
476
|
const runtime = readRuntimeTarget(this.runtimeManifest, { allowExternal: this.allowExternal });
|
|
332
477
|
if (!runtime.ok) return this.fallbackTarget;
|
|
@@ -342,6 +487,11 @@ class Supervisor {
|
|
|
342
487
|
KLYPIX_MCP_SUPERVISED: '1',
|
|
343
488
|
KLYPIX_MCP_SUPERVISOR_PID: String(process.pid),
|
|
344
489
|
KLYPIX_MCP_CONNECTION_ID: this.connectionId,
|
|
490
|
+
// Pin the session id across a hibernation wake (KLYPIX_SESSION_ID wins
|
|
491
|
+
// resolveMcpSessionId's precedence chain) so the woken worker adopts the
|
|
492
|
+
// row the supervisor kept alive instead of minting a second one. Hosts
|
|
493
|
+
// that export their own id already resolve to the same value.
|
|
494
|
+
...(this.presenceIdentity?.id ? { KLYPIX_SESSION_ID: this.presenceIdentity.id } : {}),
|
|
345
495
|
},
|
|
346
496
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
347
497
|
windowsHide: true,
|
|
@@ -591,6 +741,10 @@ class Supervisor {
|
|
|
591
741
|
return;
|
|
592
742
|
}
|
|
593
743
|
this.hostQueue.push(message);
|
|
744
|
+
// A hibernated pair wakes on demand: the queued message flushes to the
|
|
745
|
+
// new worker the moment the candidate commits, so the host sees latency,
|
|
746
|
+
// never an error, and never a reconnect.
|
|
747
|
+
if (this.status === 'hibernated') this.wake();
|
|
594
748
|
return;
|
|
595
749
|
}
|
|
596
750
|
if (message?.method === 'initialize' && Object.prototype.hasOwnProperty.call(message, 'id')) {
|
|
@@ -753,6 +907,9 @@ class Supervisor {
|
|
|
753
907
|
|
|
754
908
|
maybeCommitCandidate() {
|
|
755
909
|
if (!this.candidate?.ready) return;
|
|
910
|
+
// A woken worker owns its lane row again — hand presence back before it
|
|
911
|
+
// becomes active so exactly one writer heartbeats at any moment.
|
|
912
|
+
this.stopPresenceHeartbeat();
|
|
756
913
|
this.expireAbandonedRequests();
|
|
757
914
|
if (this.hostRequests.size || this.workerRequests.size) return;
|
|
758
915
|
const next = this.candidate;
|
|
@@ -798,9 +955,18 @@ class Supervisor {
|
|
|
798
955
|
}
|
|
799
956
|
}
|
|
800
957
|
|
|
801
|
-
retireWorker(worker, graceMs = 250) {
|
|
958
|
+
retireWorker(worker, graceMs = 250, { preservePresence = false } = {}) {
|
|
802
959
|
if (!worker || worker.exited) return;
|
|
803
960
|
worker.retiring = true;
|
|
961
|
+
if (preservePresence) {
|
|
962
|
+
// HIBERNATION ONLY. stdin EOF triggers the worker's graceful stop, which
|
|
963
|
+
// REMOVES its presence row — correct when the connection is ending, wrong
|
|
964
|
+
// when it is merely sleeping (the supervisor is about to hold that row).
|
|
965
|
+
// Signal-terminate instead so the row is never removed and peers observe
|
|
966
|
+
// no gap at all, not even a sub-second one.
|
|
967
|
+
try { worker.child.kill('SIGTERM'); } catch { /* */ }
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
804
970
|
try { worker.child.stdin.end(); } catch { /* */ }
|
|
805
971
|
if (graceMs <= 0) {
|
|
806
972
|
try { worker.child.kill('SIGTERM'); } catch { /* */ }
|
|
@@ -908,6 +1074,11 @@ class Supervisor {
|
|
|
908
1074
|
);
|
|
909
1075
|
this.autoUpdatePoller.unref?.();
|
|
910
1076
|
|
|
1077
|
+
if (this.hibernateIdleMs) {
|
|
1078
|
+
this.hibernationTimer = setInterval(() => { this.maybeHibernate().catch(() => {}); }, Math.max(1_000, Math.min(60_000, this.hibernateIdleMs)));
|
|
1079
|
+
this.hibernationTimer.unref?.();
|
|
1080
|
+
}
|
|
1081
|
+
|
|
911
1082
|
// Host watchdog: shutdown is otherwise 100% stdin-EOF-dependent, and a
|
|
912
1083
|
// host that dies holding pipes open (or a wedged IDE) pinned this pair —
|
|
913
1084
|
// supervisor AND worker — indefinitely. The parent pid is a cheap,
|
|
@@ -936,6 +1107,17 @@ class Supervisor {
|
|
|
936
1107
|
this.closed = true;
|
|
937
1108
|
clearInterval(this.poller);
|
|
938
1109
|
clearInterval(this.parentWatchdog);
|
|
1110
|
+
clearInterval(this.hibernationTimer);
|
|
1111
|
+
// The connection is ending: stop holding its row and remove it, so a
|
|
1112
|
+
// hibernated-then-closed session never lingers as a ghost peer.
|
|
1113
|
+
this.stopPresenceHeartbeat();
|
|
1114
|
+
if (this.status === 'hibernated' && this.presenceIdentity) {
|
|
1115
|
+
const who = this.presenceIdentity;
|
|
1116
|
+
this.presenceIdentity = null;
|
|
1117
|
+
// Same removal the worker performs on its own graceful stop.
|
|
1118
|
+
try { removeSession({ brainPath: who.brainPath, id: who.id, channel: 'mcp' }); }
|
|
1119
|
+
catch { /* TTL prunes it either way */ }
|
|
1120
|
+
}
|
|
939
1121
|
clearTimeout(this.autoUpdateStarter);
|
|
940
1122
|
clearInterval(this.autoUpdatePoller);
|
|
941
1123
|
if (this.recoveryTimer) { clearTimeout(this.recoveryTimer); this.recoveryTimer = null; }
|
|
@@ -181,6 +181,10 @@ export function buildRuntimeReport({
|
|
|
181
181
|
if (launchers.length) flags.push('npx-launcher-chain');
|
|
182
182
|
if (!requestedVault || requestedVault === '.') flags.push('default-root');
|
|
183
183
|
if (!host) flags.push('host-unattributed');
|
|
184
|
+
// Phase 2 visibility: a hibernated pair is a supervisor with NO worker by
|
|
185
|
+
// design — without this flag it reads like a missing/crashed worker.
|
|
186
|
+
const hibernating = state?.status === 'hibernated';
|
|
187
|
+
if (hibernating) flags.push('worker-hibernated');
|
|
184
188
|
connections.push({
|
|
185
189
|
id: state?.connectionId || `pid-${supervisor?.pid || worker?.pid}`,
|
|
186
190
|
client: state?.clientInfo?.name || (host ? classifyHostProcess(host) : 'unknown'),
|
|
@@ -206,6 +210,9 @@ export function buildRuntimeReport({
|
|
|
206
210
|
vault,
|
|
207
211
|
requestedVault,
|
|
208
212
|
rssMb: roundMb(rssBytes),
|
|
213
|
+
hibernation: state?.hibernation
|
|
214
|
+
? { hibernated: hibernating, idleMs: state.hibernation.idleMs ?? null, since: state.hibernation.since || null, count: state.hibernation.count || 0 }
|
|
215
|
+
: null,
|
|
209
216
|
flags,
|
|
210
217
|
processIds,
|
|
211
218
|
});
|
|
@@ -242,13 +249,30 @@ export function buildRuntimeReport({
|
|
|
242
249
|
.map(([key, connectionIds]) => ({ key, connectionIds, verdict: 'parallel-not-proven-duplicate' }));
|
|
243
250
|
|
|
244
251
|
const totalMb = Math.round((roleTotals.workersMb + roleTotals.supervisorsMb + roleTotals.launchersMb) * 10) / 10;
|
|
252
|
+
// Measured, not modelled: the mean resident worker is what a hibernated pair
|
|
253
|
+
// is NOT paying. Reported only when at least one worker is resident, so the
|
|
254
|
+
// number is always derived from this machine rather than a guess.
|
|
255
|
+
const residentWorkers = connections.filter((item) => item.worker);
|
|
256
|
+
const hibernated = connections.filter((item) => item.hibernation?.hibernated);
|
|
257
|
+
const avgWorkerMb = residentWorkers.length
|
|
258
|
+
? Math.round((residentWorkers.reduce((sum, item) => sum + number(item.worker.rssMb), 0) / residentWorkers.length) * 10) / 10
|
|
259
|
+
: null;
|
|
245
260
|
return {
|
|
246
261
|
schemaVersion: 1,
|
|
247
262
|
sampledAt: new Date(sampledAt).toISOString(),
|
|
248
263
|
platform,
|
|
249
264
|
passive: true,
|
|
250
265
|
mutated: false,
|
|
251
|
-
totals: {
|
|
266
|
+
totals: {
|
|
267
|
+
connections: connections.length,
|
|
268
|
+
...roleTotals,
|
|
269
|
+
totalMb,
|
|
270
|
+
hibernatedConnections: hibernated.length,
|
|
271
|
+
avgResidentWorkerMb: avgWorkerMb,
|
|
272
|
+
estimatedHibernationSavingsMb: avgWorkerMb !== null && hibernated.length
|
|
273
|
+
? Math.round(avgWorkerMb * hibernated.length * 10) / 10
|
|
274
|
+
: 0,
|
|
275
|
+
},
|
|
252
276
|
connections: connections.sort((a, b) => b.rssMb - a.rssMb),
|
|
253
277
|
parallelConnectionGroups,
|
|
254
278
|
safety: {
|
|
@@ -276,8 +300,11 @@ export function formatRuntimeReport(report) {
|
|
|
276
300
|
const lines = [
|
|
277
301
|
`KLYPIX RUNTIME V2 — PASSIVE — ${report?.sampledAt || ''}`,
|
|
278
302
|
`Connections ${t.connections || 0} · workers ${t.workersMb || 0} MB · supervisors ${t.supervisorsMb || 0} MB · launchers ${t.launchersMb || 0} MB · total ${t.totalMb || 0} MB`,
|
|
303
|
+
t.hibernatedConnections
|
|
304
|
+
? `Hibernated ${t.hibernatedConnections} connection(s) — about ${t.estimatedHibernationSavingsMb} MB not resident (mean resident worker ${t.avgResidentWorkerMb} MB); each wakes on its next request.`
|
|
305
|
+
: '',
|
|
279
306
|
'',
|
|
280
|
-
];
|
|
307
|
+
].filter((line, index) => line !== '' || index > 1);
|
|
281
308
|
for (const item of report?.connections || []) {
|
|
282
309
|
const host = item.host ? `${item.host.kind}:${item.host.pid}` : 'host:unknown';
|
|
283
310
|
const processBits = [
|