total-recall-brain 3.23.2 → 3.23.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/server/health-coverage.mjs +74 -0
- package/src/server/index.mjs +60 -36
package/package.json
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps /health cheap without making it dishonest.
|
|
3
|
+
*
|
|
4
|
+
* Embedding coverage is the expensive half of the health check: for every
|
|
5
|
+
* active brain it parses every node in that brain's vault and loads that
|
|
6
|
+
* brain's embeddings index — 12MB on one machine here. Computed inside the
|
|
7
|
+
* request it made /health take 25-57s on a loaded laptop while the dashboard
|
|
8
|
+
* answered in 116ms, so the server read as dead to everything that probes it.
|
|
9
|
+
* That is the worst endpoint to make slow: /health is what the watchdog polls,
|
|
10
|
+
* and a liveness probe that times out is indistinguishable from a server that
|
|
11
|
+
* is down — which is exactly how it was misread.
|
|
12
|
+
*
|
|
13
|
+
* A cache alone would trade one wrong answer for another, because the reading
|
|
14
|
+
* it holds is a criticality signal: a brain with nodes and no vectors serves
|
|
15
|
+
* keyword results that look like real ones. So the snapshot always carries the
|
|
16
|
+
* time it was taken, and reports "no reading yet" as null rather than as clean.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const DEFAULT_TTL_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A single-flight, time-bounded cache over an expensive async computation.
|
|
23
|
+
*
|
|
24
|
+
* Never blocks a caller: a stale value is served while a refresh runs behind
|
|
25
|
+
* it, and before the first value lands callers get null. `compute` errors are
|
|
26
|
+
* swallowed rather than left to reject an unawaited promise — a failed refresh
|
|
27
|
+
* keeps the previous reading and its original timestamp, so failure looks like
|
|
28
|
+
* staleness rather than success.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} opts
|
|
31
|
+
* @param {() => Promise<any>} opts.compute the expensive work
|
|
32
|
+
* @param {number} [opts.ttlMs] how long a reading stays fresh
|
|
33
|
+
* @param {() => number} [opts.now] clock seam, for tests
|
|
34
|
+
*/
|
|
35
|
+
export function createCoverageCache({ compute, ttlMs = DEFAULT_TTL_MS, now = () => Date.now() }) {
|
|
36
|
+
let value = null;
|
|
37
|
+
let takenAt = 0;
|
|
38
|
+
let inFlight = null;
|
|
39
|
+
|
|
40
|
+
function refresh() {
|
|
41
|
+
// Single-flight: a second caller during a refresh joins it rather than
|
|
42
|
+
// starting a parallel full-vault parse.
|
|
43
|
+
if (inFlight) return inFlight;
|
|
44
|
+
inFlight = Promise.resolve()
|
|
45
|
+
.then(() => compute())
|
|
46
|
+
.then((next) => {
|
|
47
|
+
value = next;
|
|
48
|
+
takenAt = now();
|
|
49
|
+
})
|
|
50
|
+
.catch(() => {
|
|
51
|
+
// Keep the prior reading and its timestamp; a failed refresh must not
|
|
52
|
+
// be able to masquerade as a fresh clean result.
|
|
53
|
+
})
|
|
54
|
+
.finally(() => {
|
|
55
|
+
inFlight = null;
|
|
56
|
+
});
|
|
57
|
+
return inFlight;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
/** Last known reading, kicking off a background refresh when stale. */
|
|
62
|
+
snapshot() {
|
|
63
|
+
if (!value || now() - takenAt > ttlMs) refresh();
|
|
64
|
+
return value
|
|
65
|
+
? { coverage: value, as_of: new Date(takenAt).toISOString() }
|
|
66
|
+
: { coverage: null, as_of: null };
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
/** Resolves once any in-progress refresh settles. Used to warm at boot. */
|
|
70
|
+
async settled() {
|
|
71
|
+
while (inFlight) await inFlight;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/server/index.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from './auth.mjs';
|
|
30
30
|
import { logger, logEvents } from "../core/logger.mjs";
|
|
31
31
|
import { readProcessCommand, entryPathHint, shouldHonorPidLock } from '../core/pid-lock.mjs';
|
|
32
|
+
import { createCoverageCache } from './health-coverage.mjs';
|
|
32
33
|
import { drainActiveEmbeddings } from './routes/sessions.mjs';
|
|
33
34
|
import { agentDir as configAgentDir, brainDir as configBrainDir, port as configPort, host as configHost, nodeEnv } from '../core/config.mjs';
|
|
34
35
|
import { getDaemonStatus, ensureDaemonRunning } from '../core/daemon-control.mjs';
|
|
@@ -172,6 +173,56 @@ app.use(cookieParser());
|
|
|
172
173
|
|
|
173
174
|
// ─── Health Check ───────────────────────────────────────────────────────────────
|
|
174
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Embedding coverage is the expensive half of this endpoint — see
|
|
178
|
+
* health-coverage.mjs for why it is computed off the request path. The check
|
|
179
|
+
* itself is unchanged: a brain with nodes and no vectors still reports at
|
|
180
|
+
* critical severity, because recall silently answering from keyword matching
|
|
181
|
+
* is the failure this was written to catch.
|
|
182
|
+
*/
|
|
183
|
+
async function computeEmbeddingCoverage() {
|
|
184
|
+
const coverage = [];
|
|
185
|
+
try {
|
|
186
|
+
const { loadEmbeddingsIndex } = await import('../core/embeddings.mjs');
|
|
187
|
+
const { getActiveBrains } = await import('../core/config.mjs');
|
|
188
|
+
const brains = getActiveBrains();
|
|
189
|
+
for (const b of Object.values(brains)) {
|
|
190
|
+
if (!b?.brainDir) continue;
|
|
191
|
+
const vaultPath = path.join(b.brainDir, 'memory-vault');
|
|
192
|
+
if (!fs.existsSync(vaultPath)) continue;
|
|
193
|
+
// Count only what the embedder actually indexes: type:memory nodes.
|
|
194
|
+
// Counting every .md under the vault compares against a denominator that
|
|
195
|
+
// includes proposals/ (optimizer tickets, never embedded by design), so a
|
|
196
|
+
// perfectly healthy brain reported "847/16701 — 5% coverage" and tripped
|
|
197
|
+
// a false degraded alarm. Ask the loader, don't count files.
|
|
198
|
+
let nodeCount = 0;
|
|
199
|
+
try {
|
|
200
|
+
const { loadNodes } = await import('../core/vault.mjs');
|
|
201
|
+
nodeCount = loadNodes(vaultPath).length;
|
|
202
|
+
} catch { /* unreadable vault */ }
|
|
203
|
+
let embedded = 0;
|
|
204
|
+
try {
|
|
205
|
+
embedded = Object.keys(loadEmbeddingsIndex(path.join(b.brainDir, 'memory-derived'))).length;
|
|
206
|
+
} catch { /* index unreadable — reported as 0, which is the honest value */ }
|
|
207
|
+
coverage.push({
|
|
208
|
+
layer: b.layer || 'unknown',
|
|
209
|
+
nodes: nodeCount,
|
|
210
|
+
embedded,
|
|
211
|
+
vector_search: embedded > 0 ? 'on' : 'OFF — keyword-only',
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
} catch (err) {
|
|
215
|
+
coverage.push({ error: String(err?.message || err) });
|
|
216
|
+
}
|
|
217
|
+
return coverage;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const coverageCache = createCoverageCache({ compute: computeEmbeddingCoverage });
|
|
221
|
+
const embeddingCoverageSnapshot = () => coverageCache.snapshot();
|
|
222
|
+
|
|
223
|
+
// Warm it at boot so the first probe already has a real answer.
|
|
224
|
+
embeddingCoverageSnapshot();
|
|
225
|
+
|
|
175
226
|
app.get('/health', requireAuthOrLocal, async (req, res) => {
|
|
176
227
|
let disk = { free: 0, total: 0 };
|
|
177
228
|
try {
|
|
@@ -206,7 +257,7 @@ app.get('/health', requireAuthOrLocal, async (req, res) => {
|
|
|
206
257
|
// Check daemon status
|
|
207
258
|
const daemonStatus = getDaemonStatus();
|
|
208
259
|
|
|
209
|
-
// Embedding coverage per brain.
|
|
260
|
+
// Embedding coverage per brain, served from the background cache above.
|
|
210
261
|
//
|
|
211
262
|
// This existed nowhere before, and its absence is exactly why a brain with
|
|
212
263
|
// 2602 vault nodes and 0 embeddings looked healthy for weeks: compile
|
|
@@ -214,43 +265,13 @@ app.get('/health', requireAuthOrLocal, async (req, res) => {
|
|
|
214
265
|
// real results, and nothing anywhere printed "N of M nodes embedded".
|
|
215
266
|
// Vector search silently off is a CRITICAL failure, not a cosmetic one —
|
|
216
267
|
// recall answers from the wrong brain and the caller cannot tell.
|
|
217
|
-
const embeddingCoverage =
|
|
218
|
-
try {
|
|
219
|
-
const { loadEmbeddingsIndex } = await import('../core/embeddings.mjs');
|
|
220
|
-
const { getActiveBrains } = await import('../core/config.mjs');
|
|
221
|
-
const brains = getActiveBrains();
|
|
222
|
-
for (const b of Object.values(brains)) {
|
|
223
|
-
if (!b?.brainDir) continue;
|
|
224
|
-
const vaultPath = path.join(b.brainDir, 'memory-vault');
|
|
225
|
-
if (!fs.existsSync(vaultPath)) continue;
|
|
226
|
-
// Count only what the embedder actually indexes: type:memory nodes.
|
|
227
|
-
// Counting every .md under the vault compares against a denominator that
|
|
228
|
-
// includes proposals/ (optimizer tickets, never embedded by design), so a
|
|
229
|
-
// perfectly healthy brain reported "847/16701 — 5% coverage" and tripped
|
|
230
|
-
// a false degraded alarm. Ask the loader, don't count files.
|
|
231
|
-
let nodeCount = 0;
|
|
232
|
-
try {
|
|
233
|
-
const { loadNodes } = await import('../core/vault.mjs');
|
|
234
|
-
nodeCount = loadNodes(vaultPath).length;
|
|
235
|
-
} catch { /* unreadable vault */ }
|
|
236
|
-
let embedded = 0;
|
|
237
|
-
try {
|
|
238
|
-
embedded = Object.keys(loadEmbeddingsIndex(path.join(b.brainDir, 'memory-derived'))).length;
|
|
239
|
-
} catch { /* index unreadable — reported as 0, which is the honest value */ }
|
|
240
|
-
embeddingCoverage.push({
|
|
241
|
-
layer: b.layer || 'unknown',
|
|
242
|
-
nodes: nodeCount,
|
|
243
|
-
embedded,
|
|
244
|
-
vector_search: embedded > 0 ? 'on' : 'OFF — keyword-only',
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
} catch (err) {
|
|
248
|
-
embeddingCoverage.push({ error: String(err?.message || err) });
|
|
249
|
-
}
|
|
268
|
+
const { coverage: embeddingCoverage, as_of: coverageAsOf } = embeddingCoverageSnapshot();
|
|
250
269
|
|
|
251
270
|
// A brain with nodes but no vectors answers every query from keyword
|
|
252
|
-
// matching. Surface it at the same severity as a dead daemon.
|
|
253
|
-
|
|
271
|
+
// matching. Surface it at the same severity as a dead daemon. Before the
|
|
272
|
+
// first reading lands there is nothing to judge, and an unknown is reported
|
|
273
|
+
// as unknown rather than quietly counted as healthy.
|
|
274
|
+
const unembeddedBrains = (embeddingCoverage || []).filter(c => c.nodes > 0 && c.embedded === 0);
|
|
254
275
|
|
|
255
276
|
// Determine overall status
|
|
256
277
|
const hasCriticalIssue = emergencyAlerts.length > 0 || daemonStatus === 'dead' || cliAgents.length === 0
|
|
@@ -290,6 +311,9 @@ app.get('/health', requireAuthOrLocal, async (req, res) => {
|
|
|
290
311
|
cli_agents: cliAgents,
|
|
291
312
|
daemon: daemonStatus,
|
|
292
313
|
embedding_coverage: embeddingCoverage,
|
|
314
|
+
// When this reading was taken. Null means the first computation has not
|
|
315
|
+
// landed yet, so the coverage field is unknown rather than clean.
|
|
316
|
+
embedding_coverage_as_of: coverageAsOf,
|
|
293
317
|
// Explicit, greppable signal. A brain with nodes and no vectors serves
|
|
294
318
|
// keyword-only results that look exactly like real ones.
|
|
295
319
|
vector_search_degraded: unembeddedBrains.length > 0
|