mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
fix(kanban): spawn workers headless — TUI can never eat a worker run
An inherited HERMES_TUI=1 or a `display.interface: tui` config default sent kanban workers into the Ink TUI, whose no-TTY bail-out exits 0 without doing the task — every attempt ended in "protocol violation". Two layers: - _default_spawn pins `--cli` (highest-precedence interface flag) and strips HERMES_TUI from the child env (covers older builds on PATH). - _resolve_use_tui gates ambient TUI prefs (env/config) behind a real TTY; an explicit --tui still wins so the informative bail-out stays reachable.
This commit is contained in:
parent
5829fe1378
commit
e87c495dc2
4 changed files with 108 additions and 5 deletions
|
|
@ -5,10 +5,18 @@ flip ``display.interface: tui`` in config.yaml to make the modern Ink TUI the
|
|||
default for bare ``hermes`` / ``hermes chat``. Explicit flags always win:
|
||||
|
||||
--cli forces the classic REPL (highest precedence)
|
||||
--tui / HERMES_TUI=1 forces the TUI
|
||||
--tui forces the TUI
|
||||
(no TTY) forces the classic REPL — ambient prefs don't apply
|
||||
HERMES_TUI=1 the env default
|
||||
display.interface the configured default
|
||||
(unset) classic REPL
|
||||
|
||||
The no-TTY gate exists because ambient TUI preferences must never hijack
|
||||
non-interactive invocations: kanban workers / cron / pipelines run
|
||||
``hermes … chat -q`` on a pipe, and the TUI's no-TTY bail-out exits 0
|
||||
without doing the work (a kanban worker then dies with "protocol
|
||||
violation" on every attempt).
|
||||
|
||||
These tests pin that precedence at every layer that makes the decision:
|
||||
|
||||
* ``_resolve_use_tui(args)`` — the canonical args-aware resolver used by
|
||||
|
|
@ -46,6 +54,14 @@ def _args(**kw):
|
|||
return SimpleNamespace(**kw)
|
||||
|
||||
|
||||
def _fake_tty(monkeypatch, interactive: bool):
|
||||
"""Pin stdin/stdout TTY-ness — pytest's capture is never a real TTY."""
|
||||
import sys as _sys
|
||||
|
||||
monkeypatch.setattr(_sys.stdin, "isatty", lambda: interactive, raising=False)
|
||||
monkeypatch.setattr(_sys.stdout, "isatty", lambda: interactive, raising=False)
|
||||
|
||||
|
||||
def _patch_config(monkeypatch, interface):
|
||||
import hermes_cli.config as cfg
|
||||
|
||||
|
|
@ -73,19 +89,23 @@ class TestResolveUseTui:
|
|||
|
||||
def test_env_beats_config_cli(self, monkeypatch):
|
||||
_patch_config(monkeypatch, "cli")
|
||||
_fake_tty(monkeypatch, True)
|
||||
monkeypatch.setenv("HERMES_TUI", "1")
|
||||
assert m._resolve_use_tui(_args()) is True
|
||||
|
||||
def test_config_tui_with_no_flags(self, monkeypatch):
|
||||
_patch_config(monkeypatch, "tui")
|
||||
_fake_tty(monkeypatch, True)
|
||||
assert m._resolve_use_tui(_args()) is True
|
||||
|
||||
def test_config_cli_is_default(self, monkeypatch):
|
||||
_patch_config(monkeypatch, "cli")
|
||||
_fake_tty(monkeypatch, True)
|
||||
assert m._resolve_use_tui(_args()) is False
|
||||
|
||||
def test_interface_value_is_case_insensitive(self, monkeypatch):
|
||||
_patch_config(monkeypatch, "TUI")
|
||||
_fake_tty(monkeypatch, True)
|
||||
assert m._resolve_use_tui(_args()) is True
|
||||
|
||||
def test_load_config_failure_falls_back_to_cli(self, monkeypatch):
|
||||
|
|
@ -95,8 +115,28 @@ class TestResolveUseTui:
|
|||
raise RuntimeError("config unreadable")
|
||||
|
||||
monkeypatch.setattr(cfg, "load_config", boom)
|
||||
_fake_tty(monkeypatch, True)
|
||||
assert m._resolve_use_tui(_args()) is False
|
||||
|
||||
# ── the no-TTY gate: ambient prefs never hijack non-interactive runs ────
|
||||
def test_no_tty_blocks_env_tui(self, monkeypatch):
|
||||
_patch_config(monkeypatch, "cli")
|
||||
_fake_tty(monkeypatch, False)
|
||||
monkeypatch.setenv("HERMES_TUI", "1")
|
||||
assert m._resolve_use_tui(_args()) is False
|
||||
|
||||
def test_no_tty_blocks_config_tui(self, monkeypatch):
|
||||
_patch_config(monkeypatch, "tui")
|
||||
_fake_tty(monkeypatch, False)
|
||||
assert m._resolve_use_tui(_args()) is False
|
||||
|
||||
def test_explicit_tui_flag_survives_no_tty(self, monkeypatch):
|
||||
# An explicit --tui is the user's own ask — keep the informative
|
||||
# no-TTY bail-out instead of silently swapping interfaces.
|
||||
_patch_config(monkeypatch, "cli")
|
||||
_fake_tty(monkeypatch, False)
|
||||
assert m._resolve_use_tui(_args(tui=True)) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _wants_tui_early — dependency-free early resolver
|
||||
|
|
|
|||
|
|
@ -89,6 +89,42 @@ agent:
|
|||
assert required in pinned
|
||||
|
||||
|
||||
def test_default_spawn_never_boots_the_tui(monkeypatch, tmp_path):
|
||||
"""Workers are headless: an inherited HERMES_TUI=1 (or a TUI-default
|
||||
config) must not send the quiet chat run into the Ink TUI, whose no-TTY
|
||||
bail-out exits 0 without doing the task — every attempt then ends in
|
||||
"protocol violation". The spawn pins --cli (highest-precedence interface
|
||||
flag) and strips HERMES_TUI from the child env."""
|
||||
root = tmp_path / ".hermes"
|
||||
(root / "profiles" / "elias").mkdir(parents=True)
|
||||
root.joinpath("config.yaml").write_text("display:\n interface: tui\n", encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(root))
|
||||
monkeypatch.setenv("HERMES_TUI", "1")
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
monkeypatch.setattr(kb, "_resolve_hermes_argv", lambda: ["hermes"])
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeProc:
|
||||
pid = 4243
|
||||
|
||||
def fake_popen(cmd, *args, **kwargs):
|
||||
captured["cmd"] = list(cmd)
|
||||
captured["env"] = dict(kwargs.get("env") or {})
|
||||
return FakeProc()
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
kb._default_spawn(_make_task(kb, assignee="elias"), str(workspace))
|
||||
|
||||
assert "--cli" in captured["cmd"]
|
||||
assert "HERMES_TUI" not in captured["env"]
|
||||
|
||||
|
||||
def test_resolve_worker_cli_toolsets_uses_profile_home_not_parent_config(monkeypatch, tmp_path):
|
||||
root = tmp_path / ".hermes"
|
||||
profile = root / "profiles" / "elias"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue