Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/skill-watch-fd-exhaustion.md
Original file line number Diff line number Diff line change
@@ -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 (e.g. a bundled Python runtime); the skill watcher no longer opens every file it watches.
44 changes: 43 additions & 1 deletion packages/agent-core-v2/src/_base/utils/paths.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,66 @@
/**
* Path-filter helpers — pure string predicates, no IO.
*
* `subtreeWatchFilter` builds an `ignored` predicate that confines a recursive
* fs watch to the candidate subtrees under `root` plus their ancestor chain
* (so candidates that do not exist yet are still detected once created). The
* optional knobs prune paths BELOW a candidate only, mirroring a
* recursion-gated scanner: `maxDepth` rejects anything deeper than that many
* segments below the candidate, and an entry matching `skipEntry` (e.g. the
* skill scanner's `node_modules` / dot-entry rule) stops further watching —
* but the entry itself, and with `keepEntryFile` also its direct child file
* of that name, stay watched, because a scanner still probes those without
* recursing deeper. Everything below that point is what the consumer never
* reads, and only that is pruned.
*/

function normalizeSlashes(p: string): string {
return p.replaceAll('\\', '/');
}

export interface SubtreeWatchFilterOptions {
readonly maxDepth?: number;
readonly skipEntry?: (entryName: string) => boolean;
readonly keepEntryFile?: string;
}

export function subtreeWatchFilter(
root: string,
candidates: readonly string[],
options?: SubtreeWatchFilterOptions,
): (path: string) => boolean {
const normRoot = normalizeSlashes(root);
const normCandidates = candidates.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.slice(candidate.length + 1), options);
}
if (candidate.startsWith(`${norm}/`)) return false;
}
return true;
};
}

function isPrunedBelowCandidate(
rel: string,
options: SubtreeWatchFilterOptions | undefined,
): boolean {
if (options === undefined) return false;
const segments = rel.split('/');
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
);
}
}
return options.maxDepth !== undefined && segments.length > options.maxDepth;
}
15 changes: 11 additions & 4 deletions packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
* `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, pruning `node_modules` / dot entries and capping the walk depth
* (`isSkillScanExcludedEntry`, `MAX_SKILL_SCAN_DEPTH` — exported so fs
* watches can mirror the scan's own pruning instead of watching subtrees it
* would never read). Exposes both the App-scoped `ISkillDiscovery` service
* and a stateless standalone function.
*/

import { promises as fs } from 'node:fs';
Expand All @@ -16,7 +19,11 @@
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;
Expand Down Expand Up @@ -56,11 +63,11 @@
const directorySkills = new Set<string>();
const subdirs: string[] = [];
for (const entry of entries) {
const entryPath = path.join(dirPath, entry);

Check warning on line 66 in packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts

View workflow job for this annotation

GitHub Actions / lint

eslint-plugin-import(no-named-as-default-member)

"path" also has a named export "join"
if (await isFile(path.join(entryPath, 'SKILL.md'))) {

Check warning on line 67 in packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts

View workflow job for this annotation

GitHub Actions / lint

eslint-plugin-import(no-named-as-default-member)

"path" also has a named export "join"
directorySkills.add(entry);
}
if (entry === 'node_modules' || entry.startsWith('.')) continue;
if (isSkillScanExcludedEntry(entry)) continue;
if (await isDir(entryPath)) subdirs.push(entry);
}

Expand All @@ -70,7 +77,7 @@
byDiscoveryKey,
skipped,
warn,
skillMdPath: path.join(dirPath, entry, 'SKILL.md'),

Check warning on line 80 in packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts

View workflow job for this annotation

GitHub Actions / lint

eslint-plugin-import(no-named-as-default-member)

"path" also has a named export "join"
skillDirName: entry,
root,
subSkillParentName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,25 @@
* `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.
* path. Each `watch()` call owns an independent watcher; disposing the handle
* closes it. A `signal`-mode recursive watch on darwin/win32 instead uses ONE
* native recursive `fs.watch` (FSEvents / ReadDirectoryChangesW), whose fd
* footprint stays constant in the subtree size — chokidar holds one
* `fs.watch` fd per file and per directory on macOS, so per-node watching of
* a fat subtree can exhaust the process fd budget and break every subsequent
* spawn. The signal leg owns its recovery: a native error (including sync
* creation failure) fires one root-level invalidation and re-arms the native
* watch with capped exponential backoff, so a transient failure neither
* downgrades the consumer to the per-node watcher nor silently ends hot
* reload; chokidar serves only when the platform has no recursive
* `fs.watch`. Event mapping lives in `NativeSignalMapper` as pure logic
* with the stat call injected, so it is testable off darwin/win32.
* Bound at App scope.
*/

