fix(agent-core-v2): bound the project skill-root watch fd footprint (#2612)
Some checks are pending
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions

* fix(agent-core-v2): bound the project skill-root watch fd footprint

The workspace skill-root source recursively watches the skill-root
candidates with chokidar, which holds one fs.watch fd per file and per
directory on macOS. A skill bundling a large runtime tree can exhaust
the process fd budget and break every subsequent spawn (EBADF).

Mirror the scanner's own pruning (node_modules / dot entries, scan
depth cap) in the watch filter, and add a signal mode to hostFsWatch:
rescan-style consumers get ONE native recursive fs.watch on
darwin/win32, whose fd footprint is constant in the subtree size.

* fix(agent-core-v2): align the skill watch with scanner semantics and harden signal mode

Review follow-up:

- The scanner probes every entry's direct SKILL.md before gating
  recursion, so the watch filter now keeps an excluded entry itself and
  its direct SKILL.md (keepEntryFile) instead of pruning them — skills
  under node_modules / dot directories keep their hot reload.
- The signal-mode native leg now owns its recovery: a native watch
  error fires one root invalidation and re-arms with capped exponential
  backoff; chokidar is used only where recursive fs.watch is
  unavailable, so a transient failure can neither silently end hot
  reload nor downgrade to the per-node watcher.
- Native event path resolution handles absolute filenames and the
  root-basename case, clamping out-of-root events to a root
  invalidation instead of dropping them.
- The event mapping is extracted into NativeSignalMapper with the stat
  call injected, so the native-branch decisions are unit-tested on any
  platform.

* Fix spawn EBADF issue on macOS for large file trees

The skill watcher no longer opens every file it watches, improving performance.

Signed-off-by: 7Sageer <sag77r@hotmail.com>

* fix(agent-core-v2): harden native signal watch recovery

* fix(agent-core-v2): align skill watch with scanner traversal

* fix(agent-core-v2): make skill watch handoff converge

---------

Signed-off-by: 7Sageer <sag77r@hotmail.com>
This commit is contained in:
7Sageer 2026-08-05 13:47:26 +08:00 committed by GitHub
parent 2a4990182d
commit e3570280bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 917 additions and 45 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix all tool calls failing with spawn EBADF on macOS when a skill folder contains a very large file tree.

View file

@ -1,24 +1,94 @@
/**
* Path-filter helpers pure string predicates, no IO.
* `_base/utils/paths` (cross-cutting) pure path-filter predicates.
*
* Constrains filesystem watches to selected subtrees and scanner-visible
* entries.
*/
function normalizeSlashes(p: string): string {
return p.replaceAll('\\', '/');
}
export interface SubtreeWatchFilterOptions {
readonly maxDepth?: number;
readonly skipEntry?: (entryName: string) => boolean;
readonly keepEntryFile?: string;
readonly scannedDirectories?: readonly string[];
}
export function subtreeWatchFilter(
root: string,
candidates: readonly string[],
options?: SubtreeWatchFilterOptions,
): (path: string) => boolean {
const normRoot = normalizeSlashes(root);
const normCandidates = candidates.map(normalizeSlashes);
const normScannedDirectories =
options?.scannedDirectories === undefined
? undefined
: new Set([...normCandidates, ...options.scannedDirectories.map(normalizeSlashes)]);
return (p: string): boolean => {
const norm = normalizeSlashes(p);
if (norm === normRoot) return false;
for (const candidate of normCandidates) {
if (norm === candidate || norm.startsWith(`${candidate}/`)) return false;
if (norm === candidate) return false;
if (norm.startsWith(`${candidate}/`)) {
return isPrunedBelowCandidate(
norm,
norm.slice(candidate.length + 1),
options,
normScannedDirectories,
);
}
if (candidate.startsWith(`${norm}/`)) return false;
}
return true;
};
}
function isPrunedBelowCandidate(
normPath: string,
rel: string,
options: SubtreeWatchFilterOptions | undefined,
scannedDirectories: ReadonlySet<string> | undefined,
): boolean {
if (options === undefined) return false;
const segments = rel.split('/');
if (options.maxDepth !== undefined && segments.length > options.maxDepth) return true;
if (options.skipEntry !== undefined) {
const excludedAt = segments.findIndex(options.skipEntry);
if (excludedAt !== -1) {
if (segments.length <= excludedAt + 1) return false;
return !(
options.keepEntryFile !== undefined &&
segments.length === excludedAt + 2 &&
segments.at(-1) === options.keepEntryFile
);
}
}
if (scannedDirectories !== undefined) {
return !isScannerVisiblePath(normPath, scannedDirectories, options.keepEntryFile);
}
return false;
}
function isScannerVisiblePath(
normPath: string,
scannedDirectories: ReadonlySet<string>,
keepEntryFile: string | undefined,
): boolean {
if (scannedDirectories.has(normPath)) return true;
const separatorAt = normPath.lastIndexOf('/');
if (separatorAt === -1) return false;
const parent = normPath.slice(0, separatorAt);
if (scannedDirectories.has(parent)) return true;
if (
keepEntryFile === undefined ||
normPath.slice(separatorAt + 1) !== keepEntryFile
) {
return false;
}
const parentSeparatorAt = parent.lastIndexOf('/');
if (parentSeparatorAt === -1) return false;
return scannedDirectories.has(parent.slice(0, parentSeparatorAt));
}

View file

