klypix-mcp 1.90.0 → 1.90.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/bin/klypix-install.mjs +57 -3
- package/package.json +1 -1
- package/src/brain-doctor.mjs +49 -13
- package/src/global-brain-hook.mjs +9 -2
- package/src/mcp-supervisor.mjs +132 -29
package/bin/klypix-install.mjs
CHANGED
|
@@ -212,6 +212,33 @@ function renameSyncWithBackoff(from, to) {
|
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
// The live install as its own `.mcp-runtime.json` describes it, every listed
|
|
216
|
+
// file read and hashed now: { raw, entries: [[name, bytes]] }; { absent: true }
|
|
217
|
+
// with no manifest (an install from before the supervisor); { error } when a
|
|
218
|
+
// listed file is missing or differs — a half-applied install, two versions'
|
|
219
|
+
// files side by side. Only the flat layout (bare file names) is accepted.
|
|
220
|
+
function readVerifiedLiveRuntime(dir) {
|
|
221
|
+
const manifestPath = path.join(dir, '.mcp-runtime.json');
|
|
222
|
+
let raw;
|
|
223
|
+
try { raw = fs.readFileSync(manifestPath, 'utf8'); }
|
|
224
|
+
catch (e) { return e?.code === 'ENOENT' ? { absent: true } : { error: `manifest unreadable (${e?.code || e?.message})` }; }
|
|
225
|
+
let manifest;
|
|
226
|
+
try { manifest = JSON.parse(raw); } catch { return { error: 'manifest is not valid JSON' }; }
|
|
227
|
+
const files = manifest && typeof manifest.files === 'object' && !Array.isArray(manifest.files) ? manifest.files : null;
|
|
228
|
+
if (manifest?.protocol !== 1 || !files || !Object.prototype.hasOwnProperty.call(files, String(manifest.worker || ''))) {
|
|
229
|
+
return { error: 'manifest does not hash its worker' };
|
|
230
|
+
}
|
|
231
|
+
const entries = [];
|
|
232
|
+
for (const [name, expected] of Object.entries(files)) {
|
|
233
|
+
if (path.basename(name) !== name) return { error: `unexpected path ${name}` };
|
|
234
|
+
let bytes;
|
|
235
|
+
try { bytes = fs.readFileSync(path.join(dir, name)); } catch { return { error: `${name} is missing` }; }
|
|
236
|
+
if (crypto.createHash('sha256').update(bytes).digest('hex') !== expected) return { error: `${name} differs from its hash` };
|
|
237
|
+
entries.push([name, bytes]);
|
|
238
|
+
}
|
|
239
|
+
return { raw, entries };
|
|
240
|
+
}
|
|
241
|
+
|
|
215
242
|
function migrateProjectMcpConfig() {
|
|
216
243
|
try {
|
|
217
244
|
const file = path.join(process.cwd(), '.mcp.json');
|
|
@@ -416,10 +443,37 @@ try {
|
|
|
416
443
|
const s = path.join(BIN, src); if (exists(s)) staged.push({ dst, content: flatten(fs.readFileSync(s, 'utf8')) });
|
|
417
444
|
}
|
|
418
445
|
for (const st of staged) fs.writeFileSync(path.join(BRAIN_DIR, st.dst + '.klypix-new'), st.content);
|
|
446
|
+
// .prev is what a supervisor boots while an install is half-applied (a
|
|
447
|
+
// fresh connection, a wake, a crash recovery), so it must be ONE version,
|
|
448
|
+
// whole (K1-PREV-UNVERIFIED, 2026-10-03 review). It used to be refreshed from
|
|
449
|
+
// whatever the live directory held: a retry after an install that stopped
|
|
450
|
+
// mid-rename (the updater's own, 15 min later) copied the MIXED set over the
|
|
451
|
+
// last complete snapshot. Now the live files are snapshotted only when they
|
|
452
|
+
// verify against their own manifest — the very bytes that were hashed are
|
|
453
|
+
// written — and that manifest is copied in LAST: it is what the supervisor
|
|
454
|
+
// verifies before it boots .prev (prevSnapshotAt). A live directory that
|
|
455
|
+
// fails its manifest leaves the last complete snapshot where it is.
|
|
419
456
|
try {
|
|
420
|
-
const prevDir = path.join(BRAIN_DIR, '.prev');
|
|
421
|
-
|
|
422
|
-
|
|
457
|
+
const prevDir = path.join(BRAIN_DIR, '.prev');
|
|
458
|
+
const prevManifest = path.join(prevDir, '.mcp-runtime.json');
|
|
459
|
+
const live = readVerifiedLiveRuntime(BRAIN_DIR);
|
|
460
|
+
if (live.entries) {
|
|
461
|
+
fs.mkdirSync(prevDir, { recursive: true });
|
|
462
|
+
fs.rmSync(prevManifest, { force: true }); // nothing vouches for .prev while it changes
|
|
463
|
+
for (const [name, bytes] of live.entries) fs.writeFileSync(path.join(prevDir, name), bytes);
|
|
464
|
+
fs.writeFileSync(prevManifest + '.klypix-new', live.raw);
|
|
465
|
+
renameSyncWithBackoff(prevManifest + '.klypix-new', prevManifest);
|
|
466
|
+
} else if (live.absent) {
|
|
467
|
+
// No manifest to verify against (an install from before the
|
|
468
|
+
// supervisor): a plain copy for a manual rollback, which no
|
|
469
|
+
// supervisor boots — nothing vouches for it.
|
|
470
|
+
fs.mkdirSync(prevDir, { recursive: true });
|
|
471
|
+
fs.rmSync(prevManifest, { force: true });
|
|
472
|
+
for (const st of staged) { const file = path.join(BRAIN_DIR, st.dst); if (exists(file)) fs.copyFileSync(file, path.join(prevDir, st.dst)); }
|
|
473
|
+
} else {
|
|
474
|
+
console.log(`• .prev kept: the live core files do not match their manifest (${live.error}) — the last complete snapshot stays the rollback copy`);
|
|
475
|
+
}
|
|
476
|
+
} catch { /* .prev rollback snapshot is best-effort; an unfinished one has no manifest */ }
|
|
423
477
|
const renameOrder = staged.slice().sort((a, b) => (a.dst === 'global-brain-hook.mjs' ? 1 : 0) - (b.dst === 'global-brain-hook.mjs' ? 1 : 0));
|
|
424
478
|
let n = 0;
|
|
425
479
|
for (const st of renameOrder) { renameSyncWithBackoff(path.join(BRAIN_DIR, st.dst + '.klypix-new'), path.join(BRAIN_DIR, st.dst)); n++; }
|
package/package.json
CHANGED
package/src/brain-doctor.mjs
CHANGED
|
@@ -297,9 +297,6 @@ function inspectRunning(brainDir, baked, now, self) {
|
|
|
297
297
|
const SUPERVISOR_DEAD_RECEIPT_MS = 120 * 1000;
|
|
298
298
|
const SUPERVISOR_TRANSITION_MS = 45 * 1000;
|
|
299
299
|
const WAKING_STATUSES = new Set(['validating-update', 'update-ready', 'recovering', 'recovery-ready']);
|
|
300
|
-
// readRuntimeTarget's failures (mcp-supervisor.mjs): the core files on disk do
|
|
301
|
-
// not verify, so a sleeping pair has nothing consistent to wake into.
|
|
302
|
-
const RUNTIME_INTEGRITY_ERROR = /^(runtime (manifest|worker|file|integrity)\b|unsupported runtime protocol)/;
|
|
303
300
|
const majorOf = (version) => {
|
|
304
301
|
const match = String(version || '').match(/^v?(\d+)\.\d+\.\d+/);
|
|
305
302
|
return match ? Number(match[1]) : null;
|
|
@@ -357,18 +354,26 @@ function inspectSupervisors(brainDir, baked, now = Date.now()) {
|
|
|
357
354
|
// queue. A broken or backpressured host pipe stays what it is.
|
|
358
355
|
const deliveryStatus = transition === 'waking' && !['impaired', 'backpressured'].includes(state.transport?.host)
|
|
359
356
|
? 'queued' : recordedDelivery;
|
|
360
|
-
// F6 (2026-10-03 review): a sleeping pair whose last wake found
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
357
|
+
// F6 (2026-10-03 review): a sleeping pair whose last wake found no
|
|
358
|
+
// consistent core to boot (wakeDeferred, which the supervisor writes on the
|
|
359
|
+
// first refused wake) answers every request with an error until the core
|
|
360
|
+
// files verify. It used to read "wakes on the next request".
|
|
361
|
+
// Only that refusal counts (K1-DOCTOR-WAKEBLOCKED-FALSE, 2026-10-03 review),
|
|
362
|
+
// the rule `klypix-mcp runtime` follows (runtime-inspector wakeBlock). An
|
|
363
|
+
// integrity error the receipt merely carries is not one: the wake resumes
|
|
364
|
+
// .prev when it holds this pair's version — every pair K1 booted from .prev
|
|
365
|
+
// records the error in its first receipt and wakes from .prev — or the
|
|
366
|
+
// package's own worker outside the managed directory. Read as "cannot
|
|
367
|
+
// wake", every connection that started during a failing install was
|
|
368
|
+
// printed IMPAIRED while it woke without trouble. The error itself is still
|
|
369
|
+
// shown on the pair's line.
|
|
364
370
|
const wakeDeferred = intentionallyHibernated && state.hibernation?.wakeDeferred
|
|
365
371
|
&& typeof state.hibernation.wakeDeferred === 'object' ? state.hibernation.wakeDeferred : null;
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
|
|
370
|
-
const wakeBlocked = intentionallyHibernated
|
|
371
|
-
&& (Boolean(wakeDeferred) || (!hasPreviousWorker && RUNTIME_INTEGRITY_ERROR.test(String(state.lastError || '')) && !fresh));
|
|
372
|
+
// Only a wake the supervisor actually refused proves a sleeping pair cannot
|
|
373
|
+
// wake — the same rule as `klypix-mcp runtime` (wakeBlock). An integrity error
|
|
374
|
+
// alone does not: the wake waits for the install to settle, and a pair that
|
|
375
|
+
// served from .prev resumes that snapshot while it still verifies.
|
|
376
|
+
const wakeBlocked = intentionallyHibernated && Boolean(wakeDeferred);
|
|
372
377
|
const workerImpaired = (!state.active && !intentionallyHibernated && transition !== 'waking'
|
|
373
378
|
&& status !== 'starting' && status !== 'awaiting-initialize') || wakeBlocked;
|
|
374
379
|
const deliveryImpaired = deliveryStatus === 'impaired' || state.transport?.host === 'impaired';
|
|
@@ -634,6 +639,20 @@ function autoUpdateView({ au, brainDir, supervisors, version, hooks, npmLatest,
|
|
|
634
639
|
const overdueSuppressed = rule.suppressed === 'no-live-session' ? 'no-live-session'
|
|
635
640
|
: (skew && (rule.overdue || rule.suppressed) ? 'version-skew' : (rule.suppressed || null));
|
|
636
641
|
const overdue = rule.overdue === true && !skew;
|
|
642
|
+
// TR-1 (2026-10-03 review): a long-due check the rule did NOT judge for want
|
|
643
|
+
// of a poll (K3) used to print the generic "check due now — runs within
|
|
644
|
+
// 10 min", hiding how long it had been due and repeating a promise an open
|
|
645
|
+
// session had visibly not kept. That is the normal state right after this
|
|
646
|
+
// release: every live connection runs pre-fix supervisor code, which records
|
|
647
|
+
// no poll, until it reconnects. Keep not judging, but carry what is known:
|
|
648
|
+
// how long it has been due, the latest poll on record, and how many sessions
|
|
649
|
+
// record none.
|
|
650
|
+
const pollers = live.filter((state) => state.autoUpdateEnabled !== false);
|
|
651
|
+
const pollTimes = pollers.map((state) => timeOf(state.lastPollAt)).filter((ms) => Number.isFinite(ms) && ms <= now + 1000);
|
|
652
|
+
const latestPollAt = pollTimes.length ? new Date(Math.max(...pollTimes)).toISOString() : null;
|
|
653
|
+
// Pre-fix supervisor code (no supervisorVersion, B9) never records a poll; a
|
|
654
|
+
// fixed one records its first 2 s after it starts, so it is not counted here.
|
|
655
|
+
const unpolledSessions = pollers.filter((state) => state.preFix && !Number.isFinite(timeOf(state.lastPollAt))).length;
|
|
637
656
|
|
|
638
657
|
const knownLatest = knownNpmLatest(brainDir, au, npmLatest, now);
|
|
639
658
|
// MV-2: a pre-hold updater re-installs whatever the owner rolled back from;
|
|
@@ -679,6 +698,11 @@ function autoUpdateView({ au, brainDir, supervisors, version, hooks, npmLatest,
|
|
|
679
698
|
overdue,
|
|
680
699
|
overdueByMs: overdue ? rule.overdueByMs : null,
|
|
681
700
|
overdueSuppressed,
|
|
701
|
+
// How long the check has been due by the rule's own reckoning (null when
|
|
702
|
+
// it was due since before any record), and the polls behind a suppression.
|
|
703
|
+
dueForMs: Number.isFinite(rule.dueForMs) ? rule.dueForMs : null,
|
|
704
|
+
latestPollAt,
|
|
705
|
+
unpolledSessions,
|
|
682
706
|
// What the overdue rule saw: the live sessions that polled after the check
|
|
683
707
|
// fell due, and the latest such poll (K3).
|
|
684
708
|
overdueEvidence: overdue ? rule.evidence || null : null,
|
|
@@ -1633,6 +1657,18 @@ export function render(r, opts = {}) {
|
|
|
1633
1657
|
parts.push(`next check unknown${au.scheduleError ? ` (${au.scheduleError})` : ''}`);
|
|
1634
1658
|
} else if (au.overdue) {
|
|
1635
1659
|
parts.push(`${c.yel}check overdue by ${durationText(au.overdueByMs)} — no running session performed the check${c.rst}`);
|
|
1660
|
+
} else if (au.overdueSuppressed === 'no-poll-evidence' && Number.isFinite(au.dueForMs)) {
|
|
1661
|
+
// TR-1: not judged (K3), but not "due now" either — the rule reports
|
|
1662
|
+
// this only once the check is past its 30 min grace. Say how long, and
|
|
1663
|
+
// why it is not called overdue.
|
|
1664
|
+
const sinceMs = nowMs - au.dueForMs;
|
|
1665
|
+
const polledMs = timeOf(au.latestPollAt);
|
|
1666
|
+
const why = Number.isFinite(polledMs) && polledMs >= sinceMs
|
|
1667
|
+
? `the latest poll on record (${isoMinute(polledMs)}, ${durationText(nowMs - polledMs)} ago) is too recent, or too close to the due time, to judge it overdue yet`
|
|
1668
|
+
: (au.unpolledSessions
|
|
1669
|
+
? `overdue is not judged: no open session has recorded a poll since then — ${au.unpolledSessions} connection${au.unpolledSessions === 1 ? '' : 's'} on pre-fix supervisor code record${au.unpolledSessions === 1 ? 's' : ''} none (/mcp reconnect)`
|
|
1670
|
+
: `no open session has polled since then; it runs within ${pollText} while one is open`);
|
|
1671
|
+
parts.push(`check due since ${isoMinute(sinceMs)} (${durationText(au.dueForMs)}) — ${why}`);
|
|
1636
1672
|
} else if (dueMs <= nowMs) {
|
|
1637
1673
|
parts.push(`check due now — runs within ${pollText} while any KLYPIX session is open, or 2 s after the next one starts`);
|
|
1638
1674
|
} else {
|
|
@@ -5474,6 +5474,13 @@ function updateRemedy({ plan, decision, overdue = null, latest, baked, now, spaw
|
|
|
5474
5474
|
}
|
|
5475
5475
|
return { mark: '⚠️', text: `Automatic check overdue (${late}) — run ${installedDoctorHint(baked)}.` };
|
|
5476
5476
|
}
|
|
5477
|
+
// TR-1 (2026-10-03 review): a check past its grace that the rule did not
|
|
5478
|
+
// judge for want of a poll (K3: every session on pre-fix supervisor code,
|
|
5479
|
+
// or none polled since it fell due) has been due for a while — "(due now)"
|
|
5480
|
+
// hid that. Not judged overdue, so still no alarm.
|
|
5481
|
+
const when = dueAt <= now && overdue && overdue.suppressed === 'no-poll-evidence' && Number.isFinite(overdue.dueForMs)
|
|
5482
|
+
? `due for ${spanLabel(overdue.dueForMs)}`
|
|
5483
|
+
: etaLabel(dueAt, now);
|
|
5477
5484
|
// A failed result written by a pre-2026-10-03 updater has no count in its
|
|
5478
5485
|
// stamp; it is still one failed attempt, never a clean slate.
|
|
5479
5486
|
const failures = Math.max(
|
|
@@ -5486,9 +5493,9 @@ function updateRemedy({ plan, decision, overdue = null, latest, baked, now, spaw
|
|
|
5486
5493
|
const why = plan.result === 'failed' && plan.error
|
|
5487
5494
|
? String(plan.error).replace(/[`\r\n]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120)
|
|
5488
5495
|
: 'it stopped before recording a result';
|
|
5489
|
-
return { mark: '⚠️', text: `KLYPIX will retry automatically — last automatic attempt failed (${why}, attempt ${failures}); next retry ${
|
|
5496
|
+
return { mark: '⚠️', text: `KLYPIX will retry automatically — last automatic attempt failed (${why}, attempt ${failures}); next retry ${when}.` };
|
|
5490
5497
|
}
|
|
5491
|
-
return promise(
|
|
5498
|
+
return promise(when);
|
|
5492
5499
|
}
|
|
5493
5500
|
|
|
5494
5501
|
// SessionStart footer — ambient version drift. Reads ONLY local files (zero
|
package/src/mcp-supervisor.mjs
CHANGED
|
@@ -61,6 +61,11 @@ const DEFAULT_AUTO_UPDATE_START_DELAY_MS = 2000;
|
|
|
61
61
|
const RECOVERY_MAX_ATTEMPTS = 5;
|
|
62
62
|
const RECOVERY_BACKOFF_BASE_MS = 1000;
|
|
63
63
|
const RECOVERY_BACKOFF_MAX_MS = 60_000;
|
|
64
|
+
// Hot-swap retry policy (2026-10-03): a candidate that fails TRANSIENTLY while
|
|
65
|
+
// the old worker keeps serving is retried after base × 1, 4, 20 (30 s, 2 min,
|
|
66
|
+
// 10 min by default); only then is it kept rejected until a reconnect.
|
|
67
|
+
const DEFAULT_SWAP_RETRY_BASE_MS = 30_000;
|
|
68
|
+
const SWAP_RETRY_FACTORS = [1, 4, 20];
|
|
64
69
|
// Unbounded queue growth is its own failure mode while a recovery is running.
|
|
65
70
|
const HOST_QUEUE_MAX = 200;
|
|
66
71
|
// A wake that finds the manifest failing integrity is usually racing an install:
|
|
@@ -166,17 +171,32 @@ const workerFileVersion = (file) => {
|
|
|
166
171
|
return pkg?.name === 'klypix-mcp' && typeof pkg.version === 'string' ? pkg.version : null;
|
|
167
172
|
} catch { return null; }
|
|
168
173
|
};
|
|
169
|
-
// The installer's snapshot of
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
174
|
+
// The installer's snapshot of the previous install, `.prev/` beside the worker,
|
|
175
|
+
// as a boot target — or null. It is one only while `.prev/.mcp-runtime.json`
|
|
176
|
+
// verifies here and now, byte for byte: the installer snapshots the live
|
|
177
|
+
// directory only when it verifies against its own manifest, and copies that
|
|
178
|
+
// manifest in LAST (bin/klypix-install.mjs). A baked version string used to be
|
|
179
|
+
// the whole check (K1-PREV-UNVERIFIED, 2026-10-03 review), and both installers
|
|
180
|
+
// refreshed .prev from whatever the live directory held — so a retry after a
|
|
181
|
+
// half-applied install filled .prev with two versions' files, and a boot or a
|
|
182
|
+
// wake from it loaded a mixed module graph. A .prev that no manifest vouches for
|
|
183
|
+
// (the desktop installer's, or any installer's before this) is never booted.
|
|
184
|
+
const prevSnapshotAt = (prevDir) => {
|
|
185
|
+
const manifestPath = path.join(prevDir, '.mcp-runtime.json');
|
|
186
|
+
const runtime = readRuntimeTarget(manifestPath);
|
|
187
|
+
if (!runtime.ok) return null;
|
|
188
|
+
const { path: file, version } = runtime.target;
|
|
189
|
+
// The worker itself must be among the files the manifest hashes, and carry
|
|
190
|
+
// the version the manifest names.
|
|
191
|
+
let hashed = false;
|
|
192
|
+
try {
|
|
193
|
+
const files = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))?.files;
|
|
194
|
+
hashed = isRecord(files) && Object.keys(files).some((relative) => path.resolve(prevDir, relative) === file);
|
|
195
|
+
} catch { /* unreadable since the verified read: not vouched for */ }
|
|
196
|
+
if (!hashed || readBakedVersion(file) !== version) return null;
|
|
197
|
+
return { path: file, version, signature: `previous:${file}:${version}`, source: 'rollback', dev: false };
|
|
179
198
|
};
|
|
199
|
+
const prevSnapshotTarget = (workerPath) => prevSnapshotAt(path.join(path.dirname(workerPath), '.prev'));
|
|
180
200
|
|
|
181
201
|
function createLineReader(onMessage, onError) {
|
|
182
202
|
let buffered = Buffer.alloc(0);
|
|
@@ -334,6 +354,7 @@ function createRuntimeWatch(manifestPath, {
|
|
|
334
354
|
now = () => Date.now(),
|
|
335
355
|
} = {}) {
|
|
336
356
|
let verified = null;
|
|
357
|
+
let lastKey = null;
|
|
337
358
|
const statKey = () => {
|
|
338
359
|
try {
|
|
339
360
|
// bigint: NTFS file ids exceed 2^53 and must compare exactly.
|
|
@@ -344,6 +365,7 @@ function createRuntimeWatch(manifestPath, {
|
|
|
344
365
|
return {
|
|
345
366
|
read({ force = false } = {}) {
|
|
346
367
|
const key = statKey();
|
|
368
|
+
lastKey = key;
|
|
347
369
|
const at = now();
|
|
348
370
|
if (!force && key !== null && verified?.key === key && at >= verified.at && at - verified.at < reverifyMs) {
|
|
349
371
|
return { ok: true, target: verified.target, cached: true };
|
|
@@ -352,9 +374,42 @@ function createRuntimeWatch(manifestPath, {
|
|
|
352
374
|
verified = runtime.ok && key !== null ? { key, at, target: runtime.target } : null;
|
|
353
375
|
return runtime;
|
|
354
376
|
},
|
|
377
|
+
// The manifest's stat now, and as it was just before the last read: a
|
|
378
|
+
// commit since that read moves it (settleRuntime).
|
|
379
|
+
statKey,
|
|
380
|
+
lastReadKey: () => lastKey,
|
|
355
381
|
};
|
|
356
382
|
}
|
|
357
383
|
|
|
384
|
+
// Wait for an install to settle (B3 wakes, K1 boots): repeat the read while it
|
|
385
|
+
// fails integrity (or cannot be read) for up to waitMs. Installs commit the
|
|
386
|
+
// manifest LAST, by rename-over, so until its stat moves nothing can have made
|
|
387
|
+
// a failing read verify: the wait polls the stat and re-hashes the runtime
|
|
388
|
+
// only when it moves, plus once at the deadline, before .prev or the fallback
|
|
389
|
+
// is chosen (K1-WAIT-FULL-REHASH, 2026-10-03 review). It used to re-hash every
|
|
390
|
+
// file every 250 ms — ~20 full reads (~8 MiB/s) per waiting connection, the
|
|
391
|
+
// load B8 removed from the poller because it contends with the installer's
|
|
392
|
+
// renames, whose EPERM is what leaves an install half-applied in the first place.
|
|
393
|
+
async function settleRuntime(watch, {
|
|
394
|
+
initial = watch.read({ force: true }),
|
|
395
|
+
waitMs = WAKE_INTEGRITY_WAIT_MS,
|
|
396
|
+
pollMs = WAKE_INTEGRITY_POLL_MS,
|
|
397
|
+
stopped = () => false,
|
|
398
|
+
} = {}) {
|
|
399
|
+
let runtime = initial;
|
|
400
|
+
const deadline = Date.now() + waitMs;
|
|
401
|
+
// The key taken just before that read: a commit landing during it still moves.
|
|
402
|
+
let seen = watch.lastReadKey();
|
|
403
|
+
while (!runtime.ok && !runtime.absent && !stopped() && Date.now() < deadline) {
|
|
404
|
+
await sleep(pollMs);
|
|
405
|
+
const key = watch.statKey();
|
|
406
|
+
if (key === seen && Date.now() < deadline) continue;
|
|
407
|
+
seen = key;
|
|
408
|
+
runtime = watch.read({ force: true });
|
|
409
|
+
}
|
|
410
|
+
return runtime;
|
|
411
|
+
}
|
|
412
|
+
|
|
358
413
|
// Is this supervisor receipt provably dead? The boot cleanup deletes on a yes,
|
|
359
414
|
// so every doubt keeps the receipt.
|
|
360
415
|
function deadSupervisorReceipt(state, { now = Date.now(), probe = pidState } = {}) {
|
|
@@ -440,6 +495,10 @@ class Supervisor {
|
|
|
440
495
|
this.pollMs = Number(options.pollMs || process.env.KLYPIX_MCP_SUPERVISOR_POLL_MS || DEFAULT_POLL_MS);
|
|
441
496
|
this.timeoutMs = Number(options.timeoutMs || process.env.KLYPIX_MCP_SUPERVISOR_TIMEOUT_MS || DEFAULT_TIMEOUT_MS);
|
|
442
497
|
this.rollbackGraceMs = Number(options.rollbackGraceMs || process.env.KLYPIX_MCP_ROLLBACK_GRACE_MS || DEFAULT_ROLLBACK_GRACE_MS);
|
|
498
|
+
this.swapRetryBaseMs = Math.max(1000, Number(options.swapRetryBaseMs || process.env.KLYPIX_MCP_SWAP_RETRY_BASE_MS || DEFAULT_SWAP_RETRY_BASE_MS) || DEFAULT_SWAP_RETRY_BASE_MS);
|
|
499
|
+
this.swapRetryAttempts = 0;
|
|
500
|
+
this.swapRetrySignature = null;
|
|
501
|
+
this.swapRetryTimer = null;
|
|
443
502
|
this.autoUpdate = options.autoUpdate !== false && autoUpdateEnabled();
|
|
444
503
|
this.autoUpdatePollMs = Number(
|
|
445
504
|
options.autoUpdatePollMs
|
|
@@ -756,12 +815,11 @@ class Supervisor {
|
|
|
756
815
|
return target.dev || cmp === null || cmp >= 0;
|
|
757
816
|
}
|
|
758
817
|
|
|
759
|
-
//
|
|
760
|
-
//
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
// whatever .prev held.
|
|
818
|
+
// A .prev whose manifest verifies (prevSnapshotAt) is a complete, consistent
|
|
819
|
+
// copy of ONE previous install. It is a safe place to resume only when that
|
|
820
|
+
// copy is the version this connection last ran — the version the host's tool
|
|
821
|
+
// list still describes. Any other version would be a swap no gate has seen;
|
|
822
|
+
// crash recovery used to boot whatever .prev held.
|
|
765
823
|
previousBaselineTarget(anchor) {
|
|
766
824
|
if (!anchor?.path || anchor.source === 'rollback') return null;
|
|
767
825
|
const previous = prevSnapshotTarget(anchor.path);
|
|
@@ -770,6 +828,16 @@ class Supervisor {
|
|
|
770
828
|
return previous;
|
|
771
829
|
}
|
|
772
830
|
|
|
831
|
+
// A pair that already runs from .prev resumes that same snapshot — while it
|
|
832
|
+
// still verifies and still holds this connection's version. An install
|
|
833
|
+
// rewrites .prev, and a pre-fix installer could rewrite it with a mixed set.
|
|
834
|
+
resumableRollback(anchor) {
|
|
835
|
+
const snapshot = anchor?.path ? prevSnapshotAt(path.dirname(anchor.path)) : null;
|
|
836
|
+
const baseVersion = this.baselineVersion();
|
|
837
|
+
if (!snapshot || !baseVersion || snapshot.version !== String(baseVersion)) return null;
|
|
838
|
+
return path.resolve(snapshot.path) === path.resolve(anchor.path) ? snapshot : null;
|
|
839
|
+
}
|
|
840
|
+
|
|
773
841
|
// The package's own worker as it is on disk NOW. fallbackTarget carries the
|
|
774
842
|
// version this supervisor started with, but an install (flat bundle) or an
|
|
775
843
|
// npx cache refresh (direct-package launch) can replace the file since, and a
|
|
@@ -888,14 +956,10 @@ class Supervisor {
|
|
|
888
956
|
|
|
889
957
|
// A full read of the manifest, repeated while it fails integrity (or cannot be
|
|
890
958
|
// read) for up to WAKE_INTEGRITY_WAIT_MS: an install renames its files one at
|
|
891
|
-
// a time and commits the manifest last. Wakes (B3) and boots (K1) wait alike
|
|
959
|
+
// a time and commits the manifest last. Wakes (B3) and boots (K1) wait alike;
|
|
960
|
+
// settleRuntime re-hashes only when the manifest moves.
|
|
892
961
|
async settledRuntime(runtime = this.runtimeWatch.read({ force: true })) {
|
|
893
|
-
|
|
894
|
-
while (!runtime.ok && !runtime.absent && !this.closed && Date.now() < deadline) {
|
|
895
|
-
await sleep(WAKE_INTEGRITY_POLL_MS);
|
|
896
|
-
runtime = this.runtimeWatch.read({ force: true });
|
|
897
|
-
}
|
|
898
|
-
return runtime;
|
|
962
|
+
return settleRuntime(this.runtimeWatch, { initial: runtime, stopped: () => this.closed });
|
|
899
963
|
}
|
|
900
964
|
|
|
901
965
|
// B3 (2026-10-03): the wake re-reads the manifest instead of trusting the
|
|
@@ -951,9 +1015,9 @@ class Supervisor {
|
|
|
951
1015
|
// sleeping target's path inside the managed directory — resume .prev when it
|
|
952
1016
|
// holds this connection's version...
|
|
953
1017
|
// A pair that already resumed from .prev resumes that same copy — while it
|
|
954
|
-
// still holds this connection's version (
|
|
1018
|
+
// still verifies and holds this connection's version (resumableRollback).
|
|
955
1019
|
const previous = anchor?.source === 'rollback'
|
|
956
|
-
?
|
|
1020
|
+
? this.resumableRollback(anchor)
|
|
957
1021
|
: this.previousBaselineTarget(anchor);
|
|
958
1022
|
if (previous) {
|
|
959
1023
|
log(`runtime still fails integrity after ${WAKE_INTEGRITY_WAIT_MS} ms (${runtime.error}) — waking v${previous.version} from .prev`);
|
|
@@ -1043,9 +1107,10 @@ class Supervisor {
|
|
|
1043
1107
|
// is <brainDir>/klypix-mcp-worker.mjs — inside the directory being renamed —
|
|
1044
1108
|
// and booting it at once could load a mixed module graph (a new worker beside
|
|
1045
1109
|
// an old engine, or the reverse). So the boot waits for the install to settle,
|
|
1046
|
-
// as a wake does (B3), and then
|
|
1047
|
-
//
|
|
1048
|
-
//
|
|
1110
|
+
// as a wake does (B3), and then boots .prev's complete pre-install copy — one
|
|
1111
|
+
// whose own manifest verifies (prevSnapshotAt). Without one it refuses the
|
|
1112
|
+
// connection instead of loading the known-unverified live files; the
|
|
1113
|
+
// integrity error is recorded either way. A direct-package launch
|
|
1049
1114
|
// keeps its worker outside the managed directory, which no install touches: it
|
|
1050
1115
|
// boots at once. The host's first requests queue meanwhile (run()).
|
|
1051
1116
|
async selectInitialTarget() {
|
|
@@ -1064,7 +1129,7 @@ class Supervisor {
|
|
|
1064
1129
|
log(`runtime still fails integrity after ${WAKE_INTEGRITY_WAIT_MS} ms (${runtime.error}) — starting v${previous.version} from .prev, the complete copy of the previous install`);
|
|
1065
1130
|
return previous;
|
|
1066
1131
|
}
|
|
1067
|
-
throw new Error(`KLYPIX core files do not verify (${runtime.error}) and no previous worker is available — no worker was started; retry after the install finishes, then /mcp reconnect`);
|
|
1132
|
+
throw new Error(`KLYPIX core files do not verify (${runtime.error}) and no previous worker whose own manifest verifies is available — no worker was started; retry after the install finishes, then /mcp reconnect`);
|
|
1068
1133
|
}
|
|
1069
1134
|
}
|
|
1070
1135
|
if (!runtime.ok) return this.fallbackTarget;
|
|
@@ -1532,6 +1597,37 @@ class Supervisor {
|
|
|
1532
1597
|
}
|
|
1533
1598
|
}
|
|
1534
1599
|
|
|
1600
|
+
// A hot-swap that failed while the old worker still serves. A deterministic
|
|
1601
|
+
// rejection (new major, breaking tools) stays rejected until a new install or a
|
|
1602
|
+
// reconnect. A TRANSIENT one — an initialize timeout under load, a spawn error,
|
|
1603
|
+
// an exit before activation — used to pin the connection to its old version
|
|
1604
|
+
// just the same (field, 2026-10-03: one 15 s initialize timeout during a
|
|
1605
|
+
// test-heavy hour left a Codex pair on v1.88.0 beside a v1.89.0 install until it
|
|
1606
|
+
// reconnected). It now retries the same target on the swap backoff while the
|
|
1607
|
+
// old worker keeps serving; the signature stays rejected meanwhile so the 1 s
|
|
1608
|
+
// poller cannot restart it early, and the timer lifts it.
|
|
1609
|
+
scheduleSwapRetry(candidate, reason) {
|
|
1610
|
+
const signature = candidate.target.signature;
|
|
1611
|
+
if (this.swapRetrySignature !== signature) {
|
|
1612
|
+
this.swapRetrySignature = signature;
|
|
1613
|
+
this.swapRetryAttempts = 0;
|
|
1614
|
+
}
|
|
1615
|
+
if (this.swapRetryAttempts >= SWAP_RETRY_FACTORS.length) return false;
|
|
1616
|
+
const delay = this.swapRetryBaseMs * SWAP_RETRY_FACTORS[this.swapRetryAttempts++];
|
|
1617
|
+
const rejectedVersion = candidate.version || candidate.target.version;
|
|
1618
|
+
this.rejectedSignature = signature;
|
|
1619
|
+
this.status = 'ready';
|
|
1620
|
+
if (this.swapRetryTimer) clearTimeout(this.swapRetryTimer);
|
|
1621
|
+
this.swapRetryTimer = setTimeout(() => {
|
|
1622
|
+
this.swapRetryTimer = null;
|
|
1623
|
+
if (!this.closed && this.rejectedSignature === signature) this.rejectedSignature = null;
|
|
1624
|
+
}, delay);
|
|
1625
|
+
this.swapRetryTimer.unref?.();
|
|
1626
|
+
this.writeState({ rejectedVersion, swapRetry: { attempt: this.swapRetryAttempts, of: SWAP_RETRY_FACTORS.length, inMs: delay } });
|
|
1627
|
+
log(`kept v${this.active?.version || 'none'}; v${rejectedVersion} failed transiently (${reason}) — retrying in ${Math.round(delay / 1000)} s (${this.swapRetryAttempts}/${SWAP_RETRY_FACTORS.length})`);
|
|
1628
|
+
return true;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1535
1631
|
rejectCandidate(reason, terminate = true, { deterministic = false } = {}) {
|
|
1536
1632
|
const candidate = this.candidate;
|
|
1537
1633
|
if (!candidate) return;
|
|
@@ -1542,6 +1638,7 @@ class Supervisor {
|
|
|
1542
1638
|
this.rejectWhileIdle(candidate, reason);
|
|
1543
1639
|
return;
|
|
1544
1640
|
}
|
|
1641
|
+
if (this.active && !deterministic && this.scheduleSwapRetry(candidate, reason)) return;
|
|
1545
1642
|
this.status = this.active ? 'restart-required' : 'recovery-failed';
|
|
1546
1643
|
if (!this.active) {
|
|
1547
1644
|
// RECOVERY rejection: transient spawn failures (0xC0000142-class) must
|
|
@@ -1646,6 +1743,9 @@ class Supervisor {
|
|
|
1646
1743
|
this.rejectedSignature = rejection ? rejection.signature : null;
|
|
1647
1744
|
this.recoveryAttempts = 0; // a committed worker resets the retry budget
|
|
1648
1745
|
if (this.recoveryTimer) { clearTimeout(this.recoveryTimer); this.recoveryTimer = null; }
|
|
1746
|
+
this.swapRetryAttempts = 0; // and the hot-swap retry budget
|
|
1747
|
+
this.swapRetrySignature = null;
|
|
1748
|
+
if (this.swapRetryTimer) { clearTimeout(this.swapRetryTimer); this.swapRetryTimer = null; }
|
|
1649
1749
|
this.status = rejection ? 'restart-required' : 'ready';
|
|
1650
1750
|
this.lastError = rejection ? rejection.reason : null;
|
|
1651
1751
|
this.runtimeError = null;
|
|
@@ -1941,6 +2041,7 @@ class Supervisor {
|
|
|
1941
2041
|
clearTimeout(this.autoUpdateStarter);
|
|
1942
2042
|
clearInterval(this.autoUpdatePoller);
|
|
1943
2043
|
if (this.recoveryTimer) { clearTimeout(this.recoveryTimer); this.recoveryTimer = null; }
|
|
2044
|
+
if (this.swapRetryTimer) { clearTimeout(this.swapRetryTimer); this.swapRetryTimer = null; }
|
|
1944
2045
|
// Real shutdown grace: stdin EOF lets the worker run its own presence
|
|
1945
2046
|
// cleanup (stopRuntimePresence/removeSession). An instant SIGTERM is
|
|
1946
2047
|
// TerminateProcess on Windows — the cleanup never runs and every normally
|
|
@@ -1967,6 +2068,8 @@ export const __test = {
|
|
|
1967
2068
|
compareSemver,
|
|
1968
2069
|
atomicJson,
|
|
1969
2070
|
createRuntimeWatch,
|
|
2071
|
+
settleRuntime,
|
|
2072
|
+
prevSnapshotTarget,
|
|
1970
2073
|
cleanSupervisorStateDir,
|
|
1971
2074
|
deadSupervisorReceipt,
|
|
1972
2075
|
pidState,
|