Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions tests/hermes_cli/test_kanban_core_functionality.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down