diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index ac751946a..10c62a412 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -505,6 +505,11 @@ spec: --pane "${pane}" \ --timeout 60000 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}" env: - {name: HERMES_HOME, value: /opt/data} diff --git a/services/hermes/kustomization.yaml b/services/hermes/kustomization.yaml index a79efd17e..4e70d9483 100644 --- a/services/hermes/kustomization.yaml +++ b/services/hermes/kustomization.yaml @@ -46,6 +46,7 @@ configMapGenerator: files: - gitea_askpass.sh=scripts/gitea_askpass.sh - herdr_dispatch.py=scripts/herdr_dispatch.py + - herdr_tab_router.py=scripts/herdr_tab_router.py - hermes_coordinator.py=scripts/hermes_coordinator.py - hermes_model_routing.py=scripts/hermes_model_routing.py - patch_hermes_auth.py=scripts/patch_hermes_auth.py diff --git a/services/hermes/scripts/herdr_tab_router.py b/services/hermes/scripts/herdr_tab_router.py new file mode 100644 index 000000000..eaf78ecac --- /dev/null +++ b/services/hermes/scripts/herdr_tab_router.py @@ -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() diff --git a/testing/tests/test_hermes_herdr.py b/testing/tests/test_hermes_herdr.py index b25d9941c..c1427b43f 100644 --- a/testing/tests/test_hermes_herdr.py +++ b/testing/tests/test_hermes_herdr.py @@ -24,6 +24,7 @@ def _load(name: str): dispatch = _load("herdr_dispatch") +tab_router = _load("herdr_tab_router") 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") +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(): deployment = yaml.safe_load((HERMES / "agent-deployment.yaml").read_text()) 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 'herdr workspace close "${stale_workspace}"' 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 "--timeout 60000" in server_command assert "--env AGENT_BROWSER_EXECUTABLE_PATH=" in server_command