import { watch as fsWatch, lstatSync, type FSWatcher as NodeFSWatcher } from 'node:fs';
import { basename, isAbsolute, join, relative } from 'node:path';

import { FSWatcher } from 'chokidar';

import { Emitter, type Event } from '#/_base/event';
Expand All @@ -23,6 +38,57 @@ import {

const DEFAULT_IGNORED = (p: string): boolean => /(?:^|[/\\])\.git(?:$|[/\\])/.test(p);

const NATIVE_RETRY_BASE_MS = 1000;
const NATIVE_RETRY_MAX_MS = 30000;

export interface NativeSignalStat {
kindOf(absPath: string): HostFsChangeKind | undefined;
}

export class NativeSignalMapper {
private readonly knownKinds = new Map<string, HostFsChangeKind>();

constructor(private readonly stat: NativeSignalStat) {}

map(
root: string,
eventType: string,
filename: string | null,
ignored: (path: string) => boolean,
): HostFsChange | undefined {
const absPath = this.resolveEventPath(root, filename);
if (absPath !== root && ignored(absPath)) return undefined;
if (eventType === 'change') {
return { path: absPath, action: 'modified', kind: this.knownKinds.get(absPath) ?? 'file' };
}
if (eventType !== 'rename') return undefined;
const statKind = this.stat.kindOf(absPath);
if (statKind !== undefined) {
const action: HostFsChangeAction = this.knownKinds.has(absPath) ? 'modified' : 'created';
this.knownKinds.set(absPath, statKind);
return { path: absPath, action, kind: statKind };
}
const kind = this.knownKinds.get(absPath) ?? 'file';
this.knownKinds.delete(absPath);
return { path: absPath, action: 'deleted', kind };
}

private resolveEventPath(root: string, filename: string | null): string {
if (filename === null || filename === '') return root;
if (isAbsolute(filename)) return clampToRoot(root, filename);
if (filename === basename(root) && this.stat.kindOf(join(root, filename)) === undefined) {
return root;
}
return 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;
}

class HostFsWatchHandle implements IHostFsWatchHandle {
readonly onDidChange: Event<HostFsChange>;

Expand Down Expand Up @@ -58,14 +124,111 @@ class HostFsWatchHandle implements IHostFsWatchHandle {
}
}

class SignalWatchHandle implements IHostFsWatchHandle {
readonly onDidChange: Event<HostFsChange>;

private readonly emitter: Emitter<HostFsChange>;
private readonly ignored: (path: string) => boolean;
private readonly mapper = new NativeSignalMapper({
kindOf: (absPath) => {
try {
return lstatSync(absPath).isDirectory() ? 'directory' : 'file';
} catch {
return undefined;
}
},
});
private nativeWatcher: NodeFSWatcher | undefined;
private chokidarLeg: HostFsWatchHandle | undefined;
private retryTimer: ReturnType<typeof setTimeout> | undefined;
private retryAttempts = 0;
private disposed = false;

constructor(private readonly root: string, options: HostFsWatchOptions | undefined) {
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 = fsWatch(
this.root,
{ persistent: false, recursive: true },
(eventType, filename) => {
if (this.disposed) return;
const mapped = this.mapper.map(this.root, eventType, filename, this.ignored);
if (mapped !== undefined) this.emitter.fire(mapped);
},
);
watcher.on('error', (error: NodeJS.ErrnoException) => {
this.onNativeError(error);
});
this.nativeWatcher = watcher;
this.retryAttempts = 0;
} catch (error) {
this.onNativeError(error as NodeJS.ErrnoException);
}
}

private onNativeError(error: NodeJS.ErrnoException): void {
if (this.disposed) return;
this.nativeWatcher?.close();
this.nativeWatcher = undefined;
if (error.code === 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM') {
this.startChokidarLeg();
return;
}
onUnexpectedError(error);
this.emitter.fire({ path: this.root, action: 'modified', kind: 'directory' });
const delay = Math.min(NATIVE_RETRY_BASE_MS * 2 ** this.retryAttempts, NATIVE_RETRY_MAX_MS);
this.retryAttempts += 1;
this.retryTimer = setTimeout(() => {
this.startNativeLeg();
}, delay);
this.retryTimer.unref?.();
}

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);
});
this.chokidarLeg = leg;
}

