diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index cb3863466b90c..c808e0978c01f 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -10627,6 +10627,42 @@ def _resolve_hermes_argv() -> list[str]: return _module_hermes_argv() +def _skill_available_for_home(skill_name: str, hermes_home: Optional[str]) -> bool: + """Return whether a worker can resolve ``skill_name`` in its profile. + + ``--skills`` is loaded by the child CLI before the agent loop starts. A + missing or ambiguous name therefore aborts the worker instead of merely + omitting supplementary context. Reuse the same resolver as the worker + itself so profile-local skills, configured external directories, + frontmatter ``name:`` aliases, plugin-qualified lookups, and collision + handling cannot drift between the dispatch preflight and child startup. + + The home override is context-local and does not mutate ``os.environ``; + dispatchers may have other gateway work in flight in the same process. + """ + identifier = str(skill_name or "").strip() + if not identifier: + return False + + try: + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + from agent.skill_commands import _load_skill_payload + + token = set_hermes_home_override(hermes_home) + try: + return _load_skill_payload(identifier) is not None + finally: + reset_hermes_home_override(token) + except Exception as exc: + _log.debug( + "kanban worker: skill preflight failed for %r under HERMES_HOME=%r (%s)", + identifier, + hermes_home, + exc, + ) + return False + + def _worker_terminal_timeout_env( max_runtime_seconds: Optional[int], current_timeout: Optional[str], @@ -10864,10 +10900,25 @@ def _default_spawn( # accepts both forms (action='append' + comma-split), but # per-name pairs are easier to read in `ps` output and avoid any # quoting ambiguity if a skill name ever contains unusual chars. + # + # Gate each name against the same resolver the child CLI uses. A missing + # or ambiguous skill is fatal during CLI startup; dropping only that + # supplementary flag lets the worker run its kanban task instead of + # entering a crash loop. if task.skills: + worker_home = env.get("HERMES_HOME") for sk in task.skills: - if sk: + if not sk: + continue + if _skill_available_for_home(sk, worker_home): cmd.extend(["--skills", sk]) + else: + sys.stderr.write( + f"kanban: task {task.id} requested skill " + f"{sk!r} but it does not resolve under " + f"{worker_home or '~/.hermes'} — dropping flag; " + "worker will start without it\n" + ) if task.model_override: cmd.extend(["-m", task.model_override]) # Pin the provider too when the override names one, so the worker diff --git a/tests/hermes_cli/test_kanban_core_functionality.py b/tests/hermes_cli/test_kanban_core_functionality.py index 0a47445ec1108..8e96060f37a2c 100644 --- a/tests/hermes_cli/test_kanban_core_functionality.py +++ b/tests/hermes_cli/test_kanban_core_functionality.py @@ -743,6 +743,100 @@ def fake_popen(cmd, **kwargs): assert env.get("HERMES_PROFILE") == "some-profile" +def test_skill_preflight_matches_worker_resolver(kanban_home, tmp_path): + """The dispatcher probe honors local, external, alias, and collision rules.""" + skills_root = kanban_home / "skills" + local_skill = skills_root / "kanban-test-category" / "local-skill-for-kanban-test" + local_skill.mkdir(parents=True) + (local_skill / "SKILL.md").write_text( + "---\nname: local-skill-for-kanban-test\n---\nlocal\n", + encoding="utf-8", + ) + + external_root = tmp_path / "external-skills" + aliased = external_root / "filesystem-name" + aliased.mkdir(parents=True) + (aliased / "SKILL.md").write_text( + "---\nname: alias-skill-for-kanban-test\n---\nalias\n", + encoding="utf-8", + ) + collision = external_root / "external-collision" + collision.mkdir(parents=True) + (collision / "SKILL.md").write_text( + "---\nname: collision-skill-for-kanban-test\n---\nexternal\n", + encoding="utf-8", + ) + local_collision = skills_root / "local-collision" + local_collision.mkdir() + (local_collision / "SKILL.md").write_text( + "---\nname: collision-skill-for-kanban-test\n---\nlocal\n", + encoding="utf-8", + ) + (kanban_home / "config.yaml").write_text( + f"skills:\n external_dirs:\n - {external_root}\n", + encoding="utf-8", + ) + + assert kb._skill_available_for_home( + "local-skill-for-kanban-test", str(kanban_home) + ) is True + assert kb._skill_available_for_home( + "alias-skill-for-kanban-test", str(kanban_home) + ) is True + assert kb._skill_available_for_home( + "collision-skill-for-kanban-test", str(kanban_home) + ) is False + assert kb._skill_available_for_home( + "missing-skill-for-kanban-test", str(kanban_home) + ) is False + + +def test_default_spawn_drops_unresolvable_task_skills(kanban_home, monkeypatch, capfd): + """Only skills resolved by the real filesystem resolver reach child argv.""" + skill = kanban_home / "skills" / "good-skill-for-kanban-test" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text( + "---\nname: good-skill-for-kanban-test\n---\nusable\n", + encoding="utf-8", + ) + captured = {} + + class FakeProc: + pid = 7 + + def fake_popen(cmd, **kwargs): + captured["cmd"] = cmd + return FakeProc() + + monkeypatch.setattr("subprocess.Popen", fake_popen) + + conn = kb.connect() + try: + tid = kb.create_task( + conn, + title="real skill preflight", + assignee="some-profile", + skills=["good-skill-for-kanban-test", "missing-skill-for-kanban-test"], + ) + task = kb.get_task(conn, tid) + assert task is not None + workspace = kb.resolve_workspace(task) + assert kb._default_spawn(task, str(workspace)) == 7 + finally: + conn.close() + + cmd = captured["cmd"] + skill_names = [ + cmd[index + 1] + for index, token in enumerate(cmd) + if token == "--skills" and index + 1 < len(cmd) + ] + assert skill_names == ["good-skill-for-kanban-test"] + err = capfd.readouterr().err + assert "missing-skill-for-kanban-test" in err + assert tid in err + + # --------------------------------------------------------------------------- # Per-task force-loaded skills # ---------------------------------------------------------------------------