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
+35 -2
View File
@@ -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**:
+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,
},
};
}
+23 -2
View File
@@ -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);
}
+101 -2
View File
@@ -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}`);
+7 -2
View File
@@ -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 <query> | enrich | pull`
(`import` dalle sessioni, `enrich`/`pull` dal gateway quando torna online).
- comandi: `/qmem:local status | import | find <query> | 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