@@ -3509,10 +3509,8 @@ impl Tool for LoadSkillTool {
35093509 . and_then ( |v| v. as_str ( ) )
35103510 . unwrap_or ( "load" ) ;
35113511
3512- let registry = self . registry . read ( ) . await ;
3513-
35143512 if action == "list" {
3515- return Ok ( registry. format_skill_directory ( ) ) ;
3513+ return Ok ( self . registry . read ( ) . await . format_skill_directory ( ) ) ;
35163514 }
35173515
35183516 let skill_name = args
@@ -3525,11 +3523,35 @@ impl Tool for LoadSkillTool {
35253523 . and_then ( |v| v. as_str ( ) )
35263524 . unwrap_or ( "full" ) ;
35273525
3528- if detail == "metadata" {
3529- return registry. get_skill_metadata ( skill_name) ;
3526+ // First attempt against the registry as it was last scanned. The read guard is scoped to
3527+ // this block and dropped before the rescan path below takes the write lock, so we never
3528+ // hold a read guard across a write acquisition on the same RwLock (which would deadlock).
3529+ let first = {
3530+ let registry = self . registry . read ( ) . await ;
3531+ if detail == "metadata" {
3532+ registry. get_skill_metadata ( skill_name)
3533+ } else {
3534+ registry. get_skill_instructions ( skill_name)
3535+ }
3536+ } ;
3537+ if first. is_ok ( ) {
3538+ return first;
35303539 }
35313540
3532- registry. get_skill_instructions ( skill_name)
3541+ // Miss: a SKILL.md may have been dropped into the skills directory since the registry was
3542+ // last scanned (it is scanned once at startup). Rescan once and re-resolve before reporting
3543+ // the skill missing, so a freshly added skill is loadable without restarting the agent. The
3544+ // rescan is paid only on an actual miss, so the common path (skill already present) is
3545+ // unchanged. Hold a single write guard for both the rescan and the follow-up lookup (the
3546+ // guard derefs to the registry for the immutable getters), avoiding a redundant drop-then-
3547+ // reacquire and closing the gap between scan and read.
3548+ let mut registry = self . registry . write ( ) . await ;
3549+ registry. scan_for_skills ( ) ;
3550+ if detail == "metadata" {
3551+ registry. get_skill_metadata ( skill_name)
3552+ } else {
3553+ registry. get_skill_instructions ( skill_name)
3554+ }
35333555 }
35343556}
35353557
@@ -4699,4 +4721,67 @@ mod tests {
46994721 . unwrap ( ) ;
47004722 assert ! ( full. contains( "do things" ) ) ;
47014723 }
4724+
4725+ #[ tokio:: test]
4726+ async fn load_skill_rescans_on_miss_to_pick_up_a_new_skill ( ) {
4727+ let root = LocalTempDir :: new ( ) ;
4728+ let skills_root = root. path ( ) . join ( "skills" ) ;
4729+
4730+ // One skill present when the registry is first scanned (at startup).
4731+ let first = skills_root. join ( "first_skill" ) ;
4732+ std:: fs:: create_dir_all ( & first) . unwrap ( ) ;
4733+ std:: fs:: write (
4734+ first. join ( "SKILL.md" ) ,
4735+ "---\n name: first_skill\n description: present at startup\n ---\n \n alpha body\n " ,
4736+ )
4737+ . unwrap ( ) ;
4738+
4739+ let reg = Arc :: new ( tokio:: sync:: RwLock :: new ( SkillRegistry :: new (
4740+ skills_root. clone ( ) ,
4741+ ) ) ) ;
4742+ let tool = super :: LoadSkillTool {
4743+ registry : reg. clone ( ) ,
4744+ } ;
4745+
4746+ // A skill dropped into the directory AFTER the startup scan: the in-memory registry has
4747+ // never seen it.
4748+ let late = skills_root. join ( "late_skill" ) ;
4749+ std:: fs:: create_dir_all ( & late) . unwrap ( ) ;
4750+ std:: fs:: write (
4751+ late. join ( "SKILL.md" ) ,
4752+ "---\n name: late_skill\n description: added after scan\n ---\n \n omega instructions\n " ,
4753+ )
4754+ . unwrap ( ) ;
4755+
4756+ // Without rescan-on-miss this would error "skill not found"; the on-miss rescan must pick
4757+ // the new skill up and return its instructions without an agent restart.
4758+ let loaded = tool
4759+ . execute ( serde_json:: json!( { "skill_name" : "late_skill" , "detail" : "full" } ) )
4760+ . await
4761+ . expect ( "late skill should be loadable after the on-miss rescan" ) ;
4762+ assert ! ( loaded. contains( "omega instructions" ) , "{loaded}" ) ;
4763+
4764+ // metadata path also benefits from the rescan (covers the detail == "metadata" branch).
4765+ let late_meta = tool
4766+ . execute ( serde_json:: json!( { "skill_name" : "late_skill" , "detail" : "metadata" } ) )
4767+ . await
4768+ . expect ( "late skill metadata after rescan" ) ;
4769+ assert ! ( late_meta. contains( "Available: true" ) , "{late_meta}" ) ;
4770+
4771+ // The originally-present skill still loads.
4772+ let alpha = tool
4773+ . execute ( serde_json:: json!( { "skill_name" : "first_skill" , "detail" : "full" } ) )
4774+ . await
4775+ . expect ( "first skill still loads" ) ;
4776+ assert ! ( alpha. contains( "alpha body" ) , "{alpha}" ) ;
4777+
4778+ // A genuinely non-existent skill still errors after a rescan turns up nothing new.
4779+ let missing = tool
4780+ . execute ( serde_json:: json!( { "skill_name" : "no_such_skill" } ) )
4781+ . await ;
4782+ assert ! (
4783+ missing. is_err( ) ,
4784+ "unknown skill should still error after rescan: {missing:?}"
4785+ ) ;
4786+ }
47024787}
0 commit comments