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/app/diagnostics/diag.ts b/packages/agent-core-v2/src/app/diagnostics/diag.ts new file mode 100644 index 00000000000..488508eefc3 --- /dev/null +++ b/packages/agent-core-v2/src/app/diagnostics/diag.ts @@ -0,0 +1,12 @@ +import { parseBooleanEnv } from '#/_base/utils/env'; + +export const DIAG_ENV = 'KIMI_CODE_DIAG'; +export const REBUILD_ON_CONTENTION_ENV = 'KIMI_CODE_QUERY_STORE_REBUILD_ON_CONTENTION'; + +export function diagEnabled(): boolean { + return parseBooleanEnv(process.env[DIAG_ENV]) === true; +} + +export function rebuildOnContentionEnabled(): boolean { + return parseBooleanEnv(process.env[REBUILD_ON_CONTENTION_ENV]) === true; +} diff --git a/packages/agent-core-v2/src/app/diagnostics/eventLoopMonitor.ts b/packages/agent-core-v2/src/app/diagnostics/eventLoopMonitor.ts new file mode 100644 index 00000000000..83369b712e8 --- /dev/null +++ b/packages/agent-core-v2/src/app/diagnostics/eventLoopMonitor.ts @@ -0,0 +1,64 @@ +import { monitorEventLoopDelay, type IntervalHistogram } from 'node:perf_hooks'; + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope } from '#/app/scopes'; +import { createDecorator } from '#/_base/di/instantiation'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { IntervalTimer } from '#/_base/utils/timer'; + +import { diagEnabled } from './diag'; + +const SAMPLE_INTERVAL_MS = 1_000; +const SUMMARY_EVERY_TICKS = 60; +const STALL_WARN_MS = 250; + +export const IEventLoopMonitorService = + createDecorator('eventLoopMonitor'); + +export class EventLoopMonitorService extends Disposable { + declare readonly _serviceBrand: undefined; + + private readonly timer = this._register(new IntervalTimer({ unref: true })); + private readonly histogram: IntervalHistogram | undefined; + private ticks = 0; + + constructor(@ILogService private readonly log: ILogService) { + super(); + if (!diagEnabled()) return; + this.histogram = monitorEventLoopDelay({ resolution: 20 }); + this.histogram.enable(); + this.timer.cancelAndSet(() => this.sample(), SAMPLE_INTERVAL_MS); + } + + private sample(): void { + const histogram = this.histogram; + if (histogram === undefined) return; + this.ticks += 1; + const maxMs = histogram.max / 1e6; + if (maxMs >= STALL_WARN_MS) { + this.log.warn('event loop stall detected', { + maxMs: Math.round(maxMs), + p99Ms: Math.round(histogram.percentile(99) / 1e6), + p50Ms: Math.round(histogram.percentile(50) / 1e6), + }); + } + if (this.ticks % SUMMARY_EVERY_TICKS === 0) { + this.log.info('event loop delay summary', { + p50Ms: Math.round(histogram.percentile(50) / 1e6), + p90Ms: Math.round(histogram.percentile(90) / 1e6), + p99Ms: Math.round(histogram.percentile(99) / 1e6), + maxMs: Math.round(histogram.max / 1e6), + }); + } + histogram.reset(); + } +} + +registerScopedService( + LifecycleScope.App, + IEventLoopMonitorService, + EventLoopMonitorService, + ScopeActivation.OnScopeCreated, + 'diagnostics', +); diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexMirrorService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexMirrorService.ts index 61ac995c148..07ee75c9f70 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexMirrorService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexMirrorService.ts @@ -12,6 +12,7 @@ import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { ISessionIndexMirror, type SessionSummary } from './sessionIndex'; import { markSessionDirty } from './sessionIndexDirtyJournal'; +import { diagEnabled } from '#/app/diagnostics/diag'; import { SESSION_INDEX_MANIFEST, recencyColumn, @@ -41,6 +42,7 @@ export class SessionIndexMirror extends Disposable implements ISessionIndexMirro private flushing: Promise | undefined; private consecutiveFailures = 0; private giveUpTracked = false; + private readonly diag = diagEnabled(); private disposed = false; private overflowLogged = false; private readonly sessionsScope: string; @@ -130,6 +132,7 @@ export class SessionIndexMirror extends Disposable implements ISessionIndexMirro private async flushChunk(): Promise { const chunk = [...this.pendingMap.entries()].slice(0, FLUSH_BATCH_SIZE); if (chunk.length === 0) return; + const startedAt = Date.now(); try { const manifest = await this.queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); if (manifest === undefined) { @@ -197,6 +200,7 @@ export class SessionIndexMirror extends Disposable implements ISessionIndexMirro if (this.consecutiveFailures === 1) { this.log.warn('failed to flush session index mirror chunk', { pending: this.pendingMap.size, + durationMs: this.diag ? Date.now() - startedAt : undefined, error: String(error), }); } diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index 45b2b330d48..c0e7b9dd3b7 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -32,6 +32,7 @@ import { type SessionSummary, } from './sessionIndex'; import { markSessionDirty } from './sessionIndexDirtyJournal'; +import { diagEnabled } from '#/app/diagnostics/diag'; import { PARENT_INDEX_NAME, SESSION_INDEX_MANIFEST, @@ -54,6 +55,8 @@ import { const RECONCILE_INTERVAL_MS = 60_000; const DEGRADED_RETRY_MS = 5_000; const TIE_REPAIR_LIMIT = 1_000; +const SLOW_RECONCILE_MS = 2_000; +const SLOW_AUTHORITATIVE_SCAN_MS = 2_000; const UNBOUNDED = Number.MAX_SAFE_INTEGER; function canonicalOrder(a: SessionSummary, b: SessionSummary): number { @@ -84,6 +87,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { private lastDegradedKey: string | undefined; private prepareFlight: Promise | undefined; private projectFlight: Promise | undefined; + private readonly diag = diagEnabled(); private readonly reconcileTimer = this._register(new IntervalTimer({ unref: true })); private readonly projector: SessionIndexProjector; @@ -259,6 +263,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { return; } if (this.state !== 'ready') return; + const startedAt = Date.now(); try { const manifest = await this.queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); if (manifest === undefined) { @@ -269,8 +274,18 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { this.generation = manifest.seq; if (await this.manifestFresh(manifest)) return; await this.projector.reconcile(manifest.seq); + const durationMs = Date.now() - startedAt; + if (this.diag && durationMs >= SLOW_RECONCILE_MS) { + this.log.warn('session index reconciliation slow', { + durationMs, + generation: manifest.seq, + }); + } } catch (error) { - this.log.warn('session index reconciliation failed', { error: String(error) }); + this.log.warn('session index reconciliation failed', { + error: String(error), + durationMs: this.diag ? Date.now() - startedAt : undefined, + }); } } @@ -656,6 +671,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { private async collectAuthoritative( workspaceIds: readonly string[] | undefined, ): Promise { + const startedAt = Date.now(); let collected: SessionSummary[]; if ( this.readModelEnabled() && @@ -677,13 +693,26 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { } } const pending = this.mirror.pending(); - if (pending.length === 0) return collected; - const byId = new Map(collected.map((summary) => [summary.id, summary])); - for (const summary of pending) { - if (workspaceIds !== undefined && !workspaceIds.includes(summary.workspaceId)) continue; - byId.set(summary.id, summary); + let result: SessionSummary[]; + if (pending.length === 0) { + result = collected; + } else { + const byId = new Map(collected.map((summary) => [summary.id, summary])); + for (const summary of pending) { + if (workspaceIds !== undefined && !workspaceIds.includes(summary.workspaceId)) continue; + byId.set(summary.id, summary); + } + result = [...byId.values()]; + } + const durationMs = Date.now() - startedAt; + if (this.diag && durationMs >= SLOW_AUTHORITATIVE_SCAN_MS) { + this.log.warn('session index authoritative scan slow', { + durationMs, + sessions: result.length, + state: this.state, + }); } - return [...byId.values()]; + return result; } private readModelEnabled(): boolean { 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/index.ts b/packages/agent-core-v2/src/index.ts index 688cd5e3226..b2170aac853 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -168,6 +168,8 @@ export type { KimiThinkingConfig } from '#human/llm-kimi/trait'; export * from '#/app/sessionIndex/sessionIndex'; export * from '#/app/sessionIndex/sessionIndexService'; export * from '#/app/sessionIndex/sessionIndexMirrorService'; +export * from '#/app/diagnostics/eventLoopMonitor'; +export * from '#/app/diagnostics/diag'; export * from '#/session/sessionMetadata/sessionMetadata'; export * from '#/session/sessionMetadata/sessionMetadataService'; export * from '#/session/sessionMetadata/promptMetadata'; diff --git a/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts b/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts index f6313548c27..49a0fc3850f 100644 --- a/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts +++ b/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts @@ -1,7 +1,7 @@ import { join } from 'pathe'; import { classifyStorageError, type QueryOptions } from '@moonshot-ai/minidb'; -import { ClusterDb, wipeCluster } from '@moonshot-ai/minidb/cluster'; +import { ClusterDb, LockError, wipeCluster } from '@moonshot-ai/minidb/cluster'; import { Disposable, toDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; @@ -22,6 +22,8 @@ import { type WriteOp, } from '#/persistence/interface/queryStore'; +import { rebuildOnContentionEnabled } from '#/app/diagnostics/diag'; + const SEP = String.fromCodePoint(0); const CHECKPOINT_COLLECTION = '__checkpoint__'; const STORE_SUBDIR = 'query-store'; @@ -29,6 +31,7 @@ const SHARD_COUNT = 16; const LOCK_ACQUIRE_TIMEOUT_MS = 1000; const DROP_BATCH_SIZE = 500; const TRANSIENT_ESCALATION_LIMIT = 5; +const CONTENTION_LOG_INTERVAL_MS = 60_000; function physicalKey(collection: string, key: string): string { return `${collection}${SEP}${key}`; @@ -38,6 +41,11 @@ function indexName(collection: string, name: string): string { return `${collection}:${name}`; } +export function isLockContentionError(error: unknown): boolean { + if (error instanceof LockError) return true; + return error instanceof AggregateError && error.errors.some(isLockContentionError); +} + const pendingDisposals = new Set>(); export async function drainQueryStoreDisposals(): Promise { @@ -53,6 +61,8 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore { private transientReadFailures = 0; private transientWriteFailures = 0; private storeEpochCounter = 0; + private lastContentionLogAt = 0; + private readonly rebuildOnContention = rebuildOnContentionEnabled(); private readonly ensuredIndexes = new Set(); constructor( @@ -140,6 +150,10 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore { return result; } catch (error) { if (classifyStorageError(error) !== 'rebuild') { + if (isLockContentionError(error)) { + this.noteLockContention(db, kind, error); + if (!this.rebuildOnContention) throw error; + } const failures = kind === 'write' ? (this.transientWriteFailures += 1) @@ -154,6 +168,25 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore { } } + private noteLockContention(db: ClusterDb, kind: 'read' | 'write', error: unknown): void { + const now = Date.now(); + if (now - this.lastContentionLogAt < CONTENTION_LOG_INTERVAL_MS) return; + this.lastContentionLogAt = now; + const stats = db.stats(); + this.log.warn('minidb query-store lock contention: shard locks held by another process', { + dir: this.dir, + kind, + error: String(error), + lockWaits: stats.lockWaits, + writerOpens: stats.writerOpens, + readerOpens: stats.readerOpens, + readerReopens: stats.readerReopens, + incrementalCatchups: stats.incrementalCatchups, + catchupFramesApplied: stats.catchupFramesApplied, + evictions: stats.evictions, + }); + } + async put( collection: string, key: string, 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/agent-core-v2/test/persistence/backends/minidb/miniDbQueryStore.test.ts b/packages/agent-core-v2/test/persistence/backends/minidb/miniDbQueryStore.test.ts index 0a5bbcbd444..f2ae7e5a582 100644 --- a/packages/agent-core-v2/test/persistence/backends/minidb/miniDbQueryStore.test.ts +++ b/packages/agent-core-v2/test/persistence/backends/minidb/miniDbQueryStore.test.ts @@ -237,7 +237,7 @@ describe('MiniDbQueryStore', () => { { kind: 'put', collection: COLLECTION, key: 'b', value: { v: 2 } }, ]); const found = await store.getMany<{ v: number }>(COLLECTION, ['a', 'missing', 'b']); - expect([...found.keys()].sort()).toEqual(['a', 'b']); + expect([...found.keys()].toSorted()).toEqual(['a', 'b']); expect(found.get('a')).toEqual({ v: 1 }); expect(found.get('b')).toEqual({ v: 2 }); expect(await store.getMany(COLLECTION, [])).toEqual(new Map()); diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index be2cf192e6f..7a38c8926ce 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);