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).
This commit is contained in:
Matteo Benedetto
2026-09-13 18:09:50 +02:00
parent d1985514e1
commit 9dd24298cc
11 changed files with 475 additions and 58 deletions
+21 -3
View File
@@ -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 <query> | enrich [--all] | pull",
description:
"Indice locale SQLite/FTS5: status | import | find <query> | 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 });
+26 -18
View File
@@ -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<typeof breakerInfo> }> {
const queueAllowed = opts?.queue ?? cfg.offlineQueue !== false;
const body = stripEmpty(payload as unknown as Record<string, unknown>);
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<unknown> | null = null;
+2 -1
View File
@@ -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 <query> | queue | flush | enrich | pull.
- Gestione: /qmem:local status | import | find <query> | 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).
+232 -24
View File
@@ -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<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")) };
@@ -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<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++) {
// 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() };
}
// ---------------------------------------------------------------------------
+5 -1
View File
@@ -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) },
},
};
}
+12 -3
View File
@@ -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).`,
},
],
+11
View File
@@ -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,
},
};
}