Files
pi-qmem/extensions/shared.ts
T
Matteo Benedetto 9dd24298cc perf(fallback): connect timeout breve + circuit breaker persistente (fast-fail)
Il gateway irraggiungibile costava ~30s x4 tentativi (fino a ~2 minuti) per ogni
chiamata: ora si distinguono i due casi e le chiamate successive sono immediate.

- due timeout separati: `connectTimeoutMs` (default 2500, connect+headers) e
  `timeoutMs` (default 30000, budget per il body). Nessuna risposta entro il
  primo = "gateway non raggiungibile"; body lento = "elaborazione lunga"
- circuit breaker persistente in ~/.local/share/pi-qmem/breaker.json
  (env QMEM_BREAKER_FILE): fallimento definitivo (connessione rifiutata/DNS/
  connect timeout) → nessun retry e apertura immediata per `breakerBaseMs`
  (default 120000 = 2 min) con escalation fino a `breakerMaxMs` (10 min);
  5xx/body lento sono ambigui → retry con Retry-After e apertura dopo
  `breakerTripAfter` (default 2). Un successo lo richiude; cambiando `url` lo
  stato riparte chiuso (endpoint-aware)
- con breaker aperto gatewayRequest ritorna in ~0 ms senza rete
  (`gateway_unreachable`, `breaker_open`, `retry_in_ms`): i tool passano subito
  al fallback locale e l'outbox accoda
- fix di due bug scoperti durante i test:
  * `res.json().catch(() => ({}))` trasformava un body non completato in
    "successo con dati vuoti" → l'agente vedeva "nessun risultato" invece del
    fallback locale. Ora è `timeout_body` (fallimento, ambiguo)
  * `submitOrQueue` passava un AbortSignal esterno, che con la nuova semantica
    sarebbe stato letto come annullamento utente (eccezione invece di coda)
- messaggi dei tool con lo stato del breaker e come forzare un tentativo;
  `details.breaker` per l'osservabilità
- comandi: `/qmem:local breaker [reset]` e `qmem-sqlite breaker [--reset]`;
  lo stato compare in `/qmem:local status` e nella CLI
- budget interni per enrich/pull/flush (niente AbortSignal esterni)

Misure: connessione rifiutata → 4-8 ms (prima: 4 x 30 s); front che risponde
503 dopo ~40 s → 3,5 s alla prima chiamata, poi 0 ms di rete a breaker aperto;
server che accetta e non risponde → 708 ms (connect timeout); body lento →
1,2 s senza aprire il breaker; persistenza verificata fra processi distinti.

Test: scripts/test-local.mjs 38/38 (nuova fase dedicata al breaker).
2026-09-13 18:09:50 +02:00

390 lines
14 KiB
TypeScript

