diff --git a/docs/plans/state.json b/docs/plans/state.json index cbe4b3e..9691b9a 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1299,6 +1299,17 @@ "src/skills/index.ts" ], "test_status": "typecheck + targeted schema/services tests + full test suite + lint (warnings only, 0 errors) + build passing" + }, + "watcher_callback_full_reload": { + "status": "completed", + "description": "Implemented watcher callback behavior to fully reload skills from disk and repopulate SkillRegistry on debounced change events", + "files_modified": [ + "src/daemon/services.ts", + "src/daemon/services.test.ts", + "src/skills/registry.ts", + "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" } } }, @@ -1333,7 +1344,7 @@ }, "overall_progress": { - "total_test_count": 1513, + "total_test_count": 1515, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -1353,7 +1364,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: implement watcher callback to reload skill registry on add/update/remove" + "next_up": "Skills infrastructure Phase 2: optimize watcher reload path to targeted skill-level add/update/remove with safe fallback to full reload" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/daemon/services.test.ts b/src/daemon/services.test.ts index 693fa99..be0259b 100644 --- a/src/daemon/services.test.ts +++ b/src/daemon/services.test.ts @@ -1,5 +1,5 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { mkdtempSync, mkdirSync, rmSync } from 'fs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { configSchema } from '../config/schema.js'; @@ -10,11 +10,18 @@ describe('initSkills watcher wiring', () => { const roots: string[] = []; afterEach(() => { + vi.useRealTimers(); for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }); } }); + function writeSkill(rootDir: string, name: string): void { + const skillDir = join(rootDir, name); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), `# ${name}\n\nTest skill.`); + } + function makeConfig(overrides: Record = {}) { return configSchema.parse({ telegram: { bot_token: 'test-token', allowed_chat_ids: [1] }, @@ -54,4 +61,40 @@ describe('initSkills watcher wiring', () => { await lifecycle.shutdown(); expect(result.skillsWatcher?.isRunning).toBe(false); }); + + it('reloads registry from disk when watcher callback fires', () => { + 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(managedDir, 'beta', 'SKILL.md')); + vi.advanceTimersByTime(20); + + expect(result.skillRegistry.get('beta')).toBeDefined(); + + rmSync(join(managedDir, 'alpha'), { recursive: true, force: true }); + result.skillsWatcher?.notifyPathChanged(join(managedDir, 'alpha')); + vi.advanceTimersByTime(20); + + expect(result.skillRegistry.get('alpha')).toBeUndefined(); + expect(result.skillRegistry.get('beta')).toBeDefined(); + + result.skillsWatcher?.stop(); + }); }); diff --git a/src/daemon/services.ts b/src/daemon/services.ts index 982d31f..59b4e90 100644 --- a/src/daemon/services.ts +++ b/src/daemon/services.ts @@ -30,12 +30,13 @@ 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 skills = loadAllSkills({ + const skillLoadConfig = { bundledDir: config.skills.bundled_dir, managedDir: config.skills.managed_dir ?? defaultManagedDir, workspaceDir: config.skills.workspace_dir, - }); + }; + + const skills = loadAllSkills(skillLoadConfig); for (const skill of skills) { skillRegistry.register(skill); @@ -61,7 +62,9 @@ export function initSkills(config: Config, lifecycle?: Lifecycle): SkillsResult skillDirs, debounceMs: config.skills.load.watch_debounce_ms, onSkillsChanged: ({ changedPaths }) => { - console.log(`Skills watcher detected changes in ${changedPaths.length} path(s)`); + const reloadedSkills = loadAllSkills(skillLoadConfig); + skillRegistry.reset(reloadedSkills); + console.log(`Skills watcher reloaded ${reloadedSkills.length} skill(s) after ${changedPaths.length} change(s)`); }, }); skillsWatcher.start(); diff --git a/src/skills/registry.test.ts b/src/skills/registry.test.ts index e2b9f9b..66f3a7b 100644 --- a/src/skills/registry.test.ts +++ b/src/skills/registry.test.ts @@ -106,6 +106,23 @@ describe('SkillRegistry', () => { expect(registry.unregister('ghost')).toBe(false); }); + it('reset replaces all existing skills with a new set', () => { + const registry = new SkillRegistry(); + registry.register(makeSkill({ name: 'old-one' })); + registry.register(makeSkill({ name: 'old-two' })); + + registry.reset([ + makeSkill({ name: 'new-one' }), + makeSkill({ name: 'new-two', available: false }), + ]); + + expect(registry.get('old-one')).toBeUndefined(); + expect(registry.get('old-two')).toBeUndefined(); + expect(registry.get('new-one')).toBeDefined(); + expect(registry.get('new-two')).toBeDefined(); + expect(registry.list()).toHaveLength(2); + }); + it('getSystemPromptAdditions returns empty string with no skills', () => { // Negative: with nothing registered the prompt additions should be empty. const registry = new SkillRegistry(); diff --git a/src/skills/registry.ts b/src/skills/registry.ts index 1ac9760..b6b1781 100644 --- a/src/skills/registry.ts +++ b/src/skills/registry.ts @@ -9,6 +9,14 @@ import type { Skill } from './types.js'; export class SkillRegistry { private skills: Map = new Map(); + /** Replace all registered skills with the provided set. */ + reset(skills: Skill[]): void { + this.skills.clear(); + for (const skill of skills) { + this.skills.set(skill.manifest.name, skill); + } + } + /** Register a skill. Replaces any existing skill with the same name. */ register(skill: Skill): void { this.skills.set(skill.manifest.name, skill);