diff --git a/.changeset/tame-ducks-watch.md b/.changeset/tame-ducks-watch.md new file mode 100644 index 00000000000..1d8b58fad0f --- /dev/null +++ b/.changeset/tame-ducks-watch.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Disable live watching of skill directories; new or changed skills are picked up on restart. Set KIMI_CODE_SKILL_ROOT_WATCH=1 to re-enable live refresh. diff --git a/apps/kimi-code/src/utils/startup-trace.ts b/apps/kimi-code/src/utils/startup-trace.ts index 65ac7eb441e..c2993f0533d 100644 --- a/apps/kimi-code/src/utils/startup-trace.ts +++ b/apps/kimi-code/src/utils/startup-trace.ts @@ -12,7 +12,6 @@ import path from 'node:path'; const enabled = process.env['KIMI_STARTUP_TRACE'] !== undefined && process.env['KIMI_STARTUP_TRACE'] !== ''; const logPath = process.env['KIMI_STARTUP_TRACE_LOG'] ?? '/tmp/kimi-startup-trace.log'; -const t0 = performance.now(); let prepared = false; export function startupTrace(label: string): void { @@ -27,7 +26,7 @@ export function startupTrace(label: string): void { } } try { - appendFileSync(logPath, `${(performance.now() - t0).toFixed(0).padStart(7)}ms ${label}\n`); + appendFileSync(logPath, `${performance.now().toFixed(0).padStart(7)}ms ${label}\n`); } catch { /* best effort */ } diff --git a/packages/agent-core-v2/src/_base/utils/startupTrace.ts b/packages/agent-core-v2/src/_base/utils/startupTrace.ts new file mode 100644 index 00000000000..47501d14fe6 --- /dev/null +++ b/packages/agent-core-v2/src/_base/utils/startupTrace.ts @@ -0,0 +1,21 @@ +import { appendFileSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; + +const enabled = + process.env['KIMI_STARTUP_TRACE'] !== undefined && process.env['KIMI_STARTUP_TRACE'] !== ''; +const logPath = process.env['KIMI_STARTUP_TRACE_LOG'] ?? '/tmp/kimi-startup-trace.log'; +let prepared = false; + +export function startupTrace(label: string): void { + if (!enabled) return; + if (!prepared) { + prepared = true; + try { + mkdirSync(path.dirname(logPath), { recursive: true }); + appendFileSync(logPath, `--- ${new Date().toISOString()} pid=${process.pid} ---\n`); + } catch {} + } + try { + appendFileSync(logPath, `${performance.now().toFixed(0).padStart(7)}ms ${label}\n`); + } catch {} +} diff --git a/packages/agent-core-v2/src/features/skill/catalog/skillRootWatch.ts b/packages/agent-core-v2/src/features/skill/catalog/skillRootWatch.ts new file mode 100644 index 00000000000..b61e564cb05 --- /dev/null +++ b/packages/agent-core-v2/src/features/skill/catalog/skillRootWatch.ts @@ -0,0 +1,7 @@ +import { parseBooleanEnv } from '#/_base/utils/env'; + +export const SKILL_ROOT_WATCH_ENV = 'KIMI_CODE_SKILL_ROOT_WATCH'; + +export function skillRootWatchEnabled(): boolean { + return parseBooleanEnv(process.env[SKILL_ROOT_WATCH_ENV]) === true; +} diff --git a/packages/agent-core-v2/src/features/skill/catalog/userFileSkillSource.ts b/packages/agent-core-v2/src/features/skill/catalog/userFileSkillSource.ts index 361c5cd0865..c1f5d42fbdf 100644 --- a/packages/agent-core-v2/src/features/skill/catalog/userFileSkillSource.ts +++ b/packages/agent-core-v2/src/features/skill/catalog/userFileSkillSource.ts @@ -16,6 +16,7 @@ import { type MergeAllAvailableSkillsConfig, } from './configSection'; import { ISkillDiscovery } from './skillDiscovery'; +import { skillRootWatchEnabled } from './skillRootWatch'; import { userRoots } from './skillRoots'; import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from './skillSource'; @@ -50,7 +51,7 @@ export class UserFileSkillSource extends Disposable implements IUserFileSkillSou if (event.domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) this.onDidChangeEmitter.fire(); }), ); - if ((this.bootstrap.args.skillDirs?.length ?? 0) === 0) { + if ((this.bootstrap.args.skillDirs?.length ?? 0) === 0 && skillRootWatchEnabled()) { this.watchUserSkillRoots(); } } diff --git a/packages/agent-core-v2/src/features/skill/workspace/rootFileSkillSource.ts b/packages/agent-core-v2/src/features/skill/workspace/rootFileSkillSource.ts index 29975ade550..fbb75449b47 100644 --- a/packages/agent-core-v2/src/features/skill/workspace/rootFileSkillSource.ts +++ b/packages/agent-core-v2/src/features/skill/workspace/rootFileSkillSource.ts @@ -11,6 +11,7 @@ import { } from '#/features/skill/catalog/configSection'; import { ISkillDiscovery } from '#/features/skill/catalog/skillDiscovery'; import { projectRoots, projectSkillRootCandidates } from '#/features/skill/catalog/skillRoots'; +import { skillRootWatchEnabled } from '#/features/skill/catalog/skillRootWatch'; import { SKILL_SOURCE_PRIORITY, type ISkillSource, @@ -80,6 +81,7 @@ export class WorkspaceRootSkillSource extends Disposable implements IWorkspaceRo private async updateProjectSkillRootWatch( scannedDirectories: readonly string[], ): Promise { + if (!skillRootWatchEnabled()) return false; const { projectRoot, candidates } = await projectSkillRootCandidates(this.workspace.cwd); const signature = [...scannedDirectories].toSorted().join('\0'); if (signature === this.watchSignature) return false; diff --git a/packages/agent-core-v2/src/workspace/workspaceInstance/workspaceInstanceManagerService.ts b/packages/agent-core-v2/src/workspace/workspaceInstance/workspaceInstanceManagerService.ts index 4537995107b..1e82bee9542 100644 --- a/packages/agent-core-v2/src/workspace/workspaceInstance/workspaceInstanceManagerService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceInstance/workspaceInstanceManagerService.ts @@ -2,6 +2,7 @@ import { IInstantiationService, ref, type LiveRef } from '#/_base/di/instantiati import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Emitter } from '#/_base/event'; import { ILogService } from '#/_base/log/log'; +import { startupTrace } from '#/_base/utils/startupTrace'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; import { IAgentProfileRegistry } from '#/app/agentProfileCatalog/agentProfileRegistry'; @@ -120,12 +121,14 @@ export class WorkspaceInstanceManager implements IWorkspaceInstanceManager { if (request !== undefined) return request; const promise = (async () => { let workspace: Workspace | undefined; + startupTrace('workspace:catalog:begin'); if ('workspaceId' in ref) { workspace = await this.workspaces.get(ref.workspaceId); if (workspace === undefined && ref.root !== undefined) workspace = await this.workspaces.createOrTouch(ref.root); } else { workspace = await this.workspaces.createOrTouch(ref.root); } + startupTrace('workspace:catalog:end'); if (workspace === undefined) throw new Error2(ErrorCodes.WORKSPACE_NOT_FOUND, `workspace ${'workspaceId' in ref ? ref.workspaceId : ref.root} does not exist`); const existing = this.instances.get(workspace.id); if (existing !== undefined) return existing; @@ -153,7 +156,7 @@ export class WorkspaceInstanceManager implements IWorkspaceInstanceManager { this.instances.delete(workspaceId); const attachments = this.attachments.get(workspaceId); this.attachments.delete(workspaceId); - if (attachments !== undefined) for (const attachment of [...attachments.values()].reverse()) await attachment.dispose(); + if (attachments !== undefined) for (const attachment of [...attachments.values()].toReversed()) await attachment.dispose(); await instance.dispose(); this.changeEmitter.fire({ workspaceId }); } @@ -169,23 +172,25 @@ export class WorkspaceInstanceManager implements IWorkspaceInstanceManager { } } catch (error) { this.providers.delete(factory.id); - for (const instance of attached.reverse()) await this.detach(instance.id, factory.id); + for (const instance of attached.toReversed()) await this.detach(instance.id, factory.id); throw error; } return { dispose: async () => { if (this.providers.get(factory.id) !== factory) return; this.providers.delete(factory.id); - for (const workspaceId of [...this.attachments.keys()].reverse()) await this.detach(workspaceId, factory.id); + for (const workspaceId of [...this.attachments.keys()].toReversed()) await this.detach(workspaceId, factory.id); } }; } async dispose(): Promise { - for (const workspaceId of [...this.instances.keys()].reverse()) await this.close(workspaceId); + for (const workspaceId of [...this.instances.keys()].toReversed()) await this.close(workspaceId); this.changeEmitter.dispose(); } private async materialize(workspace: Workspace): Promise { + startupTrace('workspace:materialize:begin'); await this.environment.ready; + startupTrace('workspace:materialize:envReady'); const runtimes = new RuntimeRegistry(workspace.id); const unitHost = this.unitHostFactory.create(this.instantiation, runtimes); const instance = new WorkspaceInstance( @@ -246,18 +251,20 @@ export class WorkspaceInstanceManager implements IWorkspaceInstanceManager { ), }, ); + startupTrace('workspace:materialize:constructed'); try { for (const provider of this.providers.values()) await this.attach(instance, provider); if (instance.runtimes.current('local') === undefined) throw new Error(`workspace ${workspace.id} has no local runtime`); instance.activate(); this.instances.set(workspace.id, instance); this.changeEmitter.fire({ workspaceId: workspace.id, instance }); + startupTrace('workspace:materialize:end'); return instance; } catch (error) { const attachments = this.attachments.get(instance.id); this.attachments.delete(instance.id); if (attachments !== undefined) { - for (const attachment of [...attachments.values()].reverse()) await attachment.dispose(); + for (const attachment of [...attachments.values()].toReversed()) await attachment.dispose(); } await instance.dispose(); throw error; diff --git a/packages/agent-core-v2/test/features/skill/workspace/skillCatalog.test.ts b/packages/agent-core-v2/test/features/skill/workspace/skillCatalog.test.ts index dd72e8b8e90..244f14cf4fb 100644 --- a/packages/agent-core-v2/test/features/skill/workspace/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/features/skill/workspace/skillCatalog.test.ts @@ -230,6 +230,8 @@ async function withSkillCatalogWorkspace( describe('WorkspaceSkillCatalogService', () => { beforeEach(() => { + vi.unstubAllEnvs(); + vi.stubEnv('KIMI_CODE_SKILL_ROOT_WATCH', '1'); watchMockState.calls = []; watchMockState.factory = undefined; _clearScopedRegistryForTests(); @@ -976,7 +978,7 @@ describe('WorkspaceSkillCatalogService', () => { await catalog.reloadSources(['user', 'explicit', 'extra', 'plugin']); sub.dispose(); - expect([...fired].sort()).toEqual(['explicit', 'extra', 'plugin', 'user']); + expect([...fired].toSorted()).toEqual(['explicit', 'extra', 'plugin', 'user']); expect(catalog.catalog.getSkill('user-skill')?.description).toBe('v2'); expect(catalog.catalog.getSkill('extra-skill')?.description).toBe('v2'); expect(catalog.catalog.getPluginSkill('demo', 'demo-skill')).toBeUndefined(); @@ -1089,6 +1091,63 @@ describe('WorkspaceSkillCatalogService', () => { } }); + it('does not watch the user skill roots when the watch env flag is off', async () => { + vi.stubEnv('KIMI_CODE_SKILL_ROOT_WATCH', '0'); + const host = createScopedTestHost([ + stubPair(IFlagService, stubFlag(true)), + stubPair(IBootstrapService, stubBootstrap('/home', {}, {}, '/os-home')), + stubPair(IConfigService, configStub()), + stubPair(IPluginService, pluginStub()), + stubPair(ILogService, stubLog()), + stubPair(ISkillDiscovery, new FileSkillDiscovery(stubLog())), + ]); + const workspace = host.child('program', 'w1', [ + stubPair(IWorkspaceContext, workspaceContextStub('/work')), + ]); + + try { + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + + const watchedPaths = watchMockState.calls.map((call) => call.path); + expect(watchedPaths).not.toContain('/home'); + expect(watchedPaths).not.toContain('/os-home'); + } finally { + host.dispose(); + } + }); + + it('does not watch the project skill root when the watch env flag is off', async () => { + vi.stubEnv('KIMI_CODE_SKILL_ROOT_WATCH', '0'); + const workDir = await mkdtemp(join(tmpdir(), 'skill-watch-disabled-')); + const skillRoot = join(workDir, '.agents', 'skills'); + await mkdir(skillRoot, { recursive: true }); + const watchedRoot = await realpath(workDir); + const host = createScopedTestHost([ + stubPair(IFlagService, stubFlag(true)), + stubPair(IBootstrapService, bootstrapStub), + stubPair(IConfigService, configStub()), + stubPair(IPluginService, pluginStub()), + stubPair(ILogService, stubLog()), + stubPair(ISkillDiscovery, new FileSkillDiscovery(stubLog())), + ]); + const workspace = host.child('program', 'w1', [ + stubPair(IWorkspaceContext, workspaceContextStub(workDir)), + ]); + + try { + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + + const watchedPaths = watchMockState.calls.map((call) => call.path); + expect(watchedPaths).not.toContain(workDir); + expect(watchedPaths).not.toContain(watchedRoot); + } finally { + host.dispose(); + await rm(workDir, { recursive: true, force: true }); + } + }); + it('disposes the user root watches when the app scope is disposed', async () => { const handles: { disposed: boolean }[] = []; watchMockState.factory = () => { diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 734be22ec35..d761c91d8a6 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -131,6 +131,7 @@ import { readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug'; +import { startupTrace } from '@moonshot-ai/agent-core-v2/_base/utils/startupTrace'; import { McpConnectionManager } from '@moonshot-ai/agent-core-v2/mcpCore/connection-manager'; import { loadMcpServers, @@ -688,10 +689,14 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * empty list rather than failing the caller. */ override async getWorkspaceTrustInfo(workDir: string): Promise { + startupTrace('workspaceTrust:getOrCreate:begin'); const handler = await this.engineAccessor .get(IWorkspaceInstanceManager) .getOrCreate({ root: workDir }); + startupTrace('workspaceTrust:getOrCreate:end'); + startupTrace('workspaceTrust:read:begin'); const trusted = await handler.program.trust.get(); + startupTrace('workspaceTrust:read:end'); if (trusted) return { trusted: true, gatedMcpServers: [] }; try { const fs = this.engineAccessor.get(IHostFileSystem); @@ -955,9 +960,9 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * cannot deadlock. */ private runSessionAccessAll(sessionIds: readonly string[], work: () => Promise): Promise { - const keys = [...new Set(sessionIds)].sort(); + const keys = [...new Set(sessionIds)].toSorted(); let chained: () => Promise = work; - for (const key of [...keys].reverse()) { + for (const key of [...keys].toReversed()) { const inner = chained; chained = () => this.runSessionAccess(key, inner); }