feat(skills): target watcher updates with safe fallback
This commit is contained in:
+11
-2
@@ -1310,6 +1310,15 @@
|
|||||||
"src/skills/registry.test.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"
|
"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": {
|
"overall_progress": {
|
||||||
"total_test_count": 1515,
|
"total_test_count": 1517,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "4/4 (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",
|
"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",
|
"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",
|
"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": {
|
"soul_md_and_cron_create": {
|
||||||
"date": "2026-02-11",
|
"date": "2026-02-11",
|
||||||
|
|||||||
@@ -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);
|
const skillDir = join(rootDir, name);
|
||||||
mkdirSync(skillDir, { recursive: true });
|
mkdirSync(skillDir, { recursive: true });
|
||||||
writeFileSync(join(skillDir, 'SKILL.md'), `# ${name}\n\nTest skill.`);
|
writeFileSync(join(skillDir, 'SKILL.md'), instructions);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeConfig(overrides: Record<string, unknown> = {}) {
|
function makeConfig(overrides: Record<string, unknown> = {}) {
|
||||||
@@ -62,7 +62,7 @@ describe('initSkills watcher wiring', () => {
|
|||||||
expect(result.skillsWatcher?.isRunning).toBe(false);
|
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();
|
vi.useFakeTimers();
|
||||||
const root = mkdtempSync(join(tmpdir(), 'flynn-services-'));
|
const root = mkdtempSync(join(tmpdir(), 'flynn-services-'));
|
||||||
roots.push(root);
|
roots.push(root);
|
||||||
@@ -85,7 +85,35 @@ describe('initSkills watcher wiring', () => {
|
|||||||
writeSkill(managedDir, 'beta');
|
writeSkill(managedDir, 'beta');
|
||||||
result.skillsWatcher?.notifyPathChanged(join(managedDir, 'beta', 'SKILL.md'));
|
result.skillsWatcher?.notifyPathChanged(join(managedDir, 'beta', 'SKILL.md'));
|
||||||
vi.advanceTimersByTime(20);
|
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();
|
expect(result.skillRegistry.get('beta')).toBeDefined();
|
||||||
|
|
||||||
rmSync(join(managedDir, 'alpha'), { recursive: true, force: true });
|
rmSync(join(managedDir, 'alpha'), { recursive: true, force: true });
|
||||||
@@ -97,4 +125,32 @@ describe('initSkills watcher wiring', () => {
|
|||||||
|
|
||||||
result.skillsWatcher?.stop();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+97
-5
@@ -9,9 +9,18 @@ import { GatewayServer } from '../gateway/index.js';
|
|||||||
import { ChannelRegistry, PairingManager, type PairingStore } from '../channels/index.js';
|
import { ChannelRegistry, PairingManager, type PairingStore } from '../channels/index.js';
|
||||||
import { HeartbeatMonitor } from '../automation/index.js';
|
import { HeartbeatMonitor } from '../automation/index.js';
|
||||||
import { McpManager } from '../mcp/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 { assembleSystemPrompt } from '../prompt/index.js';
|
||||||
import { resolve } from 'path';
|
import { join, relative, resolve, sep } from 'path';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import type { MemoryStore } from '../memory/store.js';
|
import type { MemoryStore } from '../memory/store.js';
|
||||||
import type { CommandRegistry } from '../commands/index.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 defaultManagedDir = resolve(homedir(), '.flynn/workspace/skills');
|
||||||
const skillRegistry = new SkillRegistry();
|
const skillRegistry = new SkillRegistry();
|
||||||
const skillInstaller = new SkillInstaller(config.skills.managed_dir ?? defaultManagedDir);
|
const skillInstaller = new SkillInstaller(config.skills.managed_dir ?? defaultManagedDir);
|
||||||
|
const tierPriority: Record<SkillTier, number> = {
|
||||||
|
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 = {
|
const skillLoadConfig = {
|
||||||
bundledDir: config.skills.bundled_dir,
|
bundledDir: config.skills.bundled_dir,
|
||||||
managedDir: config.skills.managed_dir ?? defaultManagedDir,
|
managedDir: config.skills.managed_dir ?? defaultManagedDir,
|
||||||
workspaceDir: config.skills.workspace_dir,
|
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);
|
const skills = loadAllSkills(skillLoadConfig);
|
||||||
|
|
||||||
for (const skill of skills) {
|
for (const skill of skills) {
|
||||||
@@ -62,9 +148,15 @@ export function initSkills(config: Config, lifecycle?: Lifecycle): SkillsResult
|
|||||||
skillDirs,
|
skillDirs,
|
||||||
debounceMs: config.skills.load.watch_debounce_ms,
|
debounceMs: config.skills.load.watch_debounce_ms,
|
||||||
onSkillsChanged: ({ changedPaths }) => {
|
onSkillsChanged: ({ changedPaths }) => {
|
||||||
const reloadedSkills = loadAllSkills(skillLoadConfig);
|
let targetedCount = 0;
|
||||||
skillRegistry.reset(reloadedSkills);
|
for (const changedPath of changedPaths) {
|
||||||
console.log(`Skills watcher reloaded ${reloadedSkills.length} skill(s) after ${changedPaths.length} change(s)`);
|
if (!applyTargetedSkillChange(changedPath)) {
|
||||||
|
reloadAllSkills(`ambiguous change path: ${changedPath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
targetedCount += 1;
|
||||||
|
}
|
||||||
|
console.log(`Skills watcher applied targeted updates for ${targetedCount} change(s)`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
skillsWatcher.start();
|
skillsWatcher.start();
|
||||||
|
|||||||
Reference in New Issue
Block a user