diff --git a/src/skills/runtime/refresh.test.ts b/src/skills/runtime/refresh.test.ts index e525661071b8..746862938510 100644 --- a/src/skills/runtime/refresh.test.ts +++ b/src/skills/runtime/refresh.test.ts @@ -34,6 +34,7 @@ function createMockWatcher() { } const createdWatchers: Array> = []; +const SKILLS_WORKSPACE_WATCH_MAX_ENTRIES = 128; const watchMock = vi.fn(() => { const watcher = createMockWatcher(); createdWatchers.push(watcher); @@ -1040,4 +1041,73 @@ describe("ensureSkillsWatcher", () => { expect(createdWatchers[activeSkillsIndex]?.close).not.toHaveBeenCalled(); }); + + it("bounds execution-root watcher generations by least-recently ensured ownership", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + const workspaceDir = "/tmp/workspace-execution-churn"; + const executionDir = (index: number) => `/tmp/execution-cap-${index}/skills`; + + for (let index = 0; index < SKILLS_WORKSPACE_WATCH_MAX_ENTRIES; index += 1) { + refreshModule.ensureSkillsWatcher({ workspaceDir, executionSkillsDir: executionDir(index) }); + vi.advanceTimersByTime(1); + } + refreshModule.ensureSkillsWatcher({ workspaceDir, executionSkillsDir: executionDir(0) }); + vi.advanceTimersByTime(1); + + const watcherIndexFor = (index: number) => + (watchMock.mock.calls as unknown as Array<[string]>).findIndex( + ([target]) => target === executionDir(index), + ); + const oldestWatcherIndex = watcherIndexFor(1); + const refreshedWatcherIndex = watcherIndexFor(0); + const versionBeforeEviction = getSkillsSnapshotVersion(workspaceDir); + + refreshModule.ensureSkillsWatcher({ + workspaceDir, + executionSkillsDir: executionDir(SKILLS_WORKSPACE_WATCH_MAX_ENTRIES), + }); + + expect(createdWatchers[oldestWatcherIndex]?.close).toHaveBeenCalledTimes(1); + expect(createdWatchers[refreshedWatcherIndex]?.close).not.toHaveBeenCalled(); + expect(getSkillsSnapshotVersion(workspaceDir)).toBeGreaterThan(versionBeforeEviction); + + for ( + let index = SKILLS_WORKSPACE_WATCH_MAX_ENTRIES + 1; + index < SKILLS_WORKSPACE_WATCH_MAX_ENTRIES * 10; + index += 1 + ) { + vi.advanceTimersByTime(1); + refreshModule.ensureSkillsWatcher({ workspaceDir, executionSkillsDir: executionDir(index) }); + } + const liveExecutionWatchers = () => + (watchMock.mock.calls as unknown as Array<[string]>).filter( + ([target], index) => + /^\/tmp\/execution-cap-\d+\/skills$/u.test(target) && + createdWatchers[index]?.close.mock.calls.length === 0, + ); + expect(liveExecutionWatchers()).toHaveLength(SKILLS_WORKSPACE_WATCH_MAX_ENTRIES); + const sharedWorkspaceWatcherIndexes = ( + watchMock.mock.calls as unknown as Array<[string]> + ).flatMap(([target], index) => (target === path.join(workspaceDir, "skills") ? [index] : [])); + expect(sharedWorkspaceWatcherIndexes).toHaveLength(1); + const sharedWorkspaceWatcherIndex = sharedWorkspaceWatcherIndexes[0]; + if (sharedWorkspaceWatcherIndex === undefined) { + throw new Error("expected a shared workspace watcher"); + } + expect(createdWatchers[sharedWorkspaceWatcherIndex]?.close).not.toHaveBeenCalled(); + + const callsBeforeRebuild = watchMock.mock.calls.length; + const versionBeforeRebuild = getSkillsSnapshotVersion(workspaceDir); + refreshModule.ensureSkillsWatcher({ workspaceDir, executionSkillsDir: executionDir(1) }); + const rebuildCalls = watchMock.mock.calls.slice(callsBeforeRebuild) as unknown as Array< + [string] + >; + expect(rebuildCalls.some(([target]) => target === executionDir(1))).toBe(true); + expect(getSkillsSnapshotVersion(workspaceDir)).toBeGreaterThan(versionBeforeRebuild); + expect(liveExecutionWatchers()).toHaveLength(SKILLS_WORKSPACE_WATCH_MAX_ENTRIES); + + await refreshModule.closeSkillsWatchers(); + expect(createdWatchers.every((watcher) => watcher.close.mock.calls.length > 0)).toBe(true); + }); }); diff --git a/src/skills/runtime/refresh.ts b/src/skills/runtime/refresh.ts index 7c33d5b6a774..d98e5f677cfd 100644 --- a/src/skills/runtime/refresh.ts +++ b/src/skills/runtime/refresh.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { expectDefined, normalizeOptionalString } from "@openclaw/normalization-core"; import chokidar, { type FSWatcher } from "chokidar"; import { isDefaultStateDir } from "../../config/paths.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -75,8 +75,8 @@ const workspaceWatchOwnerDirs = new Map(); // filesystem changes require a fresh root scan. const workspaceWatchTargetCache = new Map(); const workspaceWatchLastEnsuredAt = new Map(); -// Session turns re-ensure their workspace; entries older than this are treated -// as abandoned subscriptions and evicted by the next ensure call. +// Session turns re-ensure their workspace; idle and cardinality eviction share +// disposal so every unwatched interval invalidates the stable workspace version. const SKILLS_WORKSPACE_WATCH_IDLE_TTL_MS = 60 * 60_000; setSkillsChangeListenerErrorHandler((err) => { @@ -700,6 +700,10 @@ export function ensureSkillsWatcher(params: { return; } + if (!workspaceWatchLastEnsuredAt.delete(watcherKey) && workspaceWatchLastEnsuredAt.size >= 128) { + const oldestKey = expectDefined(workspaceWatchLastEnsuredAt.keys().next().value, "watcher"); + disposeWorkspaceWatchState(oldestKey); + } workspaceWatchLastEnsuredAt.set(watcherKey, now); const watchTargets = resolveWatchTargets( workspaceDir,