kanban: pre-flight skill validation to prevent worker crash-loops - #7
Conversation
|
Warning Review limit reached
Your plan includes 1 review of capacity. Refill in 31 minutes and 40 seconds. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more review capacity refills, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than trial, open-source, and free plans. In all cases, review capacity refills continuously over time. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📥 CommitsReviewing files that changed from the base of the PR and between e1d03cd8b4936bb23c755fcee052435370697f48 and 0af91e1. 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughGateway startup and main profile initialization now conditionally sync bundled skills via opt-out markers. Scratch workspace cleanup is refactored: removal moves from ChangesTask Lifecycle Improvements
🎯 3 (Moderate) | ⏱️ ~25 minutes
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🔎 Lint report:
|
| Rule | Count |
|---|---|
unresolved-reference |
1 |
First entries
gateway/run.py:17988: [unresolved-reference] unresolved-reference: Name `get_hermes_home` used when not defined
✅ Fixed issues: none
Unchanged: 4809 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@hermes_cli/kanban_db.py`:
- Around line 3061-3082: The helper _resolve_skill_under_home currently falls
back to Path.home() / ".hermes" and directly formats the raw hermes_home in
error/reason messages; replace that fallback with a call to get_hermes_home()
from hermes_constants so the logic is profile-aware (use get_hermes_home() when
hermes_home is falsy), and change any user-facing formatting of the home path
(blocked-task reason strings and logs that use the raw hermes_home) to call
display_hermes_home() from hermes_constants instead so messages show the
canonical/profile-aware presentation; adjust the places referenced in this
review (function _resolve_skill_under_home and the analogous blocks at the other
locations) to use these two helpers.
- Around line 2945-2950: The SELECT that garbage-collects scratch workspaces
incorrectly treats "blocked" as terminal; update the query in kanban_db.py (the
SQL executed where rows = conn.execute(...)) to exclude 'blocked' (e.g. change
"AND status IN ('done', 'blocked', 'archived')" to "AND status IN
('done','archived')" or otherwise filter out 'blocked') so blocked tasks keep
their scratch workspace; note related symbols unblock_task() and
resolve_workspace() to guide locating the logic.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 9cd4e39d-9c4f-457c-ae54-210d7012849b
📥 Commits
Reviewing files that changed from the base of the PR and between 6016199 and e1d03cd8b4936bb23c755fcee052435370697f48.
📒 Files selected for processing (4)
gateway/run.pyhermes_cli/kanban_db.pyhermes_cli/main.pytests/hermes_cli/test_kanban_workspace_self_delete.py
| rows = conn.execute( | ||
| "SELECT id, workspace_path FROM tasks " | ||
| "WHERE workspace_kind = 'scratch' " | ||
| " AND workspace_path IS NOT NULL " | ||
| " AND status IN ('done', 'blocked', 'archived') " | ||
| " AND claim_lock IS NULL" |
There was a problem hiding this comment.
Don't GC blocked task workspaces.
Line 2949 treats blocked as terminal, but this state is resumable here: unblock_task() can move the task back to ready/todo, and resolve_workspace() will silently recreate an empty scratch dir on the next run. That drops the blocked task's in-progress workspace exactly when an operator may need to inspect or resume it.
🛠️ Suggested change
- " AND status IN ('done', 'blocked', 'archived') "
+ " AND status IN ('done', 'archived') "🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@hermes_cli/kanban_db.py` around lines 2945 - 2950, The SELECT that
garbage-collects scratch workspaces incorrectly treats "blocked" as terminal;
update the query in kanban_db.py (the SQL executed where rows =
conn.execute(...)) to exclude 'blocked' (e.g. change "AND status IN ('done',
'blocked', 'archived')" to "AND status IN ('done','archived')" or otherwise
filter out 'blocked') so blocked tasks keep their scratch workspace; note
related symbols unblock_task() and resolve_workspace() to guide locating the
logic.
| def _resolve_skill_under_home(skill_name: str, hermes_home: Optional[str]) -> bool: | ||
| """Best-effort check that ``skill_name`` resolves under the worker's HERMES_HOME. | ||
|
|
||
| Returns True if the skill is loadable, False if it would fail with the | ||
| CLI's ``Unknown skill(s)`` startup error and crash the worker. | ||
|
|
||
| The check mirrors the resolver order used by ``--skills <name>``: | ||
| 1. ``<home>/skills`` (the profile-scoped skills dir) | ||
| 2. Each ``skills.external_dirs`` entry from ``<home>/config.yaml`` | ||
|
|
||
| This is intentionally filesystem-only — no skill_view() call, no module | ||
| import — so the dispatcher (which runs under the *default* HERMES_HOME) | ||
| can validate against a *different* HERMES_HOME without contaminating | ||
| any cached module-level state in the skills resolver. | ||
| """ | ||
| from pathlib import Path as _P | ||
|
|
||
| if not skill_name: | ||
| return True | ||
|
|
||
| base = _P(hermes_home) if hermes_home else (_P.home() / ".hermes") | ||
| roots: list[_P] = [base / "skills"] |
There was a problem hiding this comment.
Route Hermes-home resolution and diagnostics through hermes_constants.
When hermes_home is missing, this falls back to Path.home() / ".hermes", which ignores HERMES_HOME/profile-aware resolution and can falsely auto-block runnable tasks as “missing skills.” The blocked-task reason strings also format the searched home directly instead of using the display helper, so operators may see a raw path or <default> instead of the canonical Hermes-home presentation.
As per coding guidelines, "Use get_hermes_home() from hermes_constants for all code paths that reference the Hermes home directory to ensure profile-aware behavior." and "Use display_hermes_home() from hermes_constants for all user-facing messages that reference the Hermes home directory path."
🛠️ Suggested change
def _resolve_skill_under_home(skill_name: str, hermes_home: Optional[str]) -> bool:
@@
- from pathlib import Path as _P
+ from pathlib import Path as _P
+ from hermes_constants import get_hermes_home
@@
- base = _P(hermes_home) if hermes_home else (_P.home() / ".hermes")
+ base = _P(hermes_home) if hermes_home else get_hermes_home()Also applies to: 5030-5034, 5135-5138
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@hermes_cli/kanban_db.py` around lines 3061 - 3082, The helper
_resolve_skill_under_home currently falls back to Path.home() / ".hermes" and
directly formats the raw hermes_home in error/reason messages; replace that
fallback with a call to get_hermes_home() from hermes_constants so the logic is
profile-aware (use get_hermes_home() when hermes_home is falsy), and change any
user-facing formatting of the home path (blocked-task reason strings and logs
that use the raw hermes_home) to call display_hermes_home() from
hermes_constants instead so messages show the canonical/profile-aware
presentation; adjust the places referenced in this review (function
_resolve_skill_under_home and the analogous blocks at the other locations) to
use these two helpers.
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
Test failures: test_dispatch_review_spawns_with_correct_skills, test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1 but got 0. Tests inject a capture_spawn stub that never execs the CLI, so a missing skill on the test fixture's HERMES_HOME is harmless — but the pre-flight was auto-blocking the task anyway. Skip the pre-flight whenever a spawn_fn is injected: by definition the caller isn't going through _default_spawn, so the 'CLI dies at startup on missing skill' failure mode doesn't apply. Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the attribution check passes (it was failing on this PR with 'New contributor email(s) not in AUTHOR_MAP').
fec3de5 to
0af91e1
Compare
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
…ovider Cancelling the API-key prompt mid-wizard (Enter → 'Cancelled.') let the wizard continue through Terminal/Gateway/Tools and finish 'successfully' with no model configured — the user exits believing they're set up, then hits a broken chat. _print_setup_summary() (called by every setup path: full, quick, blank-slate, portal) now probes resolve_provider() and, when nothing is configured, prints an unmissable warning with the two one-line fixes (hermes model / hermes setup --portal). Consumer-onboarding audit finding #7 (sev 4), Aug 2026.
… (re-review #7) - website/docs/user-guide/configuration.md (en) and the zh-Hans translation gain a 'Session Stall Watchdog' section: default 300, 0=disabled, notify-only semantics (never kills the turn — contrast gateway_timeout), one notification per stall episode, and the exact stall message text so it is greppable. - cli-config.yaml.example: the two in-agent compression timeout keys (compression.context_timeout_seconds / compression.context_total_ceiling_seconds) are shown as commented lines next to session_stall_timeout's example for discoverability.
test(relay): document stream priming boundary
… the relay (gateway half) (NousResearch#85796) * feat(relay): live-card ops — native draft streaming + task cards over the relay (gateway half) NS-658. Three additive ops within contract v1, emitted only when the connector's negotiated descriptor advertises them: {op: draft, chat_id, draft_id, content, final, metadata} {op: task_card, chat_id, card_id, chunks, metadata} {op: task_card_stop, chat_id, card_id, metadata} The gateway side is deliberately dumb: no platform API knowledge, no new config keys. Slack mechanics (chat.startStream/appendStream/stopStream, per-workspace feature-gate cache, send+edit fallback) live connector-side where the platform adapter lives in the relay model. Semantic bridge: base send_draft is Telegram-shaped (draft clears; final is a separate send). Slack native streaming makes the stream THE message. The adapter tracks the open draft per chat and converts the turn-final send() into draft(final=true) so the connector seals the stream instead of posting a duplicate; the stream ts returns as the message identity. A failed frame disarms interception so the edit-based fallback's real send goes through untouched. BEHAVIOR CHANGE (deliberate): relay supports_draft_streaming() now requires the descriptor flag AND the draft op. Flag-only was a latent lie — send_draft inherited NotImplementedError, so a connector setting the flag without the op would have crashed the stream consumer's draft path. supported_ops stays fail-open for legacy (pre-contract) ops; draft/task_card did not exist pre-contract and must not fail open. Task cards ride NousResearch#85476's adapter-agnostic TurnRunner seam (hasattr on send_native_task_card_progress); supports_native_task_cards() is the descriptor probe. Connector half + E2E harness pair follow in the gg repo. * fix(relay): expose native_task_cards_enabled() on the relay adapter Live-canary finding (Alice, staging): the TurnRunner's task-card lane probes adapter.native_task_cards_enabled() (the native Slack adapter's opt-in contract). The relay adapter only offered supports_native_task_cards(), so the hasattr gate failed silently and tool progress stayed on the text path — draft streaming worked, cards never rendered. Alias it to the descriptor probe. * fix(relay): match task-card methods to the TurnRunner's native keyword contract Live-canary finding #2 (Alice, staging): gateway/run.py's card lane calls send/stop_native_task_card_progress with the NATIVE Slack adapter's signature (tasks/title/reply_to/metadata/fallback_text, keyword-only) — PR 85796's relay methods took a positional card_id, so every call raised TypeError('unexpected keyword argument reply_to') in the progress task, repeatedly killing the card publisher (and the retry loop resent the final delivery 4-5x). Card id now derives per turn thread (turn:<reply_to>), thread_ts anchored like draft; title/fallback_text accepted for parity, not forwarded (plan-mode stream renders chunks). * fix(relay): one draft stream per turn for stream-is-the-message adapters Live-canary finding #4 (Alice, staging): the stream consumer bumps draft_id at every tool boundary so Telegram-shaped drafts animate each text segment as a fresh preview. On relay Slack NATIVE streaming a new draft_id opens a brand-new chat.startStream — the user saw one frozen message per segment (stuck streaming cursor ▉, never sealed: only the LAST stream gets the final=true seal) plus the real final; 5-6 cumulative snapshots per turn. Adapters that mark draft_stream_is_message keep ONE stream per turn: tool progress lives in the native task card, and the connector's suffix-delta falls back to whole-text append on prefix mismatch, so segments append cleanly. Telegram-shaped drafts keep the per-segment bump. * fix(relay): don't seal the native stream at tool boundaries — only the turn-final does Live-canary finding #5 (Alice; supersedes the incomplete #4 which was necessary but not sufficient). Root cause CONFIRMED by integration trace (test_live_cards_flow_trace.py, real consumer semantics + real adapter + stub transport): at every tool boundary the consumer calls _send_or_edit(finalize=True), which skips the draft path and issues a real send(); the relay adapter's seal-interception converts THAT into draft(final=true) — sealing the stream once per segment. Timeline showed 3 seals for a 3-segment turn: exactly the frozen cumulative ▉ snapshots seen live (the replaced stream never gets stopStream, keeping its cursor). Fix: for draft_stream_is_message adapters, a segment-break finalize (finalize=True, is_turn_final=False) stays ON the draft path as another cumulative frame; only got_done (is_turn_final=True) falls through to send() and seals. Telegram-shaped platforms unchanged. Trace test now pins the invariant: ONE user-visible message per turn. * fix(relay): strip the text cursor from native draft frames Live-canary finding #6 (Alice) — the ACTUAL duplicate-content mechanism, confirmed by full-flow scan of both sides' code + logs. The consumer appends its text cursor (▉) to every non-final display_text tick. The connector's stream sender diffs CUMULATIVE frames via prefix check: 'abc▉'.startsWith → 'abc def▉' is NEVER a prefix match (the cursor sits mid-string), so deltaFor falls back to whole-text append on EVERY tick — chat.appendStream stacks each full cumulative snapshot (cursor included) into the ONE stream message. Exactly the observed thread: repeated blocks, each ending in a frozen ▉, growing per tick. Fixes #4/#5 were real (one stream per turn now) but this was the last mechanism standing. Native streams render their own typing indicator, so the text cursor is pure noise on this path: strip it from draft frames. Prefix check now holds; every tick appends only its true suffix delta. * fix(relay): seal-interception covers EVERY egress door, not just send() Live-canary finding #7 (Alice): one duplication remained after #6 — the stream froze mid-word with the live indicator (never sealed) and the final posted as a separate message. Log receipt: 'Queued follow-up: final text delivery confirmed; delivering explicit media before continuing' — the turn's final went out via the DELIVERY RESOLVER lane (gateway/delivery.py), which calls send_for_platform() DIRECTLY, bypassing send() and its seal-interception. The open stream never absorbed the final; it arrived as a plain 'send' op → chat.postMessage. Fix: hoist the open-draft check to the top of send() (ahead of the explicit-platform branch) AND add it to send_for_platform() — an open native stream absorbs the turn-final regardless of which egress door it arrives through. The stream IS the message. * fix(relay): failed seal falls back to plain send (PR 85796 AI-review point 1) A turn-final seal that fails at the transport must never swallow the final answer: the stream consumer has already disabled the draft transport for the run, so a failed _seal_open_draft returning success=False meant the user got NOTHING. Both seal-interception sites (send + send_for_platform) now fall through to the regular plain-send path on seal failure, with a warning receipt. Also mitigates AI-review point 2 (sticky _open_draft_by_chat after an abandoned turn): a stale entry's failed seal no longer blocks the next turn's delivery. * fix(relay): arm seal-interception optimistically; never disarm on ambiguous failure (audit G-D1) Deep-audit defect G-D1 (HIGH): the outbound leg is at-most-once on the wire but its ack channel is lossy — send_outbound timeout (30s) and WS-drop 'failures' frequently mean the frame WAS delivered and the connector stream is open. send_draft popped _open_draft_by_chat on any failure, disarming seal-interception while the connector stream lived: the turn-final went out as a plain send → orphaned mid-word stream + complete duplicate final (intermittent; needs a drop/timeout inside the draft window). Fix: arm the entry BEFORE the transport call and keep it armed on failure/exception. Safe in every case: sealing a non-existent stream opens+seals a single complete message connector-side, and a truly failed seal already falls back to plain send at both interception sites. Stale-entry damage is self-healing (one warning + plain send). * fix(relay): gateway-side sealed-draft tombstone — G-D1 arming must not resurrect sealed streams Regression fix on G-D1 (live: 'worse than before' — escalating frozen prefixes). Optimistic arming had no seal-awareness: a straggler frame arriving AFTER the seal re-armed _open_draft_by_chat for the already- sealed draft_id; the next send was converted to draft(final=true) on the tombstoned connector key, which CLEARED the connector tombstone (final frame = new-turn signal), re-opened a stream with cumulative content, and left it frozen — repeating per straggler: 4-5 escalating frozen snapshots. Mirror the connector: _sealed_draft_by_chat records the sealed draft_id per chat (tombstoned BEFORE the seal's transport call); send_draft for a sealed draft_id is a success no-op (content already in the sealed message) and never arms. A new turn's fresh draft_id arms normally. * fix(relay): key stream/card state per (chat, turn anchor) — parallel turns must not collide (finding #10) Live finding #10 (Alice; three concurrent turns in one flat DM): all coordination state was keyed per CHAT on a one-active-turn assumption. Three parallel turns produced: turn B's task card merged into turn A's (both were card 'turn:root' — reply_to is None in flat DMs), B left cardless, and _open/_sealed_draft_by_chat clobbered across writers (3x duplicate finals on the last turn). Per-turn machinery was correct; the keys were not. Fix: _draft_key(chat, metadata) = chat + the turn's thread anchor (inbound stamps thread_ts = event.thread_ts or ts on every top-level message, so each turn has one even in flat DMs). draft arming, seal tombstones, both interception sites, and the task-card id all derive from the same anchor. New trace test pins two interleaved turns: distinct cards, own-stream seals, no leaked plain send, no cross-turn tombstone drops (289 tests green). * fix(gateway): preserve cumulative native stream across tools * fix(gateway): consumer-declared final — the seal carries the true final Three composed fixes for the Slack live-cards duplicate-final class: 1. finish(final_text): TurnRunner passes the completed final_response (verifier footer, completion explainer included) as the authoritative finalize payload. The native-stream seal delivers the TRUE final, so post-stream mutation no longer forks a corrective plain send (#11). 2. Interim-send contract: commentary and segment-tail sends carry a gateway-internal _interim_send marker; relay seal-interception skips them at both egress doors. A mid-turn interim send can no longer seal the live stream and orphan the real final into a duplicate. 3. Queued-follow-up lane reconciles an unconfirmed final by EDITING the consumer's delivered message in place (sealed stream = regular message, chat.update live-verified); plain send only as fallback. This was the actual duplicate lane in the parallel canaries — every duplicated turn logged 'final stream delivery not confirmed; sending first response' (subagent-completion queued inbound), not parallelism. Also: draft frames stay prefix-stable gateway-side (no fence-closing, no segment state reset, no commentary reset for stream-is-the-message adapters; MagicMock-safe 'is True' guards). * test+docs: streaming-contract coverage completeness + maintenance guidelines Coverage: two gaps closed on the consumer-declared-final contract — (1) send_for_platform (the delivery-resolver egress door) honors the _interim_send contract: no seal, marker stripped before the wire; (2) finish(final_text) on a turn that never streamed does not adopt the final (delivery ownership stays with the gateway's normal send path for non-streaming models / tool-only turns). Docs: AGENTS.md 'Known Pitfalls' gains the streaming delivery contract — the four invariants of stream-is-the-message adapters (prefix-stable frames, consumer-declared final, interim-send marker, reconcile-by-edit), each traced to its live incident, plus the live-probed Slack streaming API ground truth and the MagicMock 'is True' guard-style note. * fix(relay): seal transport failure must never silently lose the final (review B1) Two halves of one silent-loss path, live-probed on the review branch: 1. adapter: _seal_open_draft did not catch transport exceptions. A socket drop at seal time raised out of send(), skipping the fail-open plain send entirely. Now: retry the SAME idempotent final frame once (the connector's sealed-key tombstone returns the original stream ts for a repeated final — a retry can never open a second stream or duplicate), then report failure so the caller's fail-open path runs. 2. consumer: the turn-final retry (elif not _already_sent) called _send_or_edit with finalize=False, which re-entered the DRAFT-FRAME branch. Its no-op dedupe compared the adopted final against the last unsealed frame, matched, and returned True with ZERO transport calls — final_response_sent went green, delivered_final_matches reconciled, the gateway suppressed its fallback, and the user never received the answer. finalize=True keeps this retry out of the draft branch. Regression suite: tests/gateway/test_relay_seal_failure.py (3 tests). Mutation evidence in follow-up verification: reverting either half sends the suite red. * fix(relay): draft ids unique across gateway incarnations (review B3) The relay connector tombstones sealed streams by (channel, draft_id) and keeps up to 512 of them; they outlive the gateway process. Relay gateways are disposable BY DESIGN (scale-to-zero), and _draft_id_counter restarted at zero every incarnation — so the first turns after every scale-from-zero in a recently-active channel replayed already-sealed wire identities. The connector answered those frames straight out of the old tombstone: zero Slack API calls, the OLD message ts returned as the new turn's identity, the new answer silently dropped while gateway-side flags recorded success. Seed the counter from wall-clock milliseconds at process start. Ids stay plain ints within the existing contract op; incarnations cannot overlap for realistic turn counts and restart gaps. Regression: tests/gateway/test_draft_id_restart_uniqueness.py — the seed test fails on the old code (seed 0 is not epoch-scale). * fix(relay): stream/card state keyed per TURN, not per thread anchor (review B2) The thread anchor is the wrong coordination identity — simultaneously: - too coarse: two parallel turns replying INSIDE ONE Slack thread share thread_ts. Live-probed on the review branch: turn A's final sealed turn B's stream with A's content while A's own stream stayed open, and B's final degraded to a plain send. - too fragile: a flat DM with no thread metadata degraded to the bare chat id, re-creating the original finding-#10 collision the anchor was meant to fix. _draft_key now prefers the triggering inbound message id (message_id / reply_to_message_id — per-turn by construction; the gateway's Slack thread metadata and the consumer's send path both stamp it), falling back to the thread anchor, then the bare chat. The consumer stamps the same reply_to_message_id on draft frames so frames and the turn-final resolve to one key. Task-card ids share the derivation via _card_key (one helper for send AND stop, so the stop always hits the stream the send opened). Legacy resolver-lane callers with placement-only metadata still seal via _match_open_draft's fallback — but ONLY when exactly one stream is open. With several open, an identity-less send stays a plain send: a duplicate message is recoverable, sealing someone else's stream is not. Regression: tests/gateway/relay/test_relay_turn_keying.py (7 tests). * fix(relay): stream-is-the-message is a Slack semantic, gate it on the descriptor (review B4) draft_stream_is_message was hardcoded True on the relay adapter class, i.e. for EVERY relay platform. The base send_draft contract is Telegram-shaped — the draft clears client-side and the final arrives as a separate real send that becomes the history message. With the flag forced on, any non-Slack connector advertising the draft op had its turn-final intercepted into draft(final=true): probed on the review branch with a telegram descriptor, the op stream was [draft(final=false), draft(final=true)] and NO send — no history message would ever be posted. Gate the flag on the negotiated descriptor platform (slack), and skip arming seal-interception entirely when it is off. A future platform with genuine stream-is-the-message native streaming should advertise it via the descriptor rather than widening the platform check by guesswork. Regression: tests/gateway/relay/test_relay_stream_semantics_gating.py (4 tests: gating both ways, telegram final is a real send, slack final still seals). * fix(gateway): mark every mid-turn status lane interim — heartbeats must not seal the stream (review B5) Seal-interception treats the first unmarked send to an armed (chat, turn) key as the turn-final. The consumer's own interim lanes (commentary, tail flush) carry _interim_send, but four gateway-side lanes that fire DURING a streaming turn did not: - long-running heartbeat (default every 180s — probed live: at 3 minutes it sealed the live stream with '⏳ Working — 3 min', the real final posted as a duplicate, and later frames were silently swallowed by the seal tombstone) - inactivity warning - plain-text approval fallback (button lane failed) - background-review notice Add _interim_metadata() beside _non_conversational_metadata and wrap all four call sites. The marker is gateway-internal; the relay adapter strips it before the wire (existing behavior, pinned by test). Note for follow-up: the opt-out shape remains fragile — any FUTURE unmarked mid-turn send lane re-creates this bug. Inverting the contract (explicitly mark the one turn-final send) is the durable fix but touches every adapter's final-delivery path; deliberately kept out of this review-fix series. Regression: tests/gateway/test_interim_send_lanes.py (4 tests). * fix(gateway): interrupted/incomplete turns must not adopt the diagnostic as the stream final (review B6) The finish(final_text) adoption gate checked only 'not failed', but the interrupt/abort returns in agent/conversation_loop.py are {completed: False, interrupted: True, final_response: 'Operation interrupted during …'} with NO failed key. Adopting that diagnostic: 1. sealed the user's streamed partial answer over with the interrupt text (stream-is-the-message: the seal rewrites the whole message), and 2. recorded the diagnostic as the turn-final payload, so delivered_final_matches reconciled and the gateway suppressed its own error-delivery path — the diagnostic became the ONLY thing delivered. Enumerated all 27 final_response-bearing return shapes in conversation_loop.py: every non-happy-path shape carries completed: False (several with a diagnostic final_response and neither failed nor interrupted — retry exhaustion, truncation, codex-incomplete); the happy path routes through turn_finalizer.finalize_turn (completed=True). Gate is therefore: not failed AND not interrupted AND completed is not False. Results lacking the completed key entirely (older callers/test doubles) keep the previous behavior. Regression: tests/gateway/test_stream_final_adoption_gate.py (6 tests, incl. a source-level pin on the run.py call site). * fix(relay): task-card transport failures degrade to failed SendResults (review B7) send_native_task_card_progress and stop_native_task_card_progress let transport exceptions escape. The stop runs inside the progress loop's finally block on the turn-cleanup path, and the post-cancel awaits in gateway/run.py caught only CancelledError — a socket drop during a card publish/stop therefore aborted cleanup BEFORE the final-delivery bookkeeping ran. Three layers, outermost defends any adapter: - both adapter methods catch transport exceptions and return failed SendResults (progress is advisory; the TurnRunner's text fallback already handles failure results) - the progress loop's finally wraps the stop (best-effort; the connector seals orphaned card streams on its own via recycling/eviction) - the cleanup awaits log-and-continue on non-cancellation errors so final-delivery bookkeeping always runs Regression: tests/gateway/relay/test_relay_task_card_failures.py. * fix(relay): a dying turn seals its native stream instead of orphaning it (review B8) Stale-generation exits (/new, /stop mid-stream) and cancellations returned from the consumer's run() with the native stream still open: - the Slack message kept its live streaming indicator forever (the cancellation best-effort edit only runs when _message_id exists, and the native draft path deliberately keeps it None); - the adapter's armed interception state survived the turn, so the next turn on the same key could inherit it and seal a dead draft_id. New adapter op abandon_open_draft(chat, content): seals in place with the text already on screen (the consumer passes its last delivered frame) — the seal adds nothing and claims nothing; delivery flags are never set, so the gateway's normal paths still own whatever happens next. Best-effort by contract (failure reported, never raised); the connector reaps truly orphaned streams via recycling/eviction. The consumer calls it from both death paths: the stale-generation early return and the CancelledError handler. Regression: tests/gateway/test_stream_abandon_on_turn_death.py (4 tests, incl. the next-turn-inheritance hazard). * fix(relay): bound the draft/seal coordination dicts (review M1) _sealed_draft_by_chat's key embeds a per-turn identity, so every completed turn wrote a permanent entry — unbounded growth for the life of a long-running gateway process (the docstring said 'one entry per chat', which stopped being true when the key gained the turn anchor). _open_draft_by_chat could grow the same way via abandoned entries. FIFO-evict both at 512 entries — the same idiom as the sibling bounded cache (_auto_thread_by_chat, capped at 256) and the same size as the connector's own tombstone store. The straggler window the tombstone exists for is seconds long; FIFO is more than enough. Regression: tests/gateway/relay/test_relay_state_bounds.py. * fix(relay): explicit connector rejection disarms interception; exceptions stay armed (review P3) The G-D1 optimistic-arming change silently dropped disarm-on-failure entirely: after an EXPLICIT connector rejection (success=False result — not a transport ambiguity), interception stayed armed even though the stream consumer disables the draft transport on that failure and falls back to edit-based streaming. Its turn-final would then be converted into a seal on a stream the connector just told us is unusable. test_draft_failure_result_propagates claimed to cover this ('must NOT leave seal-interception armed') but passed for an unrelated reason: the stub's canned failure also failed the SEAL, whose fail-open path did the plain send. Split the two semantics and pin each honestly: - explicit rejection (result success=False): disarm — turn-final is a real send (test_draft_failure_result_propagates, now testing what its comment says) - transport exception: ambiguous, stay armed — turn-final still seals (test_draft_transport_exception_keeps_interception_armed, the G-D1 contract) Also corrects commit ba3a24a's claim ('a failed frame disarms interception so the edit-based fallback's real send goes through untouched') to hold again for the rejection case it described. * fix(relay): lost acks are ambiguous, not rejections — on the RESULT channel too (review r2, finding 1) The production ws transport does not raise on ack timeout — it returns {"success": False, "error": "relay outbound timed out"}. The round-1 ambiguity handling keyed entirely on the exception channel, so the shape production actually produces was misclassified as a definite connector rejection. Probed on the head: - lost SEAL ack: skipped the idempotent retry, fell straight to a plain send — duplicate final whenever the seal had actually applied; - lost FRAME ack: the round-1 disarm-on-rejection fired — interception disarmed, frozen native stream beside a plain final. This re-created the original G-D1 ambiguous-ack defect on the result channel. Contract now spans both channels: - transport: the ack-timeout branch tags ambiguous=True. The fail-fast branches (closing / not connected) never sent anything and stay unmarked — they are definite non-delivery. - adapter frame path: ambiguous results keep interception armed (same as exceptions); only definite rejections disarm. - adapter seal path: one shared _attempt() classifier — exception and ambiguous result both mean "unknown"; the SAME idempotent frame is retried once (connector tombstone returns the original stream ts for a repeated final). Only after both attempts stay ambiguous does the caller's fail-open plain send run: a possible duplicate after double ack loss beats a silent loss, and double ack loss on one socket almost always means the transport is down for the plain send too. Regression: tests/gateway/relay/test_relay_ack_ambiguity.py (6 tests, incl. a source-of-truth check that the transport tags the timeout branch and leaves fail-fast branches unmarked). * fix(relay): stream semantics + draft capability resolve per CHAT, not per primary (review r2, finding 2) One RelayAdapter fronts N platforms (Phase 1.5): descriptors accumulate per platform on the transport and egress is tagged per chat — but the round-1 gate keyed draft_stream_is_message and supports_draft_streaming() off the PRIMARY scalar descriptor. Probed on the head: - Slack primary + Telegram chat: the Telegram chat's turn-final was intercepted into draft(final=true) — no real Telegram history message; - Telegram primary + Slack chat: the Slack chat was denied native streaming entirely. Resolve both through _descriptor_for_chat — the same per-chat machinery max_message_length already uses (added for the identical class of bug: the primary's 39000-char cap over-sending into Discord 400s): - new stream_is_message_for_chat(chat_id) on the adapter; arming and NotImplementedError gating use it. The class attribute remains as the single-platform value and legacy-probe fallback. - supports_draft_streaming() gains an optional chat_id kwarg (base signature updated; single-platform adapters ignore it). The consumer passes chat_id with a TypeError fallback for out-of-tree adapters. - the consumer's four draft_stream_is_message reads collapse into one _stream_is_message() helper that prefers the per-chat probe (class-resolved, MagicMock-safe) over the attribute. Platform-name inference ("slack") stays deliberate: a descriptor-level semantic field is the right eventual contract but is a cross-repo wire change — noted for the gg follow-up so future platforms advertise the semantic explicitly. Regression: tests/gateway/relay/test_relay_multiplatform_semantics.py (5 tests: both starvation directions, scalar fallback, per-chat capability gate). * fix(gateway): split delivery + authoritative footer reconciles by suffix, not full resend (review r2, finding 3) The _FINAL_TEXT adoption guard refuses wholesale adoption on split turns — correct (NousResearch#78541: sealed heads would repeat inside the tail) but it was absolute: a post-split verifier footer never entered the ledger, delivered_final_matches() reported a mismatch, and the gateway resent the ENTIRE body+footer after the split chunks (the #11 duplicate class, one level up). When the authoritative final strictly prefix-extends the split ledger, the missing suffix is the only undelivered content: append it to the live tail and the ledger, so the finalize carries it and the recorded payload reconciles. Non-prefix rewrites keep the full-resend fallback — a rewrite cannot be patched onto sealed heads. Regression: tests/gateway/test_split_final_suffix_reconcile.py (3 tests: suffix rides the tail + reconciles, rewrite still mismatches, unsplit adoption unchanged). * fix(relay): cancellation mid-seal restores open state so abandon can close the stream (review r2, finding 4) _seal_open_draft pops the open entry and writes the local tombstone BEFORE awaiting transport I/O — correct ordering for the straggler race, but CancelledError is not an Exception: a cancel during the await bypassed all failure handling, leaving the remote stream live (visible streaming indicator until connector eviction) while the local state said 'nothing open'. The consumer's abandon pass — added for exactly this turn-death case — found nothing to close and no-oped. On CancelledError: restore the open entry, drop the premature tombstone (only if it is still ours), re-raise. The abandon path then seals the stream in place with the on-screen text. Regression: tests/gateway/relay/test_relay_seal_cancellation.py (2 tests: state restoration, and end-to-end cancel→abandon→remote seal). * fix(relay): thread anchors are placement, not turn identity — revive the placement-only fallback (review r2, finding 5) _match_open_draft's single-open-stream fallback was dead for its primary intended callers: metadata carrying thread_ts/thread_id (placement-only resolver lanes) was classified as having 'turn identity', so those sends never reached the fallback — probed: a plain final posted beside the still-open turn-keyed stream. Only per-turn MESSAGE ids are identity now. Thread-anchored and bare callers share the fallback: absorb into the chat's open stream when EXACTLY one is open; stay a plain send when several are (duplicate is recoverable, wrong-stream seal is not). Callers WITH a message id whose key misses never fall back — their identity is authoritative and a miss means the stream belongs to a different turn. Regression: 4 new tests in test_relay_turn_keying.py (thread-anchored seal, both ambiguous-stay-plain shapes, id-mismatch never steals). * fix(relay): random process nonce for draft-id seeding (review r2, follow-up 6) The epoch-millisecond seed (round-1 B3 fix) mitigates the restart-replay class but is not a uniqueness guarantee: two gateways starting in the same millisecond, a forked process inheriting the class state, or a clock step backwards can all mint colliding wire identities against the connector's per-(channel, draft_id) tombstone store. Seed from secrets.randbits(49) instead: collision probability negligible, no clock dependence, and ids + realistic per-process turn counts stay comfortably inside the connector's JS number range (draft_id?: number, 2^53). Regression test now spawns two real interpreters and asserts their seeds differ — the exact scale-to-zero restart shape, and both start within the same second so a clock-locked seed would fail it. * fix(relay): stamp per-turn Slack egress identity — cache is fallback only (R3-5) The connector (gateway-gateway#210) fills chat.startStream's recipient_user_id / recipient_team_id — required by Slack when streaming to a channel — from metadata.user_id / metadata.scope_id. The gateway stamped only slack_team_id per-turn and left user_id (and scope_id) to RelayAdapter._with_scope, whose per-chat caches are keyed on chat_id alone and overwritten by every inbound message: with users U1 and U2 running overlapping turns in one channel, U2's arrival overwrote the cache before U1's stream opened, and U1's stream carried U2 as recipient_user_id. _thread_metadata_for_source now stamps scope_id and user_id from the turn's OWN source (setdefault — explicit values win), so identity is turn-scoped data on the wire. _with_scope is unchanged and fill-only: the caches keep serving restart/synthetic sends that carry no per-turn identity, which is all they were ever safe for. Mutation evidence: reverting the run.py hunk sends test_thread_metadata_stamps_per_turn_user_and_scope and test_concurrent_turns_carry_their_own_identity red; restore returns green. The _with_scope fill-only tests pass on both trees (existing correct behavior, now pinned against regression). --------- Co-authored-by: Ben Barclay <ben@nousresearch.com>
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
* kanban: pre-flight skill validation to prevent worker crash-loops
The dispatcher injects --skills <name> for each task-required skill
plus the auto-loaded kanban-worker. If any of those names does not
resolve under the worker's HERMES_HOME, the CLI aborts at startup
with:
Error: Unknown skill(s): <names>
The dispatcher sees exit code 1, marks the run crashed, and
re-spawns on the next tick — burning attempts indefinitely
(observed: 21 consecutive crashes / 4h of wasted compute before an
operator noticed via the diagnostics panel).
Three changes:
1. kanban_db.py: New _resolve_skill_under_home() + _validate_task_skills().
Filesystem-only check (no module import, no skill_view) so the
dispatcher can validate against a *different* HERMES_HOME than
its own without contaminating cached resolver state. Mirrors the
CLI's resolver order: <home>/skills + skills.external_dirs from
<home>/config.yaml.
2. kanban_db.py: Both spawn paths (normal + review) call the
validator after claim, before spawn. Missing skill -> block_task()
with a precise reason naming the missing skills and the HERMES_HOME
they were looked up under. Operator can fix once (install skill or
add external_dirs entry) and unblock, instead of watching the loop
tick.
3. hermes_cli/main.py + gateway/run.py: Honor a new
.no-bundled-skills marker in HERMES_HOME so bundled-skill sync
stays out of profile dirs that intentionally rely exclusively on
external_dirs for their skill library. Prevents stale per-profile
copies from re-seeding and colliding on skill name with the
root-home source of truth.
Before: 21+ crashed runs, no progress, no signal to the operator.
After: 1 blocked run with the exact failing skill list in the reason.
Repro:
- Profile P with HERMES_HOME=~/.hermes/profiles/P
- ~/.hermes/profiles/P/skills/ does not contain skill X
- skills.external_dirs in profile config does not include the dir
that hosts X
- Task assigned to P with skills: [X]
- Before: infinite crash loop until consecutive_failures cap.
- After: task moves to blocked with reason
'missing skills under HERMES_HOME=...: X. Install them or add
their source dir to skills.external_dirs ...'
* kanban: skip skill pre-flight when spawn_fn is injected (tests)
Test failures: test_dispatch_review_spawns_with_correct_skills,
test_dispatch_review_spawns_when_ready_empty asserted len(res.spawned)==1
but got 0. Tests inject a capture_spawn stub that never execs the CLI,
so a missing skill on the test fixture's HERMES_HOME is harmless —
but the pre-flight was auto-blocking the task anyway.
Skip the pre-flight whenever a spawn_fn is injected: by definition the
caller isn't going through _default_spawn, so the 'CLI dies at startup
on missing skill' failure mode doesn't apply.
Also: add sahil.marwaha@trilogy.com -> sahilm-ti to AUTHOR_MAP so the
attribution check passes (it was failing on this PR with 'New
contributor email(s) not in AUTHOR_MAP').
See commit message. Stops the dispatcher from burning 20+ retries on a missing-skill startup error; auto-blocks with a precise diagnostic instead. Repro + fix details in the commit body. Verified live on sahil's install: task t_83c080e5 (21 crashed runs) is now spawning cleanly after Layer 1 config fix + this Layer 2 code change.
Summary by CodeRabbit
Release Notes
New Features
Bug Fixes
Tests