openclaw/src/agents/skills/refresh.ts
Roshan Singh 6aaa302be5 Fix #1056: ignore heavy paths in skills watcher
On macOS, watching deep dependency trees can exhaust file descriptors and lead to spawn EBADF failures. The skills watcher only needs to observe skill changes, so ignore dotfiles, node_modules, and dist by default. Adds regression coverage.
2026-01-17 07:40:20 +00:00

163 lines
4.9 KiB
TypeScript

import path from "node:path";
import chokidar, { type FSWatcher } from "chokidar";
import type { ClawdbotConfig } from "../../config/config.js";
import { createSubsystemLogger } from "../../logging.js";
import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
type SkillsChangeEvent = {
workspaceDir?: string;
reason: "watch" | "manual" | "remote-node";
changedPath?: string;
};
type SkillsWatchState = {
watcher: FSWatcher;
pathsKey: string;
debounceMs: number;
timer?: ReturnType<typeof setTimeout>;
pendingPath?: string;
};
const log = createSubsystemLogger("gateway/skills");
const listeners = new Set<(event: SkillsChangeEvent) => void>();
const workspaceVersions = new Map<string, number>();
const watchers = new Map<string, SkillsWatchState>();
let globalVersion = 0;
function bumpVersion(current: number): number {
const now = Date.now();
return now <= current ? current + 1 : now;
}
function emit(event: SkillsChangeEvent) {
for (const listener of listeners) {
try {
listener(event);
} catch (err) {
log.warn(`skills change listener failed: ${String(err)}`);
}
}
}
function resolveWatchPaths(workspaceDir: string, config?: ClawdbotConfig): string[] {
const paths: string[] = [];
if (workspaceDir.trim()) {
paths.push(path.join(workspaceDir, "skills"));
}
paths.push(path.join(CONFIG_DIR, "skills"));
const extraDirsRaw = config?.skills?.load?.extraDirs ?? [];
const extraDirs = extraDirsRaw
.map((d) => (typeof d === "string" ? d.trim() : ""))
.filter(Boolean)
.map((dir) => resolveUserPath(dir));
paths.push(...extraDirs);
return paths;
}
export function registerSkillsChangeListener(listener: (event: SkillsChangeEvent) => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function bumpSkillsSnapshotVersion(params?: {
workspaceDir?: string;
reason?: SkillsChangeEvent["reason"];
changedPath?: string;
}): number {
const reason = params?.reason ?? "manual";
const changedPath = params?.changedPath;
if (params?.workspaceDir) {
const current = workspaceVersions.get(params.workspaceDir) ?? 0;
const next = bumpVersion(current);
workspaceVersions.set(params.workspaceDir, next);
emit({ workspaceDir: params.workspaceDir, reason, changedPath });
return next;
}
globalVersion = bumpVersion(globalVersion);
emit({ reason, changedPath });
return globalVersion;
}
export function getSkillsSnapshotVersion(workspaceDir?: string): number {
if (!workspaceDir) return globalVersion;
const local = workspaceVersions.get(workspaceDir) ?? 0;
return Math.max(globalVersion, local);
}
export function ensureSkillsWatcher(params: { workspaceDir: string; config?: ClawdbotConfig }) {
const workspaceDir = params.workspaceDir.trim();
if (!workspaceDir) return;
const watchEnabled = params.config?.skills?.load?.watch !== false;
const debounceMsRaw = params.config?.skills?.load?.watchDebounceMs;
const debounceMs =
typeof debounceMsRaw === "number" && Number.isFinite(debounceMsRaw)
? Math.max(0, debounceMsRaw)
: 250;
const existing = watchers.get(workspaceDir);
if (!watchEnabled) {
if (existing) {
watchers.delete(workspaceDir);
if (existing.timer) clearTimeout(existing.timer);
void existing.watcher.close().catch(() => {});
}
return;
}
const watchPaths = resolveWatchPaths(workspaceDir, params.config);
const pathsKey = watchPaths.join("|");
if (existing && existing.pathsKey === pathsKey && existing.debounceMs === debounceMs) {
return;
}
if (existing) {
watchers.delete(workspaceDir);
if (existing.timer) clearTimeout(existing.timer);
void existing.watcher.close().catch(() => {});
}
const watcher = chokidar.watch(watchPaths, {
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: debounceMs,
pollInterval: 100,
},
// Avoid FD exhaustion on macOS when a workspace contains huge trees.
// This watcher only needs to react to skill changes.
ignored: [
/(^|[\\/])\../, // dotfiles (includes .git)
/(^|[\\/])node_modules([\\/]|$)/,
/(^|[\\/])dist([\\/]|$)/,
],
});
const state: SkillsWatchState = { watcher, pathsKey, debounceMs };
const schedule = (changedPath?: string) => {
state.pendingPath = changedPath ?? state.pendingPath;
if (state.timer) clearTimeout(state.timer);
state.timer = setTimeout(() => {
const pendingPath = state.pendingPath;
state.pendingPath = undefined;
state.timer = undefined;
bumpSkillsSnapshotVersion({
workspaceDir,
reason: "watch",
changedPath: pendingPath,
});
}, debounceMs);
};
watcher.on("add", (p) => schedule(p));
watcher.on("change", (p) => schedule(p));
watcher.on("unlink", (p) => schedule(p));
watcher.on("error", (err) => {
log.warn(`skills watcher error (${workspaceDir}): ${String(err)}`);
});
watchers.set(workspaceDir, state);
}