fix(skill): ignore unchanged missing-root probes

This commit is contained in:
Tianyi Cui
2026-07-29 21:12:01 +08:00
parent cf7f14948c
commit e9233123eb
2 changed files with 48 additions and 7 deletions
+23 -7
View File
@@ -421,7 +421,7 @@ class SkillWatchManager {
private openAncestorWatcher(state: RootWatchState, mode: Extract<RootWatchMode, { kind: 'ancestor' }>): WatchHandle {
const listener = (_current: Stats, _previous: Stats): void => {
this.handleWatchEvent(state, mode, 'change', mode.nextPath)
void this.handleAncestorWatchEvent(state, mode)
}
watchFile(mode.nextPath, {
persistent: false,
@@ -435,6 +435,25 @@ class SkillWatchManager {
}
}
private async handleAncestorWatchEvent(
state: RootWatchState,
mode: Extract<RootWatchMode, { kind: 'ancestor' }>,
): Promise<void> {
let current: RootWatchMode
try {
current = await resolveRootWatchMode(state.root.path)
} catch (error) {
/* v8 ignore start -- Non-absence stat failures need a platform permission or I/O fault. */
if (!this.closing && state.owners.size > 0) this.handleWatcherError(state, error)
return
/* v8 ignore stop */
}
if (this.closing || state.owners.size === 0 || sameWatchMode(mode, current)) return
this.queueInvalidation()
state.unhealthy = true
this.scheduleRewatch(state)
}
private async openRootWatcher(state: RootWatchState, mode: Extract<RootWatchMode, { kind: 'root' }>): Promise<WatchHandle> {
const watcher = chokidar.watch(mode.anchor, {
persistent: false,
@@ -481,13 +500,13 @@ class SkillWatchManager {
private handleWatchEvent(
state: RootWatchState,
mode: RootWatchMode,
mode: Extract<RootWatchMode, { kind: 'root' }>,
event: SkillWatchEvent,
path: string,
): void {
if (this.closing || !isRelevantWatchEvent(state.root, mode, event, resolve(path))) return
this.queueInvalidation()
if (mode.kind === 'ancestor' || (resolve(path) === state.root.path && event === 'unlinkDir')) {
if (resolve(path) === state.root.path && event === 'unlinkDir') {
state.unhealthy = true
this.scheduleRewatch(state)
}
@@ -591,13 +610,10 @@ function sameWatchMode(left: RootWatchMode, right: RootWatchMode): boolean {
function isRelevantWatchEvent(
root: SkillRoot,
mode: RootWatchMode,
mode: Extract<RootWatchMode, { kind: 'root' }>,
event: SkillWatchEvent,
path: string,
): boolean {
if (mode.kind === 'ancestor') {
return path === mode.nextPath
}
const segments = containedSegments(root.path, path)
if (segments === undefined) return false
if (segments.length === 0) return event === 'addDir' || event === 'unlinkDir'
@@ -92,6 +92,31 @@ beforeEach(() => {
})
describe('skill-local watcher failures', () => {
it('ignores missing-path probes until the observed path actually changes', async () => {
const home = await tempDir('skill-watch-missing-stable')
const ctx = new Context()
await ctx.plugin(SkillService)
const fiber = await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
})
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true })
expect(watcherHarness.watchFiles).toHaveLength(2)
let invalidations = 0
ctx.on('skills/change', () => { invalidations += 1 })
for (const control of watcherHarness.watchFiles) {
control.listener({} as Stats, {} as Stats)
}
await settle()
expect(invalidations).toBe(0)
expect(watcherHarness.watchFiles).toHaveLength(2)
await fiber.dispose()
})
it('marks a startup failure incomplete and retries discovery without caching it', async () => {
const home = await tempDir('skill-watch-start-error')
const root = join(home, '.dsh/skills')