fix(memory): warn on gateway watcher FD risk (#89185)

* fix(memory): default gateway memory watch off

* fix(memory): warn on gateway watcher fd risk

* fix(config): avoid warning helper narrowing

* fix(config): remove redundant warning boolean cast

* docs(memory): clarify watcher default wording

* docs(memory): simplify watcher warning copy

* fix(config): scope watcher warning to local gateway
This commit is contained in:
Dallin Romney
2026-06-01 14:23:25 -07:00
committed by GitHub
parent 403190572b
commit 2405bbcbaf
10 changed files with 262 additions and 7 deletions

View File

@@ -85,6 +85,10 @@ OpenClaw indexes `MEMORY.md` and `memory/*.md` into chunks (~400 tokens with
- **Storage maintenance:** SQLite WAL sidecars are bounded with periodic and
shutdown checkpoints.
- **File watching:** changes to memory files trigger a debounced reindex (1.5s).
File watching is enabled by default, including for gateways, so memory edits
become searchable without a manual reindex. Large memory trees, `extraPaths`,
or QMD collections can use many file descriptors in long-lived gateways; set
`sync.watch: false` for affected agents if that becomes a problem.
- **Auto-reindex:** when the embedding provider, model, or chunking config
changes, the entire index is rebuilt automatically.
- **Reindex on demand:** `openclaw memory index --force`
@@ -125,8 +129,8 @@ openclaw memory index --force --agent main
Both standalone CLI commands and the Gateway use the same `local` provider id.
Set `memorySearch.provider: "local"` when you want local embeddings.
**Stale results?** Run `openclaw memory index --force` to rebuild. The watcher
may miss changes in rare edge cases.
**Stale results?** Run `openclaw memory index --force` to rebuild. Use this when
file watching is disabled or misses a change.
**sqlite-vec not loading?** OpenClaw falls back to in-process cosine similarity
automatically. `openclaw memory status --deep` reports the local vector store

View File

@@ -527,7 +527,9 @@ QMD model overrides stay on the QMD side, not OpenClaw config. If you need to ov
</Accordion>
</AccordionGroup>
QMD boot refreshes use a one-shot subprocess path during gateway startup. The long-lived QMD manager still owns the regular file watcher and interval timers when memory search is opened for interactive use.
QMD boot refreshes use a one-shot subprocess path during gateway startup. The long-lived QMD manager owns the regular file watcher and interval timers when memory search is opened for interactive use.
Local Gateway configs can warn when memory file watching may keep too many files open. If you see open-file or watcher errors, set `sync.watch: false` for the affected agents and use manual indexing or `sync.intervalMinutes` to refresh memory.
### Full QMD example

View File

@@ -569,6 +569,41 @@ describe("QmdMemoryManager", () => {
await manager.close();
});
it("logs qmd watcher errors without throwing", async () => {
cfg = {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: "openai",
model: "mock-embed",
store: { path: path.join(workspaceDir, "index.sqlite"), vector: { enabled: false } },
sync: { watch: true, watchDebounceMs: 25, onSessionStart: false, onSearch: false },
},
},
list: [{ id: agentId, default: true, workspace: workspaceDir }],
},
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 0, onBoot: false },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
const { manager } = await createManager({ mode: "full" });
const watcher = watchMock.mock.results[0]?.value as {
emit: (event: string, ...args: unknown[]) => boolean;
};
expect(watcher.emit("error", new Error("watcher error: ENOSPC"))).toBe(true);
expect(logWarnMock).toHaveBeenCalledWith("qmd watcher error: watcher error: ENOSPC");
await manager.close();
});
it("delays qmd watch sync until changed file stats settle", async () => {
vi.useFakeTimers();
cfg = {

View File

@@ -1616,6 +1616,10 @@ export class QmdMemoryManager implements MemorySearchManager {
this.watcher.on("add", markDirty);
this.watcher.on("change", markDirty);
this.watcher.on("unlink", markDirty);
this.watcher.on("error", (err) => {
const message = err instanceof Error ? err.message : String(err);
log.warn(`qmd watcher error: ${message}`);
});
this.watcher.once("ready", () => {
log.info(
`qmd watcher ready for agent "${this.agentId}" paths=${watchPathList.length} durationMs=${Date.now() - startTime}`,

View File

@@ -291,6 +291,22 @@ describe("memory search config", () => {
expect(resolveMemorySearchSyncConfig(cfg, "main")?.embeddingBatchTimeoutSeconds).toBe(600);
});
it("keeps memory watching enabled by default in gateway mode", () => {
const cfg = asConfig({
gateway: { mode: "local" },
agents: {
defaults: {
memorySearch: {
provider: "openai",
},
},
},
});
expect(resolveMemorySearchConfig(cfg, "main")?.sync.watch).toBe(true);
expect(resolveMemorySearchSyncConfig(cfg, "main")?.watch).toBe(true);
});
it("merges defaults and overrides", () => {
const cfg = asConfig({
agents: {

View File

@@ -111,6 +111,11 @@ export type ResolvedMemorySearchConfig = {
};
export type ResolvedMemorySearchSyncConfig = ResolvedMemorySearchConfig["sync"];
export type MemorySearchResolvePurpose = "default" | "status" | "cli";
export type MemorySearchResolveOptions = {
/** @deprecated No-op; kept for resolver call-site compatibility. */
purpose?: MemorySearchResolvePurpose;
};
const DEFAULT_CHUNK_TOKENS = 400;
const DEFAULT_CHUNK_OVERLAP = 80;
@@ -468,6 +473,7 @@ function resolveSyncConfig(
export function resolveMemorySearchConfig(
cfg: OpenClawConfig,
agentId: string,
_options?: MemorySearchResolveOptions,
): ResolvedMemorySearchConfig | null {
const defaults = cfg.agents?.defaults?.memorySearch;
const overrides = resolveAgentConfig(cfg, agentId)?.memorySearch;
@@ -500,6 +506,7 @@ export function resolveMemorySearchConfig(
export function resolveMemorySearchSyncConfig(
cfg: OpenClawConfig,
agentId: string,
_options?: MemorySearchResolveOptions,
): ResolvedMemorySearchSyncConfig | null {
const defaults = cfg.agents?.defaults?.memorySearch;
const overrides = resolveAgentConfig(cfg, agentId)?.memorySearch;

View File

@@ -1331,7 +1331,7 @@ export const FIELD_HELP: Record<string, string> = {
"agents.defaults.memorySearch.sync.onSearch":
"Uses lazy sync by scheduling reindex on search after content changes are detected. Keep enabled for lower idle overhead, or disable if you require pre-synced indexes before any query.",
"agents.defaults.memorySearch.sync.watch":
"Watches memory files and schedules index updates from file-change events (chokidar). Enable for near-real-time freshness; disable on very large workspaces if watch churn is too noisy.",
"Watches memory files and schedules index updates from file-change events. Default: true. Disable for large memory trees, extraPaths, QMD collections, or multi-agent gateways if watcher file-descriptor usage becomes a problem.",
"agents.defaults.memorySearch.sync.watchDebounceMs":
"Debounce window in milliseconds for coalescing rapid file-watch events before reindex runs. Increase to reduce churn on frequently-written files, or lower for faster freshness.",
"agents.defaults.memorySearch.sync.embeddingBatchTimeoutSeconds":

View File

@@ -523,6 +523,7 @@ export type MemorySearchConfig = {
sync?: {
onSessionStart?: boolean;
onSearch?: boolean;
/** Watch memory files for reindexing (default: true). */
watch?: boolean;
watchDebounceMs?: number;
intervalMinutes?: number;

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { validateConfigObjectRaw } from "./validation.js";
import { validateConfigObjectRaw, validateConfigObjectWithPlugins } from "./validation.js";
vi.mock("../channels/plugins/legacy-config.js", () => ({
collectChannelLegacyConfigRules: () => [],
@@ -41,6 +41,108 @@ vi.mock("../secrets/unsupported-surface-policy.js", async () => {
};
});
describe("gateway memory watch config warnings", () => {
it("warns when gateway memory watching stays enabled on configured memory surfaces", () => {
const result = validateConfigObjectWithPlugins(
{
gateway: { mode: "local" },
agents: {
defaults: {
memorySearch: {
extraPaths: ["/srv/shared-notes"],
},
},
},
},
{ pluginValidation: "skip" },
);
expect(result.ok).toBe(true);
expect(result.warnings).toContainEqual(
expect.objectContaining({
path: "agents.defaults.memorySearch.sync.watch",
message: expect.stringContaining("too many files open"),
}),
);
});
it("does not warn when gateway memory watching is disabled explicitly", () => {
const result = validateConfigObjectWithPlugins(
{
gateway: { mode: "local" },
agents: {
defaults: {
memorySearch: {
extraPaths: ["/srv/shared-notes"],
sync: { watch: false },
},
},
},
},
{ pluginValidation: "skip" },
);
expect(result.ok).toBe(true);
expect(result.warnings).not.toContainEqual(
expect.objectContaining({
path: "agents.defaults.memorySearch.sync.watch",
}),
);
});
it("does not warn for remote client configs", () => {
const result = validateConfigObjectWithPlugins(
{
gateway: { mode: "remote", remote: { url: "wss://gateway.example/ws" } },
agents: {
defaults: {
memorySearch: {
extraPaths: ["/srv/shared-notes"],
sync: { watch: true },
},
},
},
},
{ pluginValidation: "skip" },
);
expect(result.ok).toBe(true);
expect(result.warnings).not.toContainEqual(
expect.objectContaining({
path: "agents.defaults.memorySearch.sync.watch",
}),
);
});
it("warns for explicit per-agent watcher overrides in multi-agent gateways", () => {
const result = validateConfigObjectWithPlugins(
{
gateway: { mode: "local" },
agents: {
defaults: {
memorySearch: {
sync: { watch: false },
},
},
list: [
{ id: "main", memorySearch: { sync: { watch: false } } },
{ id: "ops", memorySearch: { sync: { watch: true } } },
],
},
},
{ pluginValidation: "skip" },
);
expect(result.ok).toBe(true);
expect(result.warnings).toContainEqual(
expect.objectContaining({
path: "agents.list.1.memorySearch.sync.watch",
message: expect.stringContaining("many agents"),
}),
);
});
});
function requireIssue<T extends { path: string }>(issues: T[], path: string): T {
const issue = issues.find((entry) => entry.path === path);
if (!issue) {

View File

@@ -46,6 +46,7 @@ import { shouldSuppressMissingCodexPluginDiagnostics } from "./codex-plugin-diag
import { materializeRuntimeConfig } from "./materialize.js";
import type { OpenClawConfig, ConfigValidationIssue } from "./types.js";
import { coerceSecretRef } from "./types.secrets.js";
import type { MemorySearchConfig } from "./types.tools.js";
import { isBuiltInModelProviderOverlayId } from "./zod-schema.core.js";
import { OpenClawSchema } from "./zod-schema.js";
@@ -871,6 +872,88 @@ function validateGatewayTailscaleAuth(config: OpenClawConfig): ConfigValidationI
];
}
function isLocalGatewayMode(config: OpenClawConfig): boolean {
return config.gateway?.mode === "local";
}
function isMemorySearchEnabled(
defaults: MemorySearchConfig | undefined,
override: MemorySearchConfig | undefined,
): boolean {
return override?.enabled ?? defaults?.enabled ?? true;
}
function isMemoryWatchEnabled(
defaults: MemorySearchConfig | undefined,
override: MemorySearchConfig | undefined,
): boolean {
return override?.sync?.watch ?? defaults?.sync?.watch ?? true;
}
function hasConfiguredMemoryWatchFdPressureSurface(
config: OpenClawConfig,
defaults: MemorySearchConfig | undefined,
override: MemorySearchConfig | undefined,
): boolean {
const hasMemorySearchConfig = Boolean(defaults || override);
const hasMultipleGatewayAgents = (config.agents?.list?.length ?? 0) > 1;
const hasQmdBackend = config.memory?.backend === "qmd";
const hasQmdPaths = Boolean(config.memory?.qmd?.paths?.length);
const hasExtraPaths = Boolean(defaults?.extraPaths?.length || override?.extraPaths?.length);
const hasExtraQmdCollections = Boolean(
defaults?.qmd?.extraCollections?.length || override?.qmd?.extraCollections?.length,
);
const hasSessionMemory = Boolean(
defaults?.experimental?.sessionMemory || override?.experimental?.sessionMemory,
);
return (
hasMemorySearchConfig ||
hasMultipleGatewayAgents ||
hasQmdBackend ||
hasQmdPaths ||
hasExtraPaths ||
hasExtraQmdCollections ||
hasSessionMemory
);
}
function memoryWatchFdPressureWarningMessage(): string {
return "Memory file watching is on for this Gateway. This keeps memory search up to date, but large memory folders, extraPaths, QMD collections, session memory, or many agents can make the Gateway keep too many files open. If you see open-file or watcher errors, set memorySearch.sync.watch: false for the affected default or agent, then use manual indexing or sync.intervalMinutes to refresh memory.";
}
function collectGatewayMemoryWatchWarnings(config: OpenClawConfig): ConfigValidationIssue[] {
if (!isLocalGatewayMode(config)) {
return [];
}
const defaults = config.agents?.defaults?.memorySearch;
const warnings: ConfigValidationIssue[] = [];
if (
isMemorySearchEnabled(defaults, undefined) &&
isMemoryWatchEnabled(defaults, undefined) &&
hasConfiguredMemoryWatchFdPressureSurface(config, defaults, undefined)
) {
warnings.push({
path: "agents.defaults.memorySearch.sync.watch",
message: memoryWatchFdPressureWarningMessage(),
});
}
for (const [index, agent] of (config.agents?.list ?? []).entries()) {
const override = agent.memorySearch;
if (
override &&
isMemorySearchEnabled(defaults, override) &&
isMemoryWatchEnabled(defaults, override) &&
hasConfiguredMemoryWatchFdPressureSurface(config, defaults, override)
) {
warnings.push({
path: `agents.list.${index}.memorySearch.sync.watch`,
message: memoryWatchFdPressureWarningMessage(),
});
}
}
return warnings;
}
/**
* Validates config without applying runtime defaults.
* Use this when you need the raw validated config (e.g., for writing back to file).
@@ -1039,16 +1122,17 @@ function validateConfigObjectWithPluginsBase(
manifestRegistry: registryInfo?.registry,
})
: base.config;
const configWarnings = collectGatewayMemoryWatchWarnings(base.config);
if (opts.pluginValidation === "skip") {
return {
ok: true,
config,
warnings: [],
warnings: configWarnings,
};
}
const issues: ConfigValidationIssue[] = [];
const warnings: ConfigValidationIssue[] = [];
const warnings: ConfigValidationIssue[] = [...configWarnings];
const hasExplicitPluginsConfig = isRecord(raw) && Object.hasOwn(raw, "plugins");
const explicitPluginReferences = collectExplicitPluginReferences(raw);