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/tame-ducks-watch.md
Original file line number Diff line number Diff line change
@@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the changeset entry to one sentence

When release tooling publishes this entry, it will include two sentences and expose the temporary environment-variable mechanism, although repository policy requires one short user-facing sentence stating only what changed. Fold the relevant user-visible behavior into a single sentence and omit mechanism-level detail.

AGENTS.md reference: AGENTS.md:L86-L87

Useful? React with 👍 / 👎.

3 changes: 1 addition & 2 deletions apps/kimi-code/src/utils/startup-trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 */
}
Expand Down
21 changes: 21 additions & 0 deletions packages/agent-core-v2/src/_base/utils/startupTrace.ts
Original file line number Diff line number Diff line change
@@ -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 {}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -80,6 +81,7 @@ export class WorkspaceRootSkillSource extends Disposable implements IWorkspaceRo
private async updateProjectSkillRootWatch(
scannedDirectories: readonly string[],
): Promise<boolean> {
if (!skillRootWatchEnabled()) return false;
const { projectRoot, candidates } = await projectSkillRootCandidates(this.workspace.cwd);
const signature = [...scannedDirectories].toSorted().join('\0');
if (signature === this.watchSignature) return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
}
Expand All @@ -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<void> {
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<WorkspaceInstance> {
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(
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 = () => {
Expand Down
9 changes: 7 additions & 2 deletions packages/node-sdk/src/sdk-rpc-client-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -688,10 +689,14 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
* empty list rather than failing the caller.
*/
override async getWorkspaceTrustInfo(workDir: string): Promise<WorkspaceTrustInfo> {
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);
Expand Down Expand Up @@ -955,9 +960,9 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
* cannot deadlock.
*/
private runSessionAccessAll<T>(sessionIds: readonly string[], work: () => Promise<T>): Promise<T> {
const keys = [...new Set(sessionIds)].sort();
const keys = [...new Set(sessionIds)].toSorted();
let chained: () => Promise<T> = work;
for (const key of [...keys].reverse()) {
for (const key of [...keys].toReversed()) {
const inner = chained;
chained = () => this.runSessionAccess(key, inner);
}
Expand Down
Loading