# UIActionScheduler 统一调度方案

> 文档版本：v1.0
> 创建日期：2026-07-17
> 范围：`bridge/woc_bridge` 全链路
> 目标：将散落在 5 个子系统的 UI 操作收口到统一调度器，消除绕过队列的竞态风险，引入优先级与可观测性

---

## 一、背景与现状

### 1.1 当前架构

当前 UI 操作分散在 5 个子系统，串行化覆盖不完整：

| 子系统 | 入口 | 是否经 SendQueue | 互斥情况 |
|---|---|---|---|
| `FlowOrchestrator.send_text/send_file` | HTTP + BatchWorker | ✅ 入队 | SendQueue 串行 + `_ui_lock` 单命令互斥 |
| `routes/contacts.py` (set_remark/add_friend/accept) | HTTP | ✅ 入队 | 同上 |
| `routes/moments.py` (publish/share/like/comment/delete) | HTTP | ✅ 入队 | 同上 |
| `routes/send.py` legacy (revoke/forward) | HTTP | ✅ 入队 | 同上 |
| `FriendRequestWatcher._handle_request` | 后台轮询 3s | ✅ 入队 | 同上 |
| `routes/login.py` (logout/restart) | HTTP | ❌ 直接调 xdotool | 仅 `_ui_lock` 单命令互斥 |
| `routes/login.py` (qr_start/qr_wait) | HTTP | ❌ 直接调 xdotool | qr_capture **无锁** |
| `routes/screenshot.py` | HTTP | ❌ 直接调 qr_capture | **无锁** |
| `routes/diagnostic.py` (autofix) | HTTP | ❌ 内联 pkill | **无锁** |
| `LoginGuard._check_loop` | 后台轮询 5s | ❌ 直接调 xdotool | 仅 `_ui_lock` |
| `WeChatWatchdog._run` | 后台轮询 10s | ❌ 直接调 pgrep/kill | **无锁** |

### 1.2 已确认的问题

#### P0 竞态风险（已发生过实际故障）

