diff --git a/api/profiles.py b/api/profiles.py index 8388a87f..0ee405f6 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -1597,7 +1597,10 @@ def _skill_tree_max_mtime_ns(skills_dir: Path, config_path: Path) -> int: return max_ns try: # Directory mtimes catch nested out-of-band deletes that leave file mtimes unchanged. - for root, dirnames, filenames in os.walk(skills_dir): + # followlinks=True mirrors agent.skill_utils.iter_skill_index_files (the compute + # path), so a symlinked skill directory is descended into and edits to its target + # SKILL.md change the probe value — otherwise such edits would stay stale up to the TTL. + for root, dirnames, filenames in os.walk(skills_dir, followlinks=True): root_path = Path(root) try: max_ns = max(max_ns, root_path.stat().st_mtime_ns) diff --git a/tests/test_issue4783_profile_skills_mtime_cache.py b/tests/test_issue4783_profile_skills_mtime_cache.py index 094b12a3..aa7841f6 100644 --- a/tests/test_issue4783_profile_skills_mtime_cache.py +++ b/tests/test_issue4783_profile_skills_mtime_cache.py @@ -308,6 +308,42 @@ class TestNestedDeletionDetected: assert result == (1, 1) +class TestSymlinkedSkillProbeFollowsLinks: + """The mtime probe must follow symlinked skill directories the same way the + compute path (iter_skill_index_files, followlinks=True) does — otherwise an + edit to a symlinked skill's target SKILL.md is invisible to the probe and + the counts stay stale up to the TTL (gate finding).""" + + def test_symlinked_skill_target_edit_changes_probe(self, profiles_mod, tmp_path): + mod, profile_dir = profiles_mod + skills_dir = profile_dir / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + config_path = profile_dir / "config.yaml" + + # Real skill living OUTSIDE the profile, linked in via a symlinked dir. + target = tmp_path / "external-skill" + target.mkdir() + skill_md = target / "SKILL.md" + skill_md.write_text("---\nname: linked\n---\n# linked\n", encoding="utf-8") + link = skills_dir / "linked" + try: + link.symlink_to(target, target_is_directory=True) + except (OSError, NotImplementedError): + import pytest + pytest.skip("symlinks not supported on this platform") + + before = mod._skill_tree_max_mtime_ns(skills_dir, config_path) + # Edit the target SKILL.md (bump its mtime well past granularity). + import os as _os + future = time.time() + 100 + _os.utime(skill_md, (future, future)) + after = mod._skill_tree_max_mtime_ns(skills_dir, config_path) + + assert after > before, ( + "probe must follow the symlinked skill dir and see the target SKILL.md mtime change" + ) + + class TestReturnSignature: """Proof matrix row 6: return signature is tuple[int, int]."""