feat(agent): hierarchical executor with path resolve, runtime probe, quiet UI
Make Zed Agent closer to Cursor: deterministic DevOps path index, live Traefik port probe before blind edits, stop-after-edit, and quieter Russian progress.
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
"""DevOps runtime probe: gather live facts before editing Traefik/compose."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
log = logging.getLogger("ai-router")
|
||||
|
||||
_HOST_RE = re.compile(
|
||||
r"\b([a-z0-9-]+(?:\.(?:ift|stage))?\.calentiq\.com)\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
# hostname pattern → docker DNS name + candidate ports
|
||||
_SERVICE_MAP: list[tuple[str, str, tuple[int, ...]]] = [
|
||||
(r"^observer\.", "observer_web", (80, 4000, 8080, 3000)),
|
||||
(r"^grafana\.", "grafana", (3000, 80)),
|
||||
(r"^prometheus\.", "prometheus", (9090, 80)),
|
||||
(r"^portainer\.", "portainer", (9000, 9443, 80)),
|
||||
(r"^ai-router\.", "ai-router", (8000, 80)),
|
||||
(r"^litellm\.", "litellm", (4000, 80)),
|
||||
(r"^logs\.|^loglynx\.", "loglynx", (6123, 80)),
|
||||
(r"^kuma\.", "uptime-kuma", (3001, 80)),
|
||||
]
|
||||
|
||||
|
||||
def extract_hosts(*texts: str) -> list[str]:
|
||||
found: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for text in texts:
|
||||
if not text:
|
||||
continue
|
||||
for m in _HOST_RE.finditer(text):
|
||||
h = m.group(1).lower()
|
||||
if h not in seen:
|
||||
seen.add(h)
|
||||
found.append(h)
|
||||
return found
|
||||
|
||||
|
||||
def is_devops_request(plan: dict[str, Any] | None, *extra: str) -> bool:
|
||||
blob = json.dumps(plan or {}, ensure_ascii=False).lower()
|
||||
for t in extra:
|
||||
blob += "\n" + str(t or "").lower()
|
||||
keys = (
|
||||
"calentiq",
|
||||
"traefik",
|
||||
"bad gateway",
|
||||
"502",
|
||||
"504",
|
||||
"eventhubdevops",
|
||||
"docker-compose",
|
||||
"swarm",
|
||||
"observer",
|
||||
"ift.",
|
||||
"stage.",
|
||||
"devops",
|
||||
"gateway",
|
||||
)
|
||||
if any(k in blob for k in keys):
|
||||
return True
|
||||
return bool(extract_hosts(blob))
|
||||
|
||||
|
||||
def service_for_host(host: str) -> tuple[str, tuple[int, ...]]:
|
||||
h = host.lower()
|
||||
for pat, name, ports in _SERVICE_MAP:
|
||||
if re.search(pat, h):
|
||||
return name, ports
|
||||
# fallback: first label as service guess
|
||||
label = h.split(".")[0]
|
||||
return label.replace("-", "_"), (80, 443, 8080, 3000, 4000)
|
||||
|
||||
|
||||
def _tcp_open(host: str, port: int, timeout: float = 2.0) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _http_status(url: str, timeout: float = 8.0) -> int | None:
|
||||
try:
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
|
||||
return int(getattr(resp, "status", 200) or 200)
|
||||
except urllib.error.HTTPError as exc:
|
||||
return int(exc.code)
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def probe_host(host: str, *, cfg: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Live probe from AiRouter container (same Docker network as services)."""
|
||||
cfg = cfg or {}
|
||||
timeout = float(cfg.get("runtime_probe_timeout_sec") or 8)
|
||||
service, ports = service_for_host(host)
|
||||
https = f"https://{host}/"
|
||||
http = f"http://{host}/"
|
||||
status = _http_status(https, timeout=timeout)
|
||||
if status is None:
|
||||
status = _http_status(http, timeout=timeout)
|
||||
|
||||
port_hits: dict[str, bool] = {}
|
||||
open_ports: list[int] = []
|
||||
for p in ports:
|
||||
ok = _tcp_open(service, p, timeout=min(2.0, timeout))
|
||||
port_hits[f"{service}:{p}"] = ok
|
||||
if ok:
|
||||
open_ports.append(p)
|
||||
|
||||
closed_preferred = not port_hits.get(f"{service}:80", True) and bool(open_ports)
|
||||
hint = ""
|
||||
suggested_url = None
|
||||
if status in (502, 503, 504) and open_ports:
|
||||
# Classic misconfig: Traefik → :80 while app on :4000
|
||||
if 80 not in open_ports and open_ports:
|
||||
suggested_url = f"http://{service}:{open_ports[0]}"
|
||||
hint = (
|
||||
f"HTTP {status}: Traefik likely targets a closed port. "
|
||||
f"{service}:80 open={port_hits.get(f'{service}:80')}; "
|
||||
f"open ports={open_ports}. Prefer {suggested_url} in dynamic_conf.yml."
|
||||
)
|
||||
else:
|
||||
hint = (
|
||||
f"HTTP {status} but {service} has open ports {open_ports or 'none'}. "
|
||||
"Check container health / upstream path."
|
||||
)
|
||||
elif status in (502, 503, 504):
|
||||
hint = (
|
||||
f"HTTP {status}: no open TCP on guessed service `{service}` "
|
||||
f"ports {list(ports)}. Service down or wrong DNS name."
|
||||
)
|
||||
elif status and status < 400:
|
||||
hint = f"HTTP {status}: host responds; issue may be app-level."
|
||||
else:
|
||||
hint = f"HTTP status={status}; ports={port_hits}"
|
||||
|
||||
facts: dict[str, Any] = {
|
||||
"host": host,
|
||||
"http_status": status,
|
||||
"service": service,
|
||||
"ports": port_hits,
|
||||
"open_ports": open_ports,
|
||||
"suggested_backend_url": suggested_url,
|
||||
"hint": hint,
|
||||
"source": "gateway_tcp",
|
||||
"closed_port_80_but_alt_open": closed_preferred,
|
||||
}
|
||||
log.info(
|
||||
"runtime_probe host=%s status=%s service=%s open=%s suggest=%s",
|
||||
host,
|
||||
status,
|
||||
service,
|
||||
open_ports,
|
||||
suggested_url,
|
||||
)
|
||||
return facts
|
||||
|
||||
|
||||
def attach_runtime_probe(
|
||||
plan: dict[str, Any] | None,
|
||||
*,
|
||||
user_text: str = "",
|
||||
cfg: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""If DevOps task — probe hosts and attach runtime_facts to plan."""
|
||||
if not isinstance(plan, dict):
|
||||
return plan
|
||||
cfg = cfg or {}
|
||||
if not cfg.get("runtime_probe_enabled", True):
|
||||
return plan
|
||||
if plan.get("runtime_facts"):
|
||||
return plan
|
||||
if not is_devops_request(plan, user_text):
|
||||
return plan
|
||||
|
||||
out = dict(plan)
|
||||
hosts = extract_hosts(
|
||||
user_text,
|
||||
json.dumps(plan, ensure_ascii=False),
|
||||
str(plan.get("user_goal") or ""),
|
||||
)
|
||||
if not hosts:
|
||||
# DevOps without host — still mark needed for terminal phase
|
||||
out["runtime_probe"] = "needed"
|
||||
out["runtime_facts"] = {
|
||||
"hint": "DevOps task without clear host — curl/docker inspect before edit.",
|
||||
"source": "none",
|
||||
}
|
||||
return out
|
||||
|
||||
facts_list = [probe_host(h, cfg=cfg) for h in hosts[:3]]
|
||||
primary = facts_list[0]
|
||||
out["runtime_facts"] = primary
|
||||
out["runtime_facts_all"] = facts_list
|
||||
out["runtime_probe"] = "gateway"
|
||||
# Strengthen paths toward Traefik dynamic conf for ift hosts
|
||||
if "ift." in primary.get("host", "") or primary.get("suggested_backend_url"):
|
||||
root = r"C:\Users\alexc\IdeaProjects\eventHub\EventHubDevOps"
|
||||
yml = root + r"\ift\traefik\dynamic_conf.yml"
|
||||
tasks = list(out.get("subtasks") or [])
|
||||
if tasks and isinstance(tasks[0], dict):
|
||||
t0 = dict(tasks[0])
|
||||
paths = list(t0.get("paths") or [])
|
||||
if yml not in paths:
|
||||
paths = [yml] + paths
|
||||
t0["paths"] = paths[:4]
|
||||
if primary.get("suggested_backend_url"):
|
||||
t0["edit_goal"] = (
|
||||
f"Set Traefik service backend to {primary['suggested_backend_url']} "
|
||||
f"(HTTP {primary.get('http_status')}: wrong upstream port)."
|
||||
)
|
||||
tasks[0] = t0
|
||||
out["subtasks"] = tasks
|
||||
return out
|
||||
|
||||
|
||||
def runtime_facts_line(plan: dict[str, Any] | None) -> str:
|
||||
facts = (plan or {}).get("runtime_facts") if isinstance(plan, dict) else None
|
||||
if not isinstance(facts, dict) or not facts:
|
||||
return ""
|
||||
parts = [
|
||||
f"host={facts.get('host')}",
|
||||
f"http={facts.get('http_status')}",
|
||||
f"service={facts.get('service')}",
|
||||
]
|
||||
if facts.get("open_ports"):
|
||||
parts.append(f"open={facts.get('open_ports')}")
|
||||
if facts.get("suggested_backend_url"):
|
||||
parts.append(f"fix→{facts.get('suggested_backend_url')}")
|
||||
hint = str(facts.get("hint") or "")[:180]
|
||||
return "RUNTIME: " + "; ".join(str(p) for p in parts) + (f" | {hint}" if hint else "")
|
||||
|
||||
|
||||
def devops_blocks_blind_force_edit(plan: dict[str, Any] | None) -> bool:
|
||||
"""Do not blind-edit DevOps YAML until runtime facts exist."""
|
||||
if not isinstance(plan, dict):
|
||||
return False
|
||||
if not is_devops_request(plan):
|
||||
return False
|
||||
facts = plan.get("runtime_facts")
|
||||
if not facts:
|
||||
return True
|
||||
# Facts present — allow targeted edit (including force with goal from facts)
|
||||
return False
|
||||
|
||||
|
||||
def suggested_traefik_edit(
|
||||
plan: dict[str, Any] | None,
|
||||
) -> dict[str, str] | None:
|
||||
"""If probe found closed:80 + open:alt — return old/new URL snippets."""
|
||||
if not isinstance(plan, dict):
|
||||
return None
|
||||
facts = plan.get("runtime_facts")
|
||||
if not isinstance(facts, dict):
|
||||
return None
|
||||
url = facts.get("suggested_backend_url")
|
||||
service = facts.get("service")
|
||||
if not url or not service:
|
||||
return None
|
||||
# Common misconfig in our dynamic_conf
|
||||
old = f'url: "http://{service}:80"'
|
||||
new = f'url: "{url}"'
|
||||
if old == new:
|
||||
return None
|
||||
return {
|
||||
"path": r"C:\Users\alexc\IdeaProjects\eventHub\EventHubDevOps\ift\traefik\dynamic_conf.yml",
|
||||
"old_text": old,
|
||||
"new_text": new,
|
||||
"reason": str(facts.get("hint") or "port mismatch"),
|
||||
}
|
||||
|
||||
|
||||
def force_runtime_fix_edit(
|
||||
*,
|
||||
plan: dict[str, Any] | None,
|
||||
tools: list[Any],
|
||||
model: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Synthetic edit_file for clear Traefik port mismatch — skip blind marker edits."""
|
||||
fix = suggested_traefik_edit(plan)
|
||||
if not fix:
|
||||
return None
|
||||
# late import to avoid cycles
|
||||
from agent_hier import _pick_edit_tool, _synthetic_tool_completion
|
||||
|
||||
edit_name = _pick_edit_tool(tools)
|
||||
if not edit_name:
|
||||
return None
|
||||
log.info(
|
||||
"runtime_probe → targeted edit %s => %s",
|
||||
fix["old_text"],
|
||||
fix["new_text"],
|
||||
)
|
||||
return _synthetic_tool_completion(
|
||||
model=model,
|
||||
tool_name=edit_name,
|
||||
arguments={
|
||||
"path": fix["path"],
|
||||
"edits": [{"old_text": fix["old_text"], "new_text": fix["new_text"]}],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def ssh_probe_fallback(host: str, cfg: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Optional: ssh to host and docker inspect (if gateway has keys)."""
|
||||
ssh_host = str(cfg.get("runtime_probe_ssh_host") or "").strip()
|
||||
if not ssh_host:
|
||||
return None
|
||||
service, _ports = service_for_host(host)
|
||||
script = (
|
||||
f"echo HTTP=$(curl -sk -o /dev/null -w '%{{http_code}}' --max-time 5 https://{host}/ || echo err); "
|
||||
f"CID=$(docker ps -q --filter name={service} | head -1); "
|
||||
f"echo CID=$CID; "
|
||||
f"if [ -n \"$CID\" ]; then docker inspect \"$CID\" --format 'PORTS={{{{json .NetworkSettings.Ports}}}}'; fi"
|
||||
)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"ssh",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=5",
|
||||
ssh_host,
|
||||
"bash",
|
||||
"-lc",
|
||||
script,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
out = (proc.stdout or "") + "\n" + (proc.stderr or "")
|
||||
return {"host": host, "ssh_raw": out[:1500], "source": "ssh", "service": service}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("runtime ssh probe failed: %s", exc)
|
||||
return None
|
||||
Reference in New Issue
Block a user