perf(status): offload memory-presence inspection (#150988)

This commit is contained in:
Peter Steinberger 2026-09-17 09:43:55 -07:00 • committed by GitHub
parent a1cc9b38c1
commit db46918013
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 359 additions and 86 deletions

View file

@ -2411,7 +2411,6 @@ src/commands/setup.ts 1
src/commands/status-all/channels.ts 2
src/commands/status-all/gateway.ts 1
src/commands/status.scan.bootstrap-shared.ts 1
src/commands/status.scan.shared.ts 1
src/commands/system-agent-with-inference.ts 2
src/commands/tasks-json.ts 2
src/commands/tasks.ts 2

View file

@ -212,6 +212,14 @@ Only pending reads coalesce; completed results are not cached. Physical integrit
verification remains with full registry restoration and Doctor, while known
database failures and quarantine still refuse summary reads.
Offline `status --json --all` checks for existing built-in memory data through
memory-core's retrieval worker before constructing a memory manager. The check
retains current and shipped table recognition, missing-store behavior, and
best-effort read failures without creating or migrating a database. Custom memory
slots and explicitly configured memory retain their existing selection paths.
This moves only the presence check; memory-manager diagnostics keep their own
lifecycle and execution contracts.
Gateway, embedded, and TUI session lists use resident materialized rows and the
subagent registry's owner-maintained memory snapshot. Each durable session store
is hydrated when first admitted, replaced, or reintroduced; departing stores lose

View file

@ -17,6 +17,7 @@ export type { PreviewRemHarnessOptions, PreviewRemHarnessResult } from "./src/re
export { configureMemoryCoreDreamingState } from "./src/dreaming-state.js";
export { filterMemorySearchHitsBySessionVisibility } from "./src/session-search-visibility.js";
export { captureMemoryRebuildNotice } from "./src/memory-rebuild-notice.js";
export { inspectMemoryIndexPresence } from "./src/memory/manager-status-presence.runtime.js";
export {
MEMORY_MANAGED_LOCAL_EMBEDDING_SETUP_CHECK_ID,
pluginStateIsolatedDoctorCheckIds,

View file

@ -34,6 +34,18 @@ const indexing = new WorkerTaskPool<MemoryIndexTask, MemoryIndexTaskResult>({
type MemoryReadTarget = { databasePath: string; agentId: string };
export async function runMemoryPresenceInspection(databasePath: string): Promise<boolean> {
ensureSqliteLibrarySelected();
const result = await retrieval.run(
{ kind: "presence", databasePath },
{ inputBytes: databasePath.length * 2 },
);
if (result.kind !== "presence") {
throw new Error("Invalid memory presence worker result");
}
return result.present;
}
export async function runMemoryKeywordSearch(
target: MemoryReadTarget,
query: MemoryKeywordWorkerQuery,

View file

@ -3,6 +3,7 @@ import { openOpenClawAgentDatabaseReadOnly } from "openclaw/plugin-sdk/memory-co
import { serveWorkerTasks } from "openclaw/plugin-sdk/process-runtime";
import { bm25RankToScore, buildFtsQuery } from "./keyword-query.js";
import { searchChunksByEmbedding, searchKeyword, searchPathKeyword } from "./manager-search.js";
import { inspectMemoryIndexPresenceInWorker } from "./manager-status-presence.js";
type KeywordParameters = Omit<
Parameters<typeof searchKeyword>[0],
@ -17,15 +18,15 @@ export type MemoryVectorWorkerQuery = Omit<
Parameters<typeof searchChunksByEmbedding>[0],
"db" | "signal"
>;
export type MemorySearchWorkerInput = {
databasePath: string;
agentId: string;
} & (
| { kind: "keyword"; query: MemoryKeywordWorkerQuery }
| { kind: "vector"; query: MemoryVectorWorkerQuery }
);
export type MemorySearchWorkerInput =
| { kind: "presence"; databasePath: string }
| ({ databasePath: string; agentId: string } & (
| { kind: "keyword"; query: MemoryKeywordWorkerQuery }
| { kind: "vector"; query: MemoryVectorWorkerQuery }
));
type QueryResult<T> = { rows: T; error?: string };
export type MemorySearchWorkerOutput =
| { kind: "presence"; present: boolean }
| {
kind: "keyword";
body: QueryResult<Awaited<ReturnType<typeof searchKeyword>>>;
@ -34,8 +35,12 @@ export type MemorySearchWorkerOutput =
| { kind: "vector"; rows: Awaited<ReturnType<typeof searchChunksByEmbedding>> };
serveWorkerTasks(async (input): Promise<MemorySearchWorkerOutput> => {
// SAFETY: The paired runtime constructs the request; the canonical reader validates the database owner.
// SAFETY: The paired runtime constructs the private request union.
const request = input as MemorySearchWorkerInput;
if (request.kind === "presence") {
// This pre-manager probe also recognizes shipped memory-only databases.
return { kind: "presence", present: inspectMemoryIndexPresenceInWorker(request.databasePath) };
}
const opened = openOpenClawAgentDatabaseReadOnly({
agentId: request.agentId,
path: request.databasePath,

View file

@ -0,0 +1,8 @@
export async function inspectMemoryIndexPresence(databasePath: string): Promise<boolean> {
const { runMemoryPresenceInspection } = await import("./manager-cpu-worker-runtime.js");
try {
return await runMemoryPresenceInspection(databasePath);
} catch {
return false;
}
}

View file

@ -0,0 +1,64 @@
import type { DatabaseSync } from "node:sqlite";
import { openNodeSqliteDatabase } from "openclaw/plugin-sdk/memory-core-host-engine-knn";
import {
MEMORY_INDEX_CHUNKS_TABLE,
MEMORY_INDEX_META_TABLE,
MEMORY_INDEX_SOURCES_TABLE,
} from "openclaw/plugin-sdk/memory-core-host-engine-schema";
const MEMORY_INDEX_META_KEY = "memory_index_meta_v1";
/** Inspect existing memory tables without constructing or migrating a manager. */
export function inspectMemoryIndexPresenceInWorker(databasePath: string): boolean {
let db: DatabaseSync | undefined;
try {
db = openNodeSqliteDatabase(databasePath, { readOnly: true });
const builtInMemoryTableSets = [
{
meta: MEMORY_INDEX_META_TABLE,
sources: MEMORY_INDEX_SOURCES_TABLE,
chunks: MEMORY_INDEX_CHUNKS_TABLE,
},
{ meta: "meta", sources: "files", chunks: "chunks" },
] as const;
const builtInMemoryTables = builtInMemoryTableSets.flatMap(({ meta, sources, chunks }) => [
meta,
sources,
chunks,
]);
const tableNames = new Set(
db
.prepare(
`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${builtInMemoryTables.map(() => "?").join(", ")})`,
)
.all(...builtInMemoryTables)
.map((row) => row.name)
.filter((name): name is string => typeof name === "string"),
);
for (const tables of builtInMemoryTableSets) {
if (
tableNames.has(tables.meta) &&
db
.prepare(`SELECT 1 AS ok FROM ${tables.meta} WHERE key = ? LIMIT 1`)
.get(MEMORY_INDEX_META_KEY)
) {
return true;
}
for (const tableName of [tables.sources, tables.chunks]) {
if (
tableNames.has(tableName) &&
db.prepare(`SELECT 1 AS ok FROM ${tableName} LIMIT 1`).get()
) {
return true;
}
}
}
return false;
} catch {
return false;
} finally {
try {
db?.close();
} catch {}
}
}

View file

@ -0,0 +1 @@
export { inspectMemoryIndexPresence } from "./src/memory/manager-status-presence.runtime.js";

View file

@ -105,7 +105,7 @@ const rawSqliteAllowPathGroups = {
],
"read-only SQLite status probes": [
"src/commands/doctor-db-bloat.read.ts",
"src/commands/status.scan.shared.ts",
"extensions/memory-core/src/memory/manager-status-presence.ts",
],
"doctor SQLite maintenance and legacy state migration": [
"src/commands/doctor-agent-memory-schema.ts",

View file

@ -0,0 +1,240 @@
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync, StatementSync } from "node:sqlite";
import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
import { buildMemoryIndexStrictSchema } from "../../packages/memory-host-sdk/src/host/memory-schema-base.js";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { OutputRuntimeEnv } from "../runtime.js";
import {
applyStatusScanDefaults,
createStatusMemorySearchManager,
createStatusScanSharedMocks,
loadStatusScanModuleForTest,
} from "./status.scan.test-helpers.js";
const tempDirs = useAutoCleanupTempDirTracker((cleanup) =>
afterEach(async () => {
try {
const pools = [...(poolRun?.pools ?? [])];
await Promise.all(pools.map((pool) => pool.rotate()));
for (const pool of pools) {
expect(pool.isClosed).toBe(false);
expect(pool.getSnapshot()).toMatchObject({ workers: 0, activeTasks: 0, pendingTasks: 0 });
}
cleanup();
} finally {
vi.unstubAllEnvs();
}
}),
);
const mocks = {
...createStatusScanSharedMocks("memory-presence-worker"),
callGateway: vi.fn(async () => null),
resolveMemorySearchConfig: vi.fn(),
};
const writeJson = vi.fn<(value: unknown, space?: number) => void>();
const runtime: OutputRuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
writeStdout: vi.fn(),
writeJson,
};
let statusJsonCommand: typeof import("./status-json.js").statusJsonCommand;
let sqliteOwner: typeof import("../infra/node-sqlite.js");
let poolRun: Awaited<ReturnType<typeof observeWorkerPools>> | undefined;
async function observeWorkerPools() {
const { resolveBundledPublicSurfaceLocation } = await import("../plugin-sdk/facade-loader.js");
const { getCachedPluginModuleLoader } = await import("../plugins/plugin-module-loader-cache.js");
const { preparePluginLoaderAliases } = await import("../plugins/sdk-alias.js");
const location = resolveBundledPublicSurfaceLocation({
dirName: "memory-core",
artifactBasename: "status-api.js",
preferSource: false,
});
if (!location) {
throw new Error("Memory status artifact was not resolved");
}
// The public artifact uses its native module graph, outside Vitest's module cache.
const facadeUrl = new URL("../plugin-sdk/facade-loader.ts", import.meta.url).href;
const loader = getCachedPluginModuleLoader({
modulePath: location.modulePath,
importerUrl: facadeUrl,
loaderFilename: facadeUrl,
preferBuiltDist: true,
});
const sdkPath = preparePluginLoaderAliases({
modulePath: location.modulePath,
moduleUrl: facadeUrl,
argv1: process.argv[1],
}).resolveAlias("openclaw/plugin-sdk/process-runtime");
if (!sdkPath) {
throw new Error("Memory worker SDK was not resolved");
}
const { WorkerTaskPool } = loader(sdkPath) as typeof import("../plugin-sdk/process-runtime.js");
type Pool = InstanceType<typeof WorkerTaskPool>;
const pools = new Set<Pool>();
let nextFailure: Error | undefined;
// oxlint-disable-next-line typescript/unbound-method -- Every intercepted call supplies the actual pool receiver via apply.
const originalRun = WorkerTaskPool.prototype.run;
const run = vi.spyOn(WorkerTaskPool.prototype, "run").mockImplementation(function (
this: Pool,
...args: Parameters<typeof originalRun>
) {
pools.add(this);
if (nextFailure) {
const failure = nextFailure;
nextFailure = undefined;
return Promise.reject(failure);
}
return originalRun.apply(this, args);
});
return {
run,
pools,
rejectNextInspection(error: Error) {
nextFailure = error;
},
};
}
beforeAll(async () => {
applyStatusScanDefaults(mocks);
await loadStatusScanModuleForTest(mocks, { fastJson: true });
vi.doMock("../gateway/connection-details.js", async () => ({
...(await vi.importActual<typeof import("../gateway/connection-details.js")>(
"../gateway/connection-details.js",
)),
buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails,
buildGatewayConnectionDetailsWithResolvers: mocks.buildGatewayConnectionDetails,
}));
vi.doMock("./status-runtime-shared.ts", () => ({
resolveStatusRuntimeSnapshot: async () => ({
gatewayService: { label: "test-service", installed: false },
nodeService: { label: "test-service", installed: false },
}),
}));
vi.doMock("../state/backup-run-records.js", () => ({
readBackupRunFreshness: async () => ({}),
}));
vi.doMock("../infra/update-run-status.js", () => ({ readUpdateRunStatus: () => ({}) }));
vi.resetModules();
({ statusJsonCommand } = await import("./status-json.js"));
sqliteOwner = await import("../infra/node-sqlite.js");
});
beforeEach(() => {
for (const mock of Object.values(mocks)) {
mock.mockClear();
}
writeJson.mockClear();
const memoryManager = createStatusMemorySearchManager();
memoryManager.manager.status.mockReturnValue({ files: 1, chunks: 0, dirty: false });
applyStatusScanDefaults(mocks, { memoryManager });
});
afterAll(async () => {
poolRun?.run.mockRestore();
const { resetFacadeLoaderStateForTest } = await import("../plugin-sdk/facade-loader.js");
const { waitForPluginCacheRetirement } = await import("../plugins/plugin-cache.js");
resetFacadeLoaderStateForTest();
await waitForPluginCacheRetirement();
});
it.each(["populated", "empty", "unrelated", "missing", "unavailable"] as const)(
"keeps offline JSON-all %s memory presence off the caller's SQLite thread",
async (kind) => {
const stateDir = tempDirs.make("openclaw-status-memory-presence-");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
const { resolveDefaultMemoryDatabasePath } = await import("./status.scan-memory.js");
const databasePath = resolveDefaultMemoryDatabasePath("main");
if (kind !== "missing") {
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
const database = new DatabaseSync(databasePath);
try {
database.exec(
kind === "unrelated"
? "CREATE TABLE unrelated_feature (id INTEGER PRIMARY KEY) STRICT"
: buildMemoryIndexStrictSchema({
embeddingCacheTable: "memory_embedding_cache",
includeEmbeddingCache: false,
}),
);
if (kind === "populated" || kind === "unavailable") {
database.exec(
"INSERT INTO memory_index_sources (path, source, hash, mtime, size) VALUES ('MEMORY.md', 'memory', 'synthetic', 1, 10)",
);
}
} finally {
database.close();
}
}
const before = kind === "missing" ? undefined : fs.readFileSync(databasePath);
mocks.resolveMemorySearchConfig.mockReturnValue({ store: { databasePath } });
const observer = (poolRun ??= await observeWorkerPools());
if (kind === "unavailable") {
// Exercise dispatch rejection without creating a failed native worker.
observer.rejectNextInspection(new Error("Synthetic inspection unavailable"));
}
const nativeCalls = [
vi.spyOn(sqliteOwner, "openNodeSqliteDatabase"),
vi.spyOn(DatabaseSync.prototype, "prepare"),
vi.spyOn(DatabaseSync.prototype, "exec"),
vi.spyOn(DatabaseSync.prototype, "close"),
vi.spyOn(StatementSync.prototype, "all"),
vi.spyOn(StatementSync.prototype, "get"),
vi.spyOn(StatementSync.prototype, "run"),
];
try {
const samples = [];
for (let attempt = 0; attempt < (kind === "populated" ? 4 : 1); attempt++) {
writeJson.mockClear();
mocks.getMemorySearchManager.mockClear();
observer.run.mockClear();
for (const call of nativeCalls) {
call.mockClear();
}
const started = performance.now();
await statusJsonCommand({ all: true }, runtime);
const durationMs = performance.now() - started;
if (kind === "missing") {
expect(observer.run).not.toHaveBeenCalled();
} else {
expect(observer.run).toHaveBeenCalled();
expect(observer.pools.size).toBeGreaterThan(0);
}
expect(writeJson).toHaveBeenCalledOnce();
expect(writeJson).toHaveBeenCalledWith(
expect.objectContaining({
gateway: expect.objectContaining({ reachable: false }),
memory:
kind === "populated" ? { agentId: "main", files: 1, chunks: 0, dirty: false } : null,
}),
2,
);
expect(mocks.getMemorySearchManager).toHaveBeenCalledTimes(kind === "populated" ? 1 : 0);
const parentSqlCalls = nativeCalls.map((call) => call.mock.calls.length);
expect(parentSqlCalls).toEqual([0, 0, 0, 0, 0, 0, 0]);
samples.push({ attempt, durationMs, parentSqlCalls });
}
console.info(
JSON.stringify({
fixture: kind,
timingScope: "handler after same-loader SDK observer setup",
samples,
json: writeJson.mock.calls[0]?.[0],
}),
);
if (before) {
expect(fs.readFileSync(databasePath)).toEqual(before);
} else {
expect(fs.existsSync(databasePath)).toBe(false);
}
} finally {
for (const call of nativeCalls) {
call.mockRestore();
}
}
},
);

View file

@ -2,7 +2,6 @@
// This file owns the cross-command contracts reused by normal, JSON, and status-all scans.
import { existsSync } from "node:fs";
import type { DatabaseSync } from "node:sqlite";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
@ -14,7 +13,6 @@ import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js";
import { isLoopbackGatewayUrl } from "../gateway/net.js";
import { resolveGatewayProbeTarget } from "../gateway/probe-target.js";
import type { GatewayProbeResult, probeGateway as probeGatewayFn } from "../gateway/probe.js";
import { openNodeSqliteDatabase } from "../infra/node-sqlite.js";
import type { MemoryProviderStatus } from "../memory-host-sdk/engine-storage.js";
import { defaultSlotIdForKey } from "../plugins/slots.js";
import { normalizeAgentId } from "../routing/session-key.js";
@ -28,10 +26,13 @@ import { isProbeReachable } from "./gateway-status/helpers.js";
const gatewayProbeModuleLoader = createLazyImportLoader(() => import("./status.gateway-probe.js"));
const probeGatewayModuleLoader = createLazyImportLoader(() => import("../gateway/probe.js"));
const gatewayCallModuleLoader = createLazyImportLoader(() => import("../gateway/call.js"));
const memoryEngineStorageModuleLoader = createLazyImportLoader(
() => import("../memory-host-sdk/engine-storage.js"),
);
const MEMORY_INDEX_META_KEY = "memory_index_meta_v1";
const memoryPresenceModuleLoader = createLazyImportLoader(async () => {
const { loadBundledPluginPublicArtifactModuleSync } =
await import("../plugins/public-surface-loader.js");
return loadBundledPluginPublicArtifactModuleSync<{
inspectMemoryIndexPresence: (databasePath: string) => Promise<boolean>;
}>({ dirName: "memory-core", artifactBasename: "status-api.js" });
});
export function resolveStatusGatewayProbeTimeoutMs(opts: {
timeoutMs?: number;
@ -56,65 +57,8 @@ async function hasBuiltInMemoryState(databasePath: string): Promise<boolean> {
if (!existsSync(databasePath)) {
return false;
}
const { MEMORY_INDEX_CHUNKS_TABLE, MEMORY_INDEX_META_TABLE, MEMORY_INDEX_SOURCES_TABLE } =
await memoryEngineStorageModuleLoader.load();
let db: DatabaseSync | undefined;
try {
db = openNodeSqliteDatabase(databasePath, { readOnly: true });
const builtInMemoryTableSets = [
{
meta: MEMORY_INDEX_META_TABLE,
sources: MEMORY_INDEX_SOURCES_TABLE,
chunks: MEMORY_INDEX_CHUNKS_TABLE,
},
{
meta: "meta",
sources: "files",
chunks: "chunks",
},
] as const;
const builtInMemoryTables = builtInMemoryTableSets.flatMap(({ meta, sources, chunks }) => [
meta,
sources,
chunks,
]);
const tableNames = new Set(
(
db
.prepare(
`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${builtInMemoryTables.map(() => "?").join(", ")})`,
)
.all(...builtInMemoryTables) as Array<{ name?: unknown }>
)
.map((row) => row.name)
.filter((name): name is string => typeof name === "string"),
);
for (const tables of builtInMemoryTableSets) {
if (
tableNames.has(tables.meta) &&
db
.prepare(`SELECT 1 AS ok FROM ${tables.meta} WHERE key = ? LIMIT 1`)
.get(MEMORY_INDEX_META_KEY)
) {
return true;
}
for (const tableName of [tables.sources, tables.chunks]) {
if (
tableNames.has(tableName) &&
db.prepare(`SELECT 1 AS ok FROM ${tableName} LIMIT 1`).get()
) {
return true;
}
}
}
return false;
} catch {
return false;
} finally {
try {
db?.close();
} catch {}
}
const { inspectMemoryIndexPresence } = await memoryPresenceModuleLoader.load();
return await inspectMemoryIndexPresence(databasePath);
}
export type MemoryStatusSnapshot = MemoryProviderStatus & {

View file

@ -1,11 +1,2 @@
/**
* Core-facing facade for memory backend storage config resolution. Keep this
* path stable while the shared SDK package owns provider status semantics.
*/
export {
MEMORY_INDEX_CHUNKS_TABLE,
MEMORY_INDEX_META_TABLE,
MEMORY_INDEX_SOURCES_TABLE,
resolveMemoryBackendConfig,
type MemoryProviderStatus,
} from "../../packages/memory-host-sdk/src/engine-storage.js";
/** Core-facing memory status types; runtime storage belongs to the memory plugin. */
export type { MemoryProviderStatus } from "../../packages/memory-host-sdk/src/engine-storage.js";