Skip to content

Commit 8831eb5

Browse files
qWaitCryptoteknium1
authored andcommitted
fix(kanban): align worker terminal timeout with task runtime
1 parent 0292398 commit 8831eb5

2 files changed

Lines changed: 173 additions & 0 deletions

File tree

hermes_cli/kanban_db.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3067,6 +3067,10 @@ def set_workspace_path(
30673067
# and rotates on spawn if the file is larger than this at spawn time.
30683068
DEFAULT_LOG_ROTATE_BYTES = 2 * 1024 * 1024 # 2 MiB
30693069

3070+
# Keep a little wall-clock budget for the worker to observe a terminal timeout
3071+
# and call kanban_block/kanban_complete before max_runtime_seconds kills it.
3072+
KANBAN_TERMINAL_TIMEOUT_GRACE_SECONDS = 30
3073+
30703074

30713075
@dataclass
30723076
class DispatchResult:
@@ -4077,6 +4081,36 @@ def _resolve_hermes_argv() -> list[str]:
40774081
return [sys.executable, "-m", "hermes_cli.main"]
40784082

40794083

4084+
def _worker_terminal_timeout_env(
4085+
max_runtime_seconds: Optional[int],
4086+
current_timeout: Optional[str],
4087+
) -> Optional[str]:
4088+
"""Return a worker-scoped TERMINAL_TIMEOUT override, if needed.
4089+
4090+
Kanban's ``max_runtime_seconds`` bounds the whole worker attempt. The
4091+
terminal tool has its own default timeout via ``TERMINAL_TIMEOUT``; when
4092+
the worker runtime is longer, raise only the child process default so a
4093+
long command is not killed by the generic terminal default first.
4094+
"""
4095+
if max_runtime_seconds is None:
4096+
return None
4097+
try:
4098+
runtime = int(max_runtime_seconds)
4099+
except (TypeError, ValueError):
4100+
return None
4101+
if runtime <= 0:
4102+
return None
4103+
4104+
desired = max(1, runtime - KANBAN_TERMINAL_TIMEOUT_GRACE_SECONDS)
4105+
try:
4106+
existing = int(str(current_timeout).strip()) if current_timeout else 0
4107+
except (TypeError, ValueError):
4108+
existing = 0
4109+
if existing >= desired:
4110+
return None
4111+
return str(desired)
4112+
4113+
40804114
def _default_spawn(
40814115
task: Task,
40824116
workspace: str,
@@ -4132,6 +4166,18 @@ def _default_spawn(
41324166
env["HERMES_KANBAN_RUN_ID"] = str(task.current_run_id)
41334167
if task.claim_lock:
41344168
env["HERMES_KANBAN_CLAIM_LOCK"] = task.claim_lock
4169+
terminal_timeout = _worker_terminal_timeout_env(
4170+
task.max_runtime_seconds,
4171+
env.get("TERMINAL_TIMEOUT"),
4172+
)
4173+
if terminal_timeout is not None:
4174+
env["TERMINAL_TIMEOUT"] = terminal_timeout
4175+
foreground_timeout = _worker_terminal_timeout_env(
4176+
task.max_runtime_seconds,
4177+
env.get("TERMINAL_MAX_FOREGROUND_TIMEOUT"),
4178+
)
4179+
if foreground_timeout is not None:
4180+
env["TERMINAL_MAX_FOREGROUND_TIMEOUT"] = foreground_timeout
41354181
# Pin the shared board + workspaces root the dispatcher resolved, so
41364182
# that even when the worker activates a profile (`hermes -p <name>`
41374183
# rewrites HERMES_HOME), its kanban paths still match the
@@ -4322,6 +4368,15 @@ def _cap(s: Optional[str], limit: int = _CTX_MAX_FIELD_BYTES) -> str:
43224368
if task.tenant:
43234369
lines.append(f"Tenant: {task.tenant}")
43244370
lines.append(f"Workspace: {task.workspace_kind} @ {task.workspace_path or '(unresolved)'}")
4371+
if task.max_runtime_seconds is not None:
4372+
terminal_timeout = _worker_terminal_timeout_env(
4373+
task.max_runtime_seconds,
4374+
os.environ.get("TERMINAL_TIMEOUT"),
4375+
)
4376+
effective_terminal_timeout = terminal_timeout or os.environ.get("TERMINAL_TIMEOUT")
4377+
lines.append(f"Max runtime: {task.max_runtime_seconds}s")
4378+
if effective_terminal_timeout:
4379+
lines.append(f"Terminal timeout: {effective_terminal_timeout}s")
43254380
lines.append("")
43264381

43274382
if task.body and task.body.strip():

tests/hermes_cli/test_kanban_core_functionality.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2679,6 +2679,124 @@ def fake_popen(cmd, **kwargs):
26792679
assert env.get("HERMES_PROFILE") == "some-profile"
26802680

26812681

2682+
def test_default_spawn_raises_terminal_timeout_to_task_runtime(kanban_home, monkeypatch):
2683+
"""A task runtime cap should raise the worker's terminal default.
2684+
2685+
This is worker-scoped env only: normal CLI/gateway terminal settings stay
2686+
untouched, but long kanban tasks no longer inherit a short generic
2687+
TERMINAL_TIMEOUT that kills their foreground command first.
2688+
"""
2689+
captured = {}
2690+
2691+
class FakeProc:
2692+
pid = 123
2693+
2694+
def fake_popen(cmd, **kwargs):
2695+
captured["env"] = kwargs.get("env", {})
2696+
return FakeProc()
2697+
2698+
monkeypatch.setattr("subprocess.Popen", fake_popen)
2699+
monkeypatch.setenv("TERMINAL_TIMEOUT", "180")
2700+
monkeypatch.delenv("TERMINAL_MAX_FOREGROUND_TIMEOUT", raising=False)
2701+
2702+
conn = kb.connect()
2703+
try:
2704+
tid = kb.create_task(
2705+
conn,
2706+
title="long worker",
2707+
assignee="ops",
2708+
max_runtime_seconds=3600,
2709+
)
2710+
task = kb.get_task(conn, tid)
2711+
workspace = kb.resolve_workspace(task)
2712+
kb._default_spawn(task, str(workspace))
2713+
finally:
2714+
conn.close()
2715+
2716+
assert captured["env"]["TERMINAL_TIMEOUT"] == "3570"
2717+
assert captured["env"]["TERMINAL_MAX_FOREGROUND_TIMEOUT"] == "3570"
2718+
assert os.environ["TERMINAL_TIMEOUT"] == "180"
2719+
2720+
2721+
def test_default_spawn_preserves_longer_terminal_timeout(kanban_home, monkeypatch):
2722+
"""Kanban should never lower an explicitly larger terminal timeout."""
2723+
captured = {}
2724+
2725+
class FakeProc:
2726+
pid = 124
2727+
2728+
def fake_popen(cmd, **kwargs):
2729+
captured["env"] = kwargs.get("env", {})
2730+
return FakeProc()
2731+
2732+
monkeypatch.setattr("subprocess.Popen", fake_popen)
2733+
monkeypatch.setenv("TERMINAL_TIMEOUT", "7200")
2734+
monkeypatch.setenv("TERMINAL_MAX_FOREGROUND_TIMEOUT", "7200")
2735+
2736+
conn = kb.connect()
2737+
try:
2738+
tid = kb.create_task(
2739+
conn,
2740+
title="already tuned",
2741+
assignee="ops",
2742+
max_runtime_seconds=3600,
2743+
)
2744+
task = kb.get_task(conn, tid)
2745+
workspace = kb.resolve_workspace(task)
2746+
kb._default_spawn(task, str(workspace))
2747+
finally:
2748+
conn.close()
2749+
2750+
assert captured["env"]["TERMINAL_TIMEOUT"] == "7200"
2751+
assert captured["env"]["TERMINAL_MAX_FOREGROUND_TIMEOUT"] == "7200"
2752+
2753+
2754+
def test_default_spawn_leaves_terminal_timeout_without_runtime_cap(kanban_home, monkeypatch):
2755+
"""Uncapped tasks keep the existing terminal timeout behavior."""
2756+
captured = {}
2757+
2758+
class FakeProc:
2759+
pid = 125
2760+
2761+
def fake_popen(cmd, **kwargs):
2762+
captured["env"] = kwargs.get("env", {})
2763+
return FakeProc()
2764+
2765+
monkeypatch.setattr("subprocess.Popen", fake_popen)
2766+
monkeypatch.setenv("TERMINAL_TIMEOUT", "180")
2767+
monkeypatch.delenv("TERMINAL_MAX_FOREGROUND_TIMEOUT", raising=False)
2768+
2769+
conn = kb.connect()
2770+
try:
2771+
tid = kb.create_task(conn, title="uncapped", assignee="ops")
2772+
task = kb.get_task(conn, tid)
2773+
workspace = kb.resolve_workspace(task)
2774+
kb._default_spawn(task, str(workspace))
2775+
finally:
2776+
conn.close()
2777+
2778+
assert captured["env"]["TERMINAL_TIMEOUT"] == "180"
2779+
assert "TERMINAL_MAX_FOREGROUND_TIMEOUT" not in captured["env"]
2780+
2781+
2782+
def test_build_worker_context_includes_runtime_timeout_budget(kanban_home, monkeypatch):
2783+
monkeypatch.setenv("TERMINAL_TIMEOUT", "180")
2784+
conn = kb.connect()
2785+
try:
2786+
tid = kb.create_task(
2787+
conn,
2788+
title="long context",
2789+
assignee="ops",
2790+
max_runtime_seconds=3600,
2791+
)
2792+
ctx = kb.build_worker_context(conn, tid)
2793+
finally:
2794+
conn.close()
2795+
2796+
assert "Max runtime: 3600s" in ctx
2797+
assert "Terminal timeout: 3570s" in ctx
2798+
2799+
26822800

26832801
# ---------------------------------------------------------------------------
26842802
# Per-task force-loaded skills

0 commit comments

Comments
 (0)