@ -2,8 +2,8 @@
* `skillCatalog` domain filesystem `ISkillDiscovery` backend.
*
* Discovers skill bundles by walking caller-supplied roots and parsing each
* SKILL.md. Exposes both the App-scoped `ISkillDiscovery` service and a
* stateless standalone function.
* SKILL.md. Exposes discovery through the App-scoped service and a stateless
* filesystem entry point.
*/
import { promises as fs } from 'node:fs';
@ -16,7 +16,11 @@ import type { SkillDiscoveryResult, ISkillDiscovery } from './skillDiscovery';
import type { SkillDefinition, SkillRoot, SkippedSkill } from './types';
import { normalizeSkillName } from './types';
const MAX_SKILL_SCAN_DEPTH = 8;
export const MAX_SKILL_SCAN_DEPTH = 8;
export function isSkillScanExcludedEntry(entryName: string): boolean {
return entryName === 'node_modules' || entryName.startsWith('.');
}
export class FileSkillDiscovery implements ISkillDiscovery {
declare readonly _serviceBrand: undefined;
@ -36,6 +40,7 @@ export async function discoverFileSkills(
): Promise<SkillDiscoveryResult> {
const byDiscoveryKey = new Map<string, SkillDefinition>();
const skipped: SkippedSkill[] = [];
const scannedDirectories: string[] = [];
async function walkSkillDir(
dirPath: string,
@ -52,6 +57,7 @@ export async function discoverFileSkills(
} catch {
return;
}
scannedDirectories.push(dirPath);
const directorySkills = new Set<string>();
const subdirs: string[] = [];
@ -60,7 +66,7 @@ export async function discoverFileSkills(
if (await isFile(path.join(entryPath, 'SKILL.md'))) {
directorySkills.add(entry);
}
if (entry === 'node_modules' || entry.startsWith('.')) continue;
if (isSkillScanExcludedEntry(entry)) continue;
if (await isDir(entryPath)) subdirs.push(entry);
}
@ -134,6 +140,7 @@ export async function discoverFileSkills(
skills: sortSkills([...byDiscoveryKey.values()]),
skipped,
scannedRoots: roots.map((root) => root.path),
scannedDirectories,
};
}

View file

@ -52,7 +52,7 @@ export class InMemorySkillDiscovery implements ISkillDiscovery {
if (roots.some((root) => root.source === 'user')) skills.push(...this.userSkills);
if (roots.some((root) => root.source === 'project')) skills.push(...this.projectSkills);
}
return { skills, skipped: [], scannedRoots: [] };
return { skills, skipped: [], scannedRoots: [], scannedDirectories: [] };
}
}

View file

