1725244faf
填报从「一把梭」变成分步:Agent 可以开浏览器、登录、建单、按步填、看页面、关,每步有截图, 中途失败不必从头重来。提交不在本步——提交不可逆,留给 P4 的 submit_preview/submit_execute。 - src/infra/browser/actor.py(新):Playwright 单线程 actor(sync API 的 greenlet 线程亲和要求所有 调用回到创建线程)+ 会话注册表 + cases/<id>/.browser.lock(PID/started_at/heartbeat/url, 心跳 180 秒过期才允许接管;锁文件损坏按无锁处理;atexit 尽力关浏览器)。与 COM actor 分开。 - base.py:login_portal 拆成 submit_login + wait_for_portal(返回布尔不抛异常),新增 portal_reached/ human_required;_screenshot 支持 screenshot_dir 并按 001-步骤名.png 编号、返回路径、失败只告警 - src/mcp/tools/browser.py(新):browser_open/login/new_form/fill/steps/page_state/close,工具 17 → 24 · need_human 用 ok:true + logged_in:false 返回(扫码不是失败,避免宿主当错误重试或另开窗口) · 步骤顺序硬约束,跳步拒绝并用 next_action 指回该补的一步;步骤报错回传 detail.screenshot · browser_close 明确不提交不存草稿;travel 五步、normal 四步(无 subsidy) - tests/test_mcp_browser.py(新,17 例,FakeBot/FakePage 不起浏览器):锁四种情形、need_human 分支与 截图落点、重复登录幂等、未打开时各工具拒绝、类型推断与两 info 并存时拒猜、未登录拒建单、跳步拦截、 步骤推进与 remaining、未知步骤、报错截图、close 释放会话与锁、open 复用、steps 两种流程 - 文档:docs/MCP.md 工具段按 wire schema 重生成(24 工具);AGENTS/SKILL/操作指南/README/浏览器 README 同步分步流程、锁与心跳、有头默认、提交必须交人工;方案文档 §13 记录实现、取舍与未完成项 验证: - 联机跑通 open(3.1s 起有头 Chromium)→ 锁写入(pid/heartbeat)→ page_state(about:blank, 001-state.png 4721 字节)→ 重复 open 复用(already_open=true,不多开窗)→ close(锁释放、注册表清空) - 登录与填报的联机验证需要用户在场(SSO 可能扫码),本轮未做 - 门禁:ruff / format --check / mypy src / deptry / pytest 129 passed / mcp_smoke 24 工具 instructions 2109 字节。仍用 --no-verify 提交并手跑等价检查(原因见 AGENTS 提交纪律)。
166 lines
5.5 KiB
Python
166 lines
5.5 KiB
Python
"""浏览器会话的常驻持有者与独占锁
|
||
|
||
两件事必须同时成立,填报才可信:
|
||
|
||
1. **线程亲和**:Playwright 同步 API 底层是 greenlet,browser/page 对象必须在创建它的那个线程里使用。
|
||
MCP 服务器是多线程的(每个工具调用可能在不同线程),所以所有浏览器操作统一提交到这个单线程 actor。
|
||
与 COM 的 actor 分开:COM 套间要求不同,混用会踩坑。
|
||
2. **进程独占**:宿主可能同时存在多代 MCP 子进程(重连窗口、subagent/teammate 各一条 transport,
|
||
实测 4 个 PID 并存)。每个进程各开一个 Chromium,Agent 看到的截图就不是它正在操作的那个页面。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import atexit
|
||
import json
|
||
import os
|
||
import threading
|
||
import time
|
||
from collections.abc import Callable
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from ... import get_logger
|
||
|
||
log = get_logger("browser.actor")
|
||
|
||
LOCK_FILENAME = ".browser.lock"
|
||
# 心跳超过这个秒数没更新就视为持有者已死,可被接管
|
||
STALE_AFTER_SECONDS = 180
|
||
|
||
_executor: ThreadPoolExecutor | None = None
|
||
_lock = threading.Lock()
|
||
# case_id -> {"bot": BaseBot, "steps": [str, ...], "kind": str|None}
|
||
_sessions: dict[str, dict[str, Any]] = {}
|
||
|
||
|
||
def _pool() -> ThreadPoolExecutor:
|
||
global _executor
|
||
if _executor is None:
|
||
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="playwright")
|
||
return _executor
|
||
|
||
|
||
def run_browser[T](fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
||
"""在专用 Playwright 线程上串行执行 fn 并等待结果。"""
|
||
with _lock:
|
||
return _pool().submit(fn, *args, **kwargs).result()
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# 会话注册表(只在 actor 线程里读写,不加锁也不竞争)
|
||
# ------------------------------------------------------------------
|
||
|
||
|
||
def session(case_id: str) -> dict[str, Any] | None:
|
||
"""取会话的浏览器上下文;未打开返回 None。"""
|
||
return _sessions.get(case_id)
|
||
|
||
|
||
def put_session(case_id: str, bot: Any, kind: str | None = None) -> dict[str, Any]:
|
||
ctx: dict[str, Any] = {"bot": bot, "steps": [], "kind": kind, "opened_at": time.time()}
|
||
_sessions[case_id] = ctx
|
||
return ctx
|
||
|
||
|
||
def drop_session(case_id: str) -> dict[str, Any] | None:
|
||
return _sessions.pop(case_id, None)
|
||
|
||
|
||
def list_sessions() -> list[str]:
|
||
return sorted(_sessions)
|
||
|
||
|
||
def mark_step(case_id: str, step: str) -> None:
|
||
ctx = _sessions.get(case_id)
|
||
if ctx is not None and step not in ctx["steps"]:
|
||
ctx["steps"].append(step)
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# 磁盘锁
|
||
# ------------------------------------------------------------------
|
||
|
||
|
||
def lock_path(case_dir: Path) -> Path:
|
||
return case_dir / LOCK_FILENAME
|
||
|
||
|
||
def read_lock(case_dir: Path) -> dict[str, Any] | None:
|
||
"""读锁文件;损坏或不存在返回 None(损坏不阻断,按无锁处理)。"""
|
||
path = lock_path(case_dir)
|
||
if not path.exists():
|
||
return None
|
||
try:
|
||
data = json.loads(path.read_text(encoding="utf-8"))
|
||
except Exception as exc:
|
||
log.warning("浏览器锁文件损坏,按无锁处理: %s (%s)", path, exc)
|
||
return None
|
||
return data if isinstance(data, dict) else None
|
||
|
||
|
||
def _write_lock(case_dir: Path, url: str = "") -> dict[str, Any]:
|
||
payload = {
|
||
"pid": os.getpid(),
|
||
"started_at": round(time.time(), 3),
|
||
"heartbeat": round(time.time(), 3),
|
||
"url": url,
|
||
}
|
||
lock_path(case_dir).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
return payload
|
||
|
||
|
||
def acquire(case_dir: Path, url: str = "") -> tuple[bool, dict[str, Any]]:
|
||
"""尝试取得该会话的浏览器所有权。返回 (是否取得, 锁信息或占用者信息)。"""
|
||
held = read_lock(case_dir)
|
||
if held is not None:
|
||
other_pid = int(held.get("pid", -1))
|
||
age = time.time() - float(held.get("heartbeat", 0))
|
||
if other_pid != os.getpid() and age < STALE_AFTER_SECONDS:
|
||
return False, {**held, "age_seconds": round(age, 1), "stale": False}
|
||
if other_pid != os.getpid():
|
||
log.warning("接管过期浏览器锁(原持有 pid=%s,%.0fs 无心跳)", other_pid, age)
|
||
return True, _write_lock(case_dir, url)
|
||
|
||
|
||
def touch(case_dir: Path, url: str = "") -> None:
|
||
"""刷心跳:每次浏览器工具调用都调一下。"""
|
||
if read_lock(case_dir) is None:
|
||
return
|
||
_write_lock(case_dir, url)
|
||
|
||
|
||
def release(case_dir: Path) -> None:
|
||
held = read_lock(case_dir)
|
||
if held is not None and int(held.get("pid", -1)) == os.getpid():
|
||
try:
|
||
lock_path(case_dir).unlink()
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# 收尾
|
||
# ------------------------------------------------------------------
|
||
|
||
|
||
def close_all() -> None:
|
||
"""尽力关闭所有浏览器并释放锁(进程退出时调用)。"""
|
||
for case_id in list(_sessions):
|
||
ctx = _sessions.pop(case_id, None)
|
||
if not ctx:
|
||
continue
|
||
bot = ctx.get("bot")
|
||
work_dir = getattr(bot, "work_dir", None)
|
||
try:
|
||
if bot is not None:
|
||
bot.close()
|
||
except Exception as exc:
|
||
log.warning("关闭浏览器失败 %s: %s", case_id, exc)
|
||
if isinstance(work_dir, Path):
|
||
release(work_dir)
|
||
|
||
|
||
atexit.register(close_all)
|