fix(skills): bound execution watcher generations

This commit is contained in:
Amp
2026-08-21 13:54:49 +00:00
parent 75c44b2b98
commit ef947abd99
2 changed files with 77 additions and 3 deletions
+70
View File
@@ -34,6 +34,7 @@ function createMockWatcher() {
}
const createdWatchers: Array<ReturnType<typeof createMockWatcher>> = [];
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);
});
});
+7 -3
View File
@@ -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<string, string>();
// filesystem changes require a fresh root scan.
const workspaceWatchTargetCache = new Map<string, WatchTargetCacheEntry>();
const workspaceWatchLastEnsuredAt = new Map<string, number>();
// 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,