fix(memory): keep ignored-name QMD roots watchable

Fix QMD watcher ignore handling for explicitly configured roots whose directory names are normally ignored, and prefer the most-specific configured watch root for overlapping collections.

Validated with focused QMD/tooling tests, full core support boundary tests, green CI, and ClawSweeper re-review.
This commit is contained in:
Vincent Koc
2026-06-11 00:12:15 +09:00
committed by GitHub
parent 0e7b5c3429
commit dbcae5b78d
3 changed files with 126 additions and 5 deletions

View File

@@ -123,6 +123,14 @@ function firstWatchOptions(): WatchOptions {
return call[1];
}
function firstWatchPaths(): string[] {
const call = watchMock.mock.calls[0] as unknown as [string[], WatchOptions] | undefined;
if (!call) {
throw new Error("Expected watch call");
}
return call[0];
}
function firstEmbedLockCall(): EmbedLockCall {
const call = withFileLockMock.mock.calls.find((entry) =>
entry[0].endsWith(path.join("qmd", "embed.lock")),
@@ -579,6 +587,92 @@ describe("QmdMemoryManager", () => {
await manager.close();
});
it("keeps explicit qmd collection roots watchable when their directory name is ignored", async () => {
const rootNames = ["build", "dist", "vendor", ".cache"];
const roots = rootNames.map((name) => path.join(workspaceDir, name));
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: roots.map((root) => ({
path: root,
pattern: "**/*.md",
name: path.basename(root),
})),
},
},
} as OpenClawConfig;
const { manager } = await createManager({ mode: "full" });
expect(watchMock).toHaveBeenCalledTimes(1);
expect(firstWatchPaths().toSorted()).toEqual(
roots.map((root) => path.join(root, "**/*.md")).toSorted(),
);
const ignored = firstWatchOptions().ignored;
for (const root of roots) {
expect(ignored?.(root)).toBe(false);
expect(ignored?.(path.join(root, "note.md"))).toBe(false);
expect(ignored?.(path.join(root, "..notes", "daily.md"))).toBe(false);
expect(ignored?.(path.join(root, "notes", "daily.md"))).toBe(false);
expect(ignored?.(path.join(root, "node_modules", "pkg", "note.md"))).toBe(true);
expect(ignored?.(path.join(root, "build", "artifact.md"))).toBe(true);
}
await manager.close();
});
it("prefers a nested explicit qmd collection root over a broader watched root", async () => {
const nestedRoot = path.join(workspaceDir, "build");
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" },
{ path: nestedRoot, pattern: "**/*.md", name: "build" },
],
},
},
} as OpenClawConfig;
const { manager } = await createManager({ mode: "full" });
const ignored = firstWatchOptions().ignored;
expect(ignored?.(path.join(nestedRoot, "note.md"))).toBe(false);
expect(ignored?.(path.join(nestedRoot, "..notes", "daily.md"))).toBe(false);
expect(ignored?.(path.join(nestedRoot, "node_modules", "pkg", "note.md"))).toBe(true);
expect(ignored?.(path.join(workspaceDir, "node_modules", "pkg", "note.md"))).toBe(true);
await manager.close();
});
it("delays qmd watch sync until changed file stats settle", async () => {
vi.useFakeTimers();
cfg = {

View File

@@ -232,14 +232,37 @@ function resolveQmdStoreWriteLockOptions(updateTimeoutMs: number, embedTimeoutMs
);
}
function shouldIgnoreMemoryWatchPath(watchPath: string): boolean {
const normalized = path.normalize(watchPath);
const parts = normalized
function hasIgnoredMemoryWatchSegment(relativePath: string): boolean {
const parts = relativePath
.split(path.sep)
.map((segment) => normalizeLowercaseStringOrEmpty(segment));
.map((segment) => normalizeLowercaseStringOrEmpty(segment))
.filter(Boolean);
return parts.some((segment) => IGNORED_MEMORY_WATCH_DIR_NAMES.has(segment));
}
function shouldIgnoreMemoryWatchPath(watchPath: string, roots: readonly string[]): boolean {
const normalized = path.normalize(watchPath);
let matchedRelative: string | null = null;
let matchedRootLength = -1;
for (const watchRoot of roots) {
const normalizedRoot = path.normalize(watchRoot);
const relative = path.relative(normalizedRoot, normalized);
if (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
if (normalizedRoot.length > matchedRootLength) {
matchedRelative = relative;
matchedRootLength = normalizedRoot.length;
}
}
}
if (matchedRelative !== null) {
if (matchedRelative === "") {
return false;
}
return hasIgnoredMemoryWatchSegment(matchedRelative);
}
return hasIgnoredMemoryWatchSegment(normalized);
}
type CollectionRoot = {
path: string;
kind: MemorySource;
@@ -1653,10 +1676,12 @@ export class QmdMemoryManager implements MemorySearchManager {
return;
}
const watchPaths = new Set<string>();
const watchRoots = new Set<string>();
for (const collection of this.qmd.collections) {
if (collection.kind === "sessions") {
continue;
}
watchRoots.add(path.normalize(collection.path));
watchPaths.add(this.resolveCollectionWatchPath(collection));
}
if (watchPaths.size === 0) {
@@ -1665,9 +1690,10 @@ export class QmdMemoryManager implements MemorySearchManager {
const watchPathList = Array.from(watchPaths);
const startTime = Date.now();
log.info(`qmd watcher starting for agent "${this.agentId}" paths=${watchPathList.length}`);
const watchRootList = Array.from(watchRoots);
const watcher = chokidar.watch(watchPathList, {
ignoreInitial: true,
ignored: (watchPath) => shouldIgnoreMemoryWatchPath(watchPath),
ignored: (watchPath) => shouldIgnoreMemoryWatchPath(watchPath, watchRootList),
});
this.watcher = watcher;
const markDirty = (watchPath?: string, stats?: MemoryWatchEventStats) => {

View File

@@ -198,6 +198,7 @@ src/a.ts: src/a.ts
const resultPromise = runKnipUnusedFiles({
env: { PATH: "" },
npmExecPath: "",
platform: "linux",
spawnCommand(command: string, args: string[], options: unknown) {
calls.push({ args, command, options });
const child = new FakeKnipProcess();