






















@@ -19,16 +19,24 @@ export {
1919type SkillsChangeEvent,
2020} from "./refresh-state.js";
212122-type SkillsWatchState = {
22+type SkillsPathWatchState = {
2323watcher: FSWatcher;
24-pathsKey: string;
2524debounceMs: number;
2625timer?: ReturnType<typeof setTimeout>;
2726pendingPath?: string;
27+readonly subscribers: Set<string>;
2828};
29293030const log = createSubsystemLogger("gateway/skills");
31-const watchers = new Map<string, SkillsWatchState>();
31+// One watcher per unique watched directory. Agent workspaces that include the
32+// same shared skill root (the global skills dir, the home skills dir, or a
33+// configured extra/plugin dir) subscribe to the same watcher instead of each
34+// opening its own, so open file descriptors scale with distinct directories
35+// rather than with agent count.
36+const pathWatchers = new Map<string, SkillsPathWatchState>();
37+// Watch targets each workspace is currently subscribed to, used to reconcile
38+// subscriptions and to detect watch-target changes across calls.
39+const workspaceWatchTargets = new Map<string, string[]>();
32403341setSkillsChangeListenerErrorHandler((err) => {
3442log.warn(`skills change listener failed: ${String(err)}`);
@@ -98,45 +106,25 @@ export function shouldIgnoreSkillsWatchPath(
98106return path.posix.basename(normalized) !== "SKILL.md";
99107}
100108101-export function ensureSkillsWatcher(params: { workspaceDir: string; config?: OpenClawConfig }) {
102-const workspaceDir = params.workspaceDir.trim();
103-if (!workspaceDir) {
104-return;
105-}
106-const watchEnabled = params.config?.skills?.load?.watch !== false;
107-const debounceMsRaw = params.config?.skills?.load?.watchDebounceMs;
108-const debounceMs =
109-typeof debounceMsRaw === "number" && Number.isFinite(debounceMsRaw)
110- ? Math.max(0, debounceMsRaw)
111- : 250;
112-113-const existing = watchers.get(workspaceDir);
114-if (!watchEnabled) {
115-if (existing) {
116-watchers.delete(workspaceDir);
117-if (existing.timer) {
118-clearTimeout(existing.timer);
119-}
120-void existing.watcher.close().catch(() => {});
121-}
122-return;
123-}
109+function resolveWatchDebounceMs(config?: OpenClawConfig): number {
110+const raw = config?.skills?.load?.watchDebounceMs;
111+return typeof raw === "number" && Number.isFinite(raw) ? Math.max(0, raw) : 250;
112+}
124113125-const watchTargets = resolveWatchTargets(workspaceDir, params.config);
126-const pathsKey = watchTargets.join("|");
127-if (existing && existing.pathsKey === pathsKey && existing.debounceMs === debounceMs) {
128-return;
114+function sameWatchTargets(a: string[], b: string[]): boolean {
115+if (a.length !== b.length) {
116+return false;
129117}
130-const watchTargetsChanged = existing ? existing.pathsKey !== pathsKey : false;
131-if (existing) {
132-watchers.delete(workspaceDir);
133-if (existing.timer) {
134-clearTimeout(existing.timer);
118+for (let index = 0; index < a.length; index++) {
119+if (a[index] !== b[index]) {
120+return false;
135121}
136-void existing.watcher.close().catch(() => {});
137122}
123+return true;
124+}
138125139-const watcher = chokidar.watch(watchTargets, {
126+function createSkillsPathWatcher(watchPath: string, debounceMs: number): SkillsPathWatchState {
127+const watcher = chokidar.watch(watchPath, {
140128ignoreInitial: true,
141129// Skill discovery reads root skills, direct child skills, and one grouped skill level.
142130depth: 2,
@@ -147,7 +135,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope
147135ignored: shouldIgnoreSkillsWatchPath,
148136});
149137150-const state: SkillsWatchState = { watcher, pathsKey, debounceMs };
138+const state: SkillsPathWatchState = { watcher, debounceMs, subscribers: new Set<string>() };
151139152140const schedule = (changedPath?: string) => {
153141state.pendingPath = changedPath ?? state.pendingPath;
@@ -158,11 +146,15 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope
158146const pendingPath = state.pendingPath;
159147state.pendingPath = undefined;
160148state.timer = undefined;
161-bumpSkillsSnapshotVersion({
162- workspaceDir,
163-reason: "watch",
164-changedPath: pendingPath,
165-});
149+// Fan the change out to every workspace subscribed to this directory so a
150+// shared skill root refreshes the snapshot for all agents that use it.
151+for (const workspaceDir of state.subscribers) {
152+bumpSkillsSnapshotVersion({
153+ workspaceDir,
154+reason: "watch",
155+changedPath: pendingPath,
156+});
157+}
166158}, debounceMs);
167159};
168160@@ -171,24 +163,113 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope
171163watcher.on("unlink", (p) => schedule(p));
172164watcher.on("unlinkDir", (p) => schedule(p));
173165watcher.on("error", (err) => {
174-log.warn(`skills watcher error (${workspaceDir}): ${String(err)}`);
166+log.warn(`skills watcher error (${watchPath}): ${String(err)}`);
175167});
176168177-watchers.set(workspaceDir, state);
169+return state;
170+}
171+172+function teardownSkillsPathWatcher(state: SkillsPathWatchState): void {
173+if (state.timer) {
174+clearTimeout(state.timer);
175+}
176+void state.watcher.close().catch(() => {});
177+}
178+179+function subscribeWorkspaceToPath(
180+workspaceDir: string,
181+watchPath: string,
182+debounceMs: number,
183+): void {
184+const existing = pathWatchers.get(watchPath);
185+if (existing && existing.debounceMs === debounceMs) {
186+existing.subscribers.add(workspaceDir);
187+return;
188+}
189+if (existing) {
190+// Debounce changed (config reload): rebuild the shared watcher while
191+// preserving existing subscribers.
192+const next = createSkillsPathWatcher(watchPath, debounceMs);
193+for (const subscriber of existing.subscribers) {
194+next.subscribers.add(subscriber);
195+}
196+next.subscribers.add(workspaceDir);
197+teardownSkillsPathWatcher(existing);
198+pathWatchers.set(watchPath, next);
199+return;
200+}
201+const state = createSkillsPathWatcher(watchPath, debounceMs);
202+state.subscribers.add(workspaceDir);
203+pathWatchers.set(watchPath, state);
204+}
205+206+function unsubscribeWorkspaceFromPath(workspaceDir: string, watchPath: string): void {
207+const state = pathWatchers.get(watchPath);
208+if (!state) {
209+return;
210+}
211+state.subscribers.delete(workspaceDir);
212+if (state.subscribers.size === 0) {
213+teardownSkillsPathWatcher(state);
214+pathWatchers.delete(watchPath);
215+}
216+}
217+218+export function ensureSkillsWatcher(params: { workspaceDir: string; config?: OpenClawConfig }) {
219+const workspaceDir = params.workspaceDir.trim();
220+if (!workspaceDir) {
221+return;
222+}
223+const watchEnabled = params.config?.skills?.load?.watch !== false;
224+const debounceMs = resolveWatchDebounceMs(params.config);
225+const previousTargets = workspaceWatchTargets.get(workspaceDir) ?? [];
226+227+if (!watchEnabled) {
228+if (previousTargets.length > 0) {
229+for (const watchPath of previousTargets) {
230+unsubscribeWorkspaceFromPath(workspaceDir, watchPath);
231+}
232+workspaceWatchTargets.delete(workspaceDir);
233+}
234+return;
235+}
236+237+const watchTargets = resolveWatchTargets(workspaceDir, params.config);
238+const targetsUnchanged = sameWatchTargets(previousTargets, watchTargets);
239+const debounceUnchanged = watchTargets.every(
240+(watchPath) => pathWatchers.get(watchPath)?.debounceMs === debounceMs,
241+);
242+if (targetsUnchanged && debounceUnchanged) {
243+return;
244+}
245+const watchTargetsChanged = previousTargets.length > 0 && !targetsUnchanged;
246+247+const nextTargets = new Set(watchTargets);
248+for (const watchPath of previousTargets) {
249+if (!nextTargets.has(watchPath)) {
250+unsubscribeWorkspaceFromPath(workspaceDir, watchPath);
251+}
252+}
253+for (const watchPath of watchTargets) {
254+subscribeWorkspaceToPath(workspaceDir, watchPath, debounceMs);
255+}
256+workspaceWatchTargets.set(workspaceDir, watchTargets);
257+178258if (watchTargetsChanged) {
179259bumpSkillsSnapshotVersion({
180260 workspaceDir,
181261reason: "watch-targets",
182-changedPath: pathsKey,
262+changedPath: watchTargets.join("|"),
183263});
184264}
185265}
186266187267export async function resetSkillsRefreshForTest(): Promise<void> {
188268resetSkillsRefreshStateForTest();
189269190-const active = Array.from(watchers.values());
191-watchers.clear();
270+const active = Array.from(pathWatchers.values());
271+pathWatchers.clear();
272+workspaceWatchTargets.clear();
192273await Promise.all(
193274active.map(async (state) => {
194275if (state.timer) {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。