@mmmbuto/nexuscrew 0.9.31 → 0.9.32

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.
@@ -6,11 +6,20 @@
6
6
  // dice se il problema e' il nome del modello, la chiave o la rete. Qui la
7
7
  // domanda si fa prima, e la risposta e' un enum chiuso.
8
8
  //
9
- // COSTO: si interroga l'elenco dei modelli (`GET .../models`) e nient'altro.
10
- // Non si genera testo, quindi non si consumano token. Dove il catalogo non
11
- // esiste l'esito e' `unverified`: NON si ricade su una richiesta di
12
- // completamento, per quanto minima. Una prova che costa e' una prova che chi
13
- // la guarda impara a non fare — e allora tanto vale non averla.
9
+ // COSTO, per gli engine DI CATALOGO: si interroga l'elenco dei modelli
10
+ // (`GET .../models`) e nient'altro. Non si genera testo, quindi non si
11
+ // consumano token. Dove il catalogo non esiste l'esito e' `unverified`: NON si
12
+ // ricade su una richiesta di completamento, per quanto minima. Una prova che
13
+ // costa e' una prova che chi la guarda impara a non fare — e allora tanto vale
14
+ // non averla.
15
+ //
16
+ // COSTO, per un endpoint DICHIARATO A MANO (`probeCustomEndpoint`): la regola
17
+ // sopra vale finche' il catalogo c'e'. Un router locale spesso non espone
18
+ // `GET /models`, e su quel ramo l'alternativa a una richiesta minima non e' una
19
+ // prova gratuita — e' nessuna prova, cioe' un `unverified` su un modello che
20
+ // quasi certamente esiste. Li' si ricade su UN completamento da un token
21
+ // (`max_tokens: 1`) e il testo generato non viene letto ne' registrato: la
22
+ // risposta serve solo a distinguere «c'e'» da «non c'e'».
14
23
  //
15
24
  // COSA NON ESCE MAI DA QUI:
16
25
  // - il testo che il modello eventualmente genera: non viene letto, non viene
@@ -44,6 +53,98 @@ function modelsUrl(profile) {
44
53
  return joinUrl(endpoint, 'models');
45
54
  }
46
55
 
