Files
Auto-Finance/src/infra/browser/actor.py
T
wandering 1725244faf feat(mcp): 浏览器分步填报 P3——7 个 browser 工具 + 单线程 actor + 磁盘锁
填报从「一把梭」变成分步: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 提交纪律)。
2026-10-20 00:46:23 +08:00

166 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""浏览器会话的常驻持有者与独占锁
两件事必须同时成立,填报才可信:
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)