fix(hermes): route new agent tabs

This commit is contained in:
jenkins 2026-08-09 14:08:45 -03:00
parent 301e5dc30a
commit 5d2b9b2765
4 changed files with 245 additions and 0 deletions

View File

@ -505,6 +505,11 @@ spec:
--pane "${pane}" \ --pane "${pane}" \
--timeout 60000 --timeout 60000
fi fi
/opt/hermes/.venv/bin/python /opt/coordinator/herdr_tab_router.py \
--workspace-label coordinator \
--interval 1 &
router_pid=$!
trap 'kill "${router_pid}" "${server_pid}" 2>/dev/null || true' TERM INT
wait "${server_pid}" wait "${server_pid}"
env: env:
- {name: HERMES_HOME, value: /opt/data} - {name: HERMES_HOME, value: /opt/data}

View File

@ -46,6 +46,7 @@ configMapGenerator:
files: files:
- gitea_askpass.sh=scripts/gitea_askpass.sh - gitea_askpass.sh=scripts/gitea_askpass.sh
- herdr_dispatch.py=scripts/herdr_dispatch.py - herdr_dispatch.py=scripts/herdr_dispatch.py
- herdr_tab_router.py=scripts/herdr_tab_router.py
- hermes_coordinator.py=scripts/hermes_coordinator.py - hermes_coordinator.py=scripts/hermes_coordinator.py
- hermes_model_routing.py=scripts/hermes_model_routing.py - hermes_model_routing.py=scripts/hermes_model_routing.py
- patch_hermes_auth.py=scripts/patch_hermes_auth.py - patch_hermes_auth.py=scripts/patch_hermes_auth.py

View File

@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Turn interactive tabs in the Agent coordinator space into Hermes sessions."""
from __future__ import annotations
import argparse
import json
import os
import re
import shlex
import subprocess
import time
from pathlib import Path
from typing import Any, Callable
HERDR_BIN = Path("/opt/data/tools/bin/herdr")
WORKSPACE_ROOT = Path("/opt/data/workspace")
PROJECT_ROOT = WORKSPACE_ROOT / "projects"
def _slug(value: str) -> str:
"""Return a stable label accepted by Herdr's agent-name argument."""
normalized = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-")
return normalized or "session"
def project_for_label(label: str, project_root: Path = PROJECT_ROOT) -> Path | None:
"""Resolve a tab label to an existing isolated project directory."""
wanted = _slug(label)
if not project_root.is_dir():
return None
for candidate in project_root.iterdir():
if candidate.is_dir() and _slug(candidate.name) == wanted:
return candidate.resolve()
return None
def _run_json(command: list[str]) -> dict[str, Any]:
"""Run a Herdr command and return its JSON object response."""
completed = subprocess.run(
command,
text=True,
capture_output=True,
timeout=130,
check=False,
env=os.environ.copy(),
)
if completed.returncode != 0:
detail = completed.stderr.strip() or completed.stdout.strip()
raise RuntimeError(detail or f"command failed with {completed.returncode}")
try:
payload = json.loads(completed.stdout)
except json.JSONDecodeError as error:
raise RuntimeError("Herdr returned non-JSON output") from error
if not isinstance(payload, dict):
raise RuntimeError("Herdr returned an unexpected response")
return payload
def _result_list(payload: dict[str, Any], key: str) -> list[dict[str, Any]]:
"""Extract a typed list from a Herdr response."""
result = payload.get("result") or {}
values = result.get(key) or []
return [item for item in values if isinstance(item, dict)]
def find_workspace(
label: str,
run: Callable[[list[str]], dict[str, Any]] = _run_json,
) -> str | None:
"""Return the live workspace id for the named Agent coordinator space."""
payload = run([str(HERDR_BIN), "workspace", "list"])
matches = [
str(item.get("workspace_id") or "")
for item in _result_list(payload, "workspaces")
if item.get("label") == label
]
return next((item for item in matches if item), None)
def route_unmanaged_tabs(
workspace_id: str,
run: Callable[[list[str]], dict[str, Any]] = _run_json,
project_root: Path = PROJECT_ROOT,
) -> list[str]:
"""Start routed Hermes in every unmanaged pane of the coordinator space."""
tabs_payload = run([str(HERDR_BIN), "tab", "list"])
panes_payload = run(
[str(HERDR_BIN), "pane", "list", "--workspace", workspace_id]
)
tab_labels = {
str(item.get("tab_id") or ""): str(item.get("label") or "session")
for item in _result_list(tabs_payload, "tabs")
if item.get("workspace_id") == workspace_id
}
started: list[str] = []
for pane in _result_list(panes_payload, "panes"):
if pane.get("workspace_id") != workspace_id or pane.get("agent"):
continue
pane_id = str(pane.get("pane_id") or "")
if not pane_id:
continue
label = tab_labels.get(str(pane.get("tab_id") or ""), "session")
project = project_for_label(label, project_root)
if project is not None and Path(str(pane.get("foreground_cwd") or "")) != project:
run(
[
str(HERDR_BIN),
"pane",
"run",
pane_id,
f"cd {shlex.quote(str(project))}",
]
)
pane_suffix = _slug(pane_id.split(":")[-1])
agent_name = f"{_slug(label)}-{pane_suffix}"
run(
[
str(HERDR_BIN),
"agent",
"start",
agent_name,
"--kind",
"hermes",
"--pane",
pane_id,
"--timeout",
"120000",
]
)
started.append(pane_id)
return started
def run_loop(label: str, interval: float) -> None:
"""Continuously reconcile Agent tabs while allowing Herdr to own workers."""
while True:
try:
workspace_id = find_workspace(label)
if workspace_id:
for pane_id in route_unmanaged_tabs(workspace_id):
print(f"Started routed Hermes session in {pane_id}", flush=True)
except Exception as error:
print(f"Agent tab routing retry: {type(error).__name__}: {error}", flush=True)
time.sleep(interval)
def main() -> None:
"""Parse controller options and reconcile the Agent coordinator space."""
parser = argparse.ArgumentParser()
parser.add_argument("--workspace-label", default="coordinator")
parser.add_argument("--interval", type=float, default=1.0)
args = parser.parse_args()
run_loop(args.workspace_label, max(args.interval, 0.25))
if __name__ == "__main__":
main()

