Files
Matteo Benedetto 12e63d09fa fix(fallback): connectTimeoutMs 2500→15000 e breakerTripAfter 2→3
Il default di 2,5 s per connect+headers è troppo stretto su percorsi VPN/AP:
misurati GET /v1/status 1,23 s e POST /v1/memories:search 1,98 s, talvolta
oltre 2,5 s. In quelle condizioni il breaker si apriva pur con gateway
raggiungibile e tutte le chiamate successive andavano in fast-fail per 2–10
minuti, spingendo di fatto ogni operazione sul solo fallback locale e
lasciando l'outbox non sincronizzata.

- extensions/shared.ts: connectTimeoutMs 2500 → 15_000 (default, fallback nel
  path di richiesta e commento), breakerTripAfter 2 → 3
- README.md: default aggiornati + motivazione nella tabella dei timeout
- skills/qmem/SKILL.md: default aggiornato e sintassi CLI del reset breaker
  (`qmem-sqlite.mjs breaker --reset`)

Verifica sul campo: con connectTimeoutMs=60000 l'outbox (18 record) è stata
sincronizzata completamente e il breaker è rimasto chiuso.
2026-09-16 10:26:52 +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 15000). */
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: 15_000,
breakerBaseMs: 120_000,
breakerMaxMs: 600_000,
breakerTripAfter: 3,
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 ?? 3);
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 ?? 15_000);
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", "");
}
}