fix(hermes): route new agent tabs
This commit is contained in:
parent
301e5dc30a
commit
5d2b9b2765
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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")
|
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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user