View File

@ -24,6 +24,7 @@ def _load(name: str):
dispatch = _load("herdr_dispatch") dispatch = _load("herdr_dispatch")
tab_router = _load("herdr_tab_router")
auth_patch = _load("patch_hermes_auth") auth_patch = _load("patch_hermes_auth")
@ -131,6 +132,83 @@ def test_auth_patch_fails_closed_on_upstream_drift(tmp_path: Path):
auth_patch.patch(source, tmp_path / "patched.py") auth_patch.patch(source, tmp_path / "patched.py")
def test_agent_tab_router_starts_hermes_in_matching_project(tmp_path: Path):
projects = tmp_path / "projects"
cassandra = projects / "cassandra"
cassandra.mkdir(parents=True)
calls = []
def fake_run(command):
calls.append(command)
if command[1:3] == ["tab", "list"]:
return {
"result": {
"tabs": [
{
"tab_id": "w2:t2",
"workspace_id": "w2",
"label": "Cassandra",
}
]
}
}
if command[1:3] == ["pane", "list"]:
return {
"result": {
"panes": [
{
"pane_id": "w2:p1",
"tab_id": "w2:t1",
"workspace_id": "w2",
"agent": "hermes",
},
{
"pane_id": "w2:p2",
"tab_id": "w2:t2",
"workspace_id": "w2",
"foreground_cwd": str(tmp_path),
},
]
}
}
return {"result": {"ok": True}}
started = tab_router.route_unmanaged_tabs("w2", fake_run, projects)
assert started == ["w2:p2"]
assert calls[-2][1:4] == ["pane", "run", "w2:p2"]
assert calls[-2][-1] == f"cd {cassandra.resolve()}"
assert calls[-1][1:] == [
"agent",
"start",
"cassandra-p2",
"--kind",
"hermes",
"--pane",
"w2:p2",
"--timeout",
"120000",
]
def test_agent_tab_router_only_targets_coordinator_workspace():
calls = []
def fake_run(command):
calls.append(command)
return {
"result": {
"workspaces": [
{"workspace_id": "w2", "label": "coordinator"},
{"workspace_id": "w3", "label": "codex-worker"},
]
}
}
assert tab_router.find_workspace("coordinator", fake_run) == "w2"
assert calls == [[str(tab_router.HERDR_BIN), "workspace", "list"]]
def test_agent_ttyd_defers_identity_to_owner_only_oauth_boundary(): def test_agent_ttyd_defers_identity_to_owner_only_oauth_boundary():
deployment = yaml.safe_load((HERMES / "agent-deployment.yaml").read_text()) deployment = yaml.safe_load((HERMES / "agent-deployment.yaml").read_text())
containers = deployment["spec"]["template"]["spec"]["containers"] containers = deployment["spec"]["template"]["spec"]["containers"]
@ -235,6 +313,8 @@ def test_agent_installs_hermes_integration_before_startup():
assert 'item["workspace_id"] != active' in server_command assert 'item["workspace_id"] != active' in server_command
assert 'herdr workspace close "${stale_workspace}"' in server_command assert 'herdr workspace close "${stale_workspace}"' in server_command
assert "herdr agent start coordinator" in server_command assert "herdr agent start coordinator" in server_command
assert "/opt/coordinator/herdr_tab_router.py" in server_command
assert "--workspace-label coordinator" in server_command
assert "--kind hermes" in server_command assert "--kind hermes" in server_command
assert "--timeout 60000" in server_command assert "--timeout 60000" in server_command
assert "--env AGENT_BROWSER_EXECUTABLE_PATH=" in server_command assert "--env AGENT_BROWSER_EXECUTABLE_PATH=" in server_command