/**
* pi-qmem — memoria centralizzata e condivisa per agenti AI (estensione pi).
*
* Espone quattro tool:
* - qmem_store → salva un record di memoria (nessun LLM in scrittura)
* - qmem_search → ricerca semantica con filtri
* - qmem_get → recupero deterministico di un record per UUID
* - qmem_correct → supersede di una memoria falsa o superata
* - qmem_meta → panoramica scope/kind/progetti/agenti
*
* Il gateway (FastAPI su brain.vpn:8082) usa una chiave condivisa con accesso
* COMPLETO in lettura e scrittura all'intera conoscenza: qualsiasi agente può
* consultare e aggiungere informazioni liberamente. L'agent_id è solo metadata
* di provenienza, non un meccanismo di isolamento.
*
* Config: ~/.config/pi-qmem/config.json
* { "url": "https://qmem.enne2.net", "apiKey": "..." }
*
* Comando: /qmem:config — menu interattivo (URL, API key, test connessione)
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
const CONFIG_DIR = path.join(os.homedir(), ".config", "pi-qmem");
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
export interface MemoryConfig {
url: string;
apiKey: string;
/** Budget (ms) per la risposta DOPO gli header: distingue l'elaborazione lunga (default 30000). */
timeoutMs?: number;
/** Timeout (ms) per connect+headers: oltre questo il gateway è "non raggiungibile" (default 2500). */
connectTimeoutMs?: number;
/** Attesa base del circuit breaker dopo un fallimento definitivo (default 120000 = 2 min). */
breakerBaseMs?: number;
/** Tetto dell'escalation del breaker (default 600000 = 10 min). */
breakerMaxMs?: number;
/** Fallimenti ambigui consecutivi (body lento, 5xx) prima di aprire il breaker (default 2). */
breakerTripAfter?: number;
correctMinScore?: number;
/** Percorso del DB SQLite locale (default ~/.local/share/pi-qmem/qmem.sqlite). */
localDbPath?: string;
/** Usa l'indice locale come fallback quando il gateway non risponde (default true). */
localFallback?: boolean;
/** Accoda i record in locale quando il gateway non è raggiungibile (default true). */
offlineQueue?: boolean;
}
const CONFIG_DEFAULTS: MemoryConfig = {
url: "https://qmem.enne2.net",
apiKey: "",
timeoutMs: 30_000,
connectTimeoutMs: 2500,
breakerBaseMs: 120_000,
breakerMaxMs: 600_000,
breakerTripAfter: 2,
correctMinScore: 0.6,
localFallback: true,
offlineQueue: true,
};
// Retry su errori transitori del SERVER (429/5xx/timeout del body): backoff + jitter.
// Nessun retry sulle connessioni fallite: il breaker copre l'intervallo successivo.
const MAX_RETRIES = 3;
const RETRY_BASE_MS = 500;
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
// ---------------------------------------------------------------------------
// Circuit breaker persistente (fast-fail quando il gateway è irraggiungibile)
// ---------------------------------------------------------------------------
// Stato in un file JSON dedicato (non nella config, non nel DB): sopravvive a
// /reload, a nuove sessioni e ai processi CLI, e non richiede node:sqlite.
// - fallimento DEFINITIVO (connessione rifiutata/DNS/timeout di connect) → apre
// subito per `breakerBaseMs` (default 2 min), con escalation esponenziale;
// - fallimento AMBIGUO (body lento, HTTP 5xx) → apre dopo `breakerTripAfter`;
// - un successo (o `resetBreaker()`) lo richiude.
// Mentre è aperto nessuna richiesta tocca la rete: i tool passano direttamente
// al fallback locale (SQLite/FTS5) e l'outbox accoda senza attese.
const BREAKER_FILE = process.env.QMEM_BREAKER_FILE ?? path.join(os.homedir(), ".local", "share", "pi-qmem", "breaker.json");
interface BreakerState {
openUntil: number;
failures: number;
trips?: number;
lastError?: string;
lastChange?: string;
/** Endpoint a cui si riferisce lo stato: cambiando URL il breaker riparte chiuso. */
url?: string;
}
let breakerCache: BreakerState | null = null;
function loadBreaker(): BreakerState {
if (breakerCache) return breakerCache;
try {
breakerCache = { openUntil: 0, failures: 0, ...JSON.parse(fs.readFileSync(BREAKER_FILE, "utf8")) };
} catch {
breakerCache = { openUntil: 0, failures: 0 };
}
return breakerCache;
}
function saveBreaker(state: BreakerState): void {
breakerCache = state;
try {
fs.mkdirSync(path.dirname(BREAKER_FILE), { recursive: true });
fs.writeFileSync(BREAKER_FILE, JSON.stringify(state, null, 2));
} catch {
/* stato solo in memoria */
}
}
export interface BreakerInfo {
open: boolean;
remainingMs: number;
failures: number;
trips: number;
lastError?: string;
lastChange?: string;
url?: string;
file: string;
}
export function breakerInfo(): BreakerInfo {
const s = loadBreaker();
return {
open: Math.max(0, s.openUntil - Date.now()) > 0,
remainingMs: Math.max(0, s.openUntil - Date.now()),
failures: s.failures ?? 0,
trips: s.trips ?? 0,
lastError: s.lastError,
lastChange: s.lastChange,
url: s.url,
file: BREAKER_FILE,
};
}
export function breakerIsOpen(): boolean {
return breakerInfo().open;
}
/** Registra un fallimento; con `definitive` (connessione) apre immediatamente. */
export function tripBreaker(reason: string, definitive: boolean, cfg?: MemoryConfig): BreakerInfo {
const s = loadBreaker();
const tripAfter = Math.max(1, cfg?.breakerTripAfter ?? CONFIG_DEFAULTS.breakerTripAfter ?? 2);
s.failures = definitive ? Math.max((s.failures ?? 0) + 1, tripAfter) : (s.failures ?? 0) + 1;
s.lastError = reason;
s.lastChange = new Date().toISOString();
if (cfg?.url) s.url = cfg.url;
if (s.failures >= tripAfter) {
const base = cfg?.breakerBaseMs ?? CONFIG_DEFAULTS.breakerBaseMs ?? 120_000;
const max = cfg?.breakerMaxMs ?? CONFIG_DEFAULTS.breakerMaxMs ?? 600_000;
const step = Math.max(0, s.failures - tripAfter);
const wait = Math.min(base * 2 ** step, max);
s.openUntil = Date.now() + wait;
s.trips = (s.trips ?? 0) + 1;
}
saveBreaker(s);
return breakerInfo();
}
/** Richiude il breaker (successo, o reset manuale). */
export function resetBreaker(): BreakerInfo {
const s = loadBreaker();
if (!s.failures && !s.openUntil && !s.trips) return breakerInfo();
saveBreaker({ openUntil: 0, failures: 0, trips: 0, lastChange: new Date().toISOString(), url: s.url });
return breakerInfo();
}
export function loadConfig(): MemoryConfig {
try {
return { ...CONFIG_DEFAULTS, ...JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")) };
} catch {
return { ...CONFIG_DEFAULTS };
}
}
export function saveConfig(cfg: MemoryConfig) {
try {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
} catch {
/* ignora */
}
}
// ---------------------------------------------------------------------------
// Identità della macchina: rilevata dinamicamente e iniettata nelle regole.
// Ogni installazione si auto-descrive (hostname + OS + arch) senza configurazione.
// ---------------------------------------------------------------------------
export function detectMachine(): string {
const hostname = os.hostname();
let osName = "OS sconosciuto";
try {
const m = fs.readFileSync("/etc/os-release", "utf8").match(/^PRETTY_NAME="?([^"\n]+)"?/m);
if (m) osName = m[1];
} catch {
/* os-release non leggibile: resta il default */
}
return `${hostname} (${osName}, ${os.arch()})`;
}
export const MACHINE = detectMachine();
export async function gatewayRequest(
cfg: MemoryConfig,
method: string,
route: string,
body?: unknown,
signal?: AbortSignal,
idempotencyKey?: string,
opts?: { timeoutMs?: number; connectTimeoutMs?: number },
): Promise<{ ok: boolean; status: number; data: any; breaker?: BreakerInfo }> {
// Cambio di endpoint (es. da front a nodo diretto): lo stato non è più valido
// per questo gateway → si riparte chiusi.
{
const st = loadBreaker();
if (st.url && st.url !== cfg.url) {
saveBreaker({ openUntil: 0, failures: 0, trips: st.trips ?? 0, lastChange: new Date().toISOString(), url: cfg.url });
} else if (!st.url) {
saveBreaker({ ...st, url: cfg.url });
}
}
// Fast-fail: con il breaker aperto nessuna richiesta di rete (tempo ~0).
const open = breakerInfo();
if (open.open) {
return {
ok: false,
status: 0,
data: {
error: "gateway_unreachable",
breaker_open: true,
retry_in_ms: Math.round(open.remainingMs),
detail: open.lastError ?? "errore precedente",
hint: "fallback locale attivo; /qmem:local breaker reset per forzare un tentativo",
},
breaker: open,
};
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-API-Key": cfg.apiKey,
};
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
const connectMs = Math.max(200, opts?.connectTimeoutMs ?? cfg.connectTimeoutMs ?? 2500);
const bodyMs = Math.max(500, opts?.timeoutMs ?? cfg.timeoutMs ?? 30_000);
const url = `${cfg.url}${route}`;
let lastError: unknown = null;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const t0 = Date.now();
const ac = new AbortController();
let phase: "connect" | "body" = "connect";
const onAbort = () => ac.abort(signal?.reason ?? new Error("aborted"));
if (signal) {
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
}
// Fase 1: nessuna risposta (header) entro connectMs → gateway non raggiungibile
let timer = setTimeout(() => ac.abort(new Error(`nessuna risposta entro ${connectMs}ms`)), connectMs);
try {
const res = await fetch(url, {
method,
signal: ac.signal,
headers,
body: body ? JSON.stringify(body) : undefined,
});
// Header ricevuti: da qui il tempo è "elaborazione", con un budget separato
phase = "body";
clearTimeout(timer);
const remaining = Math.max(1000, bodyMs - (Date.now() - t0));
timer = setTimeout(() => ac.abort(new Error(`risposta non completata entro ${remaining}ms`)), remaining);
let data: any = {};
let parseError: string | undefined;
try {
data = await res.json();
} catch (e) {
parseError = e instanceof Error ? e.message : String(e);
}
clearTimeout(timer);
if (parseError && ac.signal.aborted) {
// header arrivati ma body non completato entro il budget: NON è un
// successo vuoto (prima veniva restituito {ok:true, data:{}}), è un
// fallimento di elaborazione → il chiamante passa al fallback locale.
const br = tripBreaker(`risposta non completata: ${parseError}`, false, cfg);
return {
ok: false,
status: 0,
data: { error: "timeout_body", detail: parseError, budget_ms: remaining, url },
breaker: br,
};
}
if (res.ok) {
resetBreaker();
return { ok: true, status: res.status, data, breaker: breakerInfo() };
}
if (res.status === 429 || res.status >= 500) {
// Server raggiungibile ma in difficoltà: fallimento ambiguo, retry con Retry-After
if (attempt < MAX_RETRIES) {
const retryAfter = res.headers.get("retry-after");
const delay = retryAfter
? Math.min(Number(retryAfter) * 1000 || RETRY_BASE_MS, 10_000)
: RETRY_BASE_MS * 2 ** attempt + Math.random() * 200;
await sleep(delay);
continue;
}
const br = tripBreaker(`HTTP ${res.status} da ${url}`, false, cfg);
return { ok: false, status: res.status, data, breaker: br };
}
// 4xx applicativo (401/404/409/422): il server risponde → breaker chiuso
resetBreaker();
return { ok: false, status: res.status, data, breaker: breakerInfo() };
} catch (e) {
clearTimeout(timer);
if (signal?.aborted) throw e; // annullamento utente (Esc): propaga
const msg = e instanceof Error ? e.message : String(e);
if (phase === "connect") {
// Connessione fallita o nessuna risposta: definitivo → breaker subito aperto,
// nessun retry (era la causa delle attese di ~30s x4).
const br = tripBreaker(`gateway non raggiungibile: ${msg}`, true, cfg);
return {
ok: false,
status: 0,
data: { error: "gateway_unreachable", detail: msg, connect_timeout_ms: connectMs, url },
breaker: br,
};
}
// Header ricevuti ma body lento/interrotto: ambiguo (potrebbe essere un rerank pesante)
lastError = msg;
const br = tripBreaker(`risposta lenta: ${msg}`, false, cfg);
return {
ok: false,
status: 0,
data: { error: "timeout_body", detail: msg, budget_ms: bodyMs, url },
breaker: br,
};
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
}
}
return { ok: false, status: 0, data: { error: "network_error", detail: String(lastError ?? "unknown") }, breaker: breakerInfo() };
}
// ---------------------------------------------------------------------------
// Test connessione: verifica URL (status) e validità chiave (search minima)
// ---------------------------------------------------------------------------
export async function testConnection(ctx: any, cfg: MemoryConfig): Promise<void> {
ctx.ui.setStatus("pi-qmem", "Test connessione al gateway...");
try {
const res = await fetch(`${cfg.url}/v1/status`, {
signal: AbortSignal.timeout(8000),
});
if (!res.ok) {
ctx.ui.notify(`❌ Gateway non raggiungibile: HTTP ${res.status}`, "error");
return;
}
const data = await res.json();
if (!cfg.apiKey) {
ctx.ui.notify(`⚠️ Gateway OK (${data.points ?? "?"} punti) ma API key mancante`, "warning");
return;
}
const r2 = await fetch(`${cfg.url}/v1/memories:search`, {
method: "POST",
signal: AbortSignal.timeout(8000),
headers: { "Content-Type": "application/json", "X-API-Key": cfg.apiKey },
body: JSON.stringify({ query: "test", top_k: 1 }),
});
if (r2.status === 401) {
ctx.ui.notify(`⚠️ Gateway OK (${data.points ?? "?"} punti) ma API key non valida`, "warning");
} else if (r2.ok) {
ctx.ui.notify(`✅ Connessione OK: ${data.points ?? "?"} punti in memoria, chiave valida`, "info");
} else {
ctx.ui.notify(`⚠️ Gateway OK ma errore ${r2.status}`, "warning");
}
} catch {
ctx.ui.notify(`❌ Gateway non raggiungibile su ${cfg.url}`, "error");
} finally {
ctx.ui.setStatus("pi-qmem", "");
}
}