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:
@@ -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
@@ -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
@@ -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
@@ -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() };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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) },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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).`,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user