diff --git a/README.md b/README.md index b9cde4f..a2b206b 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,12 @@ Config salvata in `~/.config/pi-qmem/config.json` (0600): "apiKey": "...", "localDbPath": "~/.local/share/pi-qmem/qmem.sqlite", "localFallback": true, - "offlineQueue": true + "offlineQueue": true, + "connectTimeoutMs": 2500, + "timeoutMs": 30000, + "breakerBaseMs": 120000, + "breakerMaxMs": 600000, + "breakerTripAfter": 2 } ``` @@ -52,6 +57,30 @@ Config salvata in `~/.config/pi-qmem/config.json` (0600): disabilita l'accodamento offline in scrittura (lo store torna a fallire come prima). +## Circuit breaker (fast-fail quando il gateway è irraggiungibile) + +Due timeout distinti, per non confondere "gateway giù" con "elaborazione lunga": + +| Fase | Chiave | Default | Significato | +|---|---|---|---| +| **connect + headers** | `connectTimeoutMs` | **2500** | nessuna risposta entro questo tempo → **gateway non raggiungibile** (fallimento definitivo) | +| **body** (dopo gli header) | `timeoutMs` | 30000 | budget per il rerank/export/ricerca: un superamento è un fallimento **ambiguo** | + +Comportamento: + +- **Connessione fallita/nessuna risposta** (ECONNREFUSED, DNS, timeout di connect): **nessun retry**, il + **circuit breaker** si apre subito e resta aperto `breakerBaseMs` (**2 min**), con escalation + esponenziale fino a `breakerMaxMs` (10 min). +- **5xx o body lento**: fallimenti **ambigui** → retry con `Retry-After` e breaker solo dopo + `breakerTripAfter` (default 2) fallimenti consecutivi. +- **Breaker aperto**: le chiamate ritornano in **~0 ms senza toccare la rete** (`error: + "gateway_unreachable"`, `breaker_open: true`, `retry_in_ms`), quindi i tool passano subito al + fallback locale e l'outbox accoda senza attese. +- **Stato persistente**: `~/.local/share/pi-qmem/breaker.json` (env `QMEM_BREAKER_FILE`) → vale anche + per nuove sessioni, `/reload` e processi CLI. Cambiando `url` il breaker riparte chiuso. +- **Reset manuale**: `/qmem:local breaker reset` oppure `node scripts/qmem-sqlite.mjs breaker --reset`; + un successo lo richiude da solo. Stato: `/qmem:local breaker` o `qmem-sqlite breaker`. + ## Indice locale (fallback offline) Il gateway remoto non è sempre raggiungibile (VPN giù, nodi offline). L'estensione @@ -99,9 +128,13 @@ Comandi (TUI) e CLI standalone: node scripts/qmem-sqlite.mjs status|import|find "query"|enrich|pull node scripts/qmem-sqlite.mjs store --project P --text "..." [--queue-only] node scripts/qmem-sqlite.mjs queue|flush -node scripts/test-local.mjs # suite di test (24 controlli, HOME temporanea) +node scripts/test-local.mjs # suite di test (37 controlli, HOME temporanea) ``` +Nota: un body non completato **non** viene più restituito come "successo con dati vuoti" +(prima `res.json().catch(() => ({}))` mascherava il timeout: l'agente vedeva "nessun risultato" +invece del fallback locale). + Limiti dichiarati: è uno **storico osservato** (più vecchio del gateway), la ricerca è **lessicale** (nessuno score 0.45/0.60: non applicare le soglie semantiche) e un record in coda (⏳) **non è ancora nella memoria condivisa**: diff --git a/extensions/local-command.ts b/extensions/local-command.ts index 7aeb6a9..f491f44 100644 --- a/extensions/local-command.ts +++ b/extensions/local-command.ts @@ -19,11 +19,12 @@ import { queueList, queueStats, } from "./local-db.ts"; -import { loadConfig } from "./shared.ts"; +import { breakerInfo, loadConfig, resetBreaker } from "./shared.ts"; export function registerQmemLocal(pi: ExtensionAPI) { pi.registerCommand("qmem:local", { - description: "Indice locale SQLite/FTS5: status | import | find | enrich [--all] | pull", + description: + "Indice locale SQLite/FTS5: status | import | find | queue | flush | breaker [reset] | enrich [--all] | pull", handler: async (args, ctx) => { const cfg = loadConfig(); const dbFile = localDbPath(cfg); @@ -37,8 +38,10 @@ export function registerQmemLocal(pi: ExtensionAPI) { } const dup = r.duplicates.length ? ` | duplicati: ${r.duplicates.length} gruppi` : ""; const coda = r.queued || r.failedQueue || r.syncedQueue ? ` | coda: ${r.queued} in attesa, ${r.syncedQueue} sincronizzati${r.failedQueue ? `, ${r.failedQueue} falliti` : ""}${r.duplicateQueue ? `, ${r.duplicateQueue} duplicati` : ""}` : ""; + const br = breakerInfo(); ctx.ui.notify( - `Indice locale: ${r.total} record (${r.withText} con testo, ${r.active} attivi, ${r.superseded} superseduti, ${r.private} privati, ${r.pendingInIndex} in coda) | project_id ${r.withProject}/${r.total} | ${r.sizeKb} KB${dup}${coda}\n` + + `Circuit breaker: ${br.open ? `APERTO (riprova tra ${Math.ceil(br.remainingMs / 1000)}s)` : "chiuso"} | fallimenti: ${br.failures} | aperture: ${br.trips}\n` + + `Indice locale: ${r.total} record (${r.withText} con testo, ${r.active} attivi, ${r.superseded} superseduti, ${r.private} privati, ${r.pendingInIndex} in coda) | project_id ${r.withProject}/${r.total} | ${r.sizeKb} KB${dup}${coda}\n` + `ultimo import: ${r.lastImport ?? "-"} | enrich: ${r.lastEnrich ?? "-"} | export: ${r.lastExport ?? "-"} | flush: ${r.lastFlush ?? "-"}\n` + `DB: ${r.path}`, "info", @@ -134,6 +137,21 @@ export function registerQmemLocal(pi: ExtensionAPI) { ctx.ui.notify(msg, res.synced || res.duplicates ? "info" : "warning"); return; } + if (sub === "breaker") { + if (rest.includes("reset")) { + resetBreaker(); + ctx.ui.notify("Circuit breaker qmem: chiuso — il prossimo accesso al gateway sarà immediato", "info"); + return; + } + const br = breakerInfo(); + ctx.ui.notify( + `Circuit breaker qmem: ${br.open ? `APERTO (riprova tra ${Math.ceil(br.remainingMs / 1000)}s)` : "chiuso"}\n` + + `fallimenti consecutivi: ${br.failures} | aperture totali: ${br.trips}${br.lastError ? `\nultimo errore: ${br.lastError}` : ""}\n` + + `file: ${br.file}\nUso: /qmem:local breaker reset`, + "info", + ); + return; + } if (sub === "pull") { ctx.ui.setStatus("pi-qmem", "Pull export dal gateway..."); const res = await pullFromGatewayExport(cfg, { dbFile }); diff --git a/extensions/local-db.ts b/extensions/local-db.ts index 1110a47..3dd1fc8 100644 --- a/extensions/local-db.ts +++ b/extensions/local-db.ts @@ -25,7 +25,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { createHash, randomUUID } from "node:crypto"; -import { gatewayRequest, loadConfig, type MemoryConfig } from "./shared.ts"; +import { breakerInfo, gatewayRequest, loadConfig, type MemoryConfig } from "./shared.ts"; // --------------------------------------------------------------------------- // Percorsi e apertura @@ -610,7 +610,7 @@ export async function enrichFromGateway( stats.requested++; let res: { ok: boolean; status: number; data: any }; try { - res = await gatewayRequest(cfg, "GET", `/v1/memories/${id}`, undefined, AbortSignal.timeout(perRequestMs)); + res = await gatewayRequest(cfg, "GET", `/v1/memories/${id}`, undefined, undefined, undefined, { timeoutMs: perRequestMs }); } catch (e) { // timeout/abort: gateway non raggiungibile → inutile insistere sui record successivi stats.failed++; @@ -674,17 +674,17 @@ export async function pullFromGatewayExport( let fetched = 0; for (; pages < maxPages; pages++) { const route = `/v1/memories:export?limit=${limit}&include_deleted=true${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`; - const { ok, status, data } = await gatewayRequest( - cfg, - "GET", - route, - undefined, - AbortSignal.timeout(Math.min(cfg.timeoutMs ?? 30_000, 10_000)), - ); + const { ok, status, data } = await gatewayRequest(cfg, "GET", route, undefined, undefined, undefined, { + timeoutMs: Math.min(cfg.timeoutMs ?? 30_000, 10_000), + }); if (status === 404 || status === 405 || status === 400) { return { supported: false, pages, fetched, message: `endpoint di export non disponibile sul gateway (HTTP ${status})` }; } - if (!ok) return { supported: false, pages, fetched, message: `export fallito: HTTP ${status}` }; + if (!ok) { + const br = breakerInfo(); + const why = br.open ? `gateway non raggiungibile (breaker aperto: ritenta tra ${Math.ceil(br.remainingMs / 1000)}s)` : `export fallito: HTTP ${status}`; + return { supported: false, pages, fetched, message: why }; + } const items = data?.results ?? data?.records ?? []; if (Array.isArray(items) && items.length) { upsertRecords( @@ -925,11 +925,12 @@ export async function flushQueue( "POST", "/v1/memories", body, - AbortSignal.timeout(perRequestMs), + undefined, item.local_id, // Idempotency-Key: retry sicuri, nessun duplicato + { timeoutMs: perRequestMs }, ); } catch (e) { - const msg = `${e instanceof Error ? e.message : String(e)} (timeout ${perRequestMs}ms)`; + const msg = `${e instanceof Error ? e.message : String(e)} (budget ${perRequestMs}ms)`; db.prepare("UPDATE pending SET attempts=attempts+1, last_attempt=?, last_error=? WHERE local_id=?").run(nowIso(), msg, item.local_id); stats.errors.push(`${item.local_id.slice(0, 8)}: ${msg}`); stats.stopped = msg; @@ -995,7 +996,12 @@ export async function flushQueue( } } else { // 0/429/5xx: gateway non utilizzabile, resta in coda - const msg = res.status === 429 ? "rate limit del gateway (HTTP 429): ripreso al prossimo flush" : `HTTP ${res.status}`; + const br = breakerInfo(); + const msg = br.open + ? `gateway non raggiungibile (breaker aperto: ritenta tra ${Math.ceil(br.remainingMs / 1000)}s)` + : res.status === 429 + ? "rate limit del gateway (HTTP 429): ripreso al prossimo flush" + : `HTTP ${res.status}`; db.prepare("UPDATE pending SET attempts=attempts+1, last_attempt=?, last_error=? WHERE local_id=?").run(nowIso(), msg, item.local_id); stats.errors.push(`${item.local_id.slice(0, 8)}: ${msg}`); stats.stopped = msg; @@ -1019,11 +1025,13 @@ export async function submitOrQueue( cfg: MemoryConfig, payload: QueuePayload, opts?: { dbFile?: string; queue?: boolean }, -): Promise<{ queued: boolean; remote_id?: string; local_id?: string; queue_size?: number; status: number; data?: any }> { +): Promise<{ queued: boolean; remote_id?: string; local_id?: string; queue_size?: number; status: number; data?: any; breaker?: ReturnType }> { const queueAllowed = opts?.queue ?? cfg.offlineQueue !== false; const body = stripEmpty(payload as unknown as Record); const perRequestMs = Math.min(cfg.timeoutMs ?? 30_000, 15_000); - const res = await gatewayRequest(cfg, "POST", "/v1/memories", body, AbortSignal.timeout(perRequestMs), randomUUID()); + // NB: niente AbortSignal esterno (verrebbe letto come annullamento utente): + // il budget si passa via opts, e i timeout interni gestiscono connect/body. + const res = await gatewayRequest(cfg, "POST", "/v1/memories", body, undefined, randomUUID(), { timeoutMs: perRequestMs }); if (res.ok && res.data?.memory_id) { // indicizza subito in locale (senza attendere enrich/export) try { @@ -1053,14 +1061,14 @@ export async function submitOrQueue( /* l'indice locale è best-effort sul percorso online */ } maybeBackgroundFlush(cfg, opts?.dbFile); - return { queued: false, remote_id: res.data.memory_id, status: res.status, data: res.data }; + return { queued: false, remote_id: res.data.memory_id, status: res.status, data: res.data, breaker: res.breaker }; } const down = res.status === 0 || res.status >= 500 || res.status === 429; if (down && queueAllowed) { const q = await queueStore(payload, { dbFile: opts?.dbFile }); - return { queued: true, local_id: q.local_id, queue_size: q.queue_size, status: res.status, data: res.data }; + return { queued: true, local_id: q.local_id, queue_size: q.queue_size, status: res.status, data: res.data, breaker: res.breaker }; } - return { queued: false, status: res.status, data: res.data }; + return { queued: false, status: res.status, data: res.data, breaker: res.breaker }; } let flushInFlight: Promise | null = null; diff --git a/extensions/rules.ts b/extensions/rules.ts index d76b414..98da10c 100644 --- a/extensions/rules.ts +++ b/extensions/rules.ts @@ -17,7 +17,8 @@ Procedures (hierarchy L1/L2, scores, supersede, reflexion, consolidation): skill - Quando il gateway non risponde, qmem_search degrada all'INDICE LOCALE SQLite/FTS5 (ricerca testuale, NON neurale: nessuno score 0.45/0.60). Il risultato è etichettato 'fallback: local_sqlite'. - I risultati locali sono osservazioni più vecchie del gateway: verificali prima dell'uso e non applicare le soglie di score del gateway. - OUTBOX: se il gateway è giù, qmem_store accoda il record in locale (non lo perde). Il record è subito ricercabile (marcato ⏳ in coda) e viene inviato automaticamente al ritorno della connessione (flush su session_start). Finché non è sincronizzato NON è nella memoria condivisa: trattalo come non condiviso. -- Gestione: /qmem:local status | import | find | queue | flush | enrich | pull. +- Gestione: /qmem:local status | import | find | queue | flush | breaker [reset] | enrich | pull. +- CIRCUIT BREAKER: se il gateway è irraggiungibile (o non risponde entro connectTimeoutMs) qmem NON lo ritenta per ~2 minuti: le chiamate passano subito al fallback locale e l'outbox accoda. Non insistere con qmem_search/qmem_store sperando in un esito diverso; per forzare un tentativo quando sai che il gateway è tornato: /qmem:local breaker reset. ### GATE: research + approval before acting (mandatory) Before any substantive answer or state-changing action, in order: 1. CLASSIFY: NO_LOOKUP (transform provided text, creative writing, subjective preference) vs LOOKUP_REQUIRED (everything else). diff --git a/extensions/shared.ts b/extensions/shared.ts index f58c1d2..8a29842 100644 --- a/extensions/shared.ts +++ b/extensions/shared.ts @@ -29,7 +29,16 @@ 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; @@ -43,12 +52,17 @@ 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 (429/5xx/timeout): backoff esponenziale + jitter +// 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; @@ -56,6 +70,108 @@ function sleep(ms: number): Promise { 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")) }; @@ -98,47 +214,139 @@ export async function gatewayRequest( body?: unknown, signal?: AbortSignal, idempotencyKey?: string, -): Promise<{ ok: boolean; status: number; data: any }> { + 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 = { "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++) { - // timeout fallback: se pi non fornisce un signal, usa timeoutMs dalla config - const timeoutSignal = signal ?? AbortSignal.timeout(cfg.timeoutMs ?? 30_000); + 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(`${cfg.url}${route}`, { + const res = await fetch(url, { method, - signal: timeoutSignal, + signal: ac.signal, headers, body: body ? JSON.stringify(body) : undefined, }); - const data = await res.json().catch(() => ({})); - // retry solo su errori transitori (429/5xx), rispettando Retry-After - if ((res.status === 429 || res.status >= 500) && 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; + // 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); } - return { ok: res.ok, status: res.status, data }; + 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) { - // annullamento utente: propaga, non ritentare - if (e instanceof Error && e.name === "AbortError") throw e; - // errore di rete/timeout: retry con backoff - lastError = e; - if (attempt < MAX_RETRIES) { - await sleep(RETRY_BASE_MS * 2 ** attempt + Math.random() * 200); - continue; + 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") } }; + return { ok: false, status: 0, data: { error: "network_error", detail: String(lastError ?? "unknown") }, breaker: breakerInfo() }; } // --------------------------------------------------------------------------- diff --git a/extensions/tools/get.ts b/extensions/tools/get.ts index 9b4e003..af8df8f 100644 --- a/extensions/tools/get.ts +++ b/extensions/tools/get.ts @@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { gatewayRequest, loadConfig } from "../shared.ts"; import { localDbPath, localGet } from "../local-db.ts"; +import { breakerInfo } from "../shared.ts"; export function registerQmemGet(pi: ExtensionAPI) { pi.registerTool({ @@ -31,9 +32,11 @@ export function registerQmemGet(pi: ExtensionAPI) { try { const local = await localGet(p.memory_id, { dbFile: localDbPath(cfg) }); if (local) { + const br = breakerInfo(); const origine = notFound ? "non presente sul gateway (404): record dall'INDICE LOCALE" - : `gateway non raggiungibile (HTTP ${status}): record dall'INDICE LOCALE`; + : `gateway non raggiungibile (HTTP ${status}): record dall'INDICE LOCALE` + + (br.open ? ` — circuit breaker aperto (riprova tra ${Math.ceil(br.remainingMs / 1000)}s)` : ""); return { content: [ { @@ -48,6 +51,7 @@ export function registerQmemGet(pi: ExtensionAPI) { fallback: "local_sqlite", gateway_status: status, pending: local.pending === 1, + breaker: { open: br.open, remaining_ms: Math.round(br.remainingMs) }, }, }; } diff --git a/extensions/tools/search.ts b/extensions/tools/search.ts index 931f20f..bd30b2b 100644 --- a/extensions/tools/search.ts +++ b/extensions/tools/search.ts @@ -1,6 +1,6 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; -import { gatewayRequest, loadConfig } from "../shared.ts"; +import { breakerInfo, gatewayRequest, loadConfig } from "../shared.ts"; import { localDbPath, localSearch, type LocalSearchHit } from "../local-db.ts"; export function registerQmemSearch(pi: ExtensionAPI) { @@ -106,6 +106,14 @@ export function registerQmemSearch(pi: ExtensionAPI) { }, { dbFile }, ); + const br = breakerInfo(); + const motivo = + `Motivo: ${data?.error ?? "gateway non raggiungibile"}` + + (br.open + ? ` — circuit breaker aperto: nessun nuovo tentativo verso il gateway per altri ${Math.ceil(br.remainingMs / 1000)}s` + + `${br.lastError ? ` (ultimo errore: ${br.lastError})` : ""}` + : "") + + `\nPer forzare un tentativo: /qmem:local breaker reset`; if (hits.length) { const lines = hits.map( (h: LocalSearchHit, i: number) => @@ -114,7 +122,7 @@ export function registerQmemSearch(pi: ExtensionAPI) { const header = `⚠️ Gateway non raggiungibile (HTTP ${status}): risultati dall'INDICE LOCALE (SQLite/FTS5).\n` + `Ricerca TESTUALE, non neurale: nessuno score semantico, nessuna soglia 0.45/0.60 — verifica i risultati prima dell'uso.\n` + - `DB: ${dbFile}`; + `${motivo}\nDB: ${dbFile}`; return { content: [{ type: "text", text: `${header}\n${lines.join("\n")}` }], details: { @@ -122,6 +130,7 @@ export function registerQmemSearch(pi: ExtensionAPI) { hits: hits.length, gateway_status: status, match_mode: hits[0]?.match_mode ?? "and", + breaker: { open: br.open, remaining_ms: Math.round(br.remainingMs), failures: br.failures, last_error: br.lastError }, }, }; } @@ -130,7 +139,7 @@ export function registerQmemSearch(pi: ExtensionAPI) { { type: "text", text: - `Gateway non raggiungibile (HTTP ${status}) e nessun risultato nell'indice locale (${dbFile}).\n` + + `Gateway non raggiungibile (HTTP ${status}) e nessun risultato nell'indice locale (${dbFile}).\n${motivo}\n` + `Se il DB è assente o vecchio: /qmem:local import (ricostruisce l'indice dalle sessioni pi).`, }, ], diff --git a/extensions/tools/store.ts b/extensions/tools/store.ts index 555e1af..66042b5 100644 --- a/extensions/tools/store.ts +++ b/extensions/tools/store.ts @@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { gatewayRequest, loadConfig } from "../shared"; import { localDbPath, submitOrQueue } from "../local-db.ts"; +import { breakerInfo } from "../shared.ts"; export function registerQmemStore(pi: ExtensionAPI) { pi.registerTool({ @@ -111,6 +112,13 @@ export function registerQmemStore(pi: ExtensionAPI) { `⚠️ Gateway non raggiungibile (HTTP ${submitted.status}): record ACCODATO in locale (outbox).\n` + `id locale: ${submitted.local_id}\n` + `in coda: ${submitted.queue_size} record\n` + + (() => { + const br = breakerInfo(); + return br.open + ? `circuit breaker aperto: nessun nuovo tentativo verso il gateway per altri ${Math.ceil(br.remainingMs / 1000)}s ` + + `(ultimo errore: ${br.lastError ?? "?"}; per forzare: /qmem:local breaker reset)\n` + : ""; + })() + `Il contenuto è già ricercabile offline (indice locale, marcato ⏳) e verrà caricato automaticamente al ritorno della connessione ` + `(/qmem:local flush per forzare, /qmem:local queue per lo stato).`, }, @@ -120,6 +128,9 @@ export function registerQmemStore(pi: ExtensionAPI) { local_id: submitted.local_id, queue_size: submitted.queue_size, gateway_status: submitted.status, + breaker: submitted.breaker + ? { open: submitted.breaker.open, remaining_ms: Math.round(submitted.breaker.remainingMs), failures: submitted.breaker.failures } + : undefined, }, }; } diff --git a/scripts/qmem-sqlite.mjs b/scripts/qmem-sqlite.mjs index 822dd59..9d2ad85 100644 --- a/scripts/qmem-sqlite.mjs +++ b/scripts/qmem-sqlite.mjs @@ -10,6 +10,7 @@ * node scripts/qmem-sqlite.mjs store --project P [--kind K] [--text "..."] [--queue-only] * node scripts/qmem-sqlite.mjs queue [--status queued|synced|duplicate|failed] * node scripts/qmem-sqlite.mjs flush [--limit N] + * node scripts/qmem-sqlite.mjs breaker [--reset] * node scripts/qmem-sqlite.mjs enrich [--all] [--limit N] [--pace MS] * node scripts/qmem-sqlite.mjs pull [--limit N] * @@ -27,7 +28,7 @@ process.emitWarning = (warning, ...rest) => { }; const localDb = await import("../extensions/local-db.ts"); const { DEFAULT_DB_FILE, enrichFromGateway, flushQueue, importFromSessions, localDbPath, localDbReport, localSearch, pullFromGatewayExport, queueList, queueStats, queueStore, sessionRoots, submitOrQueue } = localDb; -const { loadConfig } = await import("../extensions/shared.ts"); +const { breakerInfo, loadConfig, resetBreaker } = await import("../extensions/shared.ts"); process.emitWarning = originalEmitWarning; const argv = process.argv.slice(2); @@ -65,6 +66,10 @@ if (cmd === "status") { } if (r.pendingInIndex) console.log(` in indice : ${r.pendingInIndex} record marcati ⏳ (creati offline, non ancora sul gateway)`); if (r.deleted) console.log(` tombstone : ${r.deleted} record cancellati sul gateway (soft delete)`); + { + const br = breakerInfo(); + console.log(` breaker : ${br.open ? `APERTO (riprova tra ${Math.ceil(br.remainingMs / 1000)}s)` : "chiuso"} | fallimenti ${br.failures} | aperture ${br.trips}${br.lastError ? ` | ultimo errore: ${String(br.lastError).slice(0, 70)}` : ""}`); + } console.log(` top progetti : ${r.topProjects.map((p) => `${p.project_id ?? "(null)"}=${p.n}`).join(", ") || "-"}`); if (r.duplicates.length) { console.log(` possibili duplicati (testo identico): ${r.duplicates.length} gruppi — es. ${r.duplicates[0].ids.map((i) => i.slice(0, 8)).join(", ")}`); @@ -178,6 +183,22 @@ if (cmd === "status") { if (res.stopped) console.log(` fermato: ${res.stopped}`); if (res.errors.length) console.log(` errori: ${res.errors.join(" | ")}`); } +} else if (cmd === "breaker") { + if (has("reset")) { + const br = resetBreaker(); + out(json ? br : `Circuit breaker: chiuso (fallimenti ${br.failures})`); + } else { + const br = breakerInfo(); + if (json) out(br); + else { + console.log(`Circuit breaker qmem (${br.file})`); + console.log(` stato: ${br.open ? `APERTO — riprova tra ${Math.ceil(br.remainingMs / 1000)}s` : "chiuso"}`); + console.log(` fallimenti consecutivi: ${br.failures} | aperture totali: ${br.trips}`); + if (br.lastError) console.log(` ultimo errore: ${br.lastError}`); + if (br.lastChange) console.log(` ultimo cambio: ${br.lastChange}`); + console.log(" reset: qmem-sqlite breaker --reset"); + } + } } else if (cmd === "pull") { const cfg = loadConfig(); const res = await pullFromGatewayExport(cfg, { dbFile, limit: Number(opt("limit", 500)) || 500 }); @@ -187,7 +208,7 @@ if (cmd === "status") { console.log(` supportato: ${res.supported} pagine: ${res.pages} record: ${res.fetched}${res.message ? ` — ${res.message}` : ""}`); } } else { - console.error(`Comando sconosciuto: ${cmd}\nComandi: status | import | find | store | queue | flush | enrich | pull`); + console.error(`Comando sconosciuto: ${cmd}\nComandi: status | import | find | store | queue | flush | breaker | enrich | pull`); process.exit(2); } diff --git a/scripts/test-local.mjs b/scripts/test-local.mjs index 078fcfd..e39503f 100644 --- a/scripts/test-local.mjs +++ b/scripts/test-local.mjs @@ -66,7 +66,7 @@ const lines = [ fs.writeFileSync(path.join(SESS_DIR, "2026-09-01T10-00-00-000Z_test.jsonl"), lines.join("\n") + "\n"); // ---------------------------------------------------------------- config black-hole -fs.writeFileSync(CONFIG, JSON.stringify({ url: "http://127.0.0.1:9", apiKey: "test-key", timeoutMs: 1500, localDbPath: DB }, null, 2), { mode: 0o600 }); +fs.writeFileSync(CONFIG, JSON.stringify({ url: "http://127.0.0.1:9", apiKey: "test-key", timeoutMs: 1500, connectTimeoutMs: 700, breakerBaseMs: 20000, breakerMaxMs: 60000, localDbPath: DB }, null, 2), { mode: 0o600 }); const env = { ...process.env, HOME, QMEM_SQLITE: DB, QMEM_SESSIONS_DIR: path.join(HOME, ".pi", "agent", "sessions") }; // isolamento anche per il processo di test: l'estensione caricata in-process @@ -155,6 +155,7 @@ const ctx = { const search = await tools.get("qmem_search").execute("t1", { query: "sqlite fts5" }, undefined, undefined, ctx); check("qmem_search → fallback locale con gateway giù", search.details?.fallback === "local_sqlite" && search.details?.hits > 0, `hits=${search.details?.hits} status=${search.details?.gateway_status}`); check("risultato locale etichettato come testuale", /INDICE LOCALE/i.test(search.content[0].text) && /non neurale/i.test(search.content[0].text) && /sqlite/i.test(search.content[0].text)); +check("il messaggio indica il circuit breaker e come forzare un tentativo", /circuit breaker aperto/i.test(search.content[0].text) && /breaker reset/i.test(search.content[0].text), `breaker=${JSON.stringify(search.details?.breaker)}`); const getRes = await tools.get("qmem_get").execute("t2", { memory_id: ID_E }, undefined, undefined, ctx); if (process.env.DEBUG_GET) console.log("GET RESULT:", JSON.stringify(getRes, null, 1).slice(0, 900)); check("qmem_get → fallback locale per UUID", getRes.details?.fallback === "local_sqlite" && /nmap/.test(getRes.content[0].text)); @@ -205,7 +206,7 @@ const server = createServer((req, res) => { }); await new Promise((r) => server.listen(0, "127.0.0.1", r)); const stub = `http://127.0.0.1:${server.address().port}`; -fs.writeFileSync(CONFIG, JSON.stringify({ url: stub, apiKey: "test-key", timeoutMs: 3000, localDbPath: DB }, null, 2), { mode: 0o600 }); +fs.writeFileSync(CONFIG, JSON.stringify({ url: stub, apiKey: "test-key", timeoutMs: 3000, connectTimeoutMs: 700, breakerBaseMs: 20000, localDbPath: DB }, null, 2), { mode: 0o600 }); const enrichOut = await runCliAsync(["enrich", "--json"]); let enrich = { stdout: enrichOut }; let enrichJson = null; @@ -266,6 +267,104 @@ const dupItem = (qFinal.items ?? []).find((i) => i.local_id === dupStore.local_i const badItem = (qFinal.items ?? []).find((i) => i.local_id === badStore.local_id); check("duplicato marcato con remote_id del match", dupItem?.status === "duplicate" && dupItem?.remote_id === ID_A, `status=${dupItem?.status} remote=${String(dupItem?.remote_id).slice(0, 8)}`); check("422 marcato failed con errore conservato", badItem?.status === "failed" && /422/.test(badItem?.last_error ?? ""), `err=${String(badItem?.last_error).slice(0, 40)}`); +// ---------------------------------------------------------------- 6) circuit breaker +if (process.env.SKIP_BREAKER !== "1") { + const origEmit = process.emitWarning; + process.emitWarning = (w, ...r) => { + const code = r[0]?.code ?? (typeof r[0] === "string" ? r[0] : undefined) ?? w?.code; + if (code === "MODULE_TYPELESS_PACKAGE_JSON") return; + return origEmit.call(process, w, ...r); + }; + const shared = await import("../extensions/shared.ts"); + process.emitWarning = origEmit; + const BH = { url: "http://127.0.0.1:9", apiKey: "k", connectTimeoutMs: 600, breakerBaseMs: 20000, breakerMaxMs: 60000 }; + + shared.resetBreaker(); + check("breaker inizialmente chiuso", shared.breakerInfo().open === false); + + // connessione rifiutata → fallimento immediato, nessun retry + let t = Date.now(); + const r1 = await shared.gatewayRequest(BH, "GET", "/v1/status"); + const ms1 = Date.now() - t; + check("connessione rifiutata: fallimento immediato senza retry", r1.status === 0 && r1.data?.error === "gateway_unreachable" && ms1 < 1500, `${ms1}ms`); + const br1 = shared.breakerInfo(); + check("breaker APERTO dopo il fallimento di connessione", br1.open === true, `open=${br1.open} failures=${br1.failures} trips=${br1.trips}`); + + // fast-fail: nessuna richiesta di rete + t = Date.now(); + const r2 = await shared.gatewayRequest(BH, "GET", "/v1/status"); + const ms2 = Date.now() - t; + check("fast-fail con breaker aperto (nessuna rete, < 50ms)", r2.status === 0 && r2.data?.breaker_open === true && ms2 < 50, `${ms2}ms`); + + // persistenza fra processi (nuovo processo, stesso file di stato) + const code = ` +const p = ${JSON.stringify(path.join(REPO, "extensions/shared.ts"))}; +import(p).then(async (m) => { + const t = Date.now(); + const r = await m.gatewayRequest({ url: "http://127.0.0.1:9", apiKey: "k", connectTimeoutMs: 600 }, "GET", "/v1/status"); + console.log(JSON.stringify({ ms: Date.now() - t, status: r.status, breaker_open: !!r.data?.breaker_open, open: m.breakerInfo().open })); +});`; + const child = spawnSync("node", ["-e", code], { env, encoding: "utf8" }); + let childOut = null; + try { + childOut = JSON.parse((child.stdout || "{}").trim().split("\n").pop()); + } catch { + /* ignore */ + } + check( + "breaker persistente fra processi (nuovo processo → fast-fail)", + childOut?.breaker_open === true && childOut?.ms < 200, + `ms=${childOut?.ms} open=${childOut?.open} stderr=${(child.stderr || "").slice(0, 60)}`, + ); + + // server che accetta ma non risponde mai → connect timeout breve (non 30s) + const hang = createServer(() => {}); + await new Promise((r) => hang.listen(0, "127.0.0.1", r)); + const hangUrl = `http://127.0.0.1:${hang.address().port}`; + shared.resetBreaker(); + t = Date.now(); + const r3 = await shared.gatewayRequest({ url: hangUrl, apiKey: "k", connectTimeoutMs: 700 }, "GET", "/v1/status"); + const ms3 = Date.now() - t; + check("server che non risponde: connect timeout ~700ms (non 30s) e breaker aperto", r3.status === 0 && ms3 >= 600 && ms3 < 2600 && shared.breakerInfo().open === true, `${ms3}ms`); + hang.close(); + + // header subito ma body lento: elaborazione lunga → fallimento ambiguo, breaker NON aperto + const stall = createServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.write('{"status":'); + // nessun end: il body resta appeso + }); + await new Promise((r) => stall.listen(0, "127.0.0.1", r)); + const stallUrl = `http://127.0.0.1:${stall.address().port}`; + shared.resetBreaker(); + t = Date.now(); + const r4 = await shared.gatewayRequest({ url: stallUrl, apiKey: "k", connectTimeoutMs: 700, timeoutMs: 1200 }, "GET", "/v1/status"); + const ms4 = Date.now() - t; + const br4 = shared.breakerInfo(); + check( + "body lento: timeout di elaborazione (~1.2s) senza aprire il breaker", + r4.status === 0 && r4.data?.error === "timeout_body" && ms4 < 2600 && br4.open === false, + `${ms4}ms failures=${br4.failures} open=${br4.open}`, + ); + + // store con gateway che accetta ma non risponde: deve accodare, non lanciare + fs.writeFileSync(CONFIG, JSON.stringify({ url: stallUrl, apiKey: "test-key", timeoutMs: 1200, connectTimeoutMs: 700, breakerBaseMs: 20000, localDbPath: DB }, null, 2), { mode: 0o600 }); + const queuedOnStall = await tools.get("qmem_store").execute("t9", { text: "Record accodato con gateway che non risponde", project_id: "stall-proj" }, undefined, undefined, ctx); + check( + "gateway che accetta e non risponde: store ACCODATO (nessuna eccezione)", + queuedOnStall.details?.queued === true && /ACCODATO/i.test(queuedOnStall.content[0].text), + `queued=${queuedOnStall.details?.queued} status=${queuedOnStall.details?.gateway_status}`, + ); + + // cambio di endpoint → il breaker riparte chiuso + shared.tripBreaker("endpoint A", true, { ...BH, breakerBaseMs: 60000, url: "http://a" }); + const r5 = await shared.gatewayRequest({ url: hangUrl, apiKey: "k", connectTimeoutMs: 400 }, "GET", "/v1/status"); + check("cambio endpoint: il breaker non blocca il nuovo gateway", r5.data?.breaker_open !== true, `error=${r5.data?.error}`); + shared.resetBreaker(); + check("reset manuale chiude il breaker", shared.breakerInfo().open === false); + stall.close(); +} + // ---------------------------------------------------------------- report const failed = results.filter((r) => !r.ok); console.log(`\n${results.length - failed.length}/${results.length} controlli superati — HOME di test: ${HOME}`); diff --git a/skills/qmem/SKILL.md b/skills/qmem/SKILL.md index 1afbea3..aed57fc 100644 --- a/skills/qmem/SKILL.md +++ b/skills/qmem/SKILL.md @@ -39,8 +39,13 @@ In quel caso: soglia 0.45/0.60 da applicare; - i risultati sono **osservazioni più vecchie** del gateway (storico ricostruito dalle sessioni pi + ultimo enrich): verifica prima dell'uso; -- comandi: `/qmem:local status | import | find | enrich | pull` - (`import` dalle sessioni, `enrich`/`pull` dal gateway quando torna online). +- comandi: `/qmem:local status | import | find | queue | flush | breaker [reset] | enrich | pull` + (`import` dalle sessioni, `enrich`/`pull` dal gateway quando torna online); +- **circuit breaker**: quando il gateway non risponde entro `connectTimeoutMs` (default 2,5 s) le + chiamate successive falliscono in ~0 ms **senza toccare la rete** per ~2 minuti (stato in + `~/.local/share/pi-qmem/breaker.json`): il fallback locale è immediato. Un 5xx o un body lento + sono invece "ambigui" (retry con `Retry-After`, apertura dopo 2 fallimenti). Reset: + `/qmem:local breaker reset`. `qmem_store` accoda in locale: con il gateway giù il record entra nell'**outbox** locale (SQLite), è subito ricercabile (marcato ⏳) e viene inviato al gateway al