dispose(): void {
if (this.disposed) return;
this.disposed = true;
if (this.retryTimer !== undefined) clearTimeout(this.retryTimer);
this.nativeWatcher?.close();
this.chokidarLeg?.dispose();
this.emitter.dispose();
}
}

export class HostFsWatchService implements IHostFsWatchService {
declare readonly _serviceBrand: undefined;

watch(path: string, options?: HostFsWatchOptions): IHostFsWatchHandle {
if (useNativeRecursive(options)) {
return new SignalWatchHandle(path, options);
}
return new HostFsWatchHandle(path, options);
}
}

function useNativeRecursive(options: HostFsWatchOptions | undefined): boolean {
return (
options?.signal === true &&
options.recursive !== false &&
(process.platform === 'darwin' || process.platform === 'win32')
);
}

function mapChokidarEvent(eventName: string, absPath: string): HostFsChange | undefined {
const mapped = mapActionAndKind(eventName);
if (mapped === undefined) return undefined;
Expand Down
8 changes: 7 additions & 1 deletion packages/agent-core-v2/src/os/interface/hostFsWatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
* 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), and action/kind may be coarse — a deleted entry whose
* kind was never observed is reported as 'file'. App-scoped — one shared
* instance.
*/

import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
Expand All @@ -23,6 +28,7 @@ export interface HostFsChange {
export interface HostFsWatchOptions {
readonly recursive?: boolean;
readonly ignored?: (path: string) => boolean;
readonly signal?: boolean;
}

export interface IHostFsWatchHandle extends IDisposable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@
* 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.
* re-scans THIS source only when project skill files change. The watch
* mirrors the scanner's own pruning — `node_modules` / dot entries gate
* recursion but their direct SKILL.md stays watched, and the depth cap is
* the scanner's plus two segments (the skill directory and its SKILL.md)
* — and runs in `signal` mode, so a fat skill subtree (e.g. a skill
* bundling a runtime environment) does not cost one fs-watch fd per file.
* Bound at Workspace scope so every session of the handler shares one scan.
*/

import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
Expand All @@ -24,6 +29,10 @@ import {
MERGE_ALL_AVAILABLE_SKILLS_SECTION,
type MergeAllAvailableSkillsConfig,
} from '#/app/skillCatalog/configSection';
import {
MAX_SKILL_SCAN_DEPTH,
isSkillScanExcludedEntry,
} from '#/app/skillCatalog/fileSkillDiscovery';
import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery';
import { projectRoots, projectSkillRootCandidates } from '#/app/skillCatalog/skillRoots';
import {
Expand Down Expand Up @@ -87,7 +96,12 @@ export class WorkspaceRootSkillSource extends Disposable implements IWorkspaceRo
private async watchProjectSkillRoots(): Promise<void> {
const { projectRoot, candidates } = await projectSkillRootCandidates(this.workspace.cwd);
const handle = this.fsWatch.watch(projectRoot, {
ignored: subtreeWatchFilter(projectRoot, candidates),
ignored: subtreeWatchFilter(projectRoot, candidates, {
maxDepth: MAX_SKILL_SCAN_DEPTH + 2,
skipEntry: isSkillScanExcludedEntry,
keepEntryFile: 'SKILL.md',
}),
signal: true,
});
this._register(handle);
this._register(
Expand Down
Loading
Loading