From 333e33f30f62a2b2f84ba9328a1d017713afc732 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 12 Feb 2026 17:36:32 -0800 Subject: [PATCH] feat(skills): target watcher updates with safe fallback --- docs/plans/state.json | 13 ++++- src/daemon/services.test.ts | 62 ++++++++++++++++++++-- src/daemon/services.ts | 102 ++++++++++++++++++++++++++++++++++-- 3 files changed, 167 insertions(+), 10 deletions(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index 9691b9a..0471966 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1310,6 +1310,15 @@ "src/skills/registry.test.ts" ], "test_status": "pnpm typecheck + pnpm test:run src/daemon/services.test.ts src/skills/registry.test.ts + pnpm test:run + pnpm lint (warnings only, 0 errors) + pnpm build passing" + }, + "watcher_targeted_skill_updates": { + "status": "completed", + "description": "Optimized watcher callback to perform targeted skill add/update/remove by mapped path, with safe fallback to full reload for ambiguous paths", + "files_modified": [ + "src/daemon/services.ts", + "src/daemon/services.test.ts" + ], + "test_status": "pnpm typecheck + pnpm test:run src/daemon/services.test.ts + pnpm test:run + pnpm lint (warnings only, 0 errors) + pnpm build passing" } } }, @@ -1344,7 +1353,7 @@ }, "overall_progress": { - "total_test_count": 1515, + "total_test_count": 1517, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -1364,7 +1373,7 @@ "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", "native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback", "remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 2/2 (100%) — component registry, confidence routing. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening", - "next_up": "Skills infrastructure Phase 2: optimize watcher reload path to targeted skill-level add/update/remove with safe fallback to full reload" + "next_up": "Skills infrastructure Phase 2: watcher observability polish (clear event reasoning and targeted/full-reload counts in logs)" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/daemon/services.test.ts b/src/daemon/services.test.ts index be0259b..d3fd263 100644 --- a/src/daemon/services.test.ts +++ b/src/daemon/services.test.ts @@ -16,10 +16,10 @@ describe('initSkills watcher wiring', () => { } }); - function writeSkill(rootDir: string, name: string): void { + function writeSkill(rootDir: string, name: string, instructions = `# ${name}\n\nTest skill.`): void { const skillDir = join(rootDir, name); mkdirSync(skillDir, { recursive: true }); - writeFileSync(join(skillDir, 'SKILL.md'), `# ${name}\n\nTest skill.`); + writeFileSync(join(skillDir, 'SKILL.md'), instructions); } function makeConfig(overrides: Record = {}) { @@ -62,7 +62,7 @@ describe('initSkills watcher wiring', () => { expect(result.skillsWatcher?.isRunning).toBe(false); }); - it('reloads registry from disk when watcher callback fires', () => { + it('applies targeted add/update changes for a mapped skill path', () => { vi.useFakeTimers(); const root = mkdtempSync(join(tmpdir(), 'flynn-services-')); roots.push(root); @@ -85,7 +85,35 @@ describe('initSkills watcher wiring', () => { writeSkill(managedDir, 'beta'); result.skillsWatcher?.notifyPathChanged(join(managedDir, 'beta', 'SKILL.md')); vi.advanceTimersByTime(20); + expect(result.skillRegistry.get('beta')).toBeDefined(); + writeSkill(managedDir, 'beta', '# beta\n\nUpdated instructions.'); + result.skillsWatcher?.notifyPathChanged(join(managedDir, 'beta', 'SKILL.md')); + vi.advanceTimersByTime(20); + expect(result.skillRegistry.get('beta')?.instructions).toContain('Updated instructions.'); + + result.skillsWatcher?.stop(); + }); + + it('unregisters a removed mapped skill path', () => { + vi.useFakeTimers(); + const root = mkdtempSync(join(tmpdir(), 'flynn-services-')); + roots.push(root); + const managedDir = join(root, 'skills'); + mkdirSync(managedDir, { recursive: true }); + writeSkill(managedDir, 'alpha'); + writeSkill(managedDir, 'beta'); + + const config = makeConfig({ + skills: { + managed_dir: managedDir, + load: { watch: true, watch_debounce_ms: 20 }, + }, + }); + const lifecycle = new Lifecycle(); + + const result = initSkills(config, lifecycle); + expect(result.skillRegistry.get('alpha')).toBeDefined(); expect(result.skillRegistry.get('beta')).toBeDefined(); rmSync(join(managedDir, 'alpha'), { recursive: true, force: true }); @@ -97,4 +125,32 @@ describe('initSkills watcher wiring', () => { result.skillsWatcher?.stop(); }); + + it('falls back to full reload for ambiguous paths', () => { + vi.useFakeTimers(); + const root = mkdtempSync(join(tmpdir(), 'flynn-services-')); + roots.push(root); + const managedDir = join(root, 'skills'); + mkdirSync(managedDir, { recursive: true }); + writeSkill(managedDir, 'alpha'); + + const config = makeConfig({ + skills: { + managed_dir: managedDir, + load: { watch: true, watch_debounce_ms: 20 }, + }, + }); + const lifecycle = new Lifecycle(); + + const result = initSkills(config, lifecycle); + expect(result.skillRegistry.get('alpha')).toBeDefined(); + expect(result.skillRegistry.get('beta')).toBeUndefined(); + + writeSkill(managedDir, 'beta'); + result.skillsWatcher?.notifyPathChanged(join(root, 'not-a-skill-path')); + vi.advanceTimersByTime(20); + + expect(result.skillRegistry.get('beta')).toBeDefined(); + result.skillsWatcher?.stop(); + }); }); diff --git a/src/daemon/services.ts b/src/daemon/services.ts index 59b4e90..d057995 100644 --- a/src/daemon/services.ts +++ b/src/daemon/services.ts @@ -9,9 +9,18 @@ import { GatewayServer } from '../gateway/index.js'; import { ChannelRegistry, PairingManager, type PairingStore } from '../channels/index.js'; import { HeartbeatMonitor } from '../automation/index.js'; import { McpManager } from '../mcp/index.js'; -import { SkillRegistry, SkillInstaller, SkillsWatcher, loadAllSkills } from '../skills/index.js'; +import { + SkillRegistry, + SkillInstaller, + SkillsWatcher, + loadAllSkills, + loadSkill, + discoverSkills, + type Skill, + type SkillTier, +} from '../skills/index.js'; import { assembleSystemPrompt } from '../prompt/index.js'; -import { resolve } from 'path'; +import { join, relative, resolve, sep } from 'path'; import { homedir } from 'os'; import type { MemoryStore } from '../memory/store.js'; import type { CommandRegistry } from '../commands/index.js'; @@ -30,12 +39,89 @@ export function initSkills(config: Config, lifecycle?: Lifecycle): SkillsResult const defaultManagedDir = resolve(homedir(), '.flynn/workspace/skills'); const skillRegistry = new SkillRegistry(); const skillInstaller = new SkillInstaller(config.skills.managed_dir ?? defaultManagedDir); + const tierPriority: Record = { + bundled: 0, + managed: 1, + workspace: 2, + }; + const skillSources: Array<{ dir: string; tier: SkillTier }> = [ + { dir: config.skills.bundled_dir, tier: 'bundled' }, + { dir: config.skills.managed_dir ?? defaultManagedDir, tier: 'managed' }, + { dir: config.skills.workspace_dir, tier: 'workspace' }, + ].filter((source): source is { dir: string; tier: SkillTier } => Boolean(source.dir)); const skillLoadConfig = { bundledDir: config.skills.bundled_dir, managedDir: config.skills.managed_dir ?? defaultManagedDir, workspaceDir: config.skills.workspace_dir, }; + const reloadAllSkills = (reason: string): void => { + const reloadedSkills = loadAllSkills(skillLoadConfig); + skillRegistry.reset(reloadedSkills); + console.log(`Skills watcher full reload (${reason}); now tracking ${reloadedSkills.length} skill(s)`); + }; + + const resolveChangedSkillDir = (changedPath: string): { dir: string; tier: SkillTier } | null => { + const absolutePath = resolve(changedPath); + + for (const source of skillSources) { + const rel = relative(source.dir, absolutePath); + if (rel.startsWith('..') || rel === '') { + continue; + } + const [skillDirName] = rel.split(sep); + if (!skillDirName) { + continue; + } + return { dir: join(source.dir, skillDirName), tier: source.tier }; + } + + return null; + }; + + const resolveBestSkillByName = (name: string): Skill | null => { + let selected: Skill | null = null; + for (const source of skillSources) { + const candidate = discoverSkills(source.dir, source.tier).find((skill) => skill.manifest.name === name); + if (!candidate) { + continue; + } + if (!selected || tierPriority[candidate.manifest.tier] >= tierPriority[selected.manifest.tier]) { + selected = candidate; + } + } + return selected; + }; + + const applyTargetedSkillChange = (changedPath: string): boolean => { + const resolved = resolveChangedSkillDir(changedPath); + if (!resolved) { + return false; + } + + const loaded = loadSkill(resolved.dir, resolved.tier); + if (loaded) { + const existing = skillRegistry.get(loaded.manifest.name); + if (existing && tierPriority[existing.manifest.tier] > tierPriority[loaded.manifest.tier]) { + return true; + } + skillRegistry.register(loaded); + return true; + } + + const existingAtDir = skillRegistry.list().find((skill) => resolve(skill.directory) === resolve(resolved.dir)); + if (!existingAtDir) { + return false; + } + + skillRegistry.unregister(existingAtDir.manifest.name); + const replacement = resolveBestSkillByName(existingAtDir.manifest.name); + if (replacement) { + skillRegistry.register(replacement); + } + return true; + }; + const skills = loadAllSkills(skillLoadConfig); for (const skill of skills) { @@ -62,9 +148,15 @@ export function initSkills(config: Config, lifecycle?: Lifecycle): SkillsResult skillDirs, debounceMs: config.skills.load.watch_debounce_ms, onSkillsChanged: ({ changedPaths }) => { - const reloadedSkills = loadAllSkills(skillLoadConfig); - skillRegistry.reset(reloadedSkills); - console.log(`Skills watcher reloaded ${reloadedSkills.length} skill(s) after ${changedPaths.length} change(s)`); + let targetedCount = 0; + for (const changedPath of changedPaths) { + if (!applyTargetedSkillChange(changedPath)) { + reloadAllSkills(`ambiguous change path: ${changedPath}`); + return; + } + targetedCount += 1; + } + console.log(`Skills watcher applied targeted updates for ${targetedCount} change(s)`); }, }); skillsWatcher.start();