# 架构详解 本文面向想深入理解 `auto_control` 内部设计的开发者。如果你只想使用,看 [README.md](../README.md) 即可。 --- ## 1. 分层设计 平台按"配置 / 核心 / 任务 / 前端 / 数据 / 日志 / 工具"分层,职责清晰、互不交叉: | 层 | 路径 | 职责 | |----|------|------| | 配置层 | `config.py` | 项目根配置:adb 路径、web 端口、USB 远程 adb server 等基础设施。**不放任务参数** | | 核心层 | `core/` | 框架运行时:日志、设备池、adb 操作、Worker 基类、任务管理器、u2 辅助、Action 基类 | | 任务层 | `tasks/` | 每个 App 一个子包,自包含 `task.py` + `actions/`,互不依赖 | | 前端层 | `templates/admin/` | 单页应用(纯 HTML+CSS+JS,无框架) | | 数据层 | `data/` | SQLite 持久化 | | 日志层 | `logs/` | 四类日志,10MB 滚动保留 5 份 | | 工具层 | `bin/adb/` | adb 可执行文件 | | 脚本层 | `scripts/` | 实用脚本 | ### 分层原则 - **任务自包含**:每个任务的参数、Worker、操作都放在 `tasks//` 下,不污染全局 - **核心不依赖任务**:`core/` 不 import `tasks/`,任务通过注册机制接入 - **配置最小化**:`config.py` 只放基础设施配置,任务参数在各自 `task.py` 顶部 --- ## 2. 数据流 ``` ┌──────────────┐ 创建/编辑任务 ┌─────────────┐ 分发 worker ┌──────────────┐ │ 单页应用前端 │ ───────────────► │ TaskManager │ ─────────────► │ Worker(设备) │ │ (monitor.html│ └─────────────┘ └──────────────┘ │ fetch+DOM) │ ▲ │ └──────────────┘ │ 状态/心跳 │ u2 操作 │ │ ▼ │ JSON API │ ┌─────────────────┐ ▼ ┌──────────────┐ │ 设备 adb │ ┌──────────────┐ │ 看门狗监控 │ │ (IP:5555 直连 │ │ web/ 蓝图包 │ └──────────────┘ │ / USB 远程) │ │ (Flask API) │ └─────────────────┘ └──────────────┘ │ ▼ ┌──────────────┐ │ data/users.db│ SQLite 持久化(用户/分组/任务/设备池/待连接设备/自定义动作/APK记录/AI会话/经验库 + app_meta KV) └──────────────┘ ``` ### 任务执行流程 1. 前端创建 TaskJob(HTTP POST `/api/jobs`) 2. `TaskManager` 保存到 SQLite,如启用 cron 则注册到 APScheduler 3. 手动执行或 cron 触发时,`_run_job` 解析目标设备列表 4. 每台设备起一个线程 `_run_with_retry`,含重试循环 5. 线程内 `task.create_worker()` 创建 Worker,`worker.start()` 启动 6. `BaseWorker.run()` 执行设备生命周期:占用 → 连接 → setup → run_task → teardown → 释放 7. Worker 通过 `_update_status()` 实时上报状态到全局 `_WORKERS` 字典 8. 前端轮询 `/api/status`(5 秒缓存)获取设备 + Worker 状态 --- ## 3. 核心模块详解 ### 3.1 DevicePool(`core/device_pool.py`) 设备池(已摘除 OpenSTF):SQLite `devices` 表 = 设备清单,本机 adb = 在线状态。 **关键设计**: - `list_configured()` — 清单(管理页维护,enabled=False 不参与调度) - `list_online()` — 本机 adb 在线设备;池内有 USB 设备(serial 无冒号)时合并 220 远程 adb server(`USB_ADB_HOST:PORT`,host 网络模式 5037)状态 - `list_ready()` — 清单 ∩ 在线(任务调度用) - CRUD — `add_device`(upsert)/ `remove_device` / `set_enabled` - 单实例互斥由 `TaskManager._running[serial]` 内存锁保证(无跨实例占用概念) - 全模块不 connect/kill-server/disconnect(遵守共享 adb transport 红线) ### 3.2 STFDevice + BaseWorker(`core/device_worker.py`) **STFDevice** — 单设备生命周期管理: - `acquire()`:IP:5555 直连 adb connect;USB(serial 无冒号)校验 220 远程 adb server 可见性 - `release()`:无操作(不 disconnect,红线) - 互斥由 TaskManager `_running` 保证,设备池负责在线判断 **BaseWorker** — 通用 Worker 基类(继承 threading.Thread): ``` run() 主循环(不要重写): 1. acquire 设备 2. u2.connect(30s 超时保护) 3. setup(d) ← 子类可选钩子 4. run_task(d) ← 子类必须实现 5. teardown(d) ← 子类可选钩子 6. finally: release 设备 ``` **超时保护**: - `u2.connect()` 用 `ThreadPoolExecutor + 30s 超时`,防止 atx-agent 无响应永久 hang - `d.info` 用 `ThreadPoolExecutor + 10s 超时` **心跳看门狗**(`_Watchdog`): - 后台线程,每 30 秒扫描一次 - Worker 超过 120 秒无心跳 → 标记 `error` - 防止设备被占用却不干活 **全局状态注册表**(`_WORKERS`): - `serial -> status dict`,线程安全(`_WORKERS_LOCK`) - 供 `web_server` 读取实时状态,前端通过 `/api/status` 展示 ### 3.3 TaskManager(`core/task_manager.py`) 统一管理:任务类型注册、设备分组、任务计划、定时调度、重试、持久化。 **核心组成**: | 组件 | 说明 | |------|------| | `scheduler` | APScheduler BackgroundScheduler,cron 触发任务 | | `groups` | 设备分组(内存业务对象,持久化到 SQLite) | | `jobs` | 任务计划(内存业务对象,持久化到 SQLite) | | `_running` | 运行中的 worker(serial -> worker 信息) | | `_stop_requested` | 用户请求停止的 serial 集合(阻止后续重试) | | `_fg_scanner` | 前台 App 扫描器(不打扰设备) | **调度模式**: - `once`:不注册 cron,手动执行 - `cron`:注册启动 cron,到点启动所有目标设备 - `cron_stop`:注册启动 cron + 停止 cron,到点停止本任务 worker **重试策略**: - `DeviceOfflineError`:立即放弃,不重试(设备掉线短时间不会自愈) - 其他异常:按 `retry.max_attempts` 重试,间隔 `retry.delay` - 临时错误(端口耗尽):退避 max(delay, 120s) - 用户停止:加入 `_stop_requested`,阻止任何后续重试 **并发控制**:同一 serial 同时只允许一个 worker,避免冲突。 **状态缓存**:`get_status()` 带 5 秒缓存,避免每次 /api/status 都查库/adb 阻塞前端。 ### 3.4 前台 App 扫描器(`_ForegroundScanner`) **设计原则:不打扰设备**,扫描不会让设备退出当前 App。 | 设备状态 | 处理方式 | 是否打扰 | |---------|---------|---------| | worker 运行中(IP:5555) | 复用已有 ADB 连接查询 | 否 | | worker 运行中(USB) | 经 220 远程 adb server 查询 | 否 | | 完全空闲 | 返回"空闲"(不主动 connect) | 否 | > **为什么不扫描空闲设备的前台 App**:IP:5555 的 adb transport 是共享的(历史与 STF provider 共用),外部 connect/disconnect 会扰动共享连接,遵守既有红线。 ### 3.5 ADB 操作(`core/adb_helper.py`) **全局锁串行化**:`_ADB_LOCK` 确保所有 adb 调用串行执行,避免多线程竞争 adb server。 **铁律:绝不 kill-server**: - `adb kill-server` 会断开所有设备的 adb transport - 全部设备连接被重建,影响所有运行中的任务 - 同理绝不 disconnect IP:5555(共享 transport 红线,见 DEVELOPMENT.md) | 函数 | 说明 | |------|------| | `adb_connect(url, retries=5)` | adb connect(带重试,绝不 kill-server) | | `adb_connect_light(url)` | 轻量 connect(单次尝试,扫描专用) | | `adb_disconnect(url)` | adb disconnect | | `screenshot(serial)` | 截图(adb exec-out screencap -p,只读安全) | | `get_foreground_app(url)` | 获取前台 App 包名(dumpsys window) | | `identify_device(serial)` | 让设备响铃识别 | ### 3.6 数据模型(`core/models.py`) SQLAlchemy 模型,存于 `data/users.db`: | 模型 | 表名 | 说明 | |------|------|------| | `User` | user | 后台用户(Flask-Login 认证,Werkzeug 哈希密码;`is_admin` 管理员 + `perms` 业务权限位) | | `DeviceGroup` | device_group | 设备分组(serials 存 JSON) | | `TaskJob` | task_job | 任务计划(target/params/schedule/retry 存 JSON) | | `CustomAction` | custom_action | 自定义动作(步骤打包,steps 存 JSON) | | `ApkFile` | apk_file | APK 文件元信息 | | `Device` | device | 设备池清单(替代 STF 池;enabled=False 不参与调度,含 model 型号列) | | `PendingDevice` | pending_device | 自动发现「待连接池」(扫描发现、用户确认后才入正式池) | > 表名默认取类名小写(models.py 未写 `__tablename__`)。另有三张非模型表,由原生 SQL 幂等创建、**不走 SCHEMA_MIGRATIONS**: > - `app_meta`(KV):`_migrate_schema()` 内建表,存 `schema_version`、`discovery_*`、agent 配置 `agent_*` 等; > - `agent_conversation` / `agent_experience` / `experience_audit`:AI 控制台会话 / 任务级经验(配方)/ 经验巡检(`web/agent_api.py` 顶部 `CREATE TABLE IF NOT EXISTS`)。 > - `agent_action`:**动作经验库**(命名动作 = 可复用单元,steps 用编辑器 schema 且带元素定位、禁坐标);由任务成功后从**成功步骤**蒸馏,执行前按名字/别名召回并注入(`web/agent_api.py` `_distill_actions/_find_actions`)。 **数据库初始化**(`init_db`): - 创建所有表 - 首次启动创建默认管理员 `admin/admin123` - 自动迁移旧 `groups.json` / `jobs.json` 到 SQLite(迁移后归档为 `.migrated`) - 版本化 schema 迁移(`SCHEMA_MIGRATIONS`,**当前到 v4**,见 `core/models.py`):结构变更必须追加迁移条目,`create_all` 只建新表不加列 ### 权限模型 - `User.perms` 存业务权限位 JSON 数组(`tasks`/`devices`/`apks`/`logs`),`is_admin=true` 拥有全部权限(`has_perm` 短路) - 后端统一用 `@perm_required(PERM_X)` / `@admin_required` 装饰器拦截(web/auth.py),无权限返回 403; 查看类 GET 接口只要求登录;用户管理、adb 终端(`/api/adb/cmd`)强制 `admin_required` - adb 终端安全红线:拒绝 `kill-server` / `disconnect`(共享 adb transport) - 前端 `loadMe()` 拉取 `/api/me`,用 `data-perm` 属性隐藏无权限的 tab/按钮,行内按钮用 `_can(perm)` 判断 - 安全兜底:**前端隐藏只是 UX,权限强制在后端**;新增路由时按"写操作必须带权限装饰器"的约定 ### 3.7 APK 管理(`core/apk_manager.py`) APK 上传/解析/批量安装。 **设备连接策略(直连)**: - 直接 `adb connect serial`(serial 是 IP:5555) - 不经过占用/释放(单实例互斥由调度器内存锁保证) - 安装后不主动 disconnect(共享 adb transport 红线) **安装流程**: 1. 上传 APK → 保存到 `data/apks/` → pyaxmlparser 解析包名/版本 → 入库 2. 批量安装 → 后台线程 → 每台设备直连 adb install → 验证包名 3. 跳过 worker 运行中的设备(避免打断任务) ### 3.8 元素抓取(`core/uiauto_helper.py`) 封装 uiautodev 本地服务(端口 20242)的客户端。 | 函数 | 说明 | |------|------| | `is_running()` | 探测 uiauto2 服务是否运行 | | `list_devices()` | 获取 uiauto2 已连接的设备列表 | | `get_screenshot(serial)` | 获取设备截图(JPEG) | | `get_elements(serial)` | 获取设备 UI 元素树(扁平化列表) | 元素树解析:递归提取每个节点的 `resource-id/text/content-desc/class/bounds` 等属性,并推荐最佳选择器(优先 xpath)。 **XPath 序号语义(重要)**:同一属性多个实例时,生成 **`(//*[@resource-id="x"])[k]`**(整体加括号 = 第 k 个匹配)。 不可写成 `//*[@resource-id="x"][k]`——那在 XPath 里是"**在其父节点中排第 k**",多实例时 `[2..n]` 会全部匹配不到 (2026-09-10 实测修复:抖音底部 4 个 tab 同 id,旧写法除 `[1]` 外全失效)。执行器 `tasks/generic/task.py` 对**历史遗留**的 `//*[@attr=…][k]` 形态做窄范围纠正(`_norm_legacy_xpath`,只改前缀、不动结构路径的兄弟序号)。 ### 3.9 屏幕 OCR(`core/ocr.py`) 条件判断的 `ocr` 选择器实现:截屏 → RapidOCR(ONNX 推理,中英文模型随包内置)→ 关键词匹配 → 返回文字中心像素坐标(与 u2 `d.click` 一致)。 - **跨平台**(Windows/Linux/macOS),依赖 `rapidocr_onnxruntime`;服务器无显示器环境建议将 opencv-python 换成 opencv-python-headless - 引擎懒加载单例 + 并发加锁(识别约 0.2-0.5s/次) - 返回坐标约定:像素、原点左上 ### 3.10 Web 层蓝图包(`web/`) 路由按功能域拆分(模块化开发底线,便于定位问题): | 模块 | 职责 | |------|------| | `auth.py` | 登录/登出/CSRF/权限装饰器/页面路由(/、/wall) | | `monitor.py` | 状态/运行控制/设备操作/远程看屏(流+缩略图+触控) | | `tasks_api.py` | 任务计划/分组/自定义动作/元素抓取/步骤测试 | | `admin_api.py` | 用户管理/日志 | | `tools_api.py` | adb 终端/剪贴板注入/应用版本 | | `devices_api.py` | 设备池管理 + 自动发现(扫描/确认/忽略/手动重连/型号采集) | | `apks_api.py` | 应用管理 | | `tailscale_api.py` | Tailscale 管理 | | `agent_api.py` | AI 控制台:会话 / 运行 / SSE / 停止 / 配置 / 经验库与巡检 / **动作库**(读写 `agent_conversation`、`agent_experience`、`agent_action`、app_meta `agent_*`) | | `system_api.py` | 系统数据备份导出 / 导入恢复(`/api/system/backup/*`,仅 admin) | | `common.py` | 跨模块共享(合并设备列表/屏幕状态) | | `context.py` | 共享对象注入(mgr/apk_mgr/device_pool) | `web_server.py` 只做装配与启动(266 行):app 创建;`init_db` 前消费待生效备份恢复(`consume_pending_restore`);初始化 device_pool / device_discovery;装配 TaskManager / ApkManager;注册 10 个蓝图(auth/monitor/tasks/admin/tools/devices/apks/tailscale/agent/system,`web/__init__.py`);注册经验巡检 APScheduler(03:47 Asia/Shanghai);uiautodev 子进程启停与设备池预连接线程;启动。 --- ## 4. 任务系统设计 ### 4.1 注册机制 ``` tasks/__init__.py ├── from .base import BaseTask, register_task, list_task_types, get_task_class ├── from .douyin import task # 触发 @register_task └── from .generic import task # 触发 @register_task ``` `@register_task` 装饰器将 Task 类注册到全局 `_TASK_TYPES` 字典,key 为 `task_type` 字符串。 ### 4.2 参数深合并 Job 下发时只传需要覆盖的字段,调度器做三层合并: 1. **顶层字段**:Job params 覆盖 DEFAULT_PARAMS 2. **actions 字段**:参数级深合并 - 前端没传的 action → 用默认 - 前端传了 → `enabled` 和 `params` 分别合并 - `params` 再深合并一层(保留前端没传的子参数) 示例:只想改点赞概率,Job params 只需: ```json {"actions": {"like": {"params": {"rate": 0.5}}}} ``` ### 4.3 Action 系统 每个 App 有独立的 Action 注册表(`create_action_registry()`),互不污染。 ``` core/actions/base.py — BaseAction 全局基类 + should_trigger + register_action tasks/douyin/actions/base.py — ACTIONS = create_action_registry() + list/get 函数 tasks/douyin/actions/like.py — @register_action(ACTIONS) LikeAction ``` **循环导入坑**:`actions/__init__.py` 必须先 `from .base import ACTIONS`,再 `from . import like`。 --- ## 5. 前端设计 ### 5.1 单页应用 `templates/admin/monitor.html` 是纯 HTML+CSS+JS 单页应用,无框架依赖。 - **Tab 切换**:7 个顶级 Tab(监控/任务/日志/用户/工具/AI 控制台/系统),均在 monitor.html 内 `.tab-panel` 切换(纯 DOM 操作);独立页面仅 `/login`、`/wall`。原"分组"已无顶层入口(移到工具页子分栏) - **数据获取**:`fetch()` 调 JSON API,5 秒轮询 `/api/status` - **状态渲染**:设备表格、任务卡片、进度条、徽章,纯 DOM 操作 ### 5.2 步骤编辑器 `generic_steps` 任务的步骤编辑器(任务弹窗已加宽到 1000px): - 左侧操作库(拖拽源,按 交互操作/屏幕与App/流程控制 分组) - 中间画布(步骤卡片列表,HTML5 Drag API 排序 + 跨层级嵌套:循环套循环、动作组) - 每个步骤卡片可展开参数表单 - 选择器字段旁有"抓取元素"按钮(独立模态框) - **容器步骤**(loop/group/if_el):卡片内嵌子步骤容器接收拖入;if_el 有"✅找到时/❌未找到时"两个独立分支容器,分支可嵌套任意步骤 - **条件判断**(if_el):选择器支持 xpath 等 UI 树选择器或 OCR识别(`core/ocr.py`,截屏匹配图片/画布文字,命中可自动点击) - 所有递归操作(选择打包、存自定义动作、校验、防循环自套)统一遍历 children/then/else 三个子数组 ### 5.3 页内子分栏 任务/工具/系统 Tab 用通用 `showSubTab(tabId, name)` 实现页内子分栏:每个子分栏一个 `.sub-panel`, `_activeSubs` 记住各 Tab 上次选中的子分栏。现状子分栏: - **任务**:任务计划 / 自定义动作 - **工具**:剪贴板注入 / adb 远程终端 / Tailscale 管理 / 应用管理 / 应用版本管理 / 设备已装应用 / 设备池管理 / 设备分组 - **系统**:数据备份 / 导入恢复 (原"分组"顶级 Tab 与"维护/STF 服务"子分栏已不存在——分组已移入工具页子分栏,STF 已摘除。) ### 5.4 AI 控制台的记忆面板 AI 控制台(顶级 Tab)右上角两个模态框,管理自进化记忆: - **🧠 经验库**:任务级经验(`agent_experience`,整任务配方)+ 每日 AI 巡检建议(删除需人工确认)。 - **🎬 动作库**:动作级经验(`agent_action`)——命名动作(可含 1~N 步)+ 编辑器 schema 步骤 + **元素定位(禁坐标)**;由任务成功后从**成功步骤**自动蒸馏,执行前按名/别名召回注入;面板支持查看/编辑/删除/手动新建(保存经服务端校验,坐标步骤被拒)。 - **会话列表显示会话 ID**(前 8 位,等宽小字),点击即复制完整 ID——便于反馈问题时引用 `conv=`。 #### 5.4.1 回答渲染与 token(2026-09-10) | 能力 | 落点 | 说明 | |------|------|------| | **Markdown 渲染** | `static/admin/markdown.js`(`renderMarkdown()`) | 自研轻量渲染器,**不引 CDN**(生产 220 在内网):标题/段落/软换行/粗斜体/删除线/行内代码/围栏代码块/有序无序列表(含嵌套)/引用/表格/分隔线/链接。**先 `esc()` 转义再套标记**,模型输出里的 HTML 只显示为文本(防注入) | | **推理链可折叠** | `agent.js` `_appendReasoning()` + `
` | 流式思考时自动展开、正文开始时自动收起;用户手动点过 `summary` 后不再自动改(`dataset.touched`);摘要显示「思考过程(N 字)」 | | **token 显示** | `mcp_agent/agent.py` `_accumulate_usage()` + `agent_api` SSE `usage` 事件 | 每次模型调用完成后推**本轮累计**(`prompt/completion/total/calls`);单条消息脚注 + 顶栏「本会话累计」(历史 + 运行中) | | **推理链/用量落库** | `_agent_thread` 把 `usage`、`reasoning` 写进会话 assistant 消息 | 刷新页面后仍可回看;**回灌模型上下文时只取 `role`/`content`**(不污染 token) | > **token 采集的兼容性**:请求带 `stream_options: {"include_usage": true}`,按「每次模型调用」取末尾 chunk 的 `usage` 累加(多轮工具调用会多次累加)。个别网关不认该参数会直接 **HTTP 400** → `_UsageUnsupported` 捕获后**自动关掉并重试一次**(`self._include_usage=False`),不影响主流程。 > > **推理链体积**:只保留前 `_REASONING_KEEP`=6000 字符落库(会话消息上限 60 条),避免历史无限膨胀。 > **蒸馏健壮性(2026-09-10)**:经验/动作靠**模型蒸馏**落库。推理型模型会把 token 预算烧在 `reasoning` 上,导致 `content` 为空或被截断(`finish_reason=length`)→ 早期只读 `content`,经验/动作被**静默丢弃**("小红书·苏州饭店"案例)。现策略: > 1. 蒸馏调用**关闭推理**:`"thinking": {"type": "disabled"}`(该代理支持;实测关掉后 reasoning=0、正文正常,配方 3/3 合格)——这是关键修复; > 2. 配方用**纯文本问法**(不要放可照抄的占位示例,否则模型会原样当配方存下来)+ 质量门槛 `_recipe_ok`(过短/含省略号占位 → 丢弃并重试); > 3. 动作提炼用 JSON + **截断容忍**提取(`_loads_lenient` 逐对象抢救)+ 顶层 `{action,params}` 形状归一 + 输入/产出限量(≤10 步输入、≤3 动作×4 步); > 4. 两类失败都有日志(`经验提炼:` / `动作提炼:` 含样本),不再静默。 ### 5.5 元素抓取模态框 独立的第二层模态框(`el-picker-overlay`,z-index 1100),不影响任务编辑窗口: 1. 选择设备 → 2. 加载截图 + 元素树 → 3. 点击元素/边界框 → 4. 回填选择器 5. **抓取时直接验证**(每条元素右侧两个按钮,不会与"点击回填"冲突): - 「▶ 点一下」:按元素 `bounds` 中心在设备上真点一次(`POST /api/screen/tap`,`snap=1` 自动吸附到可点元素),返回吸附结果并自动刷新截图——用于确认位置/是否可达; - 「✓ 测选择器」:用**将填入的选择器**真跑一次 click(`POST /api/steps/test`),返回 `命中/未找到/已执行`——用于确认回填的选择器在真实界面能命中(元素无有效选择器时不显示此按钮)。 --- ## 6. 关键设计决策 ### 6.1 为什么用 SQLite 而不是 JSON 文件 - 支持用户/分组/任务的关系存储 - 并发安全(WAL 模式) - 迁移旧 JSON 时归档为 `.migrated`,避免删空后重启又复原 ### 6.2 为什么用 threading 而不是 asyncio - uiautomator2 是同步阻塞库,不适合 asyncio - 多设备并发用多线程即可,每台设备一个 Worker 线程 - Flask `threaded=True` 处理并发 HTTP 请求 ### 6.3 为什么绝不 kill-server `adb kill-server` / `adb disconnect` 会断开共享的 adb transport(历史与 STF provider 共享;STF 摘除后红线仍保留——多 worker、前台扫描、设备自动发现共用同一 adb server),影响所有运行中的任务。连接失败就返回 False,由调用方处理。 ### 6.4 为什么状态查询带缓存 状态源 = 本地 SQLite 设备池 + adb + 内存 worker 状态。5 秒缓存避免每次 `/api/status` 都查库/adb 阻塞 Flask;Worker 实时状态读内存,不受缓存影响。 ### 6.5 为什么 DeviceOfflineError 不重试 设备掉线后短时间内不会自愈,重试只会浪费配额并阻塞调度器。让设备进入冷却,可依赖设备自动发现(device_discovery 对正式池断联设备每轮 adb 重连)恢复后再启用。 --- ## 7. 线程模型 ``` 主线程(Flask) ├── HTTP 请求处理(threaded=True,每请求一线程) ├── TaskManager.scheduler(APScheduler,cron 触发任务计划) ├── 经验巡检 BackgroundScheduler(03:47 Asia/Shanghai,web_server 装配) ├── 设备自动发现线程(device_discovery._discovery_loop,默认 60s 一轮) ├── 设备池型号采集后台线程(device_pool._refresh_models_bg,启动/手动触发) ├── 设备池预连接线程(web_server._preconnect_pool_devices,重启后加速恢复) ├── AI Agent 运行线程(agent_api._agent_thread,单实例 + SSE 推送) ├── 看门狗线程(_Watchdog,30s 间隔) ├── uiautodev 子进程(PID + cmdline 校验,防容器 PID 复用误杀) └── Worker 线程(每台设备一个) ├── _run_with_retry 线程(重试循环) └── BaseWorker 线程(设备生命周期 + run_task) ``` > MCP server(127.0.0.1:8033)是**独立进程**(scripts/start.sh 拉起),不是 web_server 的线程。 **线程安全**: - `_WORKERS_LOCK`:保护全局 worker 状态字典 - `_ADB_LOCK`:串行化所有 adb 调用 - `TaskManager._lock`:保护运行中任务字典 - `_ForegroundScanner._cache_lock`:保护前台 App 缓存