56
+ // Un endpoint dichiarato A MANO non sta nel catalogo pubblico: la prova si fa
57
+ // sul suo indirizzo. Stessa forma di URL della sonda di prontezza (`/v1` non si
58
+ // ripete), stesso verdetto enum della prova di catalogo — chi legge l'esito non
59
+ // deve sapere da quale dei due rami e' arrivato.
60
+ function chatCompletionsUrl(baseUrl) {
61
+ const b = String(baseUrl || '').trim().replace(/\/+$/, '');
62
+ if (!/^https?:\/\//i.test(b)) return null;
63
+ return /\/v1$/.test(b) ? `${b}/chat/completions` : `${b}/v1/chat/completions`;
64
+ }
65
+
66
+ // Un endpoint locale spesso non espone `GET /models` (o lo espone vuoto). La
67
+ // seconda via e' la richiesta minima: `max_tokens: 1`, nessun testo letto, e il
68
+ // corpo della risposta NON entra nell'esito. Serve a distinguere «il modello
69
+ // c'e'» da «il modello non c'e'», non a misurare la qualita' della risposta.
70
+ const CUSTOM_FALLBACK_TIMEOUT_MS = 10000;
71
+
72
+ async function probeCustomEndpoint({
73
+ endpoint, credential = '', model, fetchImpl = fetch,
74
+ timeoutMs = DEFAULT_TIMEOUT_MS, fallbackTimeoutMs = CUSTOM_FALLBACK_TIMEOUT_MS,
75
+ } = {}) {
76
+ const listUrl = modelsUrl({ endpoint });
77
+ if (!listUrl) return { outcome: 'unverified', latencyMs: 0, detail: 'endpoint non interrogabile' };
78
+
79
+ const started = Date.now();
80
+ const headers = credential ? { authorization: `Bearer ${credential}` } : {};
81
+ const list = await timedFetch({
82
+ fetchImpl, url: listUrl, method: 'GET', headers, timeoutMs,
83
+ });
84
+ if (list.transport === 'timeout') return { outcome: 'unreachable', latencyMs: list.latencyMs, detail: `timeout (${timeoutMs}ms)` };
85
+ if (list.transport === 'error') return { outcome: 'unreachable', latencyMs: list.latencyMs, detail: 'endpoint non raggiungibile' };
86
+ if (list.status === 401 || list.status === 403) return { outcome: 'auth', latencyMs: list.latencyMs };
87
+ if (list.status >= 200 && list.status < 300) {
88
+ // Un elenco VUOTO non e' «il modello non c'e'»: e' un endpoint che non
89
+ // espone un catalogo. Trattarlo come assenza darebbe un falso negativo su
90
+ // un modello che risponde benissimo — il caso tipico dei router locali.
91
+ const rows = Array.isArray(list.payload && list.payload.data) ? list.payload.data
92
+ : Array.isArray(list.payload && list.payload.models) ? list.payload.models : null;
93
+ if (rows && rows.length) {
94
+ const found = findInCatalog(list.payload, model);
95
+ if (found === true) return { outcome: 'ok', latencyMs: list.latencyMs };
96
+ if (found === false) return { outcome: 'unknown-model', latencyMs: list.latencyMs };
97
+ }
98
+ // Elenco assente, vuoto o illeggibile: si prova la via minima invece di
99
+ // dichiarare `unverified` un modello che probabilmente c'e'.
100
+ } else if (list.status >= 500) {
101
+ return { outcome: 'unreachable', latencyMs: list.latencyMs, detail: `http ${list.status}` };
102
+ }
103
+
104
+ const chatUrl = chatCompletionsUrl(endpoint);
105
+ if (!chatUrl) return { outcome: 'unverified', latencyMs: Date.now() - started, detail: 'endpoint non interrogabile' };
106
+ const chat = await timedFetch({
107
+ fetchImpl,
108
+ url: chatUrl,
109
+ method: 'POST',
110
+ headers: { 'content-type': 'application/json', ...headers },
111
+ body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] }),
112
+ timeoutMs: fallbackTimeoutMs,
113
+ });
114
+ const latencyMs = Date.now() - started;
115
+ if (chat.transport === 'timeout') return { outcome: 'unreachable', latencyMs, detail: `timeout (${fallbackTimeoutMs}ms)` };
116
+ if (chat.transport === 'error') return { outcome: 'unreachable', latencyMs, detail: 'endpoint non raggiungibile' };
117
+ if (chat.status === 401 || chat.status === 403) return { outcome: 'auth', latencyMs };
118
+ if (chat.status >= 200 && chat.status < 300) return { outcome: 'ok', latencyMs };
119
+ // 400/404 su un modello chiesto per nome: l'endpoint risponde e non lo
120
+ // conosce. E' l'esito piu' utile che si possa dare senza leggere il corpo.
121
+ if (chat.status === 400 || chat.status === 404 || chat.status === 422) return { outcome: 'unknown-model', latencyMs };
122
+ return { outcome: 'unverified', latencyMs, detail: `http ${chat.status}` };
123
+ }
124
+
125
+ // Una fetch sola, con budget proprio, che non solleva mai: l'esito e' un valore.
126
+ async function timedFetch({ fetchImpl, url, method, headers, body, timeoutMs }) {
127
+ const started = Date.now();
128
+ const controller = new AbortController();
129
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
130
+ try {
131
+ const res = await fetchImpl(url, {
132
+ method, signal: controller.signal, headers, ...(body ? { body } : {}),
133
+ });
134
+ const status = res && typeof res.status === 'number' ? res.status : 0;
135
+ let payload = null;
136
+ if (status >= 200 && status < 300) {
137
+ try { payload = typeof res.json === 'function' ? await res.json() : null; } catch (_) { payload = null; }
138
+ }
139
+ return { status, payload, latencyMs: Date.now() - started };
140
+ } catch (error) {
141
+ const aborted = error && (error.name === 'AbortError' || error.code === 'ABORT_ERR');
142
+ return { status: 0, payload: null, latencyMs: Date.now() - started, transport: aborted ? 'timeout' : 'error' };
143
+ } finally {
144
+ clearTimeout(timer);
145
+ }
146
+ }
147
+
47
148
  function authHeaders(profile, credential) {
48
149
  const protocol = profile && profile.protocol;
49
150
  if (protocol === 'anthropic_messages') {
@@ -126,4 +227,7 @@ async function probeModel({
126
227
  }
127
228
  }
128
229
 
129
- module.exports = { probeModel, OUTCOMES, findInCatalog, modelsUrl };
230
+ module.exports = {
231
+ probeModel, OUTCOMES, findInCatalog, modelsUrl,
232
+ probeCustomEndpoint, chatCompletionsUrl, CUSTOM_FALLBACK_TIMEOUT_MS,
233
+ };
@@ -20,6 +20,7 @@ const {
20
20
  const {
21
21
  describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels, extraModelsFrom,
22
22
  } = require('./managed.js');
23
+ const { sharedProbe: defaultEndpointProbe } = require('./endpoint-probe.js');
23
24
  const {
24
25
  httpError, minimalEnv, tmuxExec,
25
26
  composeClientInvocation, alternateScreenArgs,
@@ -170,12 +171,29 @@ function createBuiltinRuntime(ctx) {
170
171
  // Le discovery esterne hanno budget propri. Avviarle in parallelo mantiene
171
172
  // il budget dello status sotto quello del bridge invece di sommare i timeout
172
173
  // di Ollama e Pi in sequenza.
173
- const [ollamaModels, piModels] = await Promise.all([
174
+ // Stessa logica per gli endpoint dichiarati a mano: la sonda ha un budget
175
+ // suo (≤ 1,5 s) e va in parallelo alle discovery, non in coda. Si attende
176
+ // SOLO cio' che non e' in cache: entro il TTL il verdetto e' gia' noto, ed
177
+ // e' questo che tiene `nc_status` e la UI lontani dal tempestare i router.
178
+ const endpointProbe = cfg.endpointProbe || defaultEndpointProbe;
179
+ const customUrls = [...new Set(cache.defs.engines
180
+ .map((e) => (e.managed && e.managed.baseUrl ? e.managed.baseUrl : null))
181
+ .filter(Boolean))];
182
+ const endpointVerdicts = new Map();
183
+ const probeEndpoints = customUrls.length
184
+ ? Promise.all(customUrls.map((u) => endpointProbe.status(u).then((v) => [u, v]).catch(() => [u, null])))
185
+ : Promise.resolve([]);
186
+ const [ollamaModels, piModels, probedEndpoints] = await Promise.all([
174
187
  needsOllama ? discoverOllamaModels({ ...cfg, home }) : [],
175
188
  needsPi ? discoverPiModels({ ...cfg, home }) : {},
189
+ probeEndpoints,
176
190
  ]);
191
+ for (const [u, v] of probedEndpoints) endpointVerdicts.set(u, v);
177
192
  const engines = cache.defs.engines.map((e) => {
178
- const managed = e.managed ? describeManaged(e.managed, { ...cfg, home, extraModels: extraModelsFrom(cache.defs), engineId: e.id }) : null;
193
+ const managed = e.managed ? describeManaged(e.managed, {
194
+ ...cfg, home, extraModels: extraModelsFrom(cache.defs), engineId: e.id,
195
+ endpointVerdict: e.managed && e.managed.baseUrl ? endpointVerdicts.get(e.managed.baseUrl) || null : null,
196
+ }) : null;
179
197
  return {
180
198
  id: e.id, label: e.label, rc: !!e.rc,
181
199
  ...(managed ? {
@@ -190,6 +208,15 @@ function createBuiltinRuntime(ctx) {
190
208
  : (piModels[e.managed.provider] || [])))
191
209
  : managed.models),
192
210
  configured: managed.configured, reason: managed.reason,
211
+ // Provenienza della credenziale risolta: prima non arrivava affatto
212
+ // qui, quindi `nc_status` e la vista non potevano distinguere le
213
+ // origini nemmeno in teoria. Path, mtime e impronta del valore — il
214
+ // valore non esce mai da `managed.js`.
215
+ credentialSource: managed.credentialSource || 'missing',
216
+ credentialPath: managed.credentialPath || '',
217
+ credentialMtime: managed.credentialMtime || 0,
218
+ credentialHash8: managed.credentialHash8 || '',
219
+ credentialConflict: managed.credentialConflict || null,
193
220
  } : { kind: 'custom', configured: true, model: e.model?.value || '', models: [] }),
194
221
  };
195
222
  });
@@ -61,8 +61,12 @@ function createAskAnswerService({ asks, paste, receipts, onClosure, labelPrefix
61
61
  const committed = asks.commit(askId, text);
62
62
  if (request && receipts) receipts.finalize(request.peerId, askId, request.requestId, 'committed', null);
63
63
  const finalAsk = asks.get(askId);
64
- emitClosure('ask-answered', { askId, revision: (finalAsk && finalAsk.revision) || 1, cellSession: finalAsk && finalAsk.session });
65
- return { ok: true, committed };
64
+ const closure = emitClosure('ask-answered', { askId, revision: (finalAsk && finalAsk.revision) || 1, cellSession: finalAsk && finalAsk.session });
65
+ // `closure` e' il lavoro di recapito della chiusura verso i peer. Nasce QUI,
66
+ // nel punto in cui la transizione e' autorevole, e non su una rotta: legarlo
67
+ // alle route locali lasciava fuori la via federata, che e' il caso normale
68
+ // quando a rispondere e' un altro nodo. Chi puo' attendere lo attende.
69
+ return { ok: true, committed, closure };
66
70
  }
67
71
 
68
72
  // LOCAL answer: validation and binding already happened in the route.
@@ -115,8 +119,10 @@ function createAskAnswerService({ asks, paste, receipts, onClosure, labelPrefix
115
119
  }
116
120
  const out = asks.dismiss(askId);
117
121
  if (!out.ok) return out;
118
- emitClosure('ask-dismissed', { askId, revision: (out.ask && out.ask.revision) || 1, cellSession: out.ask && out.ask.session });
119
- return out;
122
+ const closure = emitClosure('ask-dismissed', { askId, revision: (out.ask && out.ask.revision) || 1, cellSession: out.ask && out.ask.session });
123
+ // Stesso punto comune dell'answer. `dismiss` resta SINCRONO — i chiamanti
124
+ // esistenti leggono `ok` subito — e il recapito viaggia come promise a parte.
125
+ return { ...out, closure };
120
126
  }
121
127
 
122
128
  // Operator reconciliation of a delivery-unknown attempt. The revision is a
@@ -13,6 +13,10 @@ const ASKS_FILE = 'asks.json';
13
13
  // RIFIUTATO (reason 'cap'), mai droppato uno aperto. MAX_KEEP pota solo gli
14
14
  // answered piu' vecchi dal file.
15
15
  const MAX_OPEN = 100;
16
+ // Cap degli ask IMPORTATI, separato da quello locale (stessa cifra): le domande
17
+ // che arrivano da un peer e quelle poste da questo nodo sono due classi
18
+ // distinte, e le prime non devono poter esaurire il budget delle seconde.
19
+ const MAX_OPEN_IMPORTED = 100;
16
20
  const MAX_KEEP = 100; // ask totali persistiti (i piu' vecchi answered si potano)
17
21
  const MAX_QUESTION = 2000;
18
22
  const MAX_OPTIONS = 8;
@@ -65,19 +69,58 @@ function createAsksStore(opts = {}) {
65
69
  return { ok: true, value: { question: question.trim(), options: opts2 } };
66
70
  }
67
71
 
68
- function openCount() {
69
- return load().filter((a) => !a.answered && !a.dismissed).length;
72
+ // Un ask IMPORTATO appartiene a un altro nodo: lo marca `originNode`, che e'
73
+ // anche il marcatore che ne impedisce la ri-esportazione.
74
+ function isImported(a) { return !!(a && a.originNode); }
75
+
76
+ function openCount(kind = 'local') {
77
+ return load().filter((a) => !a.answered && !a.dismissed
78
+ && (kind === 'imported' ? isImported(a) : !isImported(a))).length;
79
+ }
80
+
81
+ // Identita' CANONICA di un ask importato: la coppia (ownerId, ownerAskId).
82
+ // L'`id` locale e' nostro e all'owner non dice nulla; e' la coppia che nomina
83
+ // lo STESSO oggetto sui due nodi, quindi e' la chiave con cui si riconcilia.
84
+ function findImported(ownerId, ownerAskId) {
85
+ return load().find((a) => isImported(a) && a.ownerId === String(ownerId)
86
+ && a.ownerAskId === String(ownerAskId)) || null;
87
+ }
88
+
89
+ // Chiusura dell'alias locale quando l'OWNER chiude la domanda. Durevole: la
90
+ // riga resta nello storico marcata, quindi non ricompare a un reload e non
91
+ // torna risponibile.
92
+ function closeImported({ ownerId, ownerAskId, outcome }) {
93
+ const ask = findImported(ownerId, ownerAskId);
94
+ if (!ask) return { ok: true, changed: false, ask: null };
95
+ if (ask.answered || ask.dismissed) return { ok: true, changed: false, ask };
96
+ if (outcome === 'answered') { ask.answered = true; ask.answeredReconciled = true; }
97
+ else ask.dismissed = true;
98
+ ask.revision = (ask.revision || 0) + 1;
99
+ save();
100
+ return { ok: true, changed: true, ask };
70
101
  }
71
102
 
72
- function create({ question, options, session }) {
103
+ function create({ question, options, session, ownerId, ownerAskId, originNode, originCell }) {
73
104
  const v = validate({ question, options });
74
105
  if (!v.ok) return { ok: false, reason: 'invalid', error: v.error };
75
- // da revisione: cap duro sugli aperti — rifiuto esplicito, MAI drop di ask aperti.
76
- if (openCount() >= MAX_OPEN) {
106
+ // DEDUP: la stessa domanda puo' arrivare due volte allo stesso nodo (import
107
+ // diretto del fan-out e feed dell'owner). L'identita' canonica e' la coppia
108
+ // (ownerId, ownerAskId): se l'alias esiste gia', si restituisce quello, non
109
+ // se ne crea un secondo.
110
+ if (originNode && ownerId && ownerAskId) {
111
+ const existing = findImported(ownerId, ownerAskId);
112
+ if (existing) return { ok: true, ask: existing, deduped: true };
113
+ }
114
+ // Cap duro sugli aperti, SEPARATO PER CLASSE: gli importati non consumano il
115
+ // budget dei locali. Con un cap unico, cento domande ricevute da un peer
116
+ // lasciavano questo nodo senza poter porre la prima domanda propria.
117
+ const imported = !!originNode;
118
+ const cap = imported ? MAX_OPEN_IMPORTED : MAX_OPEN;
119
+ if (openCount(imported ? 'imported' : 'local') >= cap) {
77
120
  return {
78
121
  ok: false,
79
122
  reason: 'cap',
80
- error: `cap ask aperti raggiunto (${MAX_OPEN}): rispondi o attendi prima di crearne altri`,
123
+ error: `cap ask aperti raggiunto (${cap}): rispondi o attendi prima di crearne altri`,
81
124
  };
82
125
  }
83
126
  const ask = {
@@ -85,6 +128,23 @@ function createAsksStore(opts = {}) {
85
128
  question: v.value.question,
86
129
  ...(v.value.options ? { options: v.value.options } : {}),
87
130
  session: String(session),
131
+ // Identita' QUALIFICATA dell'ask. `ownerId` e' il nodo che possiede la
132
+ // domanda: la UI identifica una card con la coppia (ownerId, id) — due
133
+ // proprietari possono usare lo stesso id locale — e instrada la risposta
134
+ // al proprietario via ask-relay invece di incollarla qui. Assente = ask
135
+ // di questo nodo (il caso locale storico).
136
+ ...(ownerId ? { ownerId: String(ownerId) } : {}),
137
+ // L'id con cui l'OWNER conosce questa domanda. Su un ask importato l'`id`
138
+ // locale e' nostro e serve solo a noi; la risposta deve invece citare
139
+ // l'id dell'owner, perche' e' lui che risolve l'ask nel proprio store.
140
+ // Senza questo campo una risposta a un ask importato colpirebbe un id
141
+ // inesistente (o, peggio, un ask locale nostro con lo stesso id).
142
+ ...(ownerAskId ? { ownerAskId: String(ownerAskId) } : {}),
143
+ // Provenienza di un ask ARRIVATO dalla federazione. E' il marcatore che
144
+ // rende esplicito l'invariante: un ask con `originNode` non viene mai
145
+ // ri-esportato (nessun loop A->B->A). Un ask locale non ha questo campo.
146
+ ...(originNode ? { originNode: String(originNode) } : {}),
147
+ ...(originCell ? { originCell: String(originCell) } : {}),
88
148
  ts: now(),
89
149
  revision: 0,
90
150
  answered: false,
@@ -190,7 +250,7 @@ function createAsksStore(opts = {}) {
190
250
  return commit(id, text);
191
251
  }
192
252
 
193
- return { create, get, list, openCount, claim, release, commit, markAnswered, markReconciled, isAnswering, dismiss, validate, filePath, MAX_OPEN };
253
+ return { create, get, list, openCount, isImported, findImported, closeImported, claim, release, commit, markAnswered, markReconciled, isAnswering, dismiss, validate, filePath, MAX_OPEN, MAX_OPEN_IMPORTED };
194
254
  }
195
255
 
196
256
  module.exports = { createAsksStore };
@@ -0,0 +1,201 @@
1
+ 'use strict';
2
+ // Coda di RITENTATIVI della chiusura di un ask, lato OWNER.
3
+ //
4
+ // Il problema che risolve: la chiusura di una domanda segue la stessa strada
5
+ // dell'andata, ma il destinatario puo' essere spento in quel momento. Il suo
6
+ // alias locale resta allora aperto — e ricompare a ogni reload — perche' la
7
+ // transizione e' avvenuta altrove. Il ricevente NON puo' rimediare da solo:
8
+ // nella topologia in cui l'owner si e' collegato a lui (peer `inbound`) non ha
9
+ // ne' rotta ne' credenziale per raggiungerlo. L'unico lato che puo' riprovare
10
+ // e' chi ha emesso la transizione.
11
+ //
12
+ // La coda e' DELIBERATAMENTE piccola e limitata su tre assi indipendenti:
13
+ // - numero di voci (tetto duro, si scarta la piu' vecchia);
14
+ // - tentativi per voce (backoff esponenziale, tetto esplicito);
15
+ // - eta' della voce (TTL: oltre, si rinuncia).
16
+ // Ogni timer e' `unref()`: una coda in attesa non tiene vivo il processo.
17
+ //
18
+ // Due modi per far ripartire un tentativo:
19
+ // 1) il timer di backoff (il caso normale, nessuno guarda);
20
+ // 2) una LETTURA locale dell'elenco degli ask: e' il momento in cui qualcuno
21
+ // sta guardando lo stato, quindi e' il momento naturale per riconciliare
22
+ // il recapito. Senza questa seconda via una coda con base di 1 s non
23
+ // coprirebbe mai una finestra di riavvio del peer di pochi millisecondi,
24
+ // e il backoff non deve essere accorciato fino a diventare un busy loop.
25
+
26
+ const BASE_MS = 1000; // primo ritardo fra i tentativi
27
+ const FACTOR = 2; // crescita esponenziale
28
+ const MAX_ATTEMPTS = 6; // tentativi oltre al primo dispatch
29
+ const TTL_MS = 5 * 60 * 1000; // oltre questa eta' si rinuncia
30
+ const MAX_ENTRIES = 256; // tetto duro sulle voci in coda
31
+ const NUDGE_FLOOR_MS = 500; // distanza minima fra due risvegli da lettura
32
+
33
+ // Esiti che NON meritano un ritentativo: il peer ha risposto, la chiusura e'
34
+ // arrivata (una seconda consegna della stessa chiusura e' un no-op sul peer,
35
+ // che risponde `delivered` con `closed:false`), oppure ha rifiutato — e un
36
+ // rifiuto non cambia da solo col tempo.
37
+ const DONE_STATUSES = new Set(['delivered', 'no-delivery']);
38
+ const FINAL_STATUSES = new Set(['refused']);
39
+
40
+ function createClosureRetryQueue({
41
+ run, // async ({askId, outcome, session}) -> [{target, status, reason}]
42
+ now = () => Date.now(),
43
+ setTimer = setTimeout,
44
+ clearTimer = clearTimeout,
45
+ baseMs = BASE_MS,
46
+ factor = FACTOR,
47
+ maxAttempts = MAX_ATTEMPTS,
48
+ ttlMs = TTL_MS,
49
+ maxEntries = MAX_ENTRIES,
50
+ nudgeFloorMs = NUDGE_FLOOR_MS,
51
+ log = () => {},
52
+ } = {}) {
53
+ if (typeof run !== 'function') throw new Error('createClosureRetryQueue: run richiesta');
54
+ const entries = [];
55
+ let stopped = false;
56
+ let lastNudge = null;
57
+
58
+ function clearEntry(entry) {
59
+ if (entry.timer) { try { clearTimer(entry.timer); } catch (_) {} entry.timer = null; }
60
+ }
61
+
62
+ function remove(entry) {
63
+ clearEntry(entry);
64
+ const i = entries.indexOf(entry);
65
+ if (i >= 0) entries.splice(i, 1);
66
+ }
67
+
68
+ // Ritardo del prossimo tentativo: esponenziale, con tetto sul TTL.
69
+ function schedule(entry) {
70
+ if (stopped) return;
71
+ const delay = baseMs * Math.pow(factor, entry.attempts);
72
+ entry.nextAt = now() + delay;
73
+ entry.timer = setTimer(() => { attempt(entry, 'backoff'); }, delay);
74
+ // Non blocca la chiusura del processo: una coda in attesa e' solo una coda.
75
+ if (entry.timer && typeof entry.timer.unref === 'function') entry.timer.unref();
76
+ }
77
+
78
+ function expired(entry) {
79
+ return entry.attempts >= maxAttempts || (now() - entry.createdAt) >= ttlMs;
80
+ }
81
+
82
+ async function attempt(entry, why = 'backoff') {
83
+ if (stopped || entry.inFlight) return;
84
+ clearEntry(entry);
85
+ if (expired(entry)) {
86
+ remove(entry);
87
+ try { log(`chiusura ask ${entry.askId}: rinuncio dopo ${entry.attempts} tentativi (${why})`); } catch (_) {}
88
+ return;
89
+ }
90
+ entry.inFlight = true;
91
+ let results = [];
92
+ // Si ritenta SOLO verso i target ancora pendenti: chi ha gia' risposto non
93
+ // riceve una seconda consegna inutile.
94
+ const targets = [...entry.targets];
95
+ try {
96
+ results = await run({ askId: entry.askId, outcome: entry.outcome, session: entry.session, targets });
97
+ } catch (e) {
98
+ results = targets.map((target) => ({ target, status: 'unknown', reason: 'dispatch-threw' }));
99
+ try { log(`chiusura ask ${entry.askId}: tentativo fallito (${String(e && e.message || e)})`); } catch (_) {}
100
+ } finally {
101
+ entry.inFlight = false;
102
+ }
103
+ const list = Array.isArray(results) ? results : [];
104
+ // IL SET SI AGGIORNA QUI, dal lato della coda: ogni target che ha risposto
105
+ // esce. `delivered` e `no-delivery` sono consegne, `refused` e' un rifiuto —
106
+ // in tutti e tre i casi riprovare non aggiunge nulla. Restano i pendenti, e
107
+ // la voce si chiude quando non ne resta nessuno.
108
+ for (const r of list) {
109
+ if (!r || !r.target) continue;
110
+ if (DONE_STATUSES.has(r.status) || FINAL_STATUSES.has(r.status)) entry.targets.delete(r.target);
111
+ }
112
+ const refused = list.filter((r) => r && FINAL_STATUSES.has(r.status));
113
+ if (refused.length) {
114
+ try { log(`chiusura ask ${entry.askId}: rifiutata (${refused.map((r) => r.reason || r.status).join(',')})`); } catch (_) {}
115
+ }
116
+ if (!entry.targets.size) { remove(entry); return; }
117
+ entry.attempts += 1;
118
+ if (expired(entry)) {
119
+ remove(entry);
120
+ try { log(`chiusura ask ${entry.askId}: tetto raggiunto, alias lasciato al peer`); } catch (_) {}
121
+ return;
122
+ }
123
+ schedule(entry);
124
+ }
125
+
126
+ // Accoda i target NON raggiunti di una chiusura. La voce e' la COPPIA
127
+ // (chiusura, insieme dei pendenti): un peer che risponde esce dall'insieme, e
128
+ // la voce si chiude quando l'insieme e' vuoto. Una seconda `enqueue` per la
129
+ // stessa chiusura UNISCE i target invece di essere buttata via: e' cio' che
130
+ // impedisce a un peer spento di sparire dalla coda quando un ALTRO peer
131
+ // risponde, o quando la stessa chiusura viene riprovata piu' tardi.
132
+ function enqueue({ askId, outcome, session, targets } = {}) {
133
+ if (stopped || !askId || !outcome) return { ok: false, reason: 'invalid' };
134
+ const nuovi = [...new Set((Array.isArray(targets) ? targets : []).map((t) => String(t)).filter(Boolean))];
135
+ if (!nuovi.length) return { ok: false, reason: 'no-targets' };
136
+ const existing = entries.find((e) => e.askId === askId && e.outcome === outcome);
137
+ if (existing) {
138
+ const prima = existing.targets.size;
139
+ for (const t of nuovi) existing.targets.add(t);
140
+ // Se il tentativo precedente era in volo, il nuovo target non era nella
141
+ // sua lista: la voce va risvegliata, o aspetterebbe il backoff per nulla.
142
+ if (existing.targets.size !== prima && !existing.inFlight && !existing.timer) schedule(existing);
143
+ return { ok: true, merged: true, added: existing.targets.size - prima, size: entries.length };
144
+ }
145
+ if (entries.length >= maxEntries) {
146
+ const oldest = entries.shift();
147
+ clearEntry(oldest);
148
+ try { log(`coda chiusure piena (${maxEntries}): scartata la piu' vecchia (${oldest.askId})`); } catch (_) {}
149
+ }
150
+ const entry = {
151
+ askId, outcome, session, targets: new Set(nuovi),
152
+ attempts: 0, createdAt: now(), nextAt: 0, timer: null, inFlight: false,
153
+ };
154
+ entries.push(entry);
155
+ schedule(entry);
156
+ return { ok: true, size: entries.length };
157
+ }
158
+
159
+ // Risveglio su domanda: chi legge lo stato vuole lo stato VERO. Si ritentano
160
+ // subito le voci in attesa, senza aspettare il backoff — ma non a ogni
161
+ // lettura: c'e' una distanza minima fra due risvegli, altrimenti un refresh
162
+ // ripetuto diventerebbe un martellamento del peer.
163
+ async function drain(why = 'read') {
164
+ if (stopped || !entries.length) return { attempted: 0 };
165
+ const t = now();
166
+ if (lastNudge !== null && (t - lastNudge) < nudgeFloorMs) return { attempted: 0, throttled: true };
167
+ lastNudge = t;
168
+ const snapshot = entries.slice();
169
+ for (const entry of snapshot) await attempt(entry, why);
170
+ return { attempted: snapshot.length, size: entries.length };
171
+ }
172
+
173
+ // Svuotamento alla chiusura del server: nessun timer sopravvive.
174
+ function stop() {
175
+ stopped = true;
176
+ for (const entry of entries.slice()) remove(entry);
177
+ return { cleared: true };
178
+ }
179
+
180
+ return {
181
+ enqueue, drain, stop,
182
+ pending: () => entries.map((e) => ({
183
+ askId: e.askId, outcome: e.outcome, attempts: e.attempts, nextAt: e.nextAt,
184
+ targets: [...e.targets],
185
+ })),
186
+ size: () => entries.length,
187
+ limits: { baseMs, factor, maxAttempts, ttlMs, maxEntries, nudgeFloorMs },
188
+ };
189
+ }
190
+
191
+ module.exports = {
192
+ createClosureRetryQueue,
193
+ CLOSURE_DONE_STATUSES: DONE_STATUSES,
194
+ CLOSURE_FINAL_STATUSES: FINAL_STATUSES,
195
+ CLOSURE_RETRY_BASE_MS: BASE_MS,
196
+ CLOSURE_RETRY_FACTOR: FACTOR,
197
+ CLOSURE_RETRY_MAX_ATTEMPTS: MAX_ATTEMPTS,
198
+ CLOSURE_RETRY_TTL_MS: TTL_MS,
199
+ CLOSURE_RETRY_MAX_ENTRIES: MAX_ENTRIES,
200
+ CLOSURE_RETRY_NUDGE_FLOOR_MS: NUDGE_FLOOR_MS,
201
+ };
@@ -88,6 +88,9 @@ function createEventFeedAsksRoutes(deps) {
88
88
  askId, text: body.text, peerId: g.peer.nodeId, requestId: body.requestId,
89
89
  });
90
90
  if (out.ok) {
91
+ // La chiusura nasce dal servizio; qui si aspetta il recapito, cosi' chi ha
92
+ // risposto non vede una risposta che precede la chiusura dei peer.
93
+ if (out.closure) { try { await out.closure; } catch (_) {} }
91
94
  return res.json({ status: out.replay ? out.state : 'committed', requestId: body.requestId });
92
95
  }
93
96
  return res.status(out.code || 500).json({ error: out.error, reason: out.reason });
@@ -114,6 +117,7 @@ function createEventFeedAsksRoutes(deps) {
114
117
  if (out.reason === 'answering') return res.status(409).json({ error: 'risposta in corso: non si scarta un ask in answering' });
115
118
  return res.status(500).json({ error: 'dismiss non riuscito' });
116
119
  }
120
+ if (out.closure) { try { await out.closure; } catch (_) {} }
117
121
  return res.json({ dismissed: true, id: askId, idempotent: out.idempotent === true });
118
122
  });
119
123
 
@@ -190,8 +190,16 @@ function createEventFeedRoutes(deps) {
190
190
  // Asks: open asks of VISIBLE cells only, capped.
191
191
  let asks = [];
192
192
  try {
193
+ const self = typeof deps.localNodeId === 'function' ? deps.localNodeId() : null;
193
194
  const open = deps.asksStore.list({ open: true }) || [];
194
195
  const visible = open
196
+ // SOLO gli ask di QUESTO nodo. Un ask importato appartiene al nodo che
197
+ // l'ha posto: pubblicarlo qui lo consegnerebbe a un terzo peer
198
+ // attribuito a noi, cioe' a un destinatario che chi ha chiesto non
199
+ // aveva scelto — la domanda uscirebbe dal perimetro del fan-out. La
200
+ // guardia e' esplicita sui campi di provenienza, non dedotta dalla
201
+ // visibilita' della cella.
202
+ .filter((a) => !a.originNode && !(a.ownerId && self && a.ownerId !== self))
195
203
  .map((a) => ({ id: a.id, question: a.question, options: a.options, session: a.session, ts: a.ts }))
196
204
  .filter((a) => peer.allows({ scope: 'cell', cellId: deps.cellForSession(a.session) }))
197
205
  .slice(0, SNAPSHOT_MAX_ASKS);