| 问题 | 根因 | 后果 | 引用 |
|---|---|---|---|
| friend_watcher 与 send_text 竞态 | 早期未入队，现已修复入队 | erratic clicking、重复拨号 | 项目记忆记录 |
| logout 多步操作被 send_queue 任务穿插 | logout 不入队，仅靠 `_ui_lock` 单命令互斥 | 菜单状态错乱、登出失败 | [routes/login.py:201](../../bridge/woc_bridge/routes/login.py#L201) |
| restart kill 微信与并发 UI 操作冲突 | restart 不入队 | xdotool 对空窗口操作、TimeoutError 雪崩 | [routes/login.py:281](../../bridge/woc_bridge/routes/login.py#L281) |
| diagnostic autofix pkill 无锁 | 直接 `pkill -x wechat` | 与并发 UI 操作冲突高风险 | [routes/diagnostic.py:232-239](../../bridge/woc_bridge/routes/diagnostic.py#L232) |
| QrCapture 无锁无超时 | `proc.communicate()` 无 timeout | 与 send_text 截图竞争 X11、可能无限阻塞 | [ui/qr_capture.py:48-57](../../bridge/woc_bridge/ui/qr_capture.py#L48) |
| WeChatWatchdog autofix 无锁 | pgrep+kill 不持 `_ui_lock` | kill 微信时 UI 操作中途失败 | [ui/watchdog.py:99-191](../../bridge/woc_bridge/ui/watchdog.py#L99) |

#### P1 设计债务

| 问题 | 根因 | 后果 |
|---|---|---|
| 无优先级机制 | SendQueue 是纯 FIFO | 紧急操作（logout/restart）无法插队，100 个 send 排队时 logout 延迟分钟级 |
| 命名与实现不符 | 类名 `SendQueue`，实际是通用 UI 队列 | 误导维护者认为只管 send |
| 路由层前置 `detect_login_state` 不入队 | 多处路由在 enqueue 前直接调 xdotool | 与 send_queue 内任务竞争 `_ui_lock`，增加排队延迟 |
| 启动清场不入队 | `_full_cleanup_on_startup` 直接调 | 若 friend_watcher 已启动可能并发（实际启动顺序规避了此风险） |
| 无统一可观测性 | 各子系统独立打日志，无统一 metrics | UI 操作队列深度、等待时长、执行时长无法监控 |

#### P1.1 detect_login_state 散落调用分析

`detect_login_state` 是只读探测（截图 + 模板匹配），但散落在 14 处直接调用，每处都会与 send_queue 内任务竞争 L1 `_ui_lock`：

| 文件 | 调用位置 | 调用频率 | 与队列竞争影响 |
|---|---|---|---|
| `routes/send.py` | L307 / L413 / L587 / L690 / L774 / L852（6 处） | 每次 HTTP 请求 | 每次持锁 ~200-500ms（screenshot+模板匹配），100 并发 send 时累计 12-30s 额外延迟 |
| `routes/moments.py` | L151 / L255 / L346 / L447 / L545 / L677（6 处） | 每次 HTTP 请求 | 同上 |
| `routes/contacts.py` | L236 / L313 / L433（3 处） | 每次 HTTP 请求 | 同上 |
| `routes/login.py` | L117 / L190（2 处） | 每次 HTTP 请求 | 同上 |
| `routes/diagnostic.py` | L114（1 处） | 诊断触发 | 频率低，影响小 |
| `routes/status.py` | L51-56（内联判定，未直接调） | 每次 status 请求 | 已优化为内联 pgrep/find_window，**不持 _ui_lock** |
| `LoginGuard._check_loop` | 后台 5s 轮询 | 持续运行 | 每 5s 一次，频率低 |
| `friend_watcher._verify_with_retry` | 后台触发 | 偶发 | 频率低 |

**关键发现**：`routes/status.py` 已经通过内联 pgrep+find_window 避开了 `detect_login_state`，证明这种"只读探测不持 _ui_lock"的优化路径是可行且被项目采纳的。

**处理方案**（**本方案不强制收口，但记录优化路径**）：

1. **短期（本方案不实施）**：保持现状。`detect_login_state` 是单次截图+模板匹配，持锁时间可控（< 1s）。在 send_queue 任务执行期间（通常 1-5s），最多 1-2 次 detect_login_state 抢占 _ui_lock，影响有限。
2. **中期（独立优化项）**：参照 `routes/status.py` 的优化模式，将路由前置 `detect_login_state` 改为内联 pgrep + find_window 判定（不持 _ui_lock），仅在判定为 "logged_in" 后才入队执行真正的 UI 操作。
3. **长期（架构演进）**：LoginGuard 维护登录态缓存，路由层读缓存而非每次调 `detect_login_state`。

**为什么 UIActionScheduler 不收口 detect_login_state**：
- 入队会被 100 个 send 排队阻塞，导致登录态检测延迟分钟级，影响 friend_watcher 等依赖登录态的后台任务
- detect_login_state 是只读探测，不修改 UI 状态，与 send_queue 任务的"互斥"是性能问题而非正确性问题
- 收口 detect_login_state 会引入"队列内任务触发队列外只读探测"的循环依赖

**风险评估**：保持现状的代价是每个 send 请求多 ~300ms 延迟（detect_login_state 持锁 1 次），在 100 并发场景下累计 ~30s。可通过中期方案消除。

### 1.3 SendQueue 现状评估

SendQueue 实际上**已经是事实上的通用 UI 调度器**：
- 16 处 `enqueue` 调用点，覆盖 send(4)/contacts(3)/moments(6)/friend_watcher(1)/orchestrator(2)
- 接收任意 `CoroFactory`，不限定 send 语义
- 单 worker 串行 + 1 秒滑窗限流 + 队列满拒绝 + 等待超时

**可直接复用为基础**，但需扩展：
1. 增加 `priority` 参数（当前队列元素是 `tuple[CoroFactory, Future, Optional[int]]`，第三段是 `custom_delay_ms`，无 priority 槽位）
2. 重命名以反映通用语义（向后兼容保留别名）
3. 收口绕过队列的调用点

### 1.4 双层保护模型（L1 _ui_lock + L2 SendQueue）

当前架构存在两层互斥机制，理解其分工是设计 UIActionScheduler 的前提：

| 层级 | 互斥粒度 | 实现位置 | 持锁时长 | 保护语义 |
|---|---|---|---|---|
| **L1** `_ui_lock` | **单命令**（一次 click/type/screenshot） | `ui/backends/xdotool.py:50` `asyncio.Lock` | 单次 xdotool 子进程（~50ms-5s） | 防止两个 asyncio 协程同时调 xdotool 导致 X11 焦点错乱 |
| **L2** `SendQueue` | **多命令 Flow**（整个 send_text/logout 流程） | `messaging/send_queue.py` worker 串行 | 整个 Flow（~1-30s） | 防止多步操作之间被其他操作步骤穿插 |

**关键区别**：

```
send_text Flow（多步操作，L2 保护）：
  activate → click_search_box → type_query → open_session →
  focus_input → type_text → click_send → verify
  ↑ 每一步内部都 acquire/release L1 _ui_lock（单命令互斥）
  ↑ 整个 Flow 由 L2 SendQueue 串行执行（多步不被穿插）

logout Flow（多步操作，当前仅 L1 保护）：
  activate → click_main_menu → key_down × N → enter → click_confirm
  ↑ 每一步内部 acquire/release L1 _ui_lock
  ↑ 整个 Flow 不在 SendQueue 中 → send_text 的步骤可以穿插进来！
```

**核心问题**：L1 只保护单次命令，无法防止多步 Flow 被穿插。例如 logout 点击主菜单后释放 _ui_lock，send_text 立即获得 _ui_lock 执行 click_search_box，导致 logout 的下一步 key_down 落在错误的焦点上。

**UIActionScheduler 的职责**：提供 L2 层统一保护，所有多步 UI 操作必须入队，避免步骤穿插。L1 保持不变作为底层单命令互斥的第二道防线（防止绕过队列的极端情况，如 LoginGuard/detect_login_state）。

### 1.5 UI 操作调用点全景图

**修改前**（当前状态，散落 22 个调用点，仅 16 个入队）：

```
┌─ HTTP 路由层 ────────────────────────────────────────────────────────────┐
│                                                                          │
│  routes/send.py                                                          │
│    ├─ send_text (L337)         ── enqueue ──→ SendQueue  [✅ 入队]      │
│    ├─ send_file (L703)         ── enqueue ──→ SendQueue  [✅ 入队]      │
│    ├─ revoke_message (L787)    ── enqueue ──→ SendQueue  [✅ 入队]      │
│    ├─ forward_message (L881)   ── enqueue ──→ SendQueue  [✅ 入队]      │
│    ├─ detect_login_state × 6   ── direct ──→ xdotool    [⚠️ 仅 L1]     │
│    ├─ post_verify_revoke       ── direct ──→ DB only    [✅ 无 UI]      │
│    ├─ capture_forward_baseline ── direct ──→ DB only    [✅ 无 UI]      │
│    └─ post_verify_forward      ── direct ──→ DB only    [✅ 无 UI]      │
│                                                                          │
│  routes/contacts.py                                                      │
│    ├─ set_remark (L249)        ── enqueue ──→ SendQueue  [✅ 入队]      │
│    ├─ add_friend (L321)        ── enqueue ──→ SendQueue  [✅ 入队]      │
│    ├─ accept_friend (L452)     ── enqueue ──→ SendQueue  [✅ 入队]      │
│    ├─ detect_login_state × 3   ── direct ──→ xdotool    [⚠️ 仅 L1]     │
│    └─ post_verify_set_remark   ── direct ──→ DB only    [✅ 无 UI]      │
│       post_verify_add_friend                                              │
│                                                                          │
│  routes/moments.py                                                       │
│    ├─ publish (L167)           ── enqueue ──→ SendQueue  [✅ 入队]      │
│    ├─ share (L271)             ── enqueue ──→ SendQueue  [✅ 入队]      │
│    ├─ like (L379)              ── enqueue ──→ SendQueue  [✅ 入队]      │
│    │   └─ _like_and_verify     (含 post_verify_moment_like 含截图)      │
│    ├─ comment (L480)           ── enqueue ──→ SendQueue  [✅ 入队]      │
│    │   └─ _comment_and_verify  (含 post_verify_moment_comment 含截图)   │
│    ├─ delete (L583)            ── enqueue ──→ SendQueue  [✅ 入队]      │
│    ├─ forward_moment (L692)    ── enqueue ──→ SendQueue  [✅ 入队]      │
│    └─ detect_login_state × 6   ── direct ──→ xdotool    [⚠️ 仅 L1]     │
│                                                                          │
│  routes/login.py                                                         │
│    ├─ qr_start (L58-59)        ── direct ──→ xdotool+qr  [❌ P0 无锁]   │
│    ├─ qr_wait (L117)           ── direct ──→ xdotool    [⚠️ 仅 L1]     │
│    ├─ logout (L201)            ── direct ──→ xdotool    [❌ P0 仅 L1]   │
│    └─ wechat_restart (L281)    ── direct ──→ xdotool    [❌ P0 仅 L1]   │
│                                                                          │
│  routes/screenshot.py                                                    │
│    └─ screenshot (L42)         ── direct ──→ qr_capture [❌ P0 无锁]   │
│                                                                          │
│  routes/diagnostic.py                                                    │
│    ├─ autofix_wechat_running   ── direct ──→ pkill      [❌ P0 无锁]   │
│    └─ detect_login_state (L114)── direct ──→ xdotool    [⚠️ 仅 L1]     │
│                                                                          │
│  routes/status.py                                                        │
│    └─ 内联 pgrep+find_window  (不持 _ui_lock，已优化)    [✅ 无 UI]      │
│                                                                          │
└──────────────────────────────────────────────────────────────────────────┘

┌─ 后台任务层 ────────────────────────────────────────────────────────────┐
│                                                                          │
│  FlowOrchestrator (send_text/send_file)                                  │
│    ├─ enqueue (L231, L364)     ── enqueue ──→ SendQueue  [✅ 入队]      │
│    └─ SessionCache LRU                                                   │
│                                                                          │
│  FriendRequestWatcher._handle_request                                    │
│    └─ enqueue (L506)           ── enqueue ──→ SendQueue  [✅ 入队]      │
│                                                                          │
│  LoginGuard._check_loop                                                  │
│    └─ detect_login_state       ── direct ──→ xdotool    [⚠️ 仅 L1]     │
│                                                                          │
│  WeChatWatchdog._run                                                     │
│    ├─ pgrep/xdpyinfo           ── direct ──→ system     [✅ 只读]       │
│    └─ _autofix_wechat (kill)   ── direct ──→ kill       [❌ P0 无锁]   │
│                                                                          │
│  QrCapture                                                               │
│    └─ _run (proc.communicate) ── direct ──→ scrot       [❌ P0 无超时] │
│                                                                          │
└──────────────────────────────────────────────────────────────────────────┘
```

**修改后**（目标状态，所有 P0 收口，仅保留只读探测不入队）：

```
┌─ HTTP 路由层 ── 全部经 UIActionScheduler.execute(action, priority) ────┐
│                                                                          │
│  routes/send.py                                                          │
│    ├─ send_text/file/revoke/forward ── execute(NORMAL) ──→ Scheduler   │
│    ├─ detect_login_state × 6         ── direct (中期优化为内联 pgrep)   │
│    └─ post_verify_* / capture_baseline ── direct (DB only, 保留)        │
│                                                                          │
│  routes/contacts.py                                                      │
│    ├─ set_remark/add_friend/accept ── execute(NORMAL/HIGH) → Scheduler │
│    └─ detect_login_state × 3         ── direct (中期优化)               │
│                                                                          │
│  routes/moments.py                                                       │
│    ├─ publish/share/delete          ── execute(NORMAL) ──→ Scheduler   │
│    ├─ like/comment                  ── execute(LOW)     ──→ Scheduler   │
│    └─ detect_login_state × 6         ── direct (中期优化)               │
│                                                                          │
│  routes/login.py                                                         │
│    ├─ qr_start (capture_qr_code)   ── execute(HIGH)    ──→ Scheduler   │
│    ├─ logout                       ── execute(HIGH)    ──→ Scheduler   │
│    ├─ wechat_restart               ── execute(CRITICAL)──→ Scheduler   │
│    │                  └─ mark_wechat_dead(8.0) before execute           │
│    └─ qr_wait (detect_login_state) ── direct (只读，保留)               │
│                                                                          │
│  routes/screenshot.py                                                    │
│    └─ screenshot                   ── execute(LOW)     ──→ Scheduler   │
│                                                                          │
│  routes/diagnostic.py                                                    │
│    └─ autofix_wechat_running       ── execute(CRITICAL)──→ Scheduler   │
│                       └─ mark_wechat_dead(15.0) before execute          │
│                                                                          │
└──────────────────────────────────────────────────────────────────────────┘

┌─ 后台任务层 ────────────────────────────────────────────────────────────┐
│                                                                          │
│  FlowOrchestrator.send_text/send_file                                    │
│    └─ enqueue (向后兼容)         ── execute(NORMAL) ──→ Scheduler      │
│                                                                          │
│  FriendRequestWatcher._handle_request                                    │
│    └─ enqueue (向后兼容)         ── execute(HIGH)    ──→ Scheduler     │
│                                                                          │
│  LoginGuard._check_loop                                                  │
│    └─ detect_login_state         ── direct (只读，保留)                 │
│                                                                          │
│  WeChatWatchdog._autofix_wechat                                          │
│    ├─ scheduler.drain(5.0)       ── 等待队列清空                         │
│    ├─ scheduler.mark_wechat_dead(15.0) ── 标记 fast-fail 窗口           │
│    └─ pgrep + kill -TERM         ── 系统级操作（不入队）                 │
│                                                                          │
│  QrCapture._run                                                          │
│    └─ asyncio.wait_for(proc.communicate, 5.0) ── 超时保护               │
│                                                                          │
└──────────────────────────────────────────────────────────────────────────┘
```

### 1.6 HTTP 端点 → 优先级映射表

| HTTP 端点 | 当前状态 | 目标优先级 | delay_ms 策略 | wait_timeout_ms | 备注 |
|---|---|---|---|---|---|
| `POST /api/send/text` | ✅ enqueue | NORMAL | 同联系人 1000 / 不同 3000 | 15000 | 现有逻辑不变 |
| `POST /api/send/file` | ✅ enqueue | NORMAL | 默认 3000 | 15000 | 现有逻辑不变 |
| `POST /api/messages/revoke` | ✅ enqueue | NORMAL | 默认 3000 | 15000 | 现有逻辑不变 |
| `POST /api/messages/forward` | ✅ enqueue | NORMAL | 默认 3000 | 15000 | 现有逻辑不变 |
| `POST /api/friends/remark` | ✅ enqueue | NORMAL | 默认 3000 | 15000 | 现有逻辑不变 |
| `POST /api/friends/add` | ✅ enqueue | NORMAL | 默认 3000 | 15000 | 现有逻辑不变 |
| `POST /api/friends/accept` | ✅ enqueue | HIGH | 默认 3000 | 30000 | 时效性高 |
| `POST /api/moments/publish` | ✅ enqueue | NORMAL | 默认 3000 | 30000 | 现有逻辑不变 |
| `POST /api/moments/share` | ✅ enqueue | NORMAL | 默认 3000 | 30000 | 现有逻辑不变 |
| `POST /api/moments/delete` | ✅ enqueue | NORMAL | 默认 3000 | 15000 | 现有逻辑不变 |
| `POST /api/moments/forward` | ✅ enqueue | NORMAL | 默认 3000 | 30000 | 现有逻辑不变 |
| `POST /api/moments/like` | ✅ enqueue | LOW | 默认 3000 | 15000 | 可延迟 |
| `POST /api/moments/comment` | ✅ enqueue | LOW | 默认 3000 | 15000 | 可延迟 |
| `POST /api/screenshot` | ❌ direct | LOW | 默认 3000 | 10000 | 阶段 2 收口 |
| `POST /api/login/qr/start` | ❌ direct | HIGH | 默认 3000 | 15000 | 阶段 2 收口 |
| `POST /api/login/logout` | ❌ direct | HIGH | 默认 3000 | 30000 | 阶段 2 收口 |
| `POST /api/wechat/restart` | ❌ direct | CRITICAL | 0（自动） | 60000 | 阶段 2 收口 + fast-fail |
| `POST /api/diagnostic/autofix/wechat_running` | ❌ direct | CRITICAL | 0（自动） | 30000 | 阶段 2 收口 + fast-fail |
| `GET /api/login/qr/wait` | direct（轮询） | 不入队 | - | - | 只读探测 |
| `GET /api/status` | 内联 pgrep | 不入队 | - | - | 已优化 |
| `GET /api/diagnostic/run/*` | direct（只读） | 不入队 | - | - | 只读探测 |

---

## 二、设计方案

### 2.1 核心思路

**不推翻 SendQueue 重写，而是扩展 + 收口**：

1. **扩展 SendQueue** 为 `UIActionScheduler`，增加优先级参数
2. **收口绕过队列的 6 个调用点**，统一入队
3. **保留 `_ui_lock`** 作为底层单命令互斥（不取消，作为第二道防线）
4. **新增 metrics** 暴露队列深度、等待时长、执行时长

### 2.2 命名策略

采用**渐进式重命名**，避免一次性破坏全部调用点：

```python
# messaging/ui_action_scheduler.py（新文件，继承 SendQueue）
class UIActionScheduler(SendQueue):
    """UI 操作统一调度器。
    
    在 SendQueue 基础上增加：
    - priority 参数（CRITICAL/HIGH/NORMAL/LOW）
    - 优先级队列（asyncio.PriorityQueue 替代 asyncio.Queue）
    - metrics 暴露（队列深度、等待时长、执行时长）
    """
    async def execute(
        self,
        action: CoroFactory,
        priority: Priority = Priority.NORMAL,
        delay_ms: Optional[int] = None,
        wait_timeout_ms: Optional[int] = None,
        trace_id: str = "",
    ) -> Any:
        """提交 UI 操作，按优先级调度，串行执行。"""
        ...

# config.py
_state.send_queue: SendQueue | UIActionScheduler  # 渐进式，先保持字段名
```

**向后兼容**：
- 保留 `SendQueue.enqueue` 方法签名不变（priority 默认 NORMAL）
- 16 个现有 `enqueue` 调用点无需修改
- 新调用点用 `execute` 方法，语义更清晰

### 2.3 优先级策略

```python
class Priority(enum.IntEnum):
    """UI 操作优先级（数字越小优先级越高）。"""
    CRITICAL = 0   # 系统级紧急：wechat_restart、diagnostic_autofix
    HIGH = 1       # 用户感知延迟：logout、friend_accept、login_qr_capture
    NORMAL = 2     # 常规业务：send_text、send_file、set_remark、add_friend
    LOW = 3        # 可延迟：screenshot、moments_like、moments_comment
```

**优先级队列实现**：
- 使用 `asyncio.PriorityQueue`，元素为 `(priority, seq, coro_factory, future, delay_ms)`
- `seq` 是单调递增序列号，保证同优先级 FIFO（避免 coro_factory 不可比较导致的错误）
- worker 出队时按 `(priority, seq)` 排序

**优先级反转保护**：
- 低优先级操作持锁时，高优先级操作必须等待（无法抢占，xdotool 子进程不可中断）
- 缓解：限制单次操作超时（已有 `_Cmd_TIMEOUT_SEC=5.0` + Flow 30s 超时），避免低优先级操作长时间持锁
- 不实现优先级继承（xdotool 子进程无法感知调用方优先级）

#### 2.3.1 delay_ms 与优先级的交互策略

**当前 delay_ms 决策逻辑**（在 `orchestrator.py:212`）：
- 同联系人发送：`delay_ms=1000`（短延时，提升吞吐）
- 不同联系人发送：`delay_ms=None` → 用 `send_delay_ms=3000`（默认延时，避免风控）
- 路由层 `enqueue` 不传 `delay_ms` → 用默认 `send_delay_ms=3000`

**问题**：引入优先级后，CRITICAL/HIGH 操作的延时是否应区别处理？

**分析**：
- delay_ms 是任务**执行后**的等待时间，影响**下一个任务**的开始时机
- 例如：CRITICAL 的 restart 执行后，若 delay_ms=3000，下一个 NORMAL 任务要等 3s
- restart 本身的优先级已经让它排到队首，所以 delay_ms 不影响 restart 自身延迟

**策略**：

| 优先级 | delay_ms 策略 | 理由 |
|---|---|---|
| CRITICAL | `delay_ms=0`（不延时） | restart/autofix 后应立即让后续任务执行，避免无谓等待 |
| HIGH | `delay_ms=None`（用默认） | logout/accept_friend 后需要给微信 UI 恢复时间，保持默认延时 |
| NORMAL | 保持现有逻辑（同联系人 1000 / 不同 3000） | send 风控保护，不变 |
| LOW | `delay_ms=None`（用默认） | screenshot 后不强制延时，但也不加速，避免连续截图 |

**实现**：在 `scheduler.execute` 内根据 priority 自动覆盖 delay_ms：

```python
async def execute(
    self,
    action: CoroFactory,
    priority: Priority = Priority.NORMAL,
    delay_ms: Optional[int] = None,
    ...
) -> Any:
    # CRITICAL 任务自动应用 delay_ms=0（除非调用方显式指定）
    if priority == Priority.CRITICAL and delay_ms is None:
        delay_ms = 0
    return await self._enqueue_with_priority(
        action, priority, delay_ms, wait_timeout_ms, trace_id
    )
```

**注意**：调用方显式指定 `delay_ms` 时优先尊重调用方意图（如批量发送要求固定间隔）。

**对现有 enqueue 的影响**：无。现有 16 个 `enqueue` 调用点都走 `priority=NORMAL`，delay_ms 决策逻辑不变。

### 2.4 关键决策：watchdog 是否入队

**不入队，但加协同机制**：

WeChatWatchdog 的 `pgrep -x wechat` / `xdpyinfo` / `kill -TERM` 是**系统级操作**，不是 UI 操作：
- `pgrep`/`xdpyinfo`：只读探测，不修改 UI 状态，无需互斥
- `kill -TERM`：会杀死微信进程，导致所有进行中的 UI 操作失败

**方案**：
- watchdog 的 `pgrep`/`xdpyinfo` 保持现状（不入队，不加锁）
- watchdog 的 `_autofix_wechat` 在 kill 前检查 `scheduler.pending_count()`：
  - 若有 pending UI 操作，先 `await scheduler.drain(timeout=5.0)` 等待队列清空
  - 超时未清空则强制 kill（记 warning）
- kill 后广播 `wechat_killed` 事件，所有进行中的 Flow 捕获 `WINDOW_NOT_FOUND` 后自然失败

### 2.5 关键决策：QrCapture 如何收口

QrCapture 有两类操作：
1. `capture_qr_code`：启动扫码登录流程，**多步 UI 操作**（activate + screenshot + crop）
2. `capture_full_screenshot`：单次截图

**方案**：
- `capture_qr_code` 入队（priority=HIGH），与 send_text 串行
- `capture_full_screenshot` 入队（priority=LOW）
- QrCapture 内部增加 `_CMD_TIMEOUT_SEC=5.0` 包裹 `proc.communicate()`，消除无超时风险

### 2.6 关键决策：LoginGuard 是否入队

**不入队**，原因：
- `detect_login_state` 是只读探测（截图 + 模板匹配），不修改 UI 状态
- 5 秒轮询 + `_ui_lock` 单命令互斥已足够
- 若入队，会被 100 个 send 排队阻塞，登录态检测延迟可达分钟级，影响 friend_watcher 等依赖登录态的任务

**但需加保护**：
- `detect_login_state` 内部已有 `_ui_lock` 持锁（经 `_run`），保持现状
- 增加监控：若 `_ui_lock` 等待时长 > 2s，记 warning（说明 UI 操作积压）

---

## 三、详细设计

### 3.1 UIActionScheduler 类

```python
# bridge/woc_bridge/messaging/ui_action_scheduler.py

"""UI 操作统一调度器。

所有 xdotool/scrot 子进程调用经此调度器串行执行，避免并发 UI 操作冲突。
在 SendQueue 基础上增加优先级调度与可观测性。
"""

from __future__ import annotations

import asyncio
import enum
import logging
import time
from typing import Any, Awaitable, Callable, Optional

from woc_bridge.messaging.send_queue import SendQueue, CoroFactory
from woc_bridge.models import BridgeError

logger = logging.getLogger("woc-bridge")


class Priority(enum.IntEnum):
    """UI 操作优先级（数字越小优先级越高）。"""
    CRITICAL = 0   # 系统级紧急：wechat_restart、diagnostic_autofix
    HIGH = 1       # 用户感知延迟：logout、friend_accept、login_qr_capture
    NORMAL = 2     # 常规业务：send_text、send_file、set_remark、add_friend
    LOW = 3        # 可延迟：screenshot、moments_like、moments_comment


class UIActionScheduler(SendQueue):
    """UI 操作统一调度器。

    扩展 SendQueue：
    - execute() 方法支持 priority 参数
    - 内部用 asyncio.PriorityQueue 替代 asyncio.Queue（覆盖父类 __init__ 创建的 Queue）
    - 同优先级 FIFO（通过 seq 序列号保证，避免比较到不可比较的 coro_factory）
    - metrics 暴露（pending_count by priority、wait_duration、exec_duration）

    向后兼容：
    - enqueue() 方法保留，priority 默认 NORMAL
    - 现有 16 个 enqueue 调用点无需修改

    ⚠️ 必须覆盖父类 _run 方法：父类 _run 解包 3 元组 (coro_factory, future, delay_ms)，
       子类用 5 元组 (priority, seq, coro_factory, future, delay_ms)。
       若不覆盖会导致解包失败。
    """

    def __init__(
        self,
        send_delay_ms: int = 3000,
        max_calls_per_sec: int = 10,
        max_queue_size: int = 100,
    ) -> None:
        super().__init__(send_delay_ms, max_calls_per_sec, max_queue_size)
        # 覆盖父类 __init__ 创建的 asyncio.Queue 为 PriorityQueue
        # 父类的旧 Queue 对象会被 GC 回收（无其他引用）
        self._queue: asyncio.PriorityQueue[
            tuple[int, int, CoroFactory, asyncio.Future, Optional[int]]
        ] = asyncio.PriorityQueue(maxsize=max_queue_size)
        self._seq = 0  # 单调递增序列号，保证同优先级 FIFO

        # fast-fail 机制：mark_wechat_dead 标记的时间戳
        # 在此时间之前，所有非 CRITICAL 任务直接抛 WECHAT_NOT_READY
        # 供 watchdog kill / wechat_restart 调用，避免失败风暴
        self._wechat_dead_until: float = 0.0

        # metrics（简单计数器，供 /api/status 或 Prometheus 暴露）
        # asyncio 单线程事件循环，worker 与 HTTP handler 同线程，无需加锁
        self._metrics: dict[str, Any] = {
            "executed_total": 0,
            "executed_by_priority": {p.name: 0 for p in Priority},
            "wait_duration_ms_sum": 0.0,
            "exec_duration_ms_sum": 0.0,
            "timeout_total": 0,
            "rate_limited_total": 0,
            "fast_fail_total": 0,  # 被 mark_wechat_dead 拦截的任务数
        }

    def mark_wechat_dead(self, duration_sec: float = 10.0) -> None:
        """标记微信已死，期间所有非 CRITICAL 任务直接 fast-fail。

        供 watchdog kill / wechat_restart / diagnostic autofix 调用：
        - kill 前调 mark_wechat_dead(15.0)，覆盖 autostart 拉起窗口
        - restart 开始时调 mark_wechat_dead(8.0)，覆盖新进程启动窗口

        CRITICAL 任务（如 restart 本身）不受影响，确保 restart 能正常执行。

        Args:
            duration_sec: fast-fail 窗口时长（秒）
        """
        self._wechat_dead_until = time.monotonic() + duration_sec
        logger.warning(
            "[scheduler] mark_wechat_dead %.1fs, %d pending tasks will fast-fail",
            duration_sec, self._queue.qsize(),
        )

    async def execute(
        self,
        action: CoroFactory,
        priority: Priority = Priority.NORMAL,
        delay_ms: Optional[int] = None,
        wait_timeout_ms: Optional[int] = None,
        trace_id: str = "",
    ) -> Any:
        """提交 UI 操作，按优先级调度，串行执行。

        Args:
            action: 返回 coroutine 的工厂函数
            priority: 优先级（CRITICAL/HIGH/NORMAL/LOW）
            delay_ms: 自定义本次延时毫秒；None 时：
                      - CRITICAL 自动设为 0（执行后不延时）
                      - 其他优先级用默认 send_delay_ms
            wait_timeout_ms: 队列等待超时；None 无限等待
            trace_id: 追踪 ID（用于日志关联）

        Returns:
            coroutine 的实际执行结果

        Raises:
            BridgeError(RATE_LIMITED): 队列满
            BridgeError(TIMEOUT): 等待超时
        """
        # CRITICAL 任务自动应用 delay_ms=0（除非调用方显式指定）
        # 参见 2.3.1 节 delay_ms 与优先级交互策略
        if priority == Priority.CRITICAL and delay_ms is None:
            delay_ms = 0
        return await self._enqueue_with_priority(
            action, priority, delay_ms, wait_timeout_ms, trace_id
        )

    async def _enqueue_with_priority(
        self,
        coro_factory: CoroFactory,
        priority: Priority,
        delay_ms: Optional[int],
        wait_timeout_ms: Optional[int],
        trace_id: str,
    ) -> Any:
        """优先级入队（覆盖 SendQueue.enqueue 的内部实现）。

        与父类 enqueue 的差异：
        - 用 put_nowait 替代 await put（满队时立即抛错，语义更清晰）
        - 队列元素从 3 元组扩展为 5 元组（priority, seq, coro_factory, future, delay_ms）
        - 增加入队日志（与父类保持一致的可观测性）
        """
        loop = asyncio.get_running_loop()
        future: asyncio.Future = loop.create_future()
        self._seq += 1
        item = (int(priority), self._seq, coro_factory, future, delay_ms)

        # 入队（带队列满检查，与父类行为一致）
        try:
            self._queue.put_nowait(item)
        except asyncio.QueueFull:
            self._metrics["rate_limited_total"] += 1
            logger.warning(
                "[scheduler] 队列已满 (size=%d/%d)，拒绝入队 priority=%s",
                self._queue.maxsize, self._queue.maxsize, priority.name,
            )
            raise BridgeError(
                code="RATE_LIMITED",
                message=f"UI 调度器队列已满（{self._queue.maxsize}），请稍后重试",
                details={"retry_after": 3},
            )

        pending = self._queue.qsize()
        logger.info(
            "[scheduler] 入队 priority=%s pending=%d trace_id=%s",
            priority.name, pending, trace_id or "(none)",
        )

        # 等待结果（与父类逻辑一致，保留竞争窗口处理）
        wait_start = time.monotonic()
        if wait_timeout_ms is not None and wait_timeout_ms > 0:
            try:
                result = await asyncio.wait_for(
                    future, timeout=wait_timeout_ms / 1000.0
                )
            except asyncio.TimeoutError:
                # 竞争窗口：worker 可能刚好在此时完成并 set_result
                if future.done() and not future.cancelled():
                    logger.info(
                        "[scheduler] 等待超时但任务刚好完成，取结果 (pending=%d)",
                        pending,
                    )
                    return future.result()
                future.cancel()
                self._metrics["timeout_total"] += 1
                wait_sec = wait_timeout_ms / 1000.0
                logger.warning(
                    "[scheduler] 等待超时 (pending=%d, wait_timeout=%.1fs, priority=%s)",
                    pending, wait_sec, priority.name,
                )
                raise BridgeError(
                    code="TIMEOUT",
                    message=f"UI 调度器等待超时（{pending} 个待处理，已等 {wait_sec:.1f}s）",
                    details={"retry_after": max(1, int(self.send_delay_ms / 1000))},
                )
        else:
            result = await future

        # 记录等待时长
        wait_ms = (time.monotonic() - wait_start) * 1000
        self._metrics["wait_duration_ms_sum"] += wait_ms

        return result

    async def enqueue(
        self,
        coro_factory: CoroFactory,
        delay_ms: Optional[int] = None,
        wait_timeout_ms: Optional[int] = None,
    ) -> Any:
        """向后兼容的入队方法（priority 默认 NORMAL）。

        现有 16 个 enqueue 调用点无需修改，行为与父类一致。
        """
        return await self._enqueue_with_priority(
            coro_factory, Priority.NORMAL, delay_ms, wait_timeout_ms, ""
        )

    async def _run(self) -> None:
        """worker 主循环。

        ⚠️ 必须覆盖父类 _run：父类解包 3 元组，子类用 5 元组。

        与父类 _run 的差异：
        - 解包 5 元组 (priority, seq, coro_factory, future, custom_delay_ms)
        - 增加 metrics 记录（executed_total / executed_by_priority / exec_duration）
        - 保留限流检查（_check_rate_limit 继承父类，不覆盖）
        - 保留延时逻辑（delay_ms 优先于 send_delay_ms）
        - task_done 调用与父类一致：cancelled 分支和 finally 分支互斥
        """
        while True:
            try:
                priority, seq, coro_factory, future, custom_delay_ms = (
                    await self._queue.get()
                )
            except asyncio.CancelledError:
                raise

            # 调用方已超时取消：跳过执行与延时（与父类一致）
            if future.cancelled():
                self._queue.task_done()
                logger.info(
                    "[scheduler] 出队任务已取消，跳过 (pending=%d, priority=%s)",
                    self._queue.qsize(),
                    Priority(priority).name if 0 <= priority <= 3 else "?",
                )
                continue

            # fast-fail 检查：微信已死期间，非 CRITICAL 任务直接失败
            # 参见 6.4 节 失败风暴问题与 fast-fail 机制
            if (
                self._wechat_dead_until > time.monotonic()
                and priority > Priority.CRITICAL  # CRITICAL 任务（=0）不受影响
            ):
                logger.info(
                    "[scheduler] fast-fail (wechat dead, %.1fs remaining, priority=%s)",
                    self._wechat_dead_until - time.monotonic(),
                    Priority(priority).name if 0 <= priority <= 3 else "?",
                )
                if not future.done():
                    future.set_exception(BridgeError(
                        code="WECHAT_NOT_READY",
                        message="微信进程未就绪（重启中），请稍后重试",
                        details={"retry_after": 5},
                    ))
                self._metrics["fast_fail_total"] += 1
                self._queue.task_done()
                continue  # 不延时，立即处理下一个

            logger.info(
                "[scheduler] 出队，开始处理 (pending=%d, priority=%s)",
                self._queue.qsize(),
                Priority(priority).name if 0 <= priority <= 3 else "?",
            )
            executed = False
            t_exec = time.perf_counter()
            try:
                # 继承父类的限流检查
                self._check_rate_limit()
                self._recent_call_times.append(time.monotonic())
                executed = True
                logger.info("[scheduler] 开始执行任务 (priority=%s)", Priority(priority).name)
                result = await coro_factory()
                logger.info(
                    "[scheduler] 任务执行完成 (%.0fms, priority=%s)",
                    (time.perf_counter() - t_exec) * 1000,
                    Priority(priority).name,
                )
                if not future.done():
                    future.set_result(result)
            except asyncio.CancelledError:
                if not future.done():
                    future.cancel()
                raise
            except Exception as e:
                logger.warning(
                    "[scheduler] 任务执行抛异常 %s: %s (%.0fms, priority=%s)",
                    type(e).__name__, e,
                    (time.perf_counter() - t_exec) * 1000,
                    Priority(priority).name,
                )
                if not future.done():
                    future.set_exception(e)
            finally:
                self._queue.task_done()
                if executed:
                    # 更新 metrics
                    exec_ms = (time.perf_counter() - t_exec) * 1000
                    self._metrics["executed_total"] += 1
                    self._metrics["exec_duration_ms_sum"] += exec_ms
                    try:
                        p = Priority(priority)
                        self._metrics["executed_by_priority"][p.name] += 1
                    except ValueError:
                        pass

                    delay = (
                        custom_delay_ms
                        if custom_delay_ms is not None
                        else self.send_delay_ms
                    )
                    logger.info(
                        "[scheduler] 延时 %dms 后处理下一个 (priority=%s)",
                        delay, Priority(priority).name,
                    )
                    await asyncio.sleep(delay / 1000.0)

    def get_metrics(self) -> dict:
        """返回调度器指标（供 /api/status 或 Prometheus 暴露）。

        asyncio 单线程事件循环，与 worker 同线程，无需加锁。
        """
        return {
            **self._metrics,
            "pending_count": self._queue.qsize(),
        }

    async def drain(self, timeout: float = 5.0) -> bool:
        """等待队列清空（供 watchdog kill 微信前调用）。

        通过 _queue.join() 等待所有已入队任务被 task_done。
        worker 在 task_done 后可能仍在 sleep(delay)，但队列已空，
        sleep 结束后阻塞在 get() 上，不会执行新任务。

        边界条件：
        - worker 已停止时 join 会永远等待（无人调 task_done），靠 timeout 兜底
        - drain 返回 True 后、kill 前若有新任务入队，worker 可能开始执行
          （概率低，kill 后执行失败被正常捕获）

        Args:
            timeout: 最大等待秒数

        Returns:
            True 表示队列已清空，False 表示超时未清空
        """
        try:
            await asyncio.wait_for(self._queue.join(), timeout=timeout)
            return True
        except asyncio.TimeoutError:
            logger.warning(
                "[scheduler] drain timeout %.1fs, %d tasks pending",
                timeout, self._queue.qsize(),
            )
            return False
```

### 3.2 优先级分配

| 调用点 | 优先级 | 理由 |
|---|---|---|
| `routes/login.py` wechat_restart | CRITICAL | 系统级紧急，需尽快执行 |
| `routes/diagnostic.py` autofix_wechat_running | CRITICAL | 同上 |
| `routes/login.py` logout | HIGH | 用户主动操作，感知延迟 |
| `routes/login.py` qr_start (capture_qr_code) | HIGH | 登录流程关键步骤 |
| `routes/contacts.py` accept_friend_request | HIGH | 好友申请有时效性 |
| `friend_watcher._handle_request` | HIGH | 同上 |
| `routes/send.py` send_text (flow + legacy) | NORMAL | 常规业务 |
| `routes/send.py` send_file (flow + legacy) | NORMAL | 同上 |
| `routes/send.py` revoke / forward | NORMAL | 同上 |
| `routes/contacts.py` set_remark / add_friend | NORMAL | 同上 |
| `routes/moments.py` publish / share / delete | NORMAL | 同上 |
| `routes/moments.py` like / comment | LOW | 可延迟，不阻塞主流程 |
| `routes/screenshot.py` capture_full_screenshot | LOW | 可延迟 |
| `routes/login.py` qr_wait (detect_login_state 轮询) | **不入队** | 只读探测，保持现状 |
| `LoginGuard._check_loop` | **不入队** | 只读探测，保持现状 |
| `WeChatWatchdog._run` (pgrep/xdpyinfo) | **不入队** | 系统级探测，保持现状 |
| `WeChatWatchdog._autofix_wechat` (kill) | **不入队但协同** | kill 前调 scheduler.drain() |

### 3.3 收口方案（6 个 P0 调用点）

#### 3.3.1 routes/login.py - logout

**关键**：logout 路由在执行 logout 前会先调 `detect_login_state`（只读探测，不入队）。仅 `xdotool.logout()` 入队。

```python
# 修改前（routes/login.py:201）
if state_before == LoginState.LOGGED_IN.value:
    await xdotool.logout()  # 直接调，不入队

# 修改后
if state_before == LoginState.LOGGED_IN.value:
    scheduler = _require_send_queue()
    await scheduler.execute(
        lambda: xdotool.logout(),
        priority=Priority.HIGH,
        wait_timeout_ms=30000,  # logout 多步操作，给 30s
        trace_id="logout",
    )
```

**说明**：`detect_login_state` 保持不入队（只读探测，与 LoginGuard 一致），仅 `xdotool.logout()` 入队。

#### 3.3.2 routes/login.py - wechat_restart

```python
# 修改后（routes/login.py:281）
scheduler = _require_send_queue()
new_pid = await scheduler.execute(
    lambda: xdotool.restart_wechat(timeout_sec=timeout_sec),
    priority=Priority.CRITICAL,
    wait_timeout_ms=60000,  # restart 可能慢，给 60s
    trace_id="wechat_restart",
)
```

#### 3.3.3 routes/login.py - qr_start

```python
# 修改后（routes/login.py:58-59）
scheduler = _require_send_queue()

async def _qr_capture_flow():
    await xdotool._activate_window_fast()
    return await qr_capture.capture_qr_code()

qr_data_url = await scheduler.execute(
    _qr_capture_flow,
    priority=Priority.HIGH,
    wait_timeout_ms=15000,
    trace_id="qr_start",
)
```

#### 3.3.4 routes/screenshot.py

```python
# 修改后（routes/screenshot.py:42）
scheduler = _require_send_queue()
png_bytes = await scheduler.execute(
    lambda: qr_capture.capture_full_screenshot(),
    priority=Priority.LOW,
    wait_timeout_ms=10000,
    trace_id="screenshot",
)
```

#### 3.3.5 routes/diagnostic.py - autofix_wechat_running

```python
# 修改后（routes/diagnostic.py:230-249）
scheduler = _require_send_queue()

async def _autofix_flow():
    # pkill + check_pid + start_wechat 组合
    proc = await asyncio.create_subprocess_exec(
        "pkill", "-x", "wechat",
        stdout=asyncio.subprocess.DEVNULL,
        stderr=asyncio.subprocess.DEVNULL,
    )
    await proc.wait()
    await asyncio.sleep(2)  # 等 autostart 拉起
    new_pid = await xdotool.check_wechat_pid()
    if new_pid is None:
        new_pid = await xdotool.start_wechat(timeout_sec=10)
    return new_pid

new_pid = await scheduler.execute(
    _autofix_flow,
    priority=Priority.CRITICAL,
    wait_timeout_ms=30000,
    trace_id="autofix_wechat_running",
)
```

#### 3.3.6 QrCapture 超时修复

```python
# ui/qr_capture.py 修改（L48-57）
_CMD_TIMEOUT_SEC = 5.0  # 新增常量

async def _run(self, args: list[str]) -> tuple[int, bytes, bytes]:
    """执行一条命令并返回 (returncode, stdout, stderr)。"""
    proc = await asyncio.create_subprocess_exec(
        *args,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
        env=self._env(),
    )
    try:
        stdout, stderr = await asyncio.wait_for(
            proc.communicate(), timeout=_CMD_TIMEOUT_SEC
        )
        return proc.returncode, stdout, stderr
    except asyncio.TimeoutError:
        proc.kill()
        await proc.wait()
        raise
```

#### 3.3.7 WeChatWatchdog 协同

```python
# ui/watchdog.py 修改

class WeChatWatchdog:
    def __init__(
        self,
        backend: BackendProtocol,
        interval: float = 10.0,
        fail_threshold: int = 2,
        scheduler=None,  # 新增可选参数，向后兼容
    ) -> None:
        self.backend = backend
        self.interval = interval
        self.fail_threshold = fail_threshold
        self._scheduler = scheduler  # UIActionScheduler 实例（用于 drain）
        self._fail_count = 0
        self._task: Optional[asyncio.Task] = None
        self._stopped = False

    async def _autofix_wechat(self) -> None:
        logger.warning("[watchdog] autofix: killing wechat for restart")

        # kill 前等待 UI 操作队列清空（避免 kill 正在执行的 UI 操作）
        if self._scheduler is not None:
            drained = await self._scheduler.drain(timeout=5.0)
            if not drained:
                logger.warning(
                    "[watchdog] drain timeout, force kill with %d UI ops pending",
                    self._scheduler.pending_count(),
                )

        # 原有 kill 逻辑（pgrep + kill -TERM）保持不变
        try:
            proc = await asyncio.create_subprocess_exec(
                "pgrep", "-x", "wechat",
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
            # ... 原有逻辑
```

**app.py 注入**：
```python
# app.py:499 修改
_state.watchdog = WeChatWatchdog(
    backend=_state.xdotool_backend,
    interval=10.0,
    scheduler=_state.send_queue,  # 新增：注入调度器
)
```

### 3.4 注入与生命周期

```python
# app.py _init_state 修改（替换 L383-L387）
from woc_bridge.messaging.ui_action_scheduler import UIActionScheduler, Priority

_state.send_queue = UIActionScheduler(  # 替换原 SendQueue
    send_delay_ms=cfg.send_delay_ms,
    max_calls_per_sec=cfg.max_calls_per_sec,
    max_queue_size=cfg.max_queue_size,
)
# _state.send_queue 类型注解保持 SendQueue（多态，UIActionScheduler 是子类）
# 现有 _require_send_queue() 返回 SendQueue 类型，调用方无需修改
# 调用方可用 hasattr(scheduler, 'execute') 判断是否支持优先级

# watchdog 注入 scheduler（app.py:499 修改）
_state.watchdog = WeChatWatchdog(
    backend=_state.xdotool_backend,
    interval=10.0,
    scheduler=_state.send_queue,  # 新增：注入调度器用于 drain
)
```

**生命周期不变**：
- `send_queue.start()` / `send_queue.stop()` 继承父类，lifespan 中保持原顺序
- `watchdog.start()` / `watchdog.stop()` 保持原顺序
- shutdown 时 `verify_bus.clear()` 在 `send_queue.stop()` 之前（已有逻辑）

### 3.5 Metrics 暴露

#### 3.5.1 StatusResponse 模型扩展

```python
# bridge/woc_bridge/models/status.py 修改
class StatusResponse(BaseModel):
    # ... 现有字段保持不变 ...
    send_queue_pending: int = Field(default=0, description="发送队列积压任务数")

    # 新增字段（Optional，向后兼容）
    ui_scheduler: Optional[dict] = Field(
        default=None,
        description="UI 调度器指标（仅 UIActionScheduler 实例才有）",
    )
```

#### 3.5.2 routes/status.py 暴露 metrics

```python
# routes/status.py 修改（L96 附近）
send_queue = _state.send_queue
send_queue_pending = send_queue.pending_count() if send_queue else 0
ui_scheduler_metrics = None
if send_queue is not None and hasattr(send_queue, 'get_metrics'):
    ui_scheduler_metrics = send_queue.get_metrics()

return StatusResponse(
    # ... 现有字段 ...
    send_queue_pending=send_queue_pending,
    ui_scheduler=ui_scheduler_metrics,
    # ...
)
```

返回示例：
```json
{
  "send_queue_pending": 0,
  "ui_scheduler": {
    "executed_total": 1234,
    "executed_by_priority": {
      "CRITICAL": 2,
      "HIGH": 15,
      "NORMAL": 1200,
      "LOW": 17
    },
    "wait_duration_ms_sum": 45678.9,
    "exec_duration_ms_sum": 234567.8,
    "timeout_total": 3,
    "rate_limited_total": 1,
    "pending_count": 0
  }
}
```

#### 3.5.3 激活已存在的 Prometheus metrics

**发现问题**：`ui/metrics.py:58` 已定义 `woc_send_queue_pending` Gauge，但**从未在任何地方 set 它的值**（已存在的 bug）。

```python
# ui/metrics.py 已有定义（无需修改）
send_queue_pending = Gauge(
    "woc_send_queue_pending",
    "Send queue pending count",
)

# 新增：在 routes/status.py 或 app.py 中激活
# 方案 A：在 status 接口中 set（每次查询时更新）
from woc_bridge.ui.metrics import send_queue_pending as send_queue_pending_gauge
if send_queue is not None:
    send_queue_pending_gauge.set(send_queue.pending_count())

# 方案 B：在 UIActionScheduler._run 中 set（每次出队时更新）
# 更实时但会增加 metrics 写入频率
```

**推荐方案 A**：在 status 接口中 set，与现有 `send_queue_pending` 字段同步更新，避免高频写入 Prometheus。

#### 3.5.4 新增 Prometheus metrics（可选，阶段 3）

```python
# ui/metrics.py 新增
from prometheus_client import Counter, Gauge, Histogram

ui_action_executed = Counter(
    "woc_ui_action_executed_total",
    "UI actions executed total",
    ["priority"],  # CRITICAL / HIGH / NORMAL / LOW
)

ui_action_wait_duration = Histogram(
    "woc_ui_action_wait_duration_seconds",
    "UI action wait duration (queue wait time)",
    buckets=(0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0),
)

ui_action_exec_duration = Histogram(
    "woc_ui_action_exec_duration_seconds",
    "UI action execution duration",
    ["priority"],
    buckets=(0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0),
)

ui_action_timeout = Counter(
    "woc_ui_action_timeout_total",
    "UI action wait timeout count",
)

ui_action_rate_limited = Counter(
    "woc_ui_action_rate_limited_total",
    "UI action rate limited (queue full) count",
)
```

在 `UIActionScheduler._run` 和 `_enqueue_with_priority` 中埋点。

---

## 四、迁移路径

### 阶段 1：基础设施（无破坏性）

**目标**：引入 UIActionScheduler，向后兼容现有调用

**改动**：
1. 新增 `messaging/ui_action_scheduler.py`（Priority + UIActionScheduler 类）
2. `app.py` 将 `SendQueue(...)` 替换为 `UIActionScheduler(...)`
3. `watchdog.py` 增加 `scheduler` 参数（可选，向后兼容）
4. `routes/status.py` 暴露 metrics

**验证**：
- py_compile 通过
- 现有 16 个 `enqueue` 调用点无需修改，行为不变
- `/api/status` 返回 `ui_scheduler` 字段

### 阶段 2：收口 P0 调用点

**目标**：消除 6 个绕过队列的竞态风险 + QrCapture 超时修复

**改动**：
1. `routes/login.py`：logout / restart / qr_start 改用 `scheduler.execute`
   - logout 仅 `xdotool.logout()` 入队，`detect_login_state` 保持不入队
2. `routes/screenshot.py`：改用 `scheduler.execute(priority=LOW)`
3. `routes/diagnostic.py`：autofix 改用 `scheduler.execute(priority=CRITICAL)`
4. `ui/qr_capture.py`：增加 `_CMD_TIMEOUT_SEC=5.0` 包裹 `proc.communicate()`
5. `ui/watchdog.py`：构造函数新增 `scheduler` 参数；`_autofix_wechat` 增加 `scheduler.drain()` 调用
6. `app.py`：`WeChatWatchdog(...)` 注入 `scheduler=_state.send_queue`

**验证**：
- py_compile 通过
- 并发调用 `POST /api/login/logout` + `POST /api/send/text` 不再竞态（logout 入队等待）
- `POST /api/screenshot` 与 send_text 串行执行
- watchdog autofix 前等待 UI 队列清空（`drain` 返回 True 后再 kill）
- QrCapture scrot 卡死时 5s 超时（不再无限阻塞）

### 阶段 3：可观测性增强（可选）

**目标**：提供 Prometheus metrics 和详细日志

**改动**：
1. `models/status.py`：`StatusResponse` 新增 `ui_scheduler: Optional[dict]` 字段
2. `routes/status.py`：暴露 `ui_scheduler` metrics + 激活已存在的 `woc_send_queue_pending` Gauge
3. `ui/metrics.py`：新增 `woc_ui_action_*` 系列 metrics（Counter/Histogram）
4. `UIActionScheduler._run` / `_enqueue_with_priority`：埋点写入 Prometheus metrics
5. 日志增加 `priority` 和 `trace_id` 字段（已在阶段 1 代码中包含）

**验证**：
- `GET /api/status` 返回 `ui_scheduler` 字段
- `/metrics` 端点返回 `woc_ui_action_executed_total` 等指标
- 日志可按 `priority=CRITICAL` 过滤
- 已存在的 `woc_send_queue_pending` Gauge 不再为 0（bug 修复）

---

## 五、验收标准

### AC-01：UIActionScheduler 向后兼容

**Given** 现有 16 个 `enqueue` 调用点未修改
**When** `app.py` 将 `SendQueue(...)` 替换为 `UIActionScheduler(...)`
**Then** 所有现有功能行为不变，py_compile 通过，单元测试通过

### AC-02：优先级调度生效

**Given** 队列中有 10 个 NORMAL 优先级的 send_text 任务
**When** 提交一个 CRITICAL 优先级的 wechat_restart
**Then** wechat_restart 优先于剩余 NORMAL 任务执行（可能在当前 NORMAL 任务执行完后立即执行）

### AC-03：logout 与 send_text 不再竞态

**Given** 一个 send_text 正在 send_queue 中执行
**When** 并发调用 `POST /api/login/logout`
**Then** logout 入队等待，send_text 执行完后 logout 才开始执行

### AC-04：QrCapture 有超时保护

**Given** scrot 子进程卡死
**When** `QrCapture._run` 执行
**Then** 5 秒后超时，`proc.kill()` + `await proc.wait()`，抛出 TimeoutError

### AC-05：watchdog autofix 协同

**Given** send_queue 中有 5 个 pending 任务
**When** watchdog 触发 `_autofix_wechat`
**Then** 先 `scheduler.drain(timeout=5.0)` 等待队列清空，超时则强制 kill 并记 warning

### AC-06：metrics 暴露

**Given** 调度器执行了若干操作
**When** 调用 `GET /api/status`
**Then** 返回 `ui_scheduler` 字段，包含 `executed_total` / `executed_by_priority` / `pending_count` 等指标

### 5.1 关键场景时序图

#### 5.1.1 修改前：logout 与 send_text 并发竞态

```
时刻 T0: send_text 正在执行（队列 worker 持有 coro_factory）
         ┌─────────────────────────────────────────────────────┐
Worker:  │ send_text Flow:                                    │
         │   activate → click_search → type_query → ...       │
         │   ↑ 每步持 L1 _ui_lock，步骤间释放                  │
         └─────────────────────────────────────────────────────┘

时刻 T1: HTTP 请求 POST /api/login/logout 到达
         ↓ logout 路由直接调 xdotool.logout()（不入队）
         ↓ logout 尝试 acquire _ui_lock → 等待 send_text 释放

时刻 T2: send_text 执行 click_search_box 完成释放 _ui_lock
         ↓ logout 抢到 _ui_lock，执行 activate
         ↓ logout 释放 _ui_lock
         ↓ send_text 抢到 _ui_lock，执行 type_query
         ↓ ❌ 焦点已被 logout 改变，type_query 输入到错误位置！

时刻 T3: logout 再次抢 _ui_lock 执行 click_main_menu
         ↓ ❌ send_text 可能仍在执行，菜单状态不可预测

结果：send_text 发错会话，logout 失败，UI 状态混乱
```

#### 5.1.2 修改后：logout 与 send_text 串行执行

```
时刻 T0: send_text 正在执行（worker 持有）
         ┌─────────────────────────────────────────────────────┐
Worker:  │ send_text Flow (NORMAL priority)                   │
         │   activate → click_search → type_query → ...       │
         └─────────────────────────────────────────────────────┘

时刻 T1: HTTP 请求 POST /api/login/logout 到达
         ↓ logout 路由调 scheduler.execute(logout, HIGH, 30s)
         ↓ 入队 (HIGH, seq=N)，pending=1
         ↓ worker 仍在执行 send_text，logout 等待 future

时刻 T2: send_text 完成，worker 出队
         ↓ PriorityQueue 出队 (HIGH, N)（优先于其他 NORMAL 任务）
         ↓ 执行 xdotool.logout() 完整流程（多步操作不被穿插）
         ↓ logout 完成，返回 success

结果：send_text 与 logout 串行，UI 状态正确
```

#### 5.1.3 watchdog kill 与 drain 协同

```
时刻 T0: send_queue 中有 5 个 pending send_text
         worker 正在执行第 1 个 send_text

时刻 T1: watchdog 检测到 wechat 不响应（fail_count >= 2）
         ↓ watchdog 调 _autofix_wechat
         ↓ scheduler.drain(timeout=5.0)
         ↓ 等待 _queue.join()，worker 继续执行当前任务

时刻 T2: worker 完成当前 send_text（task_done）
         ↓ _queue.join() 检查未完成任务数
         ↓ 仍有 4 个 pending（task_done 计数未归零）
         ↓ worker 出队下一个 send_text 并执行...
         ↓ ⚠️ 若每个 send_text 耗时 > 1.25s，5s 内无法清空

时刻 T3a (5s 内清空):
         ↓ drain 返回 True
         ↓ scheduler.mark_wechat_dead(15.0)  ← 启动 fast-fail 窗口
         ↓ pgrep + kill -TERM wechat
         ↓ 后续 15s 内新入队任务直接 fast-fail（WECHAT_NOT_READY）

时刻 T3b (5s 超时未清空):
         ↓ drain 返回 False，记 warning
         ↓ scheduler.mark_wechat_dead(15.0)
         ↓ pgrep + kill -TERM wechat
         ↓ ❌ 仍有 N 个 pending 任务，kill 后每个都会失败一次

时刻 T4: autostart 拉起新 wechat 进程（~5-10s）
         ↓ mark_wechat_dead 窗口未到期，新任务继续 fast-fail
         ↓ 窗口到期后，新任务正常执行（wechat 已就绪）

结果：drain 减少失败任务数，mark_wechat_dead 避免失败风暴放大
```

#### 5.1.4 优先级插队场景

```
时刻 T0: 队列状态：[send1(NORMAL,seq=1), send2(NORMAL,seq=2), ..., send10(NORMAL,seq=10)]
         worker 正在执行 send1

时刻 T1: HTTP 请求 POST /api/wechat/restart 到达
         ↓ scheduler.execute(restart, CRITICAL, 0ms, 60s)
         ↓ 入队 (CRITICAL=0, seq=11)
         ↓ PriorityQueue 重排：[(0,11), (2,2), (2,3), ..., (2,10)]
         ↓ pending=10（restart 排第 1）

时刻 T2: send1 完成，worker 出队
         ↓ 出队 (0, 11) → restart 任务
         ↓ scheduler.mark_wechat_dead(8.0)  ← 标记 fast-fail 窗口
         ↓ 执行 restart_wechat (60s 超时)

时刻 T3: restart 完成（~5s）
         ↓ 后续 8s 内 (mark_wechat_dead 窗口)：
         ↓   send2-send10 出队 → 检查 _wechat_dead_until → fast-fail
         ↓   客户端收到 WECHAT_NOT_READY + retry_after=5
         ↓ 8s 后窗口到期，新任务正常执行

结果：restart 在 1 个 send 完成后立即执行（不等 send2-send10），
      后续 send 通过 fast-fail 快速失败，避免 9 次无谓的 xdotool 超时
```

---

## 六、风险与依赖

### 6.1 技术风险

| 风险 | 概率 | 影响 | 缓解 |
|---|---|---|---|
| PriorityQueue 元组比较失败 | 低 | worker 崩溃 | `coro_factory` 不可比较，用 `seq` 序列号作为第二排序键，`(priority, seq)` 组合唯一，不会比较到 coro_factory |
| 父类 `_run` 与子类 `_run` 元组结构不一致 | 中 | 父类被误调用时解包失败 | 子类必须覆盖 `_run`；代码注释明确标注"覆盖父类"；单元测试验证子类 `_run` 被调用 |
| 父类 `__init__` 创建 `asyncio.Queue` 后被子类 `asyncio.PriorityQueue` 覆盖 | 低 | 内存短暂多出一个未使用 Queue 对象 | Python 属性遮蔽合法，旧 Queue 被 GC 回收，无副作用 |
| 优先级反转导致 HIGH 操作被 LOW 阻塞 | 中 | logout 等待 screenshot 完成 | 限制单次操作超时（已有 5s/30s），screenshot 入 LOW 但超时 10s |
| drain 阻塞 watchdog | 中 | autofix 延迟 | drain 超时 5s 后强制 kill，不无限等待 |
| drain 返回 True 后 worker 仍在 sleep | 低 | kill 时机略早于 sleep 结束 | 队列已空，worker sleep 结束后阻塞在 `get()` 上，不会执行新任务；kill 后 worker 执行失败被正常捕获 |
| 收口后 logout 等 HTTP 接口延迟增加 | 中 | 用户感知 | priority=HIGH 保证优先级，wait_timeout_ms 兜底 |
| `put_nowait` 与父类 `await put` 行为差异 | 低 | 满队时子类立即抛错，父类先检查再 put | 行为等价（父类也有前置 qsize 检查），子类更简洁 |
| `task_done()` 调用次数 | 低 | 计数器异常 | cancelled 分支和 finally 分支互斥（continue 跳过 finally），每个 `get()` 对应一次 `task_done()`，与父类一致 |
| metrics 并发读写 | 低 | 数据轻微不准 | asyncio 单线程事件循环，worker 与 HTTP handler 同线程，无需加锁；`asyncio.to_thread` 释放事件循环时不读写 `_metrics` |

### 6.2 依赖

- 依赖现有 `SendQueue` 实现稳定（已验证 16 个调用点）
- 依赖 `_ui_lock` 作为底层互斥（保持不变）
- 依赖 `asyncio.PriorityQueue`（Python 3.8+ 标准库，无外部依赖）

### 6.3 非目标

- **不实现优先级抢占**：xdotool 子进程不可中断，低优先级操作开始后必须等其完成
- **不实现优先级继承**：xdotool 子进程无法感知调用方优先级
- **不重命名 `SendQueue` 类**：保持向后兼容，UIActionScheduler 继承之
- **不修改 `_ui_lock`**：保持作为底层单命令互斥的第二道防线
- **不收口 LoginGuard / MessageStreamer**：它们是只读探测，无需入队

### 6.4 失败风暴问题与 fast-fail 机制

**问题场景**：当 watchdog kill 微信或 `wechat_restart` 完成后，send_queue 中可能仍有 N 个已入队的 send_text 任务。这些任务会按顺序执行，每个都因 `WINDOW_NOT_FOUND` 失败一次（直到 autostart 拉起新进程）。

**影响估算**：
- 100 个 send_text 任务排队，每个执行失败耗时 ~1s（含 5s xdotool 超时 + 异常处理）
- 微信 autostart 拉起需 ~5-10s
- 失败风暴持续：`min(N, autostart拉起前积压数) × 1s` ≈ 5-10s 内 5-10 个任务连续失败
- 客户端收到 5-10 个 503 错误，可能触发重试，进一步放大风暴

**fast-fail 机制设计**（**本方案可选实施，建议阶段 2 一并实施**）：

```python
class UIActionScheduler(SendQueue):
    def __init__(self, ...):
        super().__init__(...)
        # ...
        self._wechat_dead_until: float = 0.0  # 时间戳，在此之前所有任务直接 fast-fail

    def mark_wechat_dead(self, duration_sec: float = 10.0) -> None:
        """标记微信已死，期间所有新任务直接 fast-fail。
        
        供 watchdog kill / wechat_restart 调用：
        - kill 前调 mark_wechat_dead(15.0)，覆盖 autostart 拉起窗口
        - restart 完成后调 mark_wechat_dead(5.0)，覆盖新进程启动窗口
        """
        self._wechat_dead_until = time.monotonic() + duration_sec
        logger.warning(
            "[scheduler] mark_wechat_dead %.1fs, %d pending tasks will fast-fail",
            duration_sec, self._queue.qsize(),
        )

    async def _run(self) -> None:
        while True:
            priority, seq, coro_factory, future, custom_delay_ms = await self._queue.get()
            # ... cancelled 检查 ...
            
            # fast-fail 检查：微信已死期间所有任务直接失败
            if self._wechat_dead_until > time.monotonic():
                logger.info(
                    "[scheduler] fast-fail (wechat dead, %.1fs remaining, priority=%s)",
                    self._wechat_dead_until - time.monotonic(),
                    Priority(priority).name,
                )
                if not future.done():
                    future.set_exception(BridgeError(
                        code="WECHAT_NOT_READY",
                        message="微信进程未就绪（重启中），请稍后重试",
                        details={"retry_after": 5},
                    ))
                self._queue.task_done()
                continue  # 不延时，立即处理下一个
            
            # ... 正常执行流程 ...
```

**调用点**：
- `watchdog._autofix_wechat` kill 前调 `scheduler.mark_wechat_dead(15.0)`
- `routes/login.py wechat_restart` 开始时调 `scheduler.mark_wechat_dead(8.0)`
- `routes/diagnostic.py autofix_wechat_running` 同上

**注意事项**：
- `WECHAT_NOT_READY` 是新错误码，需在 `models/errors.py` 注册 HTTP 503 映射
- fast-fail 任务不计入 `executed_total`，应单独记 `fast_fail_total` 指标
- CRITICAL 任务（如 restart 本身）不应被 fast-fail 拦截，需在检查中排除：
  ```python
  if self._wechat_dead_until > time.monotonic() and priority > Priority.CRITICAL:
  ```

**替代方案对比**：

| 方案 | 优点 | 缺点 |
|---|---|---|
| fast-fail（推荐） | 立即拒绝，客户端快速收到错误并退避 | 需新增错误码 + 调用点埋点 |
| drain + cancel pending | 队列清空，无失败任务 | cancel 已入队 future 复杂，可能误取消正在执行的任务 |
| 不处理（保持现状） | 实现简单 | 5-10 个连续失败，客户端可能重试放大风暴 |

---

## 七、变更记录

| 版本 | 日期 | 修改人 | 摘要 |
|---|---|---|---|
| v1.0 | 2026-07-17 | - | 初稿，基于 SendQueue 调研设计 UIActionScheduler 统一调度方案 |
| v1.1 | 2026-07-17 | - | 深度复核：修正 task_done 互斥说明、put_nowait 语义、logout 前置 detect_login_state 处理、StatusResponse 模型扩展、激活已存在 Prometheus Gauge、drain 边界条件、metrics 线程安全 |
| v1.2 | 2026-07-18 | - | 深度调研优化：新增 1.4 双层保护模型（L1 _ui_lock vs L2 SendQueue）、1.5 调用点全景图（before/after）、1.6 HTTP 端点→优先级映射表、2.3.1 delay_ms 与优先级交互策略、5.1 关键场景时序图（4 个）、6.4 失败风暴问题与 fast-fail 机制（mark_wechat_dead）、UIActionScheduler 代码新增 mark_wechat_dead 方法与 _run fast-fail 检查、P1.1 detect_login_state 14 处散落调用分析与处理方案 |

---

## 八、实施前验证清单

实施前需确认以下事项，避免引入新问题：

### 8.1 代码复核清单

- [ ] `asyncio.PriorityQueue.put_nowait` 满时抛 `asyncio.QueueFull`（已确认，与 `asyncio.Queue` 行为一致）
- [ ] `asyncio.PriorityQueue` 的 `task_done()` / `join()` 语义与 `asyncio.Queue` 一致（已确认，继承关系）
- [ ] 元组 `(int, int, coro_factory, future, delay_ms)` 中 `seq` 单调递增保证唯一，不会比较到 `coro_factory`（已确认）
- [ ] 父类 `__init__` 创建的 `asyncio.Queue` 会被子类 `asyncio.PriorityQueue` 覆盖，旧对象无其他引用，GC 回收（已确认）
- [ ] 父类 `_run` 被子类覆盖，不会调用父类的 3 元组解包（已确认，代码注释标注）
- [ ] `_check_rate_limit` 继承父类，行为不变（已确认，子类不覆盖）
- [ ] `send_queue.start()` / `stop()` 继承父类，worker task 管理 不变（已确认）
- [ ] `pending_count()` 继承父类，返回 `self._queue.qsize()`（已确认，PriorityQueue 也有 qsize）

### 8.2 单元测试清单

实施时需编写以下单元测试（参考 `verify_bus.py` 的测试模式）：

```python
# test_ui_action_scheduler.py

async def test_backward_compat_enqueue():
    """enqueue 方法向后兼容，priority 默认 NORMAL。"""
    scheduler = UIActionScheduler(send_delay_ms=10, max_calls_per_sec=100)
    await scheduler.start()
    try:
        result = await scheduler.enqueue(lambda: asyncio.sleep(0.01, result="ok"))
        assert result == "ok"
        metrics = scheduler.get_metrics()
        assert metrics["executed_total"] == 1
        assert metrics["executed_by_priority"]["NORMAL"] == 1
    finally:
        await scheduler.stop()

async def test_priority_ordering():
    """CRITICAL 优先于 NORMAL 执行。"""
    scheduler = UIActionScheduler(send_delay_ms=0, max_calls_per_sec=100)
    await scheduler.start()
    try:
        # 先入队一个 NORMAL（会立即开始执行）
        normal_future = scheduler.enqueue(lambda: asyncio.sleep(0.1, result="normal"))
        # 再入队 CRITICAL 和 NORMAL
        critical_future = scheduler.execute(
            lambda: asyncio.sleep(0.01, result="critical"),
            priority=Priority.CRITICAL,
        )
        normal2_future = scheduler.execute(
            lambda: asyncio.sleep(0.01, result="normal2"),
            priority=Priority.NORMAL,
        )
        # CRITICAL 应先于 normal2 完成
        critical_result = await critical_future
        normal2_result = await normal2_future
        assert critical_result == "critical"
        assert normal2_result == "normal2"
        # 验证执行顺序：CRITICAL 在 normal2 之前
        assert scheduler.get_metrics()["executed_by_priority"]["CRITICAL"] == 1
    finally:
        await scheduler.stop()

async def test_drain_empty_queue():
    """空队列 drain 立即返回 True。"""
    scheduler = UIActionScheduler()
    result = await scheduler.drain(timeout=1.0)
    assert result is True

async def test_drain_with_pending():
    """有 pending 任务时 drain 等待完成。"""
    scheduler = UIActionScheduler(send_delay_ms=100)
    await scheduler.start()
    try:
        # 入队一个任务
        await scheduler.enqueue(lambda: asyncio.sleep(0.05, result="ok"))
        # drain 等待完成
        result = await scheduler.drain(timeout=2.0)
        assert result is True
    finally:
        await scheduler.stop()

async def test_drain_timeout():
    """任务执行超过 drain timeout 时返回 False。"""
    scheduler = UIActionScheduler(send_delay_ms=0)
    await scheduler.start()
    try:
        # 入队一个长任务
        scheduler.enqueue(lambda: asyncio.sleep(1.0, result="slow"))
        # drain 短超时
        result = await scheduler.drain(timeout=0.1)
        assert result is False
    finally:
        await scheduler.stop()

async def test_queue_full_rate_limited():
    """队列满时抛 RATE_LIMITED。"""
    scheduler = UIActionScheduler(send_delay_ms=1000, max_queue_size=2)
    await scheduler.start()
    try:
        # 填满队列（1 个执行中 + 2 个排队）
        scheduler.enqueue(lambda: asyncio.sleep(0.5))
        scheduler.enqueue(lambda: asyncio.sleep(0.01))
        scheduler.enqueue(lambda: asyncio.sleep(0.01))
        # 第 4 个应被拒绝
        with pytest.raises(BridgeError) as exc_info:
            await scheduler.enqueue(lambda: asyncio.sleep(0.01))
        assert exc_info.value.code == "RATE_LIMITED"
    finally:
        await scheduler.stop()

async def test_wait_timeout():
    """等待超时抛 TIMEOUT。"""
    scheduler = UIActionScheduler(send_delay_ms=1000, max_calls_per_sec=100)
    await scheduler.start()
    try:
        # 第一个任务慢
        scheduler.enqueue(lambda: asyncio.sleep(0.5))
        # 第二个任务等待超时
        with pytest.raises(BridgeError) as exc_info:
            await scheduler.enqueue(
                lambda: asyncio.sleep(0.01),
                wait_timeout_ms=100,  # 100ms 超时
            )
        assert exc_info.value.code == "TIMEOUT"
    finally:
        await scheduler.stop()

async def test_metrics_accuracy():
    """metrics 准确记录执行次数和优先级。"""
    scheduler = UIActionScheduler(send_delay_ms=0, max_calls_per_sec=100)
    await scheduler.start()
    try:
        await scheduler.execute(lambda: "a", priority=Priority.CRITICAL)
        await scheduler.execute(lambda: "b", priority=Priority.HIGH)
        await scheduler.execute(lambda: "c", priority=Priority.NORMAL)
        await scheduler.execute(lambda: "d", priority=Priority.LOW)
        metrics = scheduler.get_metrics()
        assert metrics["executed_total"] == 4
        assert metrics["executed_by_priority"]["CRITICAL"] == 1
        assert metrics["executed_by_priority"]["HIGH"] == 1
        assert metrics["executed_by_priority"]["NORMAL"] == 1
        assert metrics["executed_by_priority"]["LOW"] == 1
        assert metrics["pending_count"] == 0
    finally:
        await scheduler.stop()
```

### 8.3 集成测试清单

阶段 2 实施后需进行集成测试：

- [ ] 并发 30 个 `POST /api/send/text` + 1 个 `POST /api/login/logout`：logout 在所有 send 之前或之后执行，不穿插
- [ ] `POST /api/screenshot` 与 `POST /api/send/text` 并发：screenshot 串行等待
- [ ] watchdog autofix 触发时 send_queue 有 pending：先 drain 再 kill
- [ ] `GET /api/status` 返回 `ui_scheduler` 字段且 `executed_by_priority` 计数正确
- [ ] `/metrics` 端点 `woc_send_queue_pending` Gauge 不再恒为 0

### 8.4 回归测试清单

阶段 1 实施后需验证现有功能不退化：

- [ ] `POST /api/send/text` 正常发送（16 个 enqueue 调用点之一）
- [ ] `POST /api/friends/accept` 正常通过好友（friend_watcher + routes/contacts 共用 enqueue）
- [ ] `POST /api/moments/publish` 正常发朋友圈（routes/moments enqueue）
- [ ] BatchWorker 群发正常串行（间接经 orchestrator → enqueue）
- [ ] `GET /api/status` 的 `send_queue_pending` 字段仍正确
