1. MCP 空闲睡眠 50ms -> 10ms:#187 后单轮 drain 约 0.3ms, 100 次/s 空闲迭代约 3% 单核,请求与远控输入拾取延迟 从平均 25ms 降到 5ms。 2. stream 轮询 50ms -> 20ms。 3. stream payload 加 url/title;面板 stream 处理合并 url/ title,5 秒 state 轮询只在未观看时跑(观看时 stream 已 全量覆盖,轮询纯冗余;不观看时保留轮询,避免会话开始 后页眉 stale)。 4. 恢复浏览器页签:#176(ccb5a86)改写 TaskDetailPanel 页签时顺带删了 BrowserPanel 的挂载,导致线上控制台 自 #176 起就没有浏览器页签(面板文件一直在,只是不 被 import,构建产物因此不变)。本次把 import、页签 与 Card 原样挂回;构建产物含“开始观看”标记可证。 console.html 由 npm run build:console 重打(342484 字节)。 console-redesign/ 为构建脚本审阅产物,不进仓库。
339 lines
12 KiB
Python
339 lines
12 KiB
Python
"""File-bridge remote control for the Playwright MCP server.
|
|
|
|
Why files, not a Unix socket (corrected 2026-10-30): the first
|
|
version of this module ran an HTTP service on ``.browser-remote.sock``
|
|
in the worktree, assuming the host control plane could connect to
|
|
it. That assumption is false under runsc/gVisor, which is what
|
|
production Docker tasks run under: a socket created inside a
|
|
runsc container on a bind mount is visible inside the container
|
|
only -- the host never sees the file (verified live: the socket
|
|
listed inside the container, the host directory showed only the
|
|
regular file). Regular files DO propagate through the same mount
|
|
(browser-live.png has always been host-visible). So the bridge
|
|
is four regular files in the worktree, all written atomically
|
|
(temp file + os.replace) except the append-only input log:
|
|
|
|
- ``.browser-remote-state.json``: {url, title, started, holder,
|
|
streaming, updated_at} -- rewritten when a meaningful field
|
|
changes or at the heartbeat interval, so its updated_at is the
|
|
session-liveness signal. The server treats a state file older
|
|
than STATE_STALE_SECONDS as a dead session (503).
|
|
- ``.browser-remote-frame.jpg``: latest screencast frame, base64
|
|
text, rewritten only when its bytes change.
|
|
- ``.browser-remote-input.jsonl``: the server appends one JSON
|
|
object per line. The MCP tracks a byte offset and executes
|
|
complete lines only (a trailing partial line waits for the
|
|
next poll); a line is executed only if the holder is "user"
|
|
at execution time, otherwise it is dropped.
|
|
- ``.browser-remote-control.json``: {holder, seq} -- written by
|
|
the server on takeover/release; the MCP applies it when seq
|
|
changes. Writing control ahead of browser start is fine: the
|
|
MCP picks it up on its first poll.
|
|
|
|
Threading contract (unchanged from the socket version, measured
|
|
2026-10-30: Playwright sync calls from any thread but the
|
|
creating one raise "Cannot switch to a different thread"):
|
|
only the MCP main thread touches the browser. It calls
|
|
RemoteBridge.poll() on every idle main-loop iteration (~10ms),
|
|
so control/input/frames flow even while the agent is idle, and
|
|
CDP frame acks keep flowing (no ack = CDP stops after the
|
|
first frame).
|
|
|
|
Control model (unchanged): holder "agent" by default; holder
|
|
"user" while the console user has taken over, during which
|
|
agent browser_* tools fail fast with a plain error.
|
|
"""
|
|
|
|
import collections
|
|
import json
|
|
import os
|
|
import threading
|
|
import time
|
|
|
|
STATE_NAME = ".browser-remote-state.json"
|
|
FRAME_NAME = ".browser-remote-frame.jpg"
|
|
INPUT_NAME = ".browser-remote-input.jsonl"
|
|
CONTROL_NAME = ".browser-remote-control.json"
|
|
STATE_STALE_SECONDS = 30
|
|
# Heartbeat interval for state writes. The server treats state
|
|
# older than STATE_STALE_SECONDS as dead, so 0.5s is plenty;
|
|
# writing every iteration was pure gofer round-trips.
|
|
STATE_HEARTBEAT_SECONDS = 0.5
|
|
|
|
|
|
class SimpleQueue:
|
|
"""Minimal thread-safe queue. Deliberately NOT stdlib ``queue``:
|
|
cloud/queue.py shadows it whenever cloud/ is on sys.path
|
|
(repo mode, tests), breaking ``queue.Queue`` at import time.
|
|
Flat-copy tenant runs don't have that file, but this class
|
|
works identically in both layouts."""
|
|
|
|
def __init__(self):
|
|
self._deque = collections.deque()
|
|
self._lock = threading.Lock()
|
|
|
|
def put(self, item):
|
|
with self._lock:
|
|
self._deque.append(item)
|
|
|
|
def get_nowait(self):
|
|
with self._lock:
|
|
if not self._deque:
|
|
raise IndexError("queue empty")
|
|
return self._deque.popleft()
|
|
|
|
def empty(self):
|
|
with self._lock:
|
|
return not self._deque
|
|
|
|
|
|
def _atomic_write(path, data):
|
|
tmp = path + ".tmp"
|
|
with open(tmp, "wb") as fh:
|
|
fh.write(data)
|
|
os.replace(tmp, path)
|
|
|
|
|
|
def execute_input(browser, item):
|
|
"""Main-thread only: run one user input."""
|
|
kind = item.get("type")
|
|
if kind == "mouse_click":
|
|
return browser.click_at(item.get("x"), item.get("y"))
|
|
if kind == "mouse_move":
|
|
return browser.mouse_move(item.get("x"), item.get("y"))
|
|
if kind == "key":
|
|
return browser.key_press(item.get("key"))
|
|
if kind == "type":
|
|
return browser.type_text(item.get("text", ""))
|
|
raise ValueError("unknown input type %r" % (kind,))
|
|
|
|
|
|
class RemoteBridge:
|
|
"""MCP-side file bridge. Main thread only (see module doc)."""
|
|
|
|
def __init__(self, browser, workdir=None):
|
|
self.browser = browser
|
|
self.workdir = workdir or getattr(browser, "cwd", None) or "."
|
|
self.holder = "agent"
|
|
self._input_offset = 0
|
|
self._control_seq = None
|
|
self._frame_written = False
|
|
self._last_frame = None
|
|
self._last_state_key = None
|
|
self._last_state_write = 0.0
|
|
|
|
def _path(self, name):
|
|
return os.path.join(self.workdir, name)
|
|
|
|
def get_holder(self):
|
|
return self.holder
|
|
|
|
def set_holder(self, holder):
|
|
if holder not in ("agent", "user"):
|
|
raise ValueError("holder must be 'agent' or 'user'")
|
|
self.holder = holder
|
|
return self.holder
|
|
|
|
def state_dict(self):
|
|
info = self.browser.cached_info()
|
|
info["holder"] = self.holder
|
|
info["streaming"] = self._frame_written
|
|
info["updated_at"] = time.time()
|
|
return info
|
|
|
|
def _poll_control(self):
|
|
try:
|
|
with open(self._path(CONTROL_NAME), "rb") as fh:
|
|
raw = fh.read()
|
|
ctrl = json.loads(raw.decode("utf-8"))
|
|
except (OSError, ValueError):
|
|
return
|
|
seq = ctrl.get("seq")
|
|
if seq is None or seq == self._control_seq:
|
|
return
|
|
self._control_seq = seq
|
|
try:
|
|
self.set_holder(ctrl.get("holder"))
|
|
except ValueError:
|
|
pass
|
|
|
|
def _poll_inputs(self):
|
|
try:
|
|
with open(self._path(INPUT_NAME), "rb") as fh:
|
|
fh.seek(self._input_offset)
|
|
data = fh.read()
|
|
except OSError:
|
|
return
|
|
if not data:
|
|
return
|
|
# Only complete lines (ending with \n) are consumed; a
|
|
# trailing partial line stays for the next poll.
|
|
lines = data.split(b"\n")
|
|
complete = lines[:-1]
|
|
consumed = sum(len(line) + 1 for line in complete)
|
|
self._input_offset += consumed
|
|
for raw in complete:
|
|
if not raw.strip():
|
|
continue
|
|
try:
|
|
item = json.loads(raw.decode("utf-8"))
|
|
except ValueError:
|
|
continue
|
|
if self.holder != "user":
|
|
continue
|
|
try:
|
|
execute_input(self.browser, item)
|
|
except Exception as exc: # noqa: BLE001 - input errors
|
|
print("playwright_remote: input error: %s" % exc)
|
|
|
|
def poll(self, browser=None):
|
|
"""One drain-loop iteration: control, inputs, frame, state.
|
|
|
|
Writes are change-gated. The old code rewrote both files
|
|
on every iteration: ~200 atomic temp+replace
|
|
round-trips per second through the runsc gofer mount for
|
|
identical bytes (pump returns the latest frame
|
|
persistently, and updated_at changes every call). Now the
|
|
frame file is rewritten only when its bytes change, and
|
|
the state file only when a meaningful field changes or
|
|
the heartbeat interval elapsed -- a static page costs
|
|
~2 writes per second instead of ~40.
|
|
"""
|
|
target = browser if browser is not None else self.browser
|
|
self._poll_control()
|
|
self._poll_inputs()
|
|
try:
|
|
frame = target.pump_screencast()
|
|
except Exception: # noqa: BLE001 - screencast best-effort
|
|
frame = ""
|
|
if frame and frame != self._last_frame:
|
|
try:
|
|
_atomic_write(self._path(FRAME_NAME),
|
|
frame.encode("utf-8"))
|
|
self._last_frame = frame
|
|
self._frame_written = True
|
|
except OSError:
|
|
pass
|
|
state = self.state_dict()
|
|
key = (state.get("url"), state.get("title"),
|
|
state.get("started"), state.get("holder"),
|
|
state.get("streaming"))
|
|
now = state["updated_at"]
|
|
if key != self._last_state_key or \
|
|
now - self._last_state_write >= STATE_HEARTBEAT_SECONDS:
|
|
try:
|
|
_atomic_write(
|
|
self._path(STATE_NAME),
|
|
json.dumps(state,
|
|
ensure_ascii=False).encode("utf-8"))
|
|
self._last_state_key = key
|
|
self._last_state_write = now
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def bridge_paths(workdir):
|
|
"""Server-side helper: the four bridge file paths."""
|
|
return {
|
|
"state": os.path.join(workdir, STATE_NAME),
|
|
"frame": os.path.join(workdir, FRAME_NAME),
|
|
"input": os.path.join(workdir, INPUT_NAME),
|
|
"control": os.path.join(workdir, CONTROL_NAME),
|
|
}
|
|
|
|
|
|
def read_state(workdir):
|
|
"""Server-side: read + freshness-check the state file.
|
|
|
|
Returns (state_dict, None), or (None, (status, message)):
|
|
missing file -> 503 session not active; stale (no MCP
|
|
heartbeat for STATE_STALE_SECONDS) -> 503; unparseable ->
|
|
502 (atomic writes mean this should not happen).
|
|
"""
|
|
path = os.path.join(workdir, STATE_NAME)
|
|
try:
|
|
with open(path, "rb") as fh:
|
|
raw = fh.read()
|
|
except OSError:
|
|
return None, (503, "browser session not active")
|
|
try:
|
|
state = json.loads(raw.decode("utf-8"))
|
|
except ValueError:
|
|
return None, (502, "bad browser response")
|
|
updated = state.get("updated_at") or 0
|
|
if time.time() - updated > STATE_STALE_SECONDS:
|
|
return None, (503, "browser session not active")
|
|
return state, None
|
|
|
|
|
|
def effective_holder(workdir, state):
|
|
"""Server-side holder with the control race closed.
|
|
|
|
The MCP polls every ~10ms idle and rewrites the state file
|
|
at once when the holder changes (a key change bypasses the
|
|
heartbeat throttle), so right after a takeover the state
|
|
file still shows the old holder for at most ~10ms. The
|
|
control file is the newer truth when its mtime is at or
|
|
after the state's updated_at; otherwise the state file
|
|
(already refreshed by a poll) wins. Both agree once the
|
|
MCP has polled, so this only closes the ~10ms window.
|
|
"""
|
|
try:
|
|
mtime = os.path.getmtime(os.path.join(workdir, CONTROL_NAME))
|
|
with open(os.path.join(workdir, CONTROL_NAME),
|
|
"rb") as fh:
|
|
ctrl = json.loads(fh.read().decode("utf-8"))
|
|
except (OSError, ValueError):
|
|
return state.get("holder")
|
|
holder = ctrl.get("holder")
|
|
if holder not in ("agent", "user"):
|
|
return state.get("holder")
|
|
if mtime >= (state.get("updated_at") or 0):
|
|
return holder
|
|
return state.get("holder")
|
|
|
|
|
|
def validate_input(body):
|
|
"""Server-side input shape check. Returns (item, None) or
|
|
(None, (status, message)). Bounds mirror Browser.click_at /
|
|
mouse_move so a bad coordinate is a 400, not a queued no-op."""
|
|
if not isinstance(body, dict):
|
|
return None, (400, "input must be a JSON object")
|
|
kind = body.get("type")
|
|
if kind in ("mouse_click", "mouse_move"):
|
|
x, y = body.get("x"), body.get("y")
|
|
if (isinstance(x, bool) or isinstance(y, bool)
|
|
or not isinstance(x, int) or not isinstance(y, int)):
|
|
return None, (400, "x and y must be integers")
|
|
if not (0 <= x <= 1280 and 0 <= y <= 800):
|
|
return None, (400, "coordinates out of viewport")
|
|
return {"type": kind, "x": x, "y": y}, None
|
|
if kind == "key":
|
|
key = body.get("key")
|
|
if not isinstance(key, str) or not key:
|
|
return None, (400, "key must be a non-empty string")
|
|
return {"type": "key", "key": key}, None
|
|
if kind == "type":
|
|
text = body.get("text")
|
|
if not isinstance(text, str):
|
|
return None, (400, "text must be a string")
|
|
return {"type": "type", "text": text}, None
|
|
return None, (400, "unknown input type %r" % (kind,))
|
|
|
|
|
|
def append_input(workdir, item):
|
|
path = os.path.join(workdir, INPUT_NAME)
|
|
with open(path, "ab") as fh:
|
|
fh.write((json.dumps(item, ensure_ascii=False) + "\n")
|
|
.encode("utf-8"))
|
|
|
|
|
|
def write_control(workdir, holder):
|
|
if holder not in ("agent", "user"):
|
|
raise ValueError("holder must be 'agent' or 'user'")
|
|
_atomic_write(
|
|
os.path.join(workdir, CONTROL_NAME),
|
|
json.dumps({"holder": holder, "seq": time.time_ns()},
|
|
ensure_ascii=False).encode("utf-8"))
|
|
return holder
|