fix(hermes): route new agent tabs
This commit is contained in:
parent
301e5dc30a
commit
5d2b9b2765
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
159
services/hermes/scripts/herdr_tab_router.py
Normal file
159
services/hermes/scripts/herdr_tab_router.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user