From 5912d4c7d19d68975e85b007976b1bef59edae5c Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Thu, 13 Aug 2026 14:35:23 +0800 Subject: [PATCH] fix: stop repeated file-watcher errors on Windows drive-root and UNC workspaces (#2876) --- .changeset/fix-windows-project-root-watch.md | 5 ++ .../agent-core-v2/src/_base/utils/paths.ts | 30 ++++++- .../src/app/skillCatalog/skillRoots.ts | 11 +-- .../internal/agentRoots.ts | 16 ++-- .../test/_base/utils/paths.test.ts | 80 ++++++++++++++++++- 5 files changed, 118 insertions(+), 24 deletions(-) create mode 100644 .changeset/fix-windows-project-root-watch.md diff --git a/.changeset/fix-windows-project-root-watch.md b/.changeset/fix-windows-project-root-watch.md new file mode 100644 index 000000000..27fd36caa --- /dev/null +++ b/.changeset/fix-windows-project-root-watch.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix repeated file-watcher errors on Windows when the workspace is a drive root (such as `E:\`) or a UNC network share. diff --git a/packages/agent-core-v2/src/_base/utils/paths.ts b/packages/agent-core-v2/src/_base/utils/paths.ts index e6b230df7..34de452b1 100644 --- a/packages/agent-core-v2/src/_base/utils/paths.ts +++ b/packages/agent-core-v2/src/_base/utils/paths.ts @@ -1,14 +1,40 @@ /** - * `_base/utils/paths` (cross-cutting) — pure path-filter predicates. + * `_base/utils/paths` (cross-cutting) — pure path predicates and directory + * walks. * * Constrains filesystem watches to selected subtrees and scanner-visible - * entries. + * entries, and walks host directory chains with platform-native path + * semantics so drive-letter / UNC roots keep their host form. */ +import nodePath from 'node:path'; + function normalizeSlashes(p: string): string { return p.replaceAll('\\', '/'); } +export interface UpwardRootPathApi { + resolve(dir: string): string; + dirname(dir: string): string; + join(...segments: string[]): string; +} + +export async function findUpwardRoot( + workDir: string, + markerName: string, + hasMarker: (markerPath: string) => Promise, + pathApi: UpwardRootPathApi = nodePath, +): Promise { + const start = pathApi.resolve(workDir); + let current = start; + while (true) { + if (await hasMarker(pathApi.join(current, markerName))) return normalizeSlashes(current); + const parent = pathApi.dirname(current); + if (parent === current) return normalizeSlashes(start); + current = parent; + } +} + export interface SubtreeWatchFilterOptions { readonly maxDepth?: number; readonly skipEntry?: (entryName: string) => boolean; diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts index 8c9d4a5f7..367a33a60 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts @@ -11,6 +11,8 @@ import { promises as fs } from 'node:fs'; import path from 'pathe'; +import { findUpwardRoot } from '#/_base/utils/paths'; + import type { SkillRoot, SkillSource } from './types'; const USER_BRAND_DIRS = ['skills'] as const; @@ -78,14 +80,7 @@ export async function configuredRoots( } async function findProjectRoot(workDir: string): Promise { - const start = path.resolve(workDir); - let current = start; - while (true) { - if (await exists(path.join(current, '.git'))) return current; - const parent = path.dirname(current); - if (parent === current) return start; - current = parent; - } + return findUpwardRoot(workDir, '.git', exists); } async function pushFirstExisting( diff --git a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentRoots.ts b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentRoots.ts index 7d0a8ab48..c09df234c 100644 --- a/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentRoots.ts +++ b/packages/agent-core-v2/src/workspace/workspaceAgentProfileLoader/internal/agentRoots.ts @@ -5,8 +5,9 @@ * filesystem boundary. Pure path probes; no scoped state. */ -import { dirname, join, resolve } from 'pathe'; +import { join } from 'pathe'; +import { findUpwardRoot } from '#/_base/utils/paths'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { HostFsError, OsFsErrors } from '#/os/interface/hostFsErrors'; @@ -86,20 +87,15 @@ async function findProjectRoot( workDir: string, warn?: AgentRootWarn, ): Promise { - const start = resolve(workDir); - let current = start; - while (true) { - const marker = join(current, '.git'); + return findUpwardRoot(workDir, '.git', async (marker) => { try { - if (await pathExists(fs, marker)) return current; + return await pathExists(fs, marker); } catch (error) { if (isUnavailable(error)) throw error; warn?.(`Skipping unreadable project marker ${marker}: ${errorMessage(error)}`, error); + return false; } - const parent = dirname(current); - if (parent === current) return start; - current = parent; - } + }); } async function pushFirstExisting( diff --git a/packages/agent-core-v2/test/_base/utils/paths.test.ts b/packages/agent-core-v2/test/_base/utils/paths.test.ts index 1f73ebc59..532c6d9f7 100644 --- a/packages/agent-core-v2/test/_base/utils/paths.test.ts +++ b/packages/agent-core-v2/test/_base/utils/paths.test.ts @@ -1,14 +1,19 @@ /** * 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. + * Responsibilities: candidate ancestry, scan-depth bounds, excluded-entry + * probing, and the marker-based upward root walk. Wiring: pure path + * predicates and walks 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 { mkdtemp, mkdir, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import nodePath, { win32 } from 'node:path'; -import { subtreeWatchFilter } from '#/_base/utils/paths'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { findUpwardRoot, subtreeWatchFilter } from '#/_base/utils/paths'; describe('subtree watch filtering', () => { const root = '/repo'; @@ -105,3 +110,70 @@ describe('subtree watch filtering', () => { expect(ignored('/repo/.agents/skills/parent/child/runtime')).toBe(true); }); }); + +describe('findUpwardRoot', () => { + const noMarker = async () => false; + + describe('with host-default path semantics', () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(nodePath.join(tmpdir(), 'upward-root-')); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + const hasMarker = async (markerPath: string): Promise => { + try { + await stat(markerPath); + return true; + } catch { + return false; + } + }; + + it('stops at the nearest ancestor holding the marker', async () => { + await mkdir(nodePath.join(root, '.git')); + const child = nodePath.join(root, 'src', 'pkg'); + await mkdir(child, { recursive: true }); + + const found = await findUpwardRoot(child, '.git', hasMarker); + + expect(found).toBe(root.replaceAll('\\', '/')); + }); + + it('falls back to the working directory when no ancestor holds the marker', async () => { + const child = nodePath.join(root, 'src', 'pkg'); + await mkdir(child, { recursive: true }); + + const found = await findUpwardRoot(child, '.git', hasMarker); + + expect(found).toBe(child.replaceAll('\\', '/')); + }); + }); + + it('keeps a Windows drive-root working directory in host form', async () => { + const found = await findUpwardRoot('E:\\', '.git', noMarker, win32); + + expect(found).toBe('E:/'); + }); + + it('keeps a Windows UNC working directory in host form', async () => { + const found = await findUpwardRoot('\\\\fs1\\share\\dir', '.git', noMarker, win32); + + expect(found).toBe('//fs1/share/dir'); + }); + + it('stops at the nearest Windows ancestor holding the marker', async () => { + const found = await findUpwardRoot( + 'E:\\repo\\src', + '.git', + async (markerPath) => markerPath === 'E:\\repo\\.git', + win32, + ); + + expect(found).toBe('E:/repo'); + }); +});