Files
LangBot/plugins/Runner/codex-agent/pkg/daemon_relay.py
T

789 lines
31 KiB
Python

"""Plugin-owned coding-relay-v1; derived from SDK b5, see RELAY-NOTICE.md."""
from __future__ import annotations
import asyncio
import contextlib
import hashlib
import hmac
import json
import logging
import os
import secrets
import threading
import time
import typing
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import websockets
from langbot_plugin.api.agent_tools.external_tools import AgentRunExternalTools
from langbot_plugin.api.agent_tools.mcp_config import AgentMCPServerConfig
from langbot_plugin.api.agent_tools.mcp_protocol import (
AgentToolsMCPResolver,
handle_mcp_payload,
jsonrpc_error,
)
logger = logging.getLogger(__name__)
DEFAULT_DAEMON_HUB_HOST = "127.0.0.1"
DEFAULT_DAEMON_HUB_PORT = 8766
DEFAULT_DAEMON_CONNECT_TIMEOUT = 30.0
DAEMON_MCP_SERVER_INFO = {"name": "langbot-agent-daemon", "version": "0.1.0"}
class AgentRuntimeDaemonError(Exception):
"""Daemon relay error surfaced as a Runner failure."""
def __init__(
self,
message: str,
*,
code: str = "agent_runtime.daemon_error",
retryable: bool = False,
) -> None:
super().__init__(message)
self.message = message
self.code = code
self.retryable = retryable
class DaemonConnection(typing.TypedDict):
daemon_id: str
websocket: typing.Any
metadata: dict[str, typing.Any]
connected_at: float
last_seen_at: float
active_jobs: set[str]
class DaemonRunSession(typing.TypedDict):
job_id: str
daemon_id: str
queue: asyncio.Queue[dict[str, typing.Any] | None]
tools: AgentRunExternalTools | None
started_at: float
def _to_bool(value: typing.Any, default: bool = False) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
return bool(value)
def _to_int(value: typing.Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def agent_runtime_daemon_config_from_plugin_config(
config: dict[str, typing.Any] | None,
*,
env_prefix: str = "LANGBOT_AGENT_RUNTIME_DAEMON",
default_host: str = DEFAULT_DAEMON_HUB_HOST,
default_port: int = DEFAULT_DAEMON_HUB_PORT,
) -> dict[str, typing.Any]:
"""Resolve daemon hub settings from plugin config and environment."""
data = dict(config or {})
return {
"enabled": _to_bool(
data.get("daemon-enabled", os.environ.get(f"{env_prefix}_ENABLED")),
False,
),
"host": str(data.get("daemon-host") or os.environ.get(f"{env_prefix}_HOST") or default_host),
"port": _to_int(
data.get("daemon-port") or os.environ.get(f"{env_prefix}_PORT"),
default_port,
),
"token": str(data.get("daemon-token") or os.environ.get(f"{env_prefix}_TOKEN") or ""),
}
async def handle_agent_runtime_mcp_payload(
tools: AgentRunExternalTools,
payload: typing.Any,
) -> dict[str, typing.Any] | list[dict[str, typing.Any]] | None:
"""Handle HTTP MCP JSON-RPC payloads forwarded by a daemon."""
return await handle_mcp_payload(
payload,
AgentToolsMCPResolver(tools),
server_info=DAEMON_MCP_SERVER_INFO,
)
class AgentRuntimeDaemonHub:
"""One installation listener; jobs belong to socket incarnations, not names.
An unresolved remote job fences its display ID. Shared workers persist
pending markers across restarts; only an operator who has verified remote
quiescence may remove an unresolved marker.
The daemon is a trusted execution target, not a hostile-process sandbox.
"""
def __init__(self, *, error_code_prefix: str = "agent_runtime", state_dir=None) -> None:
self.host = DEFAULT_DAEMON_HUB_HOST
self.port = DEFAULT_DAEMON_HUB_PORT
self.token = ""
self.error_code_prefix = error_code_prefix
self.cancel_timeout = 10.0
self._server = None
self._connections = {}
self._jobs = {}
self._fenced = set()
self._state_dir = (
Path(state_dir)
if state_dir is not None
else (
Path("/data/.coding-relay-v1") / error_code_prefix
if os.environ.get("LANGBOT_PLUGIN_RUNTIME_PROFILE") == "shared"
else None
)
)
if self._state_dir is not None and self._state_dir.exists():
self._fenced.update(p.read_text() for p in self._state_dir.glob("*.pending"))
self._start_lock = asyncio.Lock()
@property
def is_running(self) -> bool:
return self._server is not None
@property
def endpoint(self) -> str:
if self._server is None:
return ""
host, port = self._server.sockets[0].getsockname()[:2]
return f"ws://{host}:{port}"
def _error(self, message, code):
return AgentRuntimeDaemonError(message, code=f"{self.error_code_prefix}.{code}")
async def start(self, *, host: str, port: int, token: str = "") -> None:
async with self._start_lock:
if os.environ.get("LANGBOT_PLUGIN_RUNTIME_PROFILE") == "shared":
if len(token) < 32 or len(set(token)) < 12 or token.strip() != token:
raise self._error(
"shared daemon-token must be installation-specific: generate with "
"python -c 'import secrets; print(secrets.token_urlsafe(32))'",
"daemon_token_required",
)
if self._server is not None:
if (self.host, self.port, self.token) == (host, port, token):
return
raise self._error(
"daemon hub already started with different config; restart after draining runs",
"daemon_hub_conflict",
)
try:
server = await websockets.serve(self._handle_connection, host, port)
except OSError as exc:
raise self._error(
f"cannot bind daemon listener {host}:{port}; configure a unique daemon-port "
"per installation and route clients to it",
"daemon_bind_failed",
) from exc
self.host, self.port, self.token, self._server = host, port, token, server
async def ensure_started_from_config(self, config):
conf = agent_runtime_daemon_config_from_plugin_config(config)
await self.start(host=conf["host"], port=conf["port"], token=conf["token"])
async def stop(self) -> None:
async with self._start_lock:
# Cancel while the reader is still available to receive acknowledgements.
await asyncio.gather(*(self._settle(s) for s in list(self._jobs.values())))
server, self._server = self._server, None
if server is not None:
server.close()
await server.wait_closed()
async def list_daemons(self):
return [
dict(
daemon_id=c["daemon_id"],
metadata=c["metadata"],
connected_at=c["connected_at"],
last_seen_at=c["last_seen_at"],
active_jobs=sorted(c["active_jobs"]),
)
for c in self._connections.values()
]
async def wait_for_daemon(self, daemon_id, timeout):
deadline = time.monotonic() + max(0.1, timeout)
while True:
self._check_fence(daemon_id)
if daemon_id in self._connections:
return
if time.monotonic() >= deadline:
raise self._error(f"daemon {daemon_id} is not connected", "daemon_offline")
await asyncio.sleep(0.05)
def _check_fence(self, daemon_id):
if any(s["daemon_id"] == daemon_id and s["cancelling"] for s in self._jobs.values()):
raise self._error(f"daemon {daemon_id} is cancelling; await cleanup before reuse", "daemon_cancelling")
if daemon_id in self._fenced:
raise self._error(
f"daemon {daemon_id} fenced: cleanup unconfirmed; stop remote jobs before restarting this worker",
"daemon_fenced",
)
async def run_job(self, *, daemon_id, payload, tools, timeout):
if not self.is_running:
raise self._error("daemon hub is not started", "daemon_hub_not_started")
self._check_fence(daemon_id)
owner = self._connections.get(daemon_id)
if owner is None:
raise self._error(f"daemon {daemon_id} is not connected", "daemon_offline")
if len(self._jobs) >= 128:
raise self._error("daemon job capacity reached", "daemon_busy")
job_id = secrets.token_urlsafe(16)
session = dict(
job_id=job_id,
daemon_id=daemon_id,
owner=owner,
queue=asyncio.Queue(),
tools=tools,
done=asyncio.Event(),
cancelling=False,
)
# Write before dispatch: a worker crash cannot silently erase an active
# remote lease. Only acknowledged quiescence removes the marker.
marker = self._marker(daemon_id)
if marker is not None and not owner["active_jobs"]:
marker.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
with marker.open("x") as handle:
handle.write(daemon_id)
handle.flush()
os.fsync(handle.fileno())
self._jobs[job_id] = session
owner["active_jobs"].add(job_id)
try:
await self._send(owner, dict(type="run.start", job_id=job_id, payload=payload))
async with asyncio.timeout(max(0.01, timeout)):
while True:
item = await session["queue"].get()
if item is None:
break
yield item
finally:
# Repeated caller cancellation must not interrupt cleanup/fencing.
cleanup = asyncio.create_task(self._settle(session))
interrupted = False
while not cleanup.done():
try:
await asyncio.shield(cleanup)
except asyncio.CancelledError:
interrupted = True
cleanup.result()
if interrupted:
raise asyncio.CancelledError
def _marker(self, daemon_id):
if self._state_dir is None:
return None
return self._state_dir / (hashlib.sha256(daemon_id.encode()).hexdigest() + ".pending")
async def _settle(self, session):
session["cancelling"] = True
session["tools"] = None
owner = session["owner"]
try:
if not session["done"].is_set():
async with asyncio.timeout(self.cancel_timeout):
await self._send(owner, dict(type="run.cancel", job_id=session["job_id"]))
await session["done"].wait()
except (Exception, asyncio.CancelledError):
self._fenced.add(session["daemon_id"])
finally:
self._jobs.pop(session["job_id"], None)
owner["active_jobs"].discard(session["job_id"])
if not owner["active_jobs"] and session["daemon_id"] not in self._fenced:
marker = self._marker(session["daemon_id"])
if marker is not None:
marker.unlink(missing_ok=True)
async def _send(self, connection, message):
async with connection["send_lock"]:
await connection["websocket"].send(json.dumps(message, ensure_ascii=False))
async def _handle_connection(self, websocket):
connection = None
try:
hello = json.loads(await asyncio.wait_for(websocket.recv(), 10))
if not isinstance(hello, dict) or hello.get("type") != "daemon.hello":
await websocket.close(code=1008, reason="daemon.hello required")
return
name = str(hello.get("daemon_id") or "").strip()
if not name or len(name) > 128:
await websocket.close(code=1008, reason="daemon_id required (max 128 characters)")
return
if self.token and not hmac.compare_digest(str(hello.get("token") or ""), self.token):
await websocket.close(code=1008, reason="invalid token")
return
if hello.get("protocol") != "coding-relay-v1":
await websocket.close(
code=1008, reason="upgrade daemon.py and pkg from matching plugin: coding-relay-v1 required"
)
return
# Never silently replace a live socket, even when its display ID matches.
marker = self._marker(name)
if (
name in self._connections
or name in self._fenced
or (marker is not None and marker.exists())
or len(self._connections) + len(self._fenced) >= 128
):
await websocket.close(code=1008, reason="daemon ID connected or fenced; verify cleanup before reuse")
return
now = time.monotonic()
connection = dict(
daemon_id=name,
websocket=websocket,
metadata=hello.get("metadata") or {},
connected_at=now,
last_seen_at=now,
active_jobs=set(),
send_lock=asyncio.Lock(),
)
self._connections[name] = connection
await self._send(connection, dict(type="daemon.ready", daemon_id=name, protocol="coding-relay-v1"))
async for raw in websocket:
await self._handle_message(connection, raw)
except websockets.exceptions.ConnectionClosed:
pass
except Exception:
logger.exception("Daemon connection failed")
finally:
if connection is not None:
if self._connections.get(connection["daemon_id"]) is connection:
self._connections.pop(connection["daemon_id"], None)
for job_id in list(connection["active_jobs"]):
session = self._jobs.get(job_id)
if session is not None and not session["done"].is_set():
self._fenced.add(connection["daemon_id"])
session["tools"] = None
session["cancelling"] = True
session["queue"].put_nowait(
dict(
type="run.failed",
data=dict(
error="daemon disconnected; remote cleanup unconfirmed, target fenced",
code=f"{self.error_code_prefix}.daemon_disconnected",
retryable=False,
),
)
)
session["queue"].put_nowait(None)
async def _handle_message(self, connection, raw_message):
try:
message = json.loads(raw_message)
except (ValueError, TypeError):
return
if not isinstance(message, dict):
return
# Captured socket identity, never a lookup by display name.
if self._connections.get(connection["daemon_id"]) is not connection:
return
connection["last_seen_at"] = time.monotonic()
kind = message.get("type")
if kind == "daemon.ping":
await self._send(connection, dict(type="daemon.pong"))
return
session = self._jobs.get(str(message.get("job_id") or ""))
if session is None or session["owner"] is not connection:
return
if kind == "run.finished":
session["tools"] = None
session["done"].set()
session["queue"].put_nowait(None)
elif kind == "run.event" and not session["cancelling"] and not session["done"].is_set():
if isinstance(message.get("event"), dict):
session["queue"].put_nowait(message["event"])
elif kind == "mcp.request":
tools = session["tools"]
if tools is None:
payload = jsonrpc_error(None, -32000, "Run tools revoked")
else:
try:
payload = await handle_agent_runtime_mcp_payload(tools, message.get("payload"))
except Exception as exc:
payload = jsonrpc_error(None, -32000, str(exc))
await self._send(
connection,
dict(
type="mcp.response", request_id=message.get("request_id"), job_id=session["job_id"], payload=payload
),
)
class LocalMCPProxy:
"""Localhost HTTP MCP proxy that forwards requests over a daemon WebSocket."""
def __init__(
self,
daemon: AgentRuntimeDaemonClient,
job_id: str,
*,
request_timeout: float = 60.0,
server_name: str = "langbot_agent",
) -> None:
self.daemon = daemon
self.job_id = job_id
self.request_timeout = request_timeout
self.server_name = server_name
self._server: ThreadingHTTPServer | None = None
self._thread: threading.Thread | None = None
@property
def endpoint(self) -> str:
if self._server is None:
raise RuntimeError("MCP proxy is not started")
host, port = self._server.server_address[:2]
return f"http://{str(host)}:{port}"
@property
def http_mcp_endpoint(self) -> str:
return f"{self.endpoint}/mcp"
def start(self) -> None:
if self._server is not None:
return
proxy = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, _format: str, *_args: typing.Any) -> None:
return
def do_GET(self) -> None:
if self.path != "/healthz":
self.send_error(404)
return
self._write_json(200, {"ok": True})
def do_POST(self) -> None:
if self.path not in {"/mcp", "/mcp/http"}:
self.send_error(404)
return
payload = self._read_json_payload()
if isinstance(payload, Exception):
self._write_json(400, {"jsonrpc": "2.0", "id": None, "error": str(payload)})
return
try:
result = proxy.daemon.request_mcp(proxy.job_id, payload, proxy.request_timeout)
except Exception as exc:
result = {
"jsonrpc": "2.0",
"id": None,
"error": {"code": -32000, "message": str(exc)},
}
if result is None:
self._write_empty(202)
return
self._write_json(200, result)
def _read_json_payload(self) -> typing.Any:
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
length = 0
try:
body = self.rfile.read(length).decode("utf-8")
return json.loads(body) if body else {}
except Exception as exc:
return exc
def _write_json(self, status: int, payload: typing.Any) -> None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _write_empty(self, status: int) -> None:
self.send_response(status)
self.send_header("Content-Length", "0")
self.end_headers()
self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self._thread = threading.Thread(
target=self._server.serve_forever,
name="langbot-agent-mcp-proxy",
daemon=True,
)
self._thread.start()
def stop(self) -> None:
server = self._server
thread = self._thread
self._server = None
self._thread = None
if server is not None:
server.shutdown()
server.server_close()
if thread is not None:
thread.join(timeout=2)
def mcp_server(self) -> AgentMCPServerConfig:
return AgentMCPServerConfig.http(
name=self.server_name,
url=self.http_mcp_endpoint,
)
def server_config(self) -> dict[str, typing.Any]:
return self.mcp_server().to_dict(include_type=True)
class AgentRuntimeDaemonClient:
"""Outbound WebSocket daemon client for user-owned runtime processes."""
def __init__(
self,
*,
url: str,
daemon_id: str,
token: str = "",
reconnect_delay: float = 5.0,
metadata: dict[str, typing.Any] | None = None,
) -> None:
self.url = url
self.daemon_id = daemon_id
self.token = token
self.reconnect_delay = reconnect_delay
self.metadata = dict(metadata or {})
self.websocket: typing.Any | None = None
self.loop: asyncio.AbstractEventLoop | None = None
self._send_lock = asyncio.Lock()
self._pending_mcp: dict[str, asyncio.Future[typing.Any]] = {}
self._job_tasks: dict[str, asyncio.Task[None]] = {}
self._cleanup_failed = False
async def run_forever(self) -> None:
self.loop = asyncio.get_running_loop()
while True:
try:
async with websockets.connect(self.url) as websocket:
self.websocket = websocket
await self._send(
{
"type": "daemon.hello",
"daemon_id": self.daemon_id,
"protocol": "coding-relay-v1",
"token": self.token,
"metadata": {
"pid": os.getpid(),
"cwd": os.getcwd(),
"platform": os.name,
**self.metadata,
},
}
)
raw_ready = await websocket.recv()
ready = json.loads(raw_ready)
if (
not isinstance(ready, dict)
or ready.get("type") != "daemon.ready"
or ready.get("protocol") != "coding-relay-v1"
):
raise RuntimeError(f"Unexpected daemon ready response: {ready!r}")
logger.info("Connected to daemon hub as %s", self.daemon_id)
async for raw_message in websocket:
if isinstance(raw_message, bytes):
raw_message = raw_message.decode("utf-8")
await self._handle_message(raw_message)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("Disconnected from daemon hub: %s", exc)
finally:
self.websocket = None
for future in list(self._pending_mcp.values()):
if not future.done():
future.cancel()
self._pending_mcp.clear()
# A replacement socket must never inherit still-running jobs.
jobs = list(self._job_tasks.values())
for task in jobs:
if not task.cancelling():
task.cancel()
cleanup = asyncio.gather(*jobs, return_exceptions=True)
interrupted = False
while not cleanup.done():
try:
await asyncio.shield(cleanup)
except asyncio.CancelledError:
interrupted = True
results = cleanup.result()
self._job_tasks.clear()
if self._cleanup_failed or any(
isinstance(r, BaseException) and not isinstance(r, asyncio.CancelledError) for r in results
):
raise RuntimeError("daemon cleanup failed; refusing reconnect until operator verifies quiescence")
if interrupted:
raise asyncio.CancelledError
await asyncio.sleep(self.reconnect_delay)
async def run_job(self, job_id: str, payload: dict[str, typing.Any]) -> None:
raise NotImplementedError
async def emit_event(self, job_id: str, event: dict[str, typing.Any]) -> None:
await self._send({"type": "run.event", "job_id": job_id, "event": event})
async def finish_job(self, job_id: str) -> None:
await self._send({"type": "run.finished", "job_id": job_id})
def create_mcp_proxy(
self,
job_id: str,
*,
request_timeout: float = 60.0,
server_name: str = "langbot_agent",
) -> LocalMCPProxy:
return LocalMCPProxy(
self,
job_id,
request_timeout=request_timeout,
server_name=server_name,
)
def request_mcp(self, job_id: str, payload: typing.Any, timeout: float) -> typing.Any:
if self.loop is None:
raise RuntimeError("daemon event loop is not running")
request_id = secrets.token_urlsafe(12)
future = asyncio.run_coroutine_threadsafe(
self._request_mcp_async(job_id, request_id, payload),
self.loop,
)
return future.result(timeout=timeout)
async def _send(self, message: dict[str, typing.Any]) -> None:
if self.websocket is None:
raise RuntimeError("daemon websocket is not connected")
async with self._send_lock:
await self.websocket.send(json.dumps(message, ensure_ascii=False, separators=(",", ":")))
async def _handle_message(self, raw_message: str) -> None:
message = json.loads(raw_message)
if not isinstance(message, dict):
return
message_type = str(message.get("type") or "")
if message_type == "daemon.pong":
return
if message_type == "run.start":
job_id = str(message.get("job_id") or "")
raw_payload = message.get("payload")
payload: dict[str, typing.Any] = dict(raw_payload) if isinstance(raw_payload, dict) else {}
if not job_id:
return
if job_id in self._job_tasks:
raise RuntimeError("duplicate run.start")
task = asyncio.create_task(self._run_job_wrapper(job_id, payload))
self._job_tasks[job_id] = task
def drop_task(_task: asyncio.Task[None]) -> None:
self._job_tasks.pop(job_id, None)
if not _task.cancelled() and _task.exception() is not None:
self._cleanup_failed = True
if self.websocket is not None:
asyncio.create_task(
self.websocket.close(code=1011, reason="cleanup unconfirmed; daemon halted")
)
task.add_done_callback(drop_task)
return
if message_type == "run.cancel":
job_id = str(message.get("job_id") or "")
running_task = self._job_tasks.get(job_id)
if running_task is not None:
if not running_task.cancelling():
running_task.cancel()
return
if message_type == "run.cleanup":
raise RuntimeError("obsolete relay: upgrade hub and daemon together")
if message_type == "mcp.response":
request_id = str(message.get("request_id") or "")
future = self._pending_mcp.pop(request_id, None)
if future is not None and not future.done():
future.set_result(message.get("payload"))
return
async def _run_job_wrapper(self, job_id, payload):
# The inner task owns subprocess cleanup. Repeated cancel frames cannot
# interrupt its finally block and produce a premature run.finished.
work = asyncio.create_task(self.run_job(job_id, payload))
cancelled = False
try:
await asyncio.shield(work)
except asyncio.CancelledError:
cancelled = True
work.cancel()
while not work.done():
try:
await asyncio.shield(work)
except asyncio.CancelledError:
pass
except Exception:
pass # result below reports ordinary job failures after cleanup
try:
work.result()
except asyncio.CancelledError:
cancelled = True
except Exception as exc:
with contextlib.suppress(Exception):
await self.emit_event(
job_id, dict(type="run.failed", data=dict(error=str(exc), code="agent_runtime.daemon_unexpected"))
)
# BaseException cleanup failures deliberately escape WITHOUT an ack.
if cancelled:
with contextlib.suppress(Exception):
await self.emit_event(
job_id,
dict(
type="run.failed",
data=dict(error="daemon run cancelled", code="agent_runtime.daemon_cancelled"),
),
)
with contextlib.suppress(Exception):
await self.finish_job(job_id)
async def _request_mcp_async(self, job_id: str, request_id: str, payload: typing.Any) -> typing.Any:
loop = asyncio.get_running_loop()
future: asyncio.Future[typing.Any] = loop.create_future()
self._pending_mcp[request_id] = future
await self._send(
{
"type": "mcp.request",
"request_id": request_id,
"job_id": job_id,
"payload": payload,
}
)
return await future
_global_hubs: dict[str, AgentRuntimeDaemonHub] = {}
def get_agent_runtime_daemon_hub(
key: str = "default",
*,
error_code_prefix: str = "agent_runtime",
) -> AgentRuntimeDaemonHub:
hub = _global_hubs.get(key)
if hub is None:
hub = AgentRuntimeDaemonHub(error_code_prefix=error_code_prefix)
_global_hubs[key] = hub
return hub