@mmmbuto/nexuscrew 0.9.14 → 0.9.16
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/frontend/dist/assets/index-CprIQlC5.css +32 -0
- package/frontend/dist/assets/index-CwsGpQwK.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/audio/receipt.js +1 -1
- package/lib/cells/routes.js +26 -9
- package/lib/config.js +12 -0
- package/lib/diagnostics/store.js +4 -1
- package/lib/fleet/builtin.js +53 -17
- package/lib/fleet/catalogs/opencode-go.json +3 -3
- package/lib/fleet/definitions.js +29 -10
- package/lib/fleet/managed.js +86 -15
- package/lib/live-host/bridge.js +156 -8
- package/lib/live-host/routes.js +10 -2
- package/lib/mcp/server.js +28 -6
- package/lib/mcp/tools.js +24 -6
- package/lib/nodes/health.js +43 -9
- package/lib/server.js +23 -3
- package/lib/ws/bridge.js +243 -24
- package/lib/ws/drop-counter.js +37 -0
- package/package.json +1 -1
- package/skills/nexuscrew/SKILL.md +1 -1
- package/frontend/dist/assets/index-9jCxHZwQ.js +0 -93
- package/frontend/dist/assets/index-keXh4CAm.css +0 -32
package/lib/live-host/bridge.js
CHANGED
|
@@ -32,10 +32,10 @@
|
|
|
32
32
|
// La via designata per le istruzioni di lavoro della Live è il
|
|
33
33
|
// LIVE_PROMPT.md della cella: viaggia nello stesso campo.
|
|
34
34
|
// - MC3: il ponte crea le proprie conversazioni con thread/start e non
|
|
35
|
-
// tocca MAI la thread di una TUI — né turn/start né thread/resume
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
35
|
+
// tocca MAI la thread di una TUI — né turn/start né thread/resume. La sonda
|
|
36
|
+
// thread/read e' separata e sola lettura: non modifica il thread ponte ne'
|
|
37
|
+
// quello della TUI. Per questo l'aggancio funziona anche su una cella che
|
|
38
|
+
// sta già processando un turno: conversazioni separate, nessuna
|
|
39
39
|
// interruzione (rev1 HC2/rev2 JC4).
|
|
40
40
|
// - MC3.3: la connessione al socket di controllo è ON-DEMAND (connect →
|
|
41
41
|
// handshake → thread/start → close), mai permanente: la fuga notifiche
|
|
@@ -49,7 +49,8 @@
|
|
|
49
49
|
// WebSocket (text frame) sopra unix socket, JSON-RPC. Handshake: request
|
|
50
50
|
// `initialize` → response {userAgent, codexHome} → notifica `initialized`
|
|
51
51
|
// (senza params). Poi `thread/start` {cwd, developerInstructions?} → response
|
|
52
|
-
// {thread:{id}, cwd}
|
|
52
|
+
// {thread:{id}, cwd}, oppure `thread/read` {threadId, includeTurns:false} per
|
|
53
|
+
// leggere il runtime. Il socket è 0600 dell'utente: il confine è quello
|
|
53
54
|
// (MC1.3), non c'è autenticazione applicativa.
|
|
54
55
|
|
|
55
56
|
const fs = require('node:fs');
|
|
@@ -70,6 +71,98 @@ const CLIENT_NAME = 'nexuscrew-live-bridge';
|
|
|
70
71
|
// chiudiamo. Breve di proposito — non allunga la risposta a chi ha chiesto il
|
|
71
72
|
// ponte, che e' gia' stata data.
|
|
72
73
|
const ORPHAN_GRACE_MS = 1500;
|
|
74
|
+
const THREAD_STATUS_CACHE_MS = 1500;
|
|
75
|
+
|
|
76
|
+
function normalizedThreadStatus(status) {
|
|
77
|
+
const type = typeof status === 'string' ? status : status && status.type;
|
|
78
|
+
switch (String(type || '').toLowerCase()) {
|
|
79
|
+
case 'notloaded': return 'absent';
|
|
80
|
+
case 'idle': return 'present';
|
|
81
|
+
case 'active': return 'active';
|
|
82
|
+
// A server-reported system error is not evidence that the thread is absent.
|
|
83
|
+
case 'systemerror': return 'unknown';
|
|
84
|
+
default: return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Query on-demand del runtime del thread. La connessione e' dedicata a una
|
|
89
|
+
// sola lettura: ogni uscita, inclusi timeout ed errore di protocollo, chiude il
|
|
90
|
+
// WebSocket prima di risolvere la Promise. Il valore restituito e' volutamente
|
|
91
|
+
// piu' stretto del protocollo: presente/attivo/assente, oppure il chiamante
|
|
92
|
+
// classifica il fallimento come unknown.
|
|
93
|
+
function queryThreadStatusOnControlSocket({
|
|
94
|
+
socketPath, threadId, timeoutMs, WebSocket = defaultWebSocket(),
|
|
95
|
+
}) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
let settled = false;
|
|
98
|
+
let nextId = 0;
|
|
99
|
+
let ws;
|
|
100
|
+
const close = (force = false) => {
|
|
101
|
+
try {
|
|
102
|
+
if (!ws) return;
|
|
103
|
+
if (!force && ws.readyState === WebSocket.OPEN) ws.close(1000); else ws.terminate();
|
|
104
|
+
} catch (_) { /* best effort: il socket verra' raccolto dal peer */ }
|
|
105
|
+
};
|
|
106
|
+
const done = (error, value) => {
|
|
107
|
+
if (settled) return;
|
|
108
|
+
settled = true;
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
close(!!error);
|
|
111
|
+
if (error) reject(error); else resolve(value);
|
|
112
|
+
};
|
|
113
|
+
const timer = setTimeout(() => {
|
|
114
|
+
done(Object.assign(new Error('control socket thread/read timeout'), { code: 'ETIMEOUT' }));
|
|
115
|
+
}, timeoutMs);
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
ws = new WebSocket(`ws+unix://${socketPath}:/`, { handshakeTimeout: timeoutMs });
|
|
119
|
+
} catch (e) {
|
|
120
|
+
done(Object.assign(new Error(`control socket: ${e.message}`), { code: 'ESOCKET' }));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const send = (obj) => ws.send(JSON.stringify(obj));
|
|
125
|
+
const request = (method, params) => new Promise((res, rej) => {
|
|
126
|
+
const id = ++nextId;
|
|
127
|
+
ws.pending = ws.pending || new Map();
|
|
128
|
+
ws.pending.set(id, { res, rej });
|
|
129
|
+
send({ jsonrpc: '2.0', id, method, params });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
ws.on('open', async () => {
|
|
133
|
+
if (settled) { close(); return; }
|
|
134
|
+
try {
|
|
135
|
+
await request('initialize', {
|
|
136
|
+
clientInfo: { name: CLIENT_NAME, title: 'NexusCrew Thread Status', version: bridgeVersion() },
|
|
137
|
+
capabilities: { experimentalApi: true },
|
|
138
|
+
});
|
|
139
|
+
send({ jsonrpc: '2.0', method: 'initialized' });
|
|
140
|
+
const out = await request('thread/read', { threadId, includeTurns: false });
|
|
141
|
+
const value = normalizedThreadStatus(out && out.thread && out.thread.status);
|
|
142
|
+
if (!value) throw Object.assign(new Error('thread/read senza stato thread riconoscibile'), { code: 'EPROTO' });
|
|
143
|
+
done(null, value);
|
|
144
|
+
} catch (e) {
|
|
145
|
+
done(e);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
ws.on('message', (data) => {
|
|
150
|
+
let msg;
|
|
151
|
+
try { msg = JSON.parse(String(data)); } catch (_) { return; }
|
|
152
|
+
if (msg && msg.id != null && ws.pending && ws.pending.has(msg.id)) {
|
|
153
|
+
const waiter = ws.pending.get(msg.id);
|
|
154
|
+
ws.pending.delete(msg.id);
|
|
155
|
+
if (msg.error) waiter.rej(Object.assign(new Error(msg.error.message || 'jsonrpc error'), { code: 'ERPC', detail: msg.error }));
|
|
156
|
+
else waiter.res(msg.result);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
ws.on('error', (e) => done(Object.assign(new Error(`control socket: ${e.message}`), { code: 'ESOCKET' })));
|
|
161
|
+
ws.on('close', () => {
|
|
162
|
+
if (!settled) done(Object.assign(new Error('control socket chiuso prima della risposta'), { code: 'ESOCKET' }));
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
73
166
|
|
|
74
167
|
// —— Prompt per-cella (rev4 LC2, nome fisso confermato il 2026-08-15) ——
|
|
75
168
|
// Collocazione: filesRoot/<tmuxSession>/LIVE_PROMPT.md — la sessione tmux
|
|
@@ -167,6 +260,7 @@ function identityHeader(cellId, tmuxSession) {
|
|
|
167
260
|
// e la finestra resta minima.
|
|
168
261
|
function startThreadOnControlSocket({
|
|
169
262
|
socketPath, cwd, developerInstructions, timeoutMs,
|
|
263
|
+
declaredSession,
|
|
170
264
|
WebSocket = defaultWebSocket(), now = () => Date.now(), log = () => {},
|
|
171
265
|
}) {
|
|
172
266
|
return new Promise((resolve, reject) => {
|
|
@@ -244,7 +338,17 @@ function startThreadOnControlSocket({
|
|
|
244
338
|
if (settled) { chiudi(); return; }
|
|
245
339
|
try {
|
|
246
340
|
await request('initialize', {
|
|
247
|
-
clientInfo: {
|
|
341
|
+
clientInfo: {
|
|
342
|
+
name: CLIENT_NAME,
|
|
343
|
+
title: 'NexusCrew Live Bridge',
|
|
344
|
+
version: bridgeVersion(),
|
|
345
|
+
// Identita della connessione: la sessione gia' risolta della cella
|
|
346
|
+
// designata (mai env grezzi, mai guess — roster MC3.4). Se assente
|
|
347
|
+
// il campo SI OMETTE (Option None lato protocollo): il resolver a
|
|
348
|
+
// valle classifica MISSING nominando la causa; sanitizzare qui la
|
|
349
|
+
// duplicherebbe senza guadagno.
|
|
350
|
+
...(declaredSession ? { nexuscrewSession: declaredSession } : {}),
|
|
351
|
+
},
|
|
248
352
|
capabilities: { experimentalApi: true },
|
|
249
353
|
});
|
|
250
354
|
send({ jsonrpc: '2.0', method: 'initialized' }); // notifica, senza params
|
|
@@ -309,6 +413,11 @@ function createLiveBridge({
|
|
|
309
413
|
log = () => {},
|
|
310
414
|
}) {
|
|
311
415
|
const root = filesRoot || cfg.filesRoot || path.join(os.homedir(), 'NexusFiles');
|
|
416
|
+
const threadIdsByCell = new Map();
|
|
417
|
+
const threadStatusCache = new Map();
|
|
418
|
+
const threadStatusInFlight = new Map();
|
|
419
|
+
const threadStatusCacheMs = Number.isFinite(cfg.liveThreadStatusCacheMs)
|
|
420
|
+
? Math.max(0, cfg.liveThreadStatusCacheMs) : THREAD_STATUS_CACHE_MS;
|
|
312
421
|
|
|
313
422
|
const none = (reason, extra) => ({ mode: 'none', reason, ...(extra || {}), at: now() });
|
|
314
423
|
|
|
@@ -345,6 +454,32 @@ function createLiveBridge({
|
|
|
345
454
|
return cells.find((c) => c && c.cell === cellId) || null;
|
|
346
455
|
}
|
|
347
456
|
|
|
457
|
+
async function threadStatus(cellId) {
|
|
458
|
+
const threadId = threadIdsByCell.get(cellId);
|
|
459
|
+
if (!threadId) return 'unknown';
|
|
460
|
+
|
|
461
|
+
const current = now();
|
|
462
|
+
const cached = threadStatusCache.get(cellId);
|
|
463
|
+
if (cached && cached.expiresAt > current) return cached.value;
|
|
464
|
+
if (cached) threadStatusCache.delete(cellId);
|
|
465
|
+
if (threadStatusInFlight.has(cellId)) return threadStatusInFlight.get(cellId);
|
|
466
|
+
|
|
467
|
+
const query = queryThreadStatusOnControlSocket({
|
|
468
|
+
socketPath: cfg.liveBridgeSocketPath,
|
|
469
|
+
threadId,
|
|
470
|
+
timeoutMs: cfg.liveBridgeTimeoutMs,
|
|
471
|
+
WebSocket,
|
|
472
|
+
}).catch(() => 'unknown').then((value) => {
|
|
473
|
+
// Anche unknown e' la risposta della query corrente: non riusiamo un
|
|
474
|
+
// valore buono oltre la sua finestra, e il prossimo tick potra' misurare
|
|
475
|
+
// di nuovo il nodo.
|
|
476
|
+
threadStatusCache.set(cellId, { value, expiresAt: now() + threadStatusCacheMs });
|
|
477
|
+
return value;
|
|
478
|
+
}).finally(() => { threadStatusInFlight.delete(cellId); });
|
|
479
|
+
threadStatusInFlight.set(cellId, query);
|
|
480
|
+
return query;
|
|
481
|
+
}
|
|
482
|
+
|
|
348
483
|
// Risolve il puntamento per l'avvio di una Live. Sempre una risposta utile:
|
|
349
484
|
// i `none` sono modi legittimi di non puntare, e il reason distingue le
|
|
350
485
|
// cause (designazione assente, cella non idonea, fallback su fallimento).
|
|
@@ -411,6 +546,10 @@ function createLiveBridge({
|
|
|
411
546
|
socketPath: cfg.liveBridgeSocketPath,
|
|
412
547
|
cwd: cell.cwd,
|
|
413
548
|
developerInstructions,
|
|
549
|
+
// Stessa fonte dell'intestazione e del prompt per-cella (roster:
|
|
550
|
+
// MC3.4). Dichiarata per connessione: batte qualunque ambiente
|
|
551
|
+
// ereditato, anche quando e' popolato ma stantio (B1-bis).
|
|
552
|
+
declaredSession: cell.tmuxSession,
|
|
414
553
|
timeoutMs: cfg.liveBridgeTimeoutMs,
|
|
415
554
|
WebSocket,
|
|
416
555
|
log,
|
|
@@ -429,6 +568,8 @@ function createLiveBridge({
|
|
|
429
568
|
socketPath: cfg.liveBridgeSocketPath,
|
|
430
569
|
at: now(),
|
|
431
570
|
};
|
|
571
|
+
threadIdsByCell.set(snap.hostCell, started.threadId);
|
|
572
|
+
threadStatusCache.delete(snap.hostCell);
|
|
432
573
|
// LC1.4: il puntamento è visibile lato nostro — log con cella, thread e
|
|
433
574
|
// prompt applicato. È il "dirottamento dichiarato" del contratto. Il
|
|
434
575
|
// campo SOSTITUISCE le developer instructions della config (MC2, la
|
|
@@ -438,7 +579,11 @@ function createLiveBridge({
|
|
|
438
579
|
return out;
|
|
439
580
|
}
|
|
440
581
|
|
|
441
|
-
return {
|
|
582
|
+
return {
|
|
583
|
+
resolveForLive,
|
|
584
|
+
threadStatus,
|
|
585
|
+
readCellPrompt: (tmuxSession) => { const { text, ...rest } = readCellPrompt(root, tmuxSession); return rest; },
|
|
586
|
+
};
|
|
442
587
|
}
|
|
443
588
|
|
|
444
589
|
let cachedVersion = null;
|
|
@@ -452,4 +597,7 @@ function bridgeVersion() {
|
|
|
452
597
|
return cachedVersion;
|
|
453
598
|
}
|
|
454
599
|
|
|
455
|
-
module.exports = {
|
|
600
|
+
module.exports = {
|
|
601
|
+
createLiveBridge, readCellPrompt, startThreadOnControlSocket,
|
|
602
|
+
queryThreadStatusOnControlSocket, CLIENT_NAME,
|
|
603
|
+
};
|
package/lib/live-host/routes.js
CHANGED
|
@@ -75,7 +75,9 @@ function eligibleOf(fleet, cell, hostCell) {
|
|
|
75
75
|
function liveHostRoutes({ fleetP, store, readonly = () => false, now = () => Date.now(), bridge = null }) {
|
|
76
76
|
const r = express.Router();
|
|
77
77
|
|
|
78
|
-
// GET /api/live-host — { hostCell, revision, eligible,
|
|
78
|
+
// GET /api/live-host — { hostCell, revision, eligible, threadStatus,
|
|
79
|
+
// host: {lease}, at }. threadStatus misura il runtime del thread ponte:
|
|
80
|
+
// absent/present/active/unknown; non dichiara la presenza del client Live.
|
|
79
81
|
// hostCell e revision vengono dallo store (preservato); eligible e' la verita'
|
|
80
82
|
// COMPOSTA roster+lease (si veda hostLeaseState sopra); host.lease espone lo
|
|
81
83
|
// stato del lease della cella designata, distinto, perche' chi legge distingue.
|
|
@@ -84,14 +86,20 @@ function liveHostRoutes({ fleetP, store, readonly = () => false, now = () => Dat
|
|
|
84
86
|
const snap = store.snapshot();
|
|
85
87
|
let eligible = false;
|
|
86
88
|
let lease = null;
|
|
89
|
+
let threadStatus = 'absent';
|
|
87
90
|
if (snap.hostCell != null) {
|
|
88
91
|
const fleet = await fleetP.catch(() => null);
|
|
89
92
|
const cells = await localCells(fleetP).catch(() => []);
|
|
90
93
|
const cell = Array.isArray(cells) ? cells.find((c) => c && c.cell === snap.hostCell) : null;
|
|
91
94
|
lease = hostLeaseState(fleet, snap.hostCell);
|
|
92
95
|
eligible = eligibleOf(fleet, cell, snap.hostCell);
|
|
96
|
+
if (bridge && typeof bridge.threadStatus === 'function') {
|
|
97
|
+
threadStatus = await Promise.resolve(bridge.threadStatus(snap.hostCell)).catch(() => 'unknown');
|
|
98
|
+
} else {
|
|
99
|
+
threadStatus = 'unknown';
|
|
100
|
+
}
|
|
93
101
|
}
|
|
94
|
-
res.json({ hostCell: snap.hostCell, revision: snap.revision, eligible, host: { lease }, at: now() });
|
|
102
|
+
res.json({ hostCell: snap.hostCell, revision: snap.revision, eligible, threadStatus, host: { lease }, at: now() });
|
|
95
103
|
} catch (e) {
|
|
96
104
|
res.status(500).json({ error: String(e && e.message || e) });
|
|
97
105
|
}
|
package/lib/mcp/server.js
CHANGED
|
@@ -202,13 +202,35 @@ function createMcpServer(opts = {}) {
|
|
|
202
202
|
const cfg = opts.config || loadConfig();
|
|
203
203
|
const baseUrl = `http://127.0.0.1:${cfg.port}`;
|
|
204
204
|
|
|
205
|
-
// Identita' risolta una volta e cacheata
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
205
|
+
// Identita' risolta una volta e cacheata — ma solo se riesce.
|
|
206
|
+
//
|
|
207
|
+
// Perche' il successo e il fallimento hanno vita diversa: una sessione
|
|
208
|
+
// risolta non cambia per la vita del processo (cache storica, invariata);
|
|
209
|
+
// un FALLIMENTO invece non deve restare bloccato per sempre. Il caso reale:
|
|
210
|
+
// questo server MCP parte in un daemon avviato da systemd PRIMA che il
|
|
211
|
+
// server tmux sia raggiungibile, `display-message` fallisce, e con la cache
|
|
212
|
+
// a vita l'identita' restava assente anche dopo che tmux era su. Un
|
|
213
|
+
// fallimento viene quindi ri-tentato, con anti-hammering: al piu' una
|
|
214
|
+
// risoluzione ogni IDENTITY_RETRY_MS finche' non riesce (un tmux rotto non
|
|
215
|
+
// puo' trasformare ogni tool call in una execFile da 3 s).
|
|
216
|
+
// `identity()` per la diagnostica completa (source/code/presence),
|
|
217
|
+
// `session()` estrae solo il nome per gli handler storici (compatibilita').
|
|
218
|
+
// Nessuna API/token coinvolta qui.
|
|
219
|
+
// Iniettabile nei test per non attendere 30 s reali (opts.identityRetryMs).
|
|
220
|
+
const IDENTITY_RETRY_MS = opts.identityRetryMs ?? 30_000;
|
|
221
|
+
let identityP = null; // promise condivisa in corso/cacheata
|
|
222
|
+
let identityOk = false; // solo un esito OK resta cacheato a vita
|
|
223
|
+
let identityAttemptAt = 0; // istante dell'ultimo tentativo (anti-spam)
|
|
210
224
|
const identity = () => {
|
|
211
|
-
if (
|
|
225
|
+
if (identityOk) return identityP;
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
if (identityP && now - identityAttemptAt < IDENTITY_RETRY_MS) return identityP;
|
|
228
|
+
identityAttemptAt = now;
|
|
229
|
+
identityP = resolveIdentity({ env, tmuxBin: cfg.tmuxBin || 'tmux', execFileImpl })
|
|
230
|
+
.then((i) => {
|
|
231
|
+
identityOk = i.code === IDENTITY_CODE.OK;
|
|
232
|
+
return i;
|
|
233
|
+
});
|
|
212
234
|
return identityP;
|
|
213
235
|
};
|
|
214
236
|
const session = () => identity().then((i) => i.session);
|
package/lib/mcp/tools.js
CHANGED
|
@@ -100,15 +100,33 @@ function vlLookupFailure(directory, { instanceId, nodeId = null }) {
|
|
|
100
100
|
+ ' — copia l\'id esatto da nc_vl_nodes invece di ribatterlo';
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
// Perche' il messaggio nomina la causa invece della sola remediation.
|
|
104
|
+
//
|
|
105
|
+
// `resolveIdentity` distingue quattro modi di non identificarsi (STALE_PANE,
|
|
106
|
+
// SESSION_MISMATCH, INVALID, MISSING) e `nc_identity` li mostra — ma il tool
|
|
107
|
+
// che si blocca e' quello che il modello sta chiamando, e il suo errore era
|
|
108
|
+
// ridotto a MISSING con la remediation generica. Chi lo leggeva andava a
|
|
109
|
+
// allowlistare variabili che erano gia' allowlistate: il pane era morto, o le
|
|
110
|
+
// due fonti discordavano. Ogni causa porta un'azione diversa: il messaggio del
|
|
111
|
+
// tool che si blocca deve dire quale e' la sua.
|
|
112
|
+
const IDENTITY_CAUSE = Object.freeze({
|
|
113
|
+
[IDENTITY_CODE.STALE_PANE]: 'il pane tmux del chiamante non risponde (morto o non verificabile)',
|
|
114
|
+
[IDENTITY_CODE.SESSION_MISMATCH]: 'tmux e NEXUSCREW_MCP_SESSION dichiarano due sessioni diverse: identita ambigua, nessuna delle due viene attribuita',
|
|
115
|
+
[IDENTITY_CODE.INVALID]: 'una fonte di identita e presente ma invalida',
|
|
116
|
+
[IDENTITY_CODE.MISSING]: 'nessuna fonte di identita: serve $TMUX (dentro tmux) o NEXUSCREW_MCP_SESSION',
|
|
117
|
+
});
|
|
118
|
+
|
|
103
119
|
function requireSession(session, tool, code = IDENTITY_CODE.MISSING) {
|
|
104
120
|
if (session) return session;
|
|
105
|
-
const stableCode =
|
|
106
|
-
|
|
107
|
-
//
|
|
108
|
-
//
|
|
121
|
+
const stableCode = Object.values(IDENTITY_CODE).includes(code) ? code : IDENTITY_CODE.MISSING;
|
|
122
|
+
const cause = IDENTITY_CAUSE[stableCode];
|
|
123
|
+
// Errore umano per il modello: causa nominata + remediation + codice stabile
|
|
124
|
+
// fra parentesi quadre. isError=true e' impostato dal server (toolsCall);
|
|
125
|
+
// qui si propaga solo il testo. La regex storica /NEXUSCREW_MCP_SESSION/
|
|
126
|
+
// resta soddisfatta (presente nella remediation e nel caso MISSING).
|
|
109
127
|
throw new Error(
|
|
110
|
-
`${tool}: sessione tmux non identificata —
|
|
111
|
-
+
|
|
128
|
+
`${tool}: sessione tmux non identificata — ${cause}. `
|
|
129
|
+
+ `${IDENTITY_REMEDIATION} [${stableCode}]`,
|
|
112
130
|
);
|
|
113
131
|
}
|
|
114
132
|
|
package/lib/nodes/health.js
CHANGED
|
@@ -20,9 +20,40 @@ const nodesTunnel = require('./tunnel.js');
|
|
|
20
20
|
const { probeHealth } = require('../proxy/federation.js');
|
|
21
21
|
|
|
22
22
|
const TTL_MS = 5000;
|
|
23
|
+
const FAILURE_THRESHOLD = 3;
|
|
23
24
|
const cache = new Map(); // name -> { health, at }
|
|
25
|
+
const probeFailures = new Map(); // cache key -> consecutive transport probe failures
|
|
24
26
|
|
|
25
|
-
function clearHealthCache() { cache.clear(); }
|
|
27
|
+
function clearHealthCache() { cache.clear(); probeFailures.clear(); }
|
|
28
|
+
|
|
29
|
+
// A single late probe is not evidence that a still-up tunnel has died. Keep
|
|
30
|
+
// the last authoritative health while the threshold is pending; once reached,
|
|
31
|
+
// expose the real down result. Recovery is immediate on the first good probe.
|
|
32
|
+
function applyProbeHysteresis(key, probed, previous, now, threshold) {
|
|
33
|
+
if (probed.transport !== 'down') {
|
|
34
|
+
probeFailures.delete(key);
|
|
35
|
+
return probed;
|
|
36
|
+
}
|
|
37
|
+
const failures = (probeFailures.get(key) || 0) + 1;
|
|
38
|
+
probeFailures.set(key, failures);
|
|
39
|
+
if (failures >= threshold) return { ...probed, consecutiveFailures: failures };
|
|
40
|
+
if (previous && previous.transport === 'up') {
|
|
41
|
+
return {
|
|
42
|
+
...previous,
|
|
43
|
+
at: now,
|
|
44
|
+
probePending: true,
|
|
45
|
+
consecutiveFailures: failures,
|
|
46
|
+
detail: `health probe transitorio (${failures}/${threshold})`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// No previous authoritative 'up' to protect: the first measurement is the
|
|
50
|
+
// real one. A node that has never been seen answering and does not answer
|
|
51
|
+
// now is simply down — reporting 'unknown' would keep a declared-dead peer
|
|
52
|
+
// out of the down state forever (one probe per process, never reaching the
|
|
53
|
+
// threshold unless someone keeps probing). The hysteresis only exists to
|
|
54
|
+
// protect a measured up state from jitter, per 239e2e6.
|
|
55
|
+
return { ...probed, consecutiveFailures: failures };
|
|
56
|
+
}
|
|
26
57
|
|
|
27
58
|
// Compatibilita' tunnel per il frontend attuale (che legge tunnel.status): deriva
|
|
28
59
|
// uno {status, managed} retro-compatibile dal model health, senza reintrodurre il
|
|
@@ -39,11 +70,14 @@ function tunnelFromHealth(h) {
|
|
|
39
70
|
return { status: 'unknown', managed: false }; // inbound / unknown
|
|
40
71
|
}
|
|
41
72
|
|
|
42
|
-
async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = false }) {
|
|
73
|
+
async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = false, failureThreshold = FAILURE_THRESHOLD }) {
|
|
43
74
|
if (!node || typeof node !== 'object') return null;
|
|
44
75
|
const cacheKey = `${home || ''}\0${node.direction || 'outbound'}\0${node.name}\0${node.localPort}\0${node.nodeId || ''}\0${node.shared === true}\0${node.rolesKnown === true}\0${node.roles?.node === true}`;
|
|
45
|
-
const cached =
|
|
46
|
-
if (cached && (now - cached.at) < TTL_MS) return cached.health;
|
|
76
|
+
const cached = cache.get(cacheKey);
|
|
77
|
+
if (!force && cached && (now - cached.at) < TTL_MS) return cached.health;
|
|
78
|
+
const previous = cached?.health || null;
|
|
79
|
+
const threshold = Number.isSafeInteger(failureThreshold) && failureThreshold > 0
|
|
80
|
+
? failureThreshold : FAILURE_THRESHOLD;
|
|
47
81
|
|
|
48
82
|
let health;
|
|
49
83
|
if (node.direction === 'inbound') {
|
|
@@ -91,7 +125,7 @@ async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = fal
|
|
|
91
125
|
detail: node.rolesKnown === true ? 'client peer offline (expected)' : 'inbound peer offline',
|
|
92
126
|
};
|
|
93
127
|
} else {
|
|
94
|
-
health = { ...probed, managed: false };
|
|
128
|
+
health = { ...applyProbeHysteresis(cacheKey, probed, previous, now, threshold), managed: false };
|
|
95
129
|
}
|
|
96
130
|
}
|
|
97
131
|
} else {
|
|
@@ -115,16 +149,16 @@ async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = fal
|
|
|
115
149
|
const probed = await probeHealth({
|
|
116
150
|
port: node.localPort, token: node.token, expectedInstanceId: node.nodeId || null, fetchImpl, now,
|
|
117
151
|
});
|
|
118
|
-
health = { ...probed, transportEngine: ts.transport || 'ssh', managed: true };
|
|
152
|
+
health = { ...applyProbeHysteresis(cacheKey, probed, previous, now, threshold), transportEngine: ts.transport || 'ssh', managed: true };
|
|
119
153
|
}
|
|
120
154
|
}
|
|
121
155
|
cache.set(cacheKey, { health, at: now });
|
|
122
156
|
return health;
|
|
123
157
|
}
|
|
124
158
|
|
|
125
|
-
async function nodesHealth({ nodes, home, fetchImpl, now = Date.now() }) {
|
|
159
|
+
async function nodesHealth({ nodes, home, fetchImpl, now = Date.now(), failureThreshold = FAILURE_THRESHOLD }) {
|
|
126
160
|
if (!Array.isArray(nodes) || nodes.length === 0) return [];
|
|
127
|
-
return Promise.all(nodes.map((node) => nodeHealth({ node, home, fetchImpl, now })));
|
|
161
|
+
return Promise.all(nodes.map((node) => nodeHealth({ node, home, fetchImpl, now, failureThreshold })));
|
|
128
162
|
}
|
|
129
163
|
|
|
130
|
-
module.exports = { nodeHealth, nodesHealth, tunnelFromHealth, clearHealthCache, TTL_MS };
|
|
164
|
+
module.exports = { nodeHealth, nodesHealth, tunnelFromHealth, clearHealthCache, TTL_MS, FAILURE_THRESHOLD };
|
package/lib/server.js
CHANGED
|
@@ -14,7 +14,7 @@ const { createSession, killSession, isProtectedSession } = require('./tmux/lifec
|
|
|
14
14
|
const { createPreviewSampler } = require('./tmux/preview.js');
|
|
15
15
|
const { requireSharedTmuxProtection } = require('./tmux/shared-server.js');
|
|
16
16
|
const { openAttach } = require('./pty/attach.js');
|
|
17
|
-
const { bindWs } = require('./ws/bridge.js');
|
|
17
|
+
const { bindWs, createPtyGraceStore } = require('./ws/bridge.js');
|
|
18
18
|
const { loadOrCreateToken, verify } = require('./auth/token.js');
|
|
19
19
|
const { requireToken, bearerFrom } = require('./auth/middleware.js');
|
|
20
20
|
const { filesRoutes } = require('./files/routes.js');
|
|
@@ -71,6 +71,7 @@ const { createNotifier } = require('./notify/notifier.js');
|
|
|
71
71
|
const { notifyRoutes } = require('./notify/routes.js');
|
|
72
72
|
const { createNpmUpdater } = require('./update/manager.js');
|
|
73
73
|
const { createDiagnostics } = require('./diagnostics/store.js');
|
|
74
|
+
const { createDropCounter } = require('./ws/drop-counter.js');
|
|
74
75
|
const { diagnosticsRoutes } = require('./diagnostics/routes.js');
|
|
75
76
|
const vlNodeStore = require('./vl-nodes/store.js');
|
|
76
77
|
const { createBroker: createVlNodeBroker } = require('./vl-nodes/broker.js');
|
|
@@ -105,6 +106,11 @@ function createServer(opts = {}) {
|
|
|
105
106
|
// wss viene creato piu' sotto; closeSessions lo raggiunge a request-time (mai durante
|
|
106
107
|
// createServer) per chiudere le sessioni WS attive sulla rotazione token (§4b(3)).
|
|
107
108
|
let wss = null;
|
|
109
|
+
const ptyGrace = createPtyGraceStore({
|
|
110
|
+
graceMs: cfg.ptyGraceMs,
|
|
111
|
+
maxSessions: cfg.ptyGraceMaxSessions,
|
|
112
|
+
maxMemoryBytes: cfg.ptyGraceMaxMemoryBytes,
|
|
113
|
+
});
|
|
108
114
|
const closeSessions = () => {
|
|
109
115
|
if (wss) {
|
|
110
116
|
for (const ws of wss.clients) { try { ws.close(4001, 'token rotated'); } catch (_) { /* best-effort */ } }
|
|
@@ -141,6 +147,8 @@ function createServer(opts = {}) {
|
|
|
141
147
|
const asksStore = createAsksStore({ dir: notifyDir });
|
|
142
148
|
const notifier = createNotifier({ hub: eventsHub, push: pushSvc });
|
|
143
149
|
const diagnostics = opts.diagnostics || createDiagnostics();
|
|
150
|
+
// Contatore cadute WS (rolling 10 min): alimenta i meta dei log di bridge.
|
|
151
|
+
const wsDropCounter = createDropCounter();
|
|
144
152
|
const updater = opts.updateManager || createNpmUpdater({
|
|
145
153
|
currentVersion: VERSION,
|
|
146
154
|
home: cfg.home || os.homedir(),
|
|
@@ -937,6 +945,7 @@ function createServer(opts = {}) {
|
|
|
937
945
|
}));
|
|
938
946
|
api.use('/cells', cellsRoutes({
|
|
939
947
|
fleetP,
|
|
948
|
+
diagnostics,
|
|
940
949
|
instanceId: () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
|
|
941
950
|
submit: opts.cellSubmit || ((session, text, meta) => submitToSession(cfg.tmuxBin, session, text, {
|
|
942
951
|
engine: meta && meta.engine,
|
|
@@ -983,6 +992,7 @@ function createServer(opts = {}) {
|
|
|
983
992
|
const view = nodesStore.redactStore(st);
|
|
984
993
|
const healths = await nodesHealth.nodesHealth({
|
|
985
994
|
nodes: st.nodes, home: cfg.home || os.homedir(), fetchImpl: healthFetch, now: Date.now(),
|
|
995
|
+
failureThreshold: cfg.nodeHealthFailureThreshold,
|
|
986
996
|
});
|
|
987
997
|
const nodes = view.nodes.map((n, i) => {
|
|
988
998
|
const h = healths[i] || null;
|
|
@@ -1006,6 +1016,7 @@ function createServer(opts = {}) {
|
|
|
1006
1016
|
const direct = nodesStore.redactStore(st).nodes;
|
|
1007
1017
|
const healths = await nodesHealth.nodesHealth({
|
|
1008
1018
|
nodes: st.nodes, home: cfg.home || os.homedir(), fetchImpl: healthFetch, now: Date.now(),
|
|
1019
|
+
failureThreshold: cfg.nodeHealthFailureThreshold,
|
|
1009
1020
|
});
|
|
1010
1021
|
const extras = new Map(direct.map((node, index) => {
|
|
1011
1022
|
const health = healths[index] || null;
|
|
@@ -1115,7 +1126,7 @@ function createServer(opts = {}) {
|
|
|
1115
1126
|
});
|
|
1116
1127
|
server.on('close', () => {
|
|
1117
1128
|
diagnostics.record('info', 'server', 'SERVER_STOPPED', 'NexusCrew server stopped', { reason: 'close' });
|
|
1118
|
-
watcher.close(); previews.close(); eventsHub.closeAll(); updater.close();
|
|
1129
|
+
watcher.close(); previews.close(); eventsHub.closeAll(); updater.close(); ptyGrace.close();
|
|
1119
1130
|
for (const timer of reverseWatchers.values()) clearInterval(timer);
|
|
1120
1131
|
reverseWatchers.clear(); rotatableReverse.clear(); void reverseSlotListeners?.closeAll();
|
|
1121
1132
|
// Il pannello non sopravvive al control plane: senza requireToken sopra,
|
|
@@ -1132,7 +1143,13 @@ function createServer(opts = {}) {
|
|
|
1132
1143
|
// lato browser, che il client riconnette senza richiedere refresh pagina.
|
|
1133
1144
|
const heartbeat = setInterval(() => {
|
|
1134
1145
|
for (const client of wss.clients) {
|
|
1135
|
-
if (client.isAlive === false) {
|
|
1146
|
+
if (client.isAlive === false) {
|
|
1147
|
+
// Marca il motivo PRIMA del terminate: il close event (1006) nel bridge
|
|
1148
|
+
// lo classifichera' come heartbeat-timeout invece di «drop TCP generico».
|
|
1149
|
+
try { client.__ncCloseReason = 'heartbeat-timeout'; } catch (_) {}
|
|
1150
|
+
try { client.terminate(); } catch (_) {}
|
|
1151
|
+
continue;
|
|
1152
|
+
}
|
|
1136
1153
|
client.isAlive = false;
|
|
1137
1154
|
try { client.ping(); } catch (_) { try { client.terminate(); } catch (_e) {} }
|
|
1138
1155
|
}
|
|
@@ -1225,7 +1242,10 @@ function createServer(opts = {}) {
|
|
|
1225
1242
|
runAction: (sess, action) => runAction(cfg.tmuxBin, sess, action),
|
|
1226
1243
|
countClients: (sess) => attachedClients(cfg.tmuxBin, sess),
|
|
1227
1244
|
defaults: { readonlyDefault: cfg.readonlyDefault, tmuxBin: cfg.tmuxBin },
|
|
1245
|
+
ptyGrace,
|
|
1228
1246
|
onAttach: (sess) => attachedWs.set(ws, sess),
|
|
1247
|
+
diagnostics,
|
|
1248
|
+
dropCounter: wsDropCounter,
|
|
1229
1249
|
});
|
|
1230
1250
|
ws.on('close', () => attachedWs.delete(ws));
|
|
1231
1251
|
});
|