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:
Brooklyn Nicholson 2026-07-09 09:02:04 -05:00
parent 5829fe1378
commit e87c495dc2
4 changed files with 108 additions and 5 deletions

View file

@ -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