@ -19,6 +19,7 @@ export interface SkillDiscoveryResult {
readonly skills: readonly SkillDefinition[];
readonly skipped: readonly SkippedSkill[];
readonly scannedRoots: readonly string[];
readonly scannedDirectories: readonly string[];
}
export interface ISkillDiscovery {

View file

@ -54,7 +54,7 @@ export interface ProjectSkillRootCandidates {
export async function projectSkillRootCandidates(
workDir: string,
): Promise<ProjectSkillRootCandidates> {
const projectRoot = await findProjectRoot(workDir);
const projectRoot = await realpathOrSelf(await findProjectRoot(workDir));
return {
projectRoot,
candidates: [...PROJECT_BRAND_DIRS, ...PROJECT_GENERIC_DIRS].map((dir) =>
@ -145,6 +145,14 @@ async function realpath(p: string): Promise<string> {
return (await fs.realpath(p)).replaceAll('\\', '/');
}
async function realpathOrSelf(p: string): Promise<string> {
try {
return await realpath(p);
} catch {
return p.replaceAll('\\', '/');
}
}
async function exists(p: string): Promise<boolean> {
try {
await fs.stat(p);

View file

@ -1,13 +1,16 @@
/**
* `hostFsWatch` domain `IHostFsWatchService` implementation.
*
* Wraps `chokidar` to report raw create/modify/delete events under an absolute
* path. Each `watch()` call owns an independent `FSWatcher`; disposing the
* handle closes it. Bound at App scope.
* Reports precise or coarse host filesystem changes through platform
* watchers. Each handle owns and disposes its watcher. Bound at App scope.
*/
import { watch as fsWatch } from 'node:fs';
import { basename, isAbsolute, join, relative } from 'node:path';
import { FSWatcher } from 'chokidar';
import type { IDisposable } from '#/_base/di/lifecycle';
import { Emitter, type Event } from '#/_base/event';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { onUnexpectedError } from '#/_base/errors/unexpectedError';
@ -23,14 +26,79 @@ import {
const DEFAULT_IGNORED = (p: string): boolean => /(?:^|[/\\])\.git(?:$|[/\\])/.test(p);
const NATIVE_RETRY_BASE_MS = 1000;
const NATIVE_RETRY_MAX_MS = 30000;
interface NativeFsWatcher {
close(): void;
on(event: 'error', listener: (error: NodeJS.ErrnoException) => void): this;
}
interface HostFsWatchRuntime {
readonly platform: NodeJS.Platform;
watchNative(
root: string,
listener: (eventType: string, filename: string | null) => void,
): NativeFsWatcher;
scheduleRetry(callback: () => void, delayMs: number): IDisposable;
}
const NODE_HOST_FS_WATCH_RUNTIME: HostFsWatchRuntime = {
platform: process.platform,
watchNative: (root, listener) =>
fsWatch(root, { persistent: false, recursive: true }, listener),
scheduleRetry: (callback, delayMs) => {
const timer = setTimeout(callback, delayMs);
timer.unref?.();
return {
dispose: () => {
clearTimeout(timer);
},
};
},
};
interface WatchReadiness {
readonly promise: Promise<void>;
resolve(): void;
reject(error: unknown): void;
}
function createWatchReadiness(): WatchReadiness {
let resolvePromise!: () => void;
let rejectPromise!: (error: unknown) => void;
let settled = false;
const promise = new Promise<void>((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
void promise.catch(() => undefined);
return {
promise,
resolve: () => {
if (settled) return;
settled = true;
resolvePromise();
},
reject: (error) => {
if (settled) return;
settled = true;
rejectPromise(error);
},
};
}
class HostFsWatchHandle implements IHostFsWatchHandle {
readonly ready: Promise<void>;
readonly onDidChange: Event<HostFsChange>;
private readonly readiness = createWatchReadiness();
private readonly emitter: Emitter<HostFsChange>;
private readonly watcher: FSWatcher;
private disposed = false;
constructor(path: string, options: HostFsWatchOptions | undefined) {
this.ready = this.readiness.promise;
this.emitter = new Emitter<HostFsChange>();
this.onDidChange = this.emitter.event;
this.watcher = new FSWatcher({
@ -45,27 +113,158 @@ class HostFsWatchHandle implements IHostFsWatchHandle {
if (mapped !== undefined) this.emitter.fire(mapped);
});
this.watcher.on('error', (error: unknown) => {
this.readiness.reject(error);
onUnexpectedError(error);
});
this.watcher.once('ready', () => this.readiness.resolve());
this.watcher.add(path);
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.readiness.resolve();
void this.watcher.close().catch(() => undefined);
this.emitter.dispose();
}
}
class SignalWatchHandle implements IHostFsWatchHandle {
readonly ready: Promise<void>;
readonly onDidChange: Event<HostFsChange>;
private readonly readiness = createWatchReadiness();
private readonly emitter: Emitter<HostFsChange>;
private readonly ignored: (path: string) => boolean;
private nativeWatcher: NativeFsWatcher | undefined;
private chokidarLeg: HostFsWatchHandle | undefined;
private retry: IDisposable | undefined;
private retryAttempts = 0;
private recovering = false;
private disposed = false;
constructor(
private readonly root: string,
options: HostFsWatchOptions | undefined,
private readonly runtime: HostFsWatchRuntime,
) {
this.ready = this.readiness.promise;
this.emitter = new Emitter<HostFsChange>();
this.onDidChange = this.emitter.event;
this.ignored = options?.ignored ?? DEFAULT_IGNORED;
this.startNativeLeg();
}
private startNativeLeg(): void {
if (this.disposed) return;
try {
const watcher = this.runtime.watchNative(this.root, (_eventType, filename) => {
if (this.disposed) return;
this.retryAttempts = 0;
const absPath = resolveNativeSignalPath(this.root, filename);
if (absPath !== this.root && this.ignored(absPath)) return;
this.fireInvalidation();
});
watcher.on('error', (error: NodeJS.ErrnoException) => {
this.onNativeError(watcher, error);
});
this.nativeWatcher = watcher;
this.readiness.resolve();
if (this.recovering) {
this.recovering = false;
this.fireInvalidation();
}
} catch (error) {
this.onNativeError(undefined, error as NodeJS.ErrnoException);
}
}
private onNativeError(watcher: NativeFsWatcher | undefined, error: NodeJS.ErrnoException): void {
if (this.disposed) return;
if (watcher !== undefined && watcher !== this.nativeWatcher) return;
watcher?.close();
this.nativeWatcher = undefined;
if (error.code === 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM') {
this.recovering = false;
this.startChokidarLeg();
this.fireInvalidation();
return;
}
onUnexpectedError(error);
this.recovering = true;
this.fireInvalidation();
const delay = Math.min(NATIVE_RETRY_BASE_MS * 2 ** this.retryAttempts, NATIVE_RETRY_MAX_MS);
this.retryAttempts += 1;
this.retry?.dispose();
this.retry = this.runtime.scheduleRetry(() => {
this.retry = undefined;
this.startNativeLeg();
}, delay);
}
private startChokidarLeg(): void {
if (this.chokidarLeg !== undefined) return;
const leg = new HostFsWatchHandle(this.root, { recursive: true, ignored: this.ignored });
leg.onDidChange((event) => {
if (!this.disposed) this.emitter.fire(event);
});
void leg.ready.then(
() => this.readiness.resolve(),
(error: unknown) => this.readiness.reject(error),
);
this.chokidarLeg = leg;
}
private fireInvalidation(): void {
this.emitter.fire({ path: this.root, action: 'modified', kind: 'directory' });
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.readiness.resolve();
this.retry?.dispose();
this.nativeWatcher?.close();
this.chokidarLeg?.dispose();
this.emitter.dispose();
}
}
export class HostFsWatchService implements IHostFsWatchService {
declare readonly _serviceBrand: undefined;
constructor(private readonly runtime: HostFsWatchRuntime = NODE_HOST_FS_WATCH_RUNTIME) {}
watch(path: string, options?: HostFsWatchOptions): IHostFsWatchHandle {
if (useNativeRecursive(options, this.runtime.platform)) {
return new SignalWatchHandle(path, options, this.runtime);
}
return new HostFsWatchHandle(path, options);
}
}
function useNativeRecursive(
options: HostFsWatchOptions | undefined,
platform: NodeJS.Platform,
): boolean {
return (
options?.signal === true &&
options.recursive !== false &&
(platform === 'darwin' || platform === 'win32')
);
}
function resolveNativeSignalPath(root: string, filename: string | null): string {
if (filename === null || filename === '' || filename === basename(root)) return root;
return clampToRoot(root, isAbsolute(filename) ? filename : join(root, filename));
}
function clampToRoot(root: string, absPath: string): string {
const rel = relative(root, absPath);
if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) return absPath;
return root;
}
function mapChokidarEvent(eventName: string, absPath: string): HostFsChange | undefined {
const mapped = mapActionAndKind(eventName);
if (mapped === undefined) return undefined;

View file

@ -4,7 +4,13 @@
* Defines the `IHostFsWatchService`, a thin primitive over the host OS file
* watcher. It reports raw create/modify/delete events under an absolute path
* and knows nothing about sessions, connections, workspaces or wire frames.
* App-scoped one shared instance.
* `HostFsWatchOptions.signal` marks callers that consume events as a mere
* "something changed" signal (ignoring action/kind); the backend may then
* pick a cheaper implementation (one native recursive watch instead of
* per-node watchers). Signal events may use the watched root as their path
* and report coarse action/kind values. A handle's `ready` promise resolves
* after its backend has installed the initial subscription and rejects when
* initialization fails. App-scoped one shared instance.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
@ -23,9 +29,11 @@ export interface HostFsChange {
export interface HostFsWatchOptions {
readonly recursive?: boolean;
readonly ignored?: (path: string) => boolean;
readonly signal?: boolean;
}
export interface IHostFsWatchHandle extends IDisposable {
readonly ready: Promise<void>;
readonly onDidChange: Event<HostFsChange>;
}

View file

@ -4,16 +4,13 @@
*
* Discovers project skills from the handler's workspace root
* (`workspaceContext.cwd`) through `ISkillDiscovery`, contributing them at
* priority 30 (above user / extra / plugin / builtin). Watches the project
* skill-root candidates (`.kimi-code/skills`, `.agents/skills` under the
* project root, watched whether or not they exist yet) through
* `hostFsWatch` and re-fires `onDidChange` debounced, so the catalog
* re-scans THIS source only when project skill files change. Bound at
* Workspace scope so every session of the handler shares one scan.
* priority 30. Watches project skill-root candidates through `hostFsWatch`
* and emits debounced invalidations for source reloads. Bound at Workspace
* scope so every session of the handler shares one scan.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { Disposable } from '#/_base/di/lifecycle';
import { Disposable, DisposableStore } from '#/_base/di/lifecycle';
import { Emitter, type Event } from '#/_base/event';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { TimeoutTimer } from '#/_base/utils/timer';
@ -53,7 +50,10 @@ export class WorkspaceRootSkillSource extends Disposable implements IWorkspaceRo
private readonly onDidChangeEmitter = this._register(new Emitter<void>());
readonly onDidChange: Event<void> = this.onDidChangeEmitter.event;
private readonly watchDebounce = this._register(new TimeoutTimer());
private readonly watchResources = this._register(new DisposableStore());
private readonly watchReady: Promise<void>;
private activeWatchResources: DisposableStore | undefined;
private watchSignature: string | undefined;
constructor(
@ISkillDiscovery private readonly discovery: ISkillDiscovery,
@ -68,7 +68,7 @@ export class WorkspaceRootSkillSource extends Disposable implements IWorkspaceRo
if (event.domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) this.onDidChangeEmitter.fire();
}),
);
this.watchReady = this.watchProjectSkillRoots();
this.watchReady = this.updateProjectSkillRootWatch([]).then(() => undefined);
}
async load(): Promise<SkillContribution> {
@ -79,22 +79,49 @@ export class WorkspaceRootSkillSource extends Disposable implements IWorkspaceRo
await this.config.ready;
const mergeAllAvailableSkills =
this.config.get<MergeAllAvailableSkillsConfig>(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true;
return this.discovery.discover(
await projectRoots(this.workspace.cwd, { mergeAllAvailableSkills }),
);
const discover = async () =>
this.discovery.discover(
await projectRoots(this.workspace.cwd, { mergeAllAvailableSkills }),
);
let contribution = await discover();
while (await this.updateProjectSkillRootWatch(contribution.scannedDirectories)) {
contribution = await discover();
}
return contribution;
}
private async watchProjectSkillRoots(): Promise<void> {
private async updateProjectSkillRootWatch(
scannedDirectories: readonly string[],
): Promise<boolean> {
const { projectRoot, candidates } = await projectSkillRootCandidates(this.workspace.cwd);
const signature = [...scannedDirectories].toSorted().join('\0');
if (signature === this.watchSignature) return false;
const resources = this.watchResources.add(new DisposableStore());
const handle = this.fsWatch.watch(projectRoot, {
ignored: subtreeWatchFilter(projectRoot, candidates),
ignored: subtreeWatchFilter(projectRoot, candidates, {
scannedDirectories,
keepEntryFile: 'SKILL.md',
}),
signal: true,
});
this._register(handle);
this._register(
resources.add(handle);
resources.add(
handle.onDidChange(() => {
this.watchDebounce.cancelAndSet(() => this.onDidChangeEmitter.fire(), WATCH_DEBOUNCE_MS);
}),
);
try {
await handle.ready;
} catch (error) {
this.watchResources.delete(resources);
throw error;
}
if (this.watchResources.isDisposed) return false;
const previous = this.activeWatchResources;
this.activeWatchResources = resources;
this.watchSignature = signature;
if (previous !== undefined) this.watchResources.delete(previous);
return true;
}
}

View file

@ -0,0 +1,107 @@
/**
* Scenario: recursive watches constrained to selected candidate subtrees.
* Responsibilities: candidate ancestry, scan-depth bounds, and excluded-entry
* probing. Wiring: pure path predicates with no external collaborators.
* Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run
* test/_base/utils/paths.test.ts`.
*/
import { describe, expect, it } from 'vitest';
import { subtreeWatchFilter } from '#/_base/utils/paths';
describe('subtree watch filtering', () => {
const root = '/repo';
const candidates = ['/repo/.kimi-code/skills', '/repo/.agents/skills'];
it('keeps the root, candidate ancestors and candidate subtrees watched', () => {
const ignored = subtreeWatchFilter(root, candidates);
expect(ignored('/repo')).toBe(false);
expect(ignored('/repo/.agents')).toBe(false);
expect(ignored('/repo/.agents/skills')).toBe(false);
expect(ignored('/repo/.agents/skills/demo/SKILL.md')).toBe(false);
expect(ignored('/repo/src')).toBe(true);
expect(ignored('/repo/src/index.ts')).toBe(true);
});
it('prunes what an excluded entry hides from the scanner, keeps what it still probes', () => {
const ignored = subtreeWatchFilter(root, candidates, {
maxDepth: 3,
skipEntry: (name) => name === 'node_modules' || name.startsWith('.'),
keepEntryFile: 'SKILL.md',
});
expect(ignored('/repo/.agents/skills/demo/node_modules')).toBe(false);
expect(ignored('/repo/.agents/skills/demo/node_modules/SKILL.md')).toBe(false);
expect(ignored('/repo/.agents/skills/demo/.hidden')).toBe(false);
expect(ignored('/repo/.agents/skills/demo/.hidden/SKILL.md')).toBe(false);
expect(ignored('/repo/.agents/skills/.flat.md')).toBe(false);
expect(ignored('/repo/.agents/skills/demo/node_modules/pkg')).toBe(true);
expect(ignored('/repo/.agents/skills/demo/node_modules/pkg/x.js')).toBe(true);
expect(ignored('/repo/.agents/skills/demo/.venv/bin/python')).toBe(true);
expect(ignored('/repo/.agents/skills/demo/scripts/run.sh')).toBe(false);
expect(ignored('/repo/.agents/skills/a/b/c/d/e/f/SKILL.md')).toBe(true);
});
it('prunes an excluded entry beyond itself when no keepEntryFile is set', () => {
const ignored = subtreeWatchFilter(root, candidates, {
skipEntry: (name) => name === 'node_modules',
});
expect(ignored('/repo/.agents/skills/demo/node_modules')).toBe(false);
expect(ignored('/repo/.agents/skills/demo/node_modules/SKILL.md')).toBe(true);
expect(ignored('/repo/.agents/skills/demo/node_modules/pkg')).toBe(true);
});
it('applies max depth before excluded-entry exceptions', () => {
const ignored = subtreeWatchFilter(root, candidates, {
maxDepth: 3,
skipEntry: (name) => name === 'node_modules',
keepEntryFile: 'SKILL.md',
});
expect(ignored('/repo/.agents/skills/demo/a/b/node_modules')).toBe(true);
expect(ignored('/repo/.agents/skills/demo/a/b/node_modules/SKILL.md')).toBe(true);
});
it('never prunes the candidate ancestor chain itself', () => {
const ignored = subtreeWatchFilter(root, candidates, {
skipEntry: (name) => name.startsWith('.'),
});
expect(ignored('/repo/.agents')).toBe(false);
expect(ignored('/repo/.agents/skills')).toBe(false);
expect(ignored('/repo/.agents/skills/demo')).toBe(false);
});
it('keeps direct probes but prunes payload below a terminal bundle', () => {
const ignored = subtreeWatchFilter(root, candidates, {
scannedDirectories: ['/repo/.agents/skills'],
keepEntryFile: 'SKILL.md',
});
expect(ignored('/repo/.agents/skills/demo')).toBe(false);
expect(ignored('/repo/.agents/skills/demo/SKILL.md')).toBe(false);
expect(ignored('/repo/.agents/skills/demo/runtime')).toBe(true);
expect(ignored('/repo/.agents/skills/demo/runtime/0.py')).toBe(true);
expect(ignored('/repo/.agents/skills/.flat.md')).toBe(false);
});
it('keeps direct bundle probes when the candidate root has not been scanned yet', () => {
const ignored = subtreeWatchFilter(root, candidates, {
scannedDirectories: [],
keepEntryFile: 'SKILL.md',
});
expect(ignored('/repo/.agents/skills/new-skill')).toBe(false);
expect(ignored('/repo/.agents/skills/new-skill/SKILL.md')).toBe(false);
expect(ignored('/repo/.agents/skills/new-skill/runtime')).toBe(true);
});
it('keeps direct sub-skill probes below a directory the scanner traversed', () => {
const ignored = subtreeWatchFilter(root, candidates, {
scannedDirectories: [
'/repo/.agents/skills',
'/repo/.agents/skills/parent',
],
keepEntryFile: 'SKILL.md',
});
expect(ignored('/repo/.agents/skills/parent/child')).toBe(false);
expect(ignored('/repo/.agents/skills/parent/child/SKILL.md')).toBe(false);
expect(ignored('/repo/.agents/skills/parent/child/runtime')).toBe(true);
});
});

View file

@ -251,6 +251,7 @@ describe('PluginManager consumption plane', () => {
skills: [stubSkill('provided')],
skipped: [],
scannedRoots: [],
scannedDirectories: [],
}),
});
await manager.load();

View file

@ -58,7 +58,12 @@ function makeHost(
stubPair(IProviderService, providers),
stubPair(ISkillDiscovery, {
_serviceBrand: undefined,
discover: async () => ({ skills: [], skipped: [], scannedRoots: [] }),
discover: async () => ({
skills: [],
skipped: [],
scannedRoots: [],
scannedDirectories: [],
}),
} satisfies ISkillDiscovery),
]);
}

View file

@ -198,6 +198,23 @@ describe('FileSkillDiscovery', () => {
expect(result.skills.find((s) => s.name === 'outer.child')?.metadata.isSubSkill).toBe(true);
});
it('reports only directories the scanner actually traverses', async () => {
await writeSkill('skills/terminal/SKILL.md', 'name: terminal\ndescription: terminal');
await mkdir(join(root, 'skills', 'terminal', 'runtime', 'nested'), { recursive: true });
await writeSkill(
'skills/parent/SKILL.md',
'name: parent\ndescription: parent\nhas-sub-skill: true',
);
await writeSkill('skills/parent/child/SKILL.md', 'name: child\ndescription: child');
const result = await discover([skillRoot('skills')]);
expect(result.scannedDirectories).toEqual([
join(root, 'skills'),
join(root, 'skills', 'parent'),
]);
});
it('ignores node_modules and dot directories while walking', async () => {
await writeSkill(
'skills/node_modules/hidden/SKILL.md',

View file

@ -1,27 +1,138 @@
/**
* `hostFsWatch` domain integration test against the real `chokidar`
* watcher on a temporary directory.
* Scenario: precise host watches and coarse native signal watches.
* Responsibilities: event delivery, filtering, recovery, disposal, and the
* macOS descriptor bound. Wiring: real temporary files for integration and an
* injected native-watch boundary with a manual retry scheduler for recovery.
* Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run
* test/os/backends/node-local/hostFsWatchService.test.ts`.
*/
import { readdirSync } from 'node:fs';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
resetUnexpectedErrorHandler,
setUnexpectedErrorHandler,
} from '#/_base/errors/unexpectedError';
import { HostFsWatchService } from '#/os/backends/node-local/hostFsWatchService';
import type { HostFsChange, IHostFsWatchHandle } from '#/os/interface/hostFsWatch';
import type {
HostFsChange,
IHostFsWatchHandle,
IHostFsWatchService,
} from '#/os/interface/hostFsWatch';
const wait = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
describe('HostFsWatchService', () => {
type HostFsWatchRuntime = NonNullable<ConstructorParameters<typeof HostFsWatchService>[0]>;
class TestNativeWatcher {
private errorListener: ((error: NodeJS.ErrnoException) => void) | undefined;
closed = false;
on(_event: 'error', listener: (error: NodeJS.ErrnoException) => void): this {
this.errorListener = listener;
return this;
}
close(): void {
this.closed = true;
}
fail(code = 'EIO'): void {
this.errorListener?.(Object.assign(new Error('native watch failed'), { code }));
}
}
interface TestNativeAttempt {
readonly watcher: TestNativeWatcher;
emit(filename: string | null): void;
}
interface TestRetry {
readonly delayMs: number;
readonly active: boolean;
run(): void;
}
function signalRig(options?: { readonly synchronousFailures?: number }): {
readonly service: IHostFsWatchService;
readonly attempts: TestNativeAttempt[];
readonly retries: TestRetry[];
attempt(index: number): TestNativeAttempt;
retry(index: number): TestRetry;
} {
const attempts: TestNativeAttempt[] = [];
const retries: TestRetry[] = [];
let synchronousFailures = options?.synchronousFailures ?? 0;
const runtime: HostFsWatchRuntime = {
platform: 'darwin',
watchNative: (_root, listener) => {
if (synchronousFailures > 0) {
synchronousFailures -= 1;
throw Object.assign(new Error('native watch creation failed'), { code: 'EIO' });
}
const watcher = new TestNativeWatcher();
attempts.push({
watcher,
emit: (filename) => {
listener('rename', filename);
},
});
return watcher;
},
scheduleRetry: (callback, delayMs) => {
let active = true;
retries.push({
delayMs,
get active() {
return active;
},
run: () => {
if (!active) return;
active = false;
callback();
},
});
return {
dispose: () => {
active = false;
},
};
},
};
return {
service: new HostFsWatchService(runtime),
attempts,
retries,
attempt: (index) => requiredAt(attempts, index),
retry: (index) => requiredAt(retries, index),
};
}
function requiredAt<T>(values: readonly T[], index: number): T {
const value = values[index];
if (value === undefined) throw new Error(`missing test value at index ${index}`);
return value;
}
describe('host filesystem change notifications', () => {
let root: string;
let handle: IHostFsWatchHandle | undefined;
beforeEach(() => {
setUnexpectedErrorHandler(() => undefined);
});
afterEach(async () => {
handle?.dispose();
handle = undefined;
if (root) await rm(root, { recursive: true, force: true });
root = '';
resetUnexpectedErrorHandler();
});
async function start(recursive = true): Promise<HostFsChange[]> {
@ -29,10 +140,110 @@ describe('HostFsWatchService', () => {
const svc = new HostFsWatchService();
handle = svc.watch(root, { recursive });
handle.onDidChange((e) => events.push(e));
await wait(200);
await handle.ready;
return events;
}
async function startSignal(ignored?: (path: string) => boolean): Promise<HostFsChange[]> {
const events: HostFsChange[] = [];
const svc = new HostFsWatchService();
handle = svc.watch(root, { recursive: true, signal: true, ignored });
handle.onDidChange((e) => events.push(e));
await handle.ready;
return events;
}
it('emits a coarse root invalidation when a native signal path changes', () => {
const rig = signalRig();
const events: HostFsChange[] = [];
handle = rig.service.watch('/repo', { signal: true });
handle.onDidChange((event) => events.push(event));
rig.attempt(0).emit('skills/demo/SKILL.md');
expect(events).toEqual([{ path: '/repo', action: 'modified', kind: 'directory' }]);
});
it('does not invalidate when a native signal path is ignored', () => {
const rig = signalRig();
const events: HostFsChange[] = [];
handle = rig.service.watch('/repo', {
signal: true,
ignored: (path) => path.includes('node_modules'),
});
handle.onDidChange((event) => events.push(event));
rig.attempt(0).emit('node_modules/pkg/index.js');
expect(events).toEqual([]);
});
it('increases the retry delay after consecutive native failures', () => {
const rig = signalRig();
handle = rig.service.watch('/repo', { signal: true });
rig.attempt(0).watcher.fail();
rig.retry(0).run();
rig.attempt(1).watcher.fail();
rig.retry(1).run();
rig.attempt(2).watcher.fail();
expect(rig.retries.map((retry) => retry.delayMs)).toEqual([1000, 2000, 4000]);
});
it('invalidates again after a native watch is rearmed', () => {
const rig = signalRig();
const events: HostFsChange[] = [];
handle = rig.service.watch('/repo', { signal: true });
handle.onDidChange((event) => events.push(event));
rig.attempt(0).watcher.fail();
rig.retry(0).run();
expect(events).toEqual([
{ path: '/repo', action: 'modified', kind: 'directory' },
{ path: '/repo', action: 'modified', kind: 'directory' },
]);
});
it('invalidates after recovering from a synchronous native-watch creation failure', () => {
const rig = signalRig({ synchronousFailures: 1 });
const events: HostFsChange[] = [];
handle = rig.service.watch('/repo', { signal: true });
handle.onDidChange((event) => events.push(event));
rig.retry(0).run();
expect(rig.attempts).toHaveLength(1);
expect(events).toEqual([{ path: '/repo', action: 'modified', kind: 'directory' }]);
});
it('resets the retry delay after the recovered native watch emits an event', () => {
const rig = signalRig();
handle = rig.service.watch('/repo', { signal: true });
rig.attempt(0).watcher.fail();
rig.retry(0).run();
rig.attempt(1).emit('skills/demo/SKILL.md');
rig.attempt(1).watcher.fail();
expect(rig.retries.map((retry) => retry.delayMs)).toEqual([1000, 1000]);
});
it('cancels a pending native retry when the watch handle is disposed', () => {
const rig = signalRig();
handle = rig.service.watch('/repo', { signal: true });
rig.attempt(0).watcher.fail();
handle.dispose();
handle = undefined;
rig.retry(0).run();
expect(rig.retry(0).active).toBe(false);
expect(rig.attempt(0).watcher.closed).toBe(true);
expect(rig.attempts).toHaveLength(1);
});
it('reports create / modify / delete for a file', async () => {
root = await mkdtemp(join(tmpdir(), 'hostfswatch-'));
const events = await start();
@ -86,4 +297,23 @@ describe('HostFsWatchService', () => {
expect(events).toHaveLength(0);
});
it.skipIf(process.platform !== 'darwin')(
'signal mode keeps the fd footprint bounded on a fat subtree',
async () => {
root = await mkdtemp(join(tmpdir(), 'hostfswatch-fat-'));
const fat = join(root, 'fat');
await mkdir(fat, { recursive: true });
for (let i = 0; i < 1200; i++) {
await writeFile(join(fat, `f${i}.txt`), 'x');
}
const fdsBefore = readdirSync('/dev/fd').length;
await startSignal();
const fdsAfter = readdirSync('/dev/fd').length;
expect(fdsAfter - fdsBefore).toBeLessThan(50);
},
30000,
);
});

View file

@ -113,6 +113,7 @@ function fsWatchStub(): IHostFsWatchService {
return {
_serviceBrand: undefined,
watch: (): IHostFsWatchHandle => ({
ready: Promise.resolve(),
onDidChange: Event.None as Event<HostFsChange>,
dispose: () => {},
}),

View file

@ -68,6 +68,7 @@ function fakeHostFsWatch(): FakeWatch {
let listener: ((e: HostFsChange) => void) | undefined;
let disposedCount = 0;
const handle: IHostFsWatchHandle = {
ready: Promise.resolve(),
onDidChange: (l) => {
listener = l;
return { dispose: () => (listener = undefined) };

View file

@ -73,7 +73,7 @@ describe('WorkspaceInstructionsService', () => {
emitter = new Emitter<HostFsChange>();
watchFires.set(path, emitter);
}
return { onDidChange: emitter.event, dispose: () => {} };
return { ready: Promise.resolve(), onDidChange: emitter.event, dispose: () => {} };
},
};
}

View file

@ -92,6 +92,7 @@ describe('Workspace MCP initialization', () => {
});
reg.definePartialInstance(IHostFsWatchService, {
watch: (): IHostFsWatchHandle => ({
ready: Promise.resolve(),
onDidChange: Event.None as Event<HostFsChange>,
dispose: () => {},
}),

View file

@ -91,7 +91,7 @@ describe('WorkspaceMcpConfigService', () => {
emitter = new Emitter<HostFsChange>();
watchFires.set(path, emitter);
}
return { onDidChange: emitter.event, dispose: () => {} };
return { ready: Promise.resolve(), onDidChange: emitter.event, dispose: () => {} };
},
};
}

View file

@ -3,7 +3,7 @@
*
* Exercises the real Workspace-scoped catalog and source services with
* filesystem or in-memory discovery boundaries, including controlled
* concurrent refreshes and the fs-watch-driven single-source rescan.
* concurrent refreshes and fs-watch-driven single-source rescans.
* Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run
* test/workspace/workspaceSkillCatalog/skillCatalog.test.ts`.
*/
@ -26,7 +26,12 @@ import { IPluginService } from '#/app/plugin/plugin';
import { PluginService } from '#/app/plugin/pluginService';
import type { ReloadSummary } from '#/app/plugin/types';
import { IProviderService } from '#/kosong/provider/provider';
import { IHostFsWatchService, type HostFsChange, type IHostFsWatchHandle } from '#/os/interface/hostFsWatch';
import {
IHostFsWatchService,
type HostFsChange,
type HostFsWatchOptions,
type IHostFsWatchHandle,
} from '#/os/interface/hostFsWatch';
import { IAppStateService } from '#/app/state/appState';
import { AppStateService } from '#/app/state/appStateService';
import { IWorkspaceStateService } from '#/workspace/state/workspaceState';
@ -149,13 +154,19 @@ function workspaceContextStub(workDir: string): IWorkspaceContext {
};
}
function fsWatchStub(): IHostFsWatchService {
function fsWatchStub(
onWatch?: (options: HostFsWatchOptions | undefined) => void,
): IHostFsWatchService {
return {
_serviceBrand: undefined,
watch: (): IHostFsWatchHandle => ({
onDidChange: Event.None as Event<HostFsChange>,
dispose: () => {},
}),
watch: (_path, options): IHostFsWatchHandle => {
onWatch?.(options);
return {
ready: Promise.resolve(),
onDidChange: Event.None as Event<HostFsChange>,
dispose: () => {},
};
},
};
}
@ -408,7 +419,7 @@ describe('WorkspaceSkillCatalogService', () => {
calls = 0;
async discover() {
this.calls++;
return { skills: [], skipped: [], scannedRoots: [] };
return { skills: [], skipped: [], scannedRoots: [], scannedDirectories: [] };
}
}
const store = new CountingDiscovery();
@ -492,7 +503,7 @@ describe('WorkspaceSkillCatalogService', () => {
const pluginSkills = roots
.filter((root) => root.plugin !== undefined)
.map((root) => stubSkill('demo-skill', { source: 'extra', plugin: root.plugin }));
return { skills: pluginSkills, skipped: [], scannedRoots: [] };
return { skills: pluginSkills, skipped: [], scannedRoots: [], scannedDirectories: [] };
}
}
const store = new ExtraRootStore();
@ -519,6 +530,7 @@ describe('WorkspaceSkillCatalogService', () => {
scannedRoots: roots
.filter((root) => root.source === 'project')
.map((root) => root.path),
scannedDirectories: [],
};
}
}
@ -551,6 +563,7 @@ describe('WorkspaceSkillCatalogService', () => {
skills: [],
skipped: isProject ? [skippedEntry] : [],
scannedRoots: [],
scannedDirectories: [],
};
}
}
@ -793,6 +806,99 @@ describe('WorkspaceSkillCatalogService', () => {
await rm(homeDir, { recursive: true, force: true });
}
});
it('keeps the old watch active until a ready replacement converges on post-scan changes', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'skill-watch-handoff-'));
const skillRoot = join(workDir, '.agents', 'skills');
await mkdir(join(skillRoot, 'demo'), { recursive: true });
await writeFile(
join(skillRoot, 'demo', 'SKILL.md'),
'---\nname: demo\ndescription: initial\n---\nbody',
'utf8',
);
const scannedDirectory = await realpath(skillRoot);
const replacementReady = deferred<void>();
const replacementStarted = deferred<void>();
class TestWatchHandle implements IHostFsWatchHandle {
readonly changes = new Emitter<HostFsChange>();
readonly onDidChange = this.changes.event;
disposed = false;
constructor(readonly ready: Promise<void>) {}
dispose(): void {
this.disposed = true;
this.changes.dispose();
}
}
const handles: TestWatchHandle[] = [];
const watchService: IHostFsWatchService = {
_serviceBrand: undefined,
watch: () => {
const handle = new TestWatchHandle(
handles.length === 0 ? Promise.resolve() : replacementReady.promise,
);
handles.push(handle);
if (handles.length === 2) replacementStarted.resolve(undefined);
return handle;
},
};
let scans = 0;
const discovery: ISkillDiscovery = {
_serviceBrand: undefined,
discover: async () => {
scans += 1;
return {
skills: [stubSkill('demo', { description: scans === 1 ? 'stale' : 'fresh' })],
skipped: [],
scannedRoots: [scannedDirectory],
scannedDirectories: [scannedDirectory],
};
},
};
_clearScopedRegistryForTests();
registerScopedService(
LifecycleScope.Workspace,
IWorkspaceRootSkillSource,
WorkspaceRootSkillSource,
);
const host = createScopedTestHost([
stubPair(ISkillDiscovery, discovery),
stubPair(IBootstrapService, bootstrapStub),
stubPair(IConfigService, configStub()),
stubPair(IHostFsWatchService, watchService),
]);
const workspace = host.child(LifecycleScope.Workspace, 'w1', [
stubPair(IWorkspaceContext, workspaceContextStub(workDir)),
]);
try {
const loading = workspace.accessor.get(IWorkspaceRootSkillSource).load();
await replacementStarted.promise;
const initialHandle = handles[0];
const replacementHandle = handles[1];
if (initialHandle === undefined || replacementHandle === undefined) {
throw new Error('expected initial and replacement watch handles');
}
expect(initialHandle.disposed).toBe(false);
replacementReady.resolve(undefined);
const contribution = await loading;
expect(initialHandle.disposed).toBe(true);
expect(replacementHandle.disposed).toBe(false);
expect(scans).toBe(2);
expect(contribution.skills.map((skill) => skill.description)).toEqual(['fresh']);
} finally {
host.dispose();
await rm(workDir, { recursive: true, force: true });
}
});
it('rescans the workspace-root source when a project skill file changes on disk', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'skill-watch-'));
const host = createScopedTestHost([
@ -835,4 +941,82 @@ describe('WorkspaceSkillCatalogService', () => {
await rm(workDir, { recursive: true, force: true });
}
}, 15000);
it('prunes terminal skill payloads from the workspace watch after discovery', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'skill-watch-plan-'));
const skillDir = join(workDir, '.agents', 'skills', 'demo');
const runtimeFile = join(skillDir, 'runtime', '0.py');
await mkdir(join(skillDir, 'runtime'), { recursive: true });
await writeFile(
join(skillDir, 'SKILL.md'),
'---\nname: demo\ndescription: demo\n---\nbody',
'utf8',
);
await writeFile(runtimeFile, 'x', 'utf8');
const watchedSkillDir = await realpath(skillDir);
const watchedRuntimeFile = await realpath(runtimeFile);
let ignored: ((path: string) => boolean) | undefined;
const host = createScopedTestHost([
stubPair(IBootstrapService, bootstrapStub),
stubPair(IConfigService, configStub()),
stubPair(IPluginService, pluginStub()),
stubPair(ILogService, stubLog()),
stubPair(ISkillDiscovery, new FileSkillDiscovery(stubLog())),
stubPair(
IHostFsWatchService,
fsWatchStub((options) => {
ignored = options?.ignored;
}),
),
]);
const workspace = host.child(LifecycleScope.Workspace, 'w1', [
stubPair(IWorkspaceContext, workspaceContextStub(workDir)),
]);
try {
const catalog = workspace.accessor.get(IWorkspaceSkillCatalog);
await catalog.load();
expect(ignored?.(join(watchedSkillDir, 'SKILL.md'))).toBe(false);
expect(ignored?.(watchedRuntimeFile)).toBe(true);
} finally {
host.dispose();
await rm(workDir, { recursive: true, force: true });
}
});
it('rescans when a skill under a dot directory appears on disk', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'skill-watch-dot-'));
const host = createScopedTestHost([
stubPair(IBootstrapService, bootstrapStub),
stubPair(IConfigService, configStub()),
stubPair(IPluginService, pluginStub()),
stubPair(ILogService, stubLog()),
stubPair(ISkillDiscovery, new FileSkillDiscovery(stubLog())),
stubPair(IHostFsWatchService, new HostFsWatchService()),
]);
const workspace = host.child(LifecycleScope.Workspace, 'w1', [
stubPair(IWorkspaceContext, workspaceContextStub(workDir)),
]);
try {
const catalog = workspace.accessor.get(IWorkspaceSkillCatalog);
await catalog.load();
expect(catalog.catalog.getSkill('dot-skill')).toBeUndefined();
await mkdir(join(workDir, '.agents', 'skills', '.dot-skill'), { recursive: true });
const refreshed = waitForEvents(catalog.onDidChange, 1);
await writeFile(
join(workDir, '.agents', 'skills', '.dot-skill', 'SKILL.md'),
'---\nname: dot-skill\ndescription: under dot dir\n---\nbody',
'utf8',
);
await refreshed;
expect(catalog.catalog.getSkill('dot-skill')?.description).toBe('under dot dir');
} finally {
host.dispose();
await rm(workDir, { recursive: true, force: true });
}
}, 15000);
});