Files
auto_control/doc/TASK_DEV.md
T
butubb cf075ba4e1 feat(任务): 滑动拟人化 + 每台设备有自己的手感(core/humanize.py)
用户反馈"滑动太像机器人""批量执行时每台设备都一样"。

core/humanize.py(新)分两个层次:
- **每次不同**:起止点/幅度/时长抖动、弧线方向随机——最容易被识别的不是"慢",
  是"每次都一模一样";
- **每台设备不同**:由 crc32(serial) 派生稳定的"性格"(手速 0.82~1.32、
  幅度 0.86~1.16、弧度、常用横坐标 ±9% 屏宽、停顿 0.80~1.35)。同设备多次运行
  风格一致,不同设备明显不同——13 台批量跑看着像 13 个人各刷各的。
  用独立 Random 实例播种,不碰全局 random(多线程 worker 会打乱取值顺序)。

轨迹用二次贝塞尔走 d.swipe_points(曲线),异常时自动退回直线 d.swipe。
**点数固定 4 个**:swipe_points 在慢设备上每多一个点约多 1 秒(实测 2 点
1.4s / 6 点 6.0s / 10 点 10.6s),设备自己会插值几十步,4 点已足够弯。

顺带修一个真机上的老毛病:滑动原本每次都要读 d.info,而它在部分设备上要
**14 秒**。改用 humanize.screen_size()(走 window_size,同设备 0.6s,缓存
120s)——所以哪怕多了弧线,真机单次滑动反而从 ~15s 降到 ~5s。

- tasks/generic/task.py:swipe / swipe_until 走拟人(新增 distance_ratio、
  jitter、humanize 参数,默认开);swipe_until 每轮停顿也抖动;wait 步骤新增
  可选 vary_pace(默认关,按设备节奏缩放 0.8~1.35 倍);
- static/admin/editor.js:滑动类步骤参数面板加"幅度/抖动/拟人轨迹"+ 说明;
- 文档:TASK_DEV.md §8.4(含"点数别调大""别用 d.info"两个坑)、步骤表、
  README 能力表与结构。

自测:假设备单测(曲线/抖动/设备间差异/退化路径/不越界/尺寸缓存)+ 真机联调
(4 次滑动全部 ok,标注"弧线"、坐标每次不同、对照的 humanize=false 仍是直线)。
2026-09-16 12:39:47 +08:00

26 KiB
Raw Blame History

任务与步骤开发(TASK_DEV)

适用读者:写任务、编排步骤、加步骤类型的开发者。 相关文档:ARCHITECTURE.md §5(调度链路)、API.md §5(任务接口)、AI_CONSOLE.md(AI 也会产步骤)。 现状:平台只有 generic_steps 一种任务类型,绝大多数 App 操作直接用步骤编辑器编排即可。


目录


1. 核心概念

1.1 TaskType — 任务类型

一个 TaskType = Task 子类 + Worker 子类 + DEFAULT_PARAMS,用 @register_task 注册进全局 _TASK_TYPES(key 为 task_type 字符串)。

# tasks/base.py
_TASK_TYPES = {}

def register_task(task_cls):
    """任务类型注册装饰器(无需传 name,用 task_cls.task_type)"""
    _TASK_TYPES[task_cls.task_type] = task_cls
    return task_cls

def get_task_class(task_type):
    return _TASK_TYPES.get(task_type)     # 未注册返回 None

注册位置:tasks/__init__.py 只做两件事——from .base import ... 聚合导出,以及 import 各任务包子包触发注册:

# tasks/__init__.py
from .base import BaseTask, register_task, list_task_types, get_task_class
from .generic import task  # 触发 @register_task(当前唯一任务类型)

前端「新建任务」的任务类型下拉来自 GET /api/task_types。新增类型要重启 Web 才生效。

未知任务类型的行为:调度时打 error 返回;POST /api/jobs/<id>/run 直接返回明确错误;启动加载时会把库里残留的已删类型任务列进日志告警(只告警,不自动删用户数据)。

1.2 TaskJob — 任务计划

字段 说明
task_type 任务类型(不传默认 generic_steps)
target {"mode":"all"|"group"|"serial", "group_name":…, "serial":…},默认 {"mode":"all"}
params 任务参数,与 DEFAULT_PARAMS 深合并;含两个隐藏开关:skip_offline(默认 true,serial/group 模式先跳过离线设备)、preempt(默认 false,是否抢占正在跑别的任务的设备)
schedule {"mode":"once"|"cron"|"cron_stop", "cron":…, "stop_cron":…, "window":{…}}
retry {"max_attempts":1, "delay":60}
enabled 是否参与调度

后端不做 params 校验:只校验 name 非空 + task_type 已注册,其余原样盲存——步骤编辑器是参数正确性的唯一关卡,保存前务必用编辑器的 validate / "测试此步骤" 自校验。

1.3 Worker — 单设备执行

每台设备一个 BaseWorker 线程(core/device_worker.py)。基类已封装:设备获取(IP:5555 直连 / USB 走远程 adb server,try/finally 保证释放)、u2 连接(30s 超时)、状态上报、异常分类(设备离线不重试)、self.stopped() 停止信号、心跳看门狗(120s)。子类只实现 run_task(d)。

可用的基类能力:

方法 用途
self.set_action(text) 上报"当前动作"(同时刷新心跳)
self.set_progress(done=, total=, unit=, action_counts=, elapsed=) 上报进度(前端统一渲染进度条 + 徽章)
self.heartbeat() 长循环内手动刷新心跳
self.stopped() / self.is_time_up() 检查停止信号 / 任务时长上限
self.serial / self.params 当前设备 / 合并后的参数
self._start_timer() / self.elapsed() 计时

2. 通用步骤任务 generic_steps

task_type="generic_steps"(tasks/generic/task.py):把任意 App 操作编排成"步骤链"。前端步骤编辑器拖拽节点 → 存为 params.steps(JSON 数组)→ worker 按顺序执行。

{
  "max_duration": 0,
  "steps": [
    {"id": "step_1", "type": "open_app", "label": "打开抖音",
     "params": {"package": "com.ss.android.ugc.aweme", "wait_home": true, "home_feature": "拍摄"}}
  ]
}
  • 任务级参数:DEFAULT_PARAMS = {"max_duration": 0}(0 = 不限时)
  • 没有默认步骤:steps 只能由编辑器产出;为空时 worker 立即报错「通用步骤任务没有可执行步骤:请在「任务」页编辑该任务并添加步骤」(设备「最近错误」可见),不会静默空跑
  • 顶层 steps 只顺序执行一次——需要重复的动作必须显式放进 loop 步骤
  • 每步 schema:{id, type, label, params};id 由前端生成保证唯一(导出为自定义动作时会剥掉 id)

进度上报:done = 累计执行的非容器步骤数(loop/group/if_el 不计入,避免噪音)、total=0、unit="操作",前端显示"已执行 N 次操作"。

选择器健康跟踪:某选择器连续未命中达阈值(10 次)会记 last_warning("选择器连续 N 次未命中"),提示 App 改版导致选择器失效。


3. 18 种步骤全表

参数与默认值以 tasks/generic/task.py 为准;前端 STEP_LIB(static/admin/editor.js)负责在编辑器里呈现这些字段。

# type 名称 必填 params 可选 params(默认) 子步骤 语义要点
1 open_app 打开 App package wait_home(False)、home_feature("") — app_start(package, wait=True);wait_home=true 时以 descriptionContains=home_feature 作为"到首页"判定,超时只提示不失败。缺 package 只告警跳过
2 stop_app 结束 App package — — app_stop(am force-stop,下次打开是冷启动)
3 screen_on 亮屏 — — — d.unlock()(息屏时唤醒并滑动解锁)
4 screen_off 息屏 — — — d.screen_off()
5 keep_screen 保持亮屏 mode("on") — — mode="off" → 恢复自动息屏;否则把系统息屏超时顶到最大(screen_off_timeout=2147483647)+ svc power stayon true + 唤醒一次。⚠️ 只发 svc power stayon true 对没插充电器的设备(走 WiFi 的那些)完全无效——它管的是"充电时屏幕常亮",2026-09-14 就是因此被反馈"保持亮屏没用"
6 key_event 按键 key("back") — — d.press(key);值直传,无白名单校验
7 swipe 滑动 direction("up") duration_min(0.25)、duration_max(0.50)、distance_ratio(0.6)、jitter(0.15)、humanize(True) — 默认拟人:弧线轨迹 + 起止点/幅度/时长抖动 + 本设备手速偏好(见 §8.4);humanize:false 还原老的「正中直线」;jitter:0 时位置/幅度不抖(时长仍随机);非法 direction 静默不滑
8 swipe_until 滑动直到元素 selector_type、selector_value direction("up")、max_swipes(8)、click_when_found(True)、duration_min(0.25)、duration_max(0.50)、distance_ratio(0.6)、jitter(0.15)、humanize(True) — 每次滑动都重新生成轨迹(同 §8.4);每轮滑动后随机停 0.35~0.75s;命中即(可选)点击并返回 True;只有 direction="down" 才是向下滑,其余按上滑
9 click 点击元素 selector_type、selector_value wait_timeout(2) — xpath 用 wait(timeout),其它选择器用 exists(timeout);wait_timeout=0 为立即判断。空选择器只告警
10 click_xy 点击坐标 — x(50)、y(50) — 屏幕百分比(0-100,会夹取),50/50 = 屏幕中心。无选择器时的兜底,最脆的方式
11 long_click 长按元素 selector_type、selector_value duration(1.0)、wait_timeout(2) — 先等元素出现再 long_click
12 wait_el 等待元素 selector_type、selector_value timeout(10) — 等元素出现(条件等待,优于固定 wait)
13 input_text 输入文字 — mode("random")、texts("你好\n有趣\n支持")、fixed_text("")、clear_first(True) — mode="fixed" 用 fixed_text,否则从 texts 按行随机选一条;只负责输入,不负责定位输入框(要先 click 输入框)
14 clipboard 剪贴板注入 text paste(True) — 走 ClipInject 通道(绕开 Android 10+ 后台写剪贴板限制);paste=true 时再触发一次粘贴
15 wait 等待 — min(1.0)、max(3.0)、vary_pace(False) — 随机时长;分片 sleep(每 ≤0.5s 检查停止/超时),可被抢占打断;勾了 vary_pace 再按本设备节奏缩放 0.8~1.35 倍(批量跑时设备之间会逐渐错开)
16 loop 循环块 — loop_mode("rounds")、max_iterations(10)、loop_duration(600) children 见 §4.1
17 group 动作组 — — children 子步骤按序执行一次(不循环);自定义动作拖入画布就是展开成 group
18 if_el 条件判断 selector_type、selector_value、timeout(3) ocr_click(False) then / else 见 §4.2

4. 容器步骤与公共参数

4.1 loop 三种模式

loop_mode 行为
rounds(默认) 跑 max_iterations 轮
forever 一直循环,直到任务被外部停止或达到 max_duration(配合调度"定时启动+停止"或任务时长上限使用)
time 跑满 loop_duration 秒(<=0 时跳过)

未知 loop_mode 会按 rounds 处理(不报错)。

4.2 if_el 条件判断

  • 先做元素判断:命中 → 执行 then 分支;未命中 → 执行 else 分支(分支都可继续嵌套)
  • selector_type="ocr" 时改用 OCR:截屏 → find_on_screen(img, selector_value) 子串匹配;命中且 ocr_click=true 会点击命中位置。
    • OCR 模式下 timeout 不生效(单次截图即判定)
    • OCR 不可用(未装 rapidocr_onnxruntime)时该步返回 None(不判定)
    • 命中长文本时点击位置按关键词在文本中的比例估算(避免点到整块中心偏离)

4.3 公共参数

参数 说明
probability 0-100,默认 100。小于 100 时按概率决定本次是否执行该步(如 30 ≈ 隔几次才触发一次);未触发会打日志"概率 X% 未触发,跳过"

4.4 嵌套与容错

  • 嵌套深度上限 5 层:loop.children / group.children / if_el.then/else 递归超过 5 层会整段跳过并告警
  • 每步执行前检查 stopped() / is_time_up()
  • 静默跳过:未知 type、缺必填参数(如 click 没选择器、open_app 没包名)只打 WARNING,任务照常"成功"结束。排查"任务成功了但什么都没做"时先看 logs/task.log
  • 单个步骤内部异常被捕获并记 error,不中断后续步骤

5. 选择器与元素定位

5.1 selector_type 支持的值

值 匹配语义 谁支持
xpath d.xpath(value)(存在性判断必须用 wait()) 所有带选择器的步骤
description content-desc 精确匹配 同上
descriptionContains content-desc 模糊匹配(适配文案版本差异) 同上
text 文本精确匹配 同上
resourceId resource-id 精确匹配(要带包名前缀 com.xxx:id/…) 同上
className 类名匹配(如 android.widget.EditText) 同上
ocr 截屏 OCR 文字子串匹配 仅 if_el

选择器值是直接透传给 uiautomator2 的 kwarg,没有服务端校验——写错了只会"找不到元素"。

5.2 定位优先级(重要)

① 目标有可见文字 → 用 text / descriptionContains(最稳)
② 有稳定 resource-id → 用 resourceId 或 xpath
③ 文案会变但有结构 → 用 descriptionContains 或结构 XPath
④ 纯图形、树里找不到 → 才用 click_xy(坐标,最脆)

坐标是最脆的:分辨率/布局一变就失配,且抓取时的坐标在其他设备上未必有效。抓取元素请在任务实际运行的那台设备的同一界面上做。

5.3 XPath 序号语义(踩过的坑)

写法 含义
//*[@resource-id="x"][2] 「在其父节点中排第 2 的匹配」——不是"第 2 个匹配"
(//*[@resource-id="x"])[2] 「第 2 个匹配」← 抓取器生成的形式

历史实现写了前者,导致同 id 多实例(如底部导航 4 个 tab)时 [2..n] 全部失配。现在:

  • 执行器 tasks/generic/task.py 的 _norm_legacy_xpath 会自动纠正旧任务里的 //*[@x][k] 写法(仅前缀,类名/位置步进不受影响)
  • 抓取器生成 (…)[k] 前先做语义消歧(见 §5.4),序号型只是最后的退路

⚠️ 序号型选择器仍然脆弱:它依赖"抓取那一刻该属性有 ≥k 个实例"。界面不同(如 App 还在闪屏页)就会失配——优先选带文字的元素,抓取器会自动产出语义选择器。

5.4 抓取器给的建议选择器

GET /api/uiauto/elements、GET /api/uiauto/snapshot 返回的元素都带 suggested,生成优先级:

  1. 有 resource-id → //*[@resource-id="v"];无 id 但有 text/content-desc 同理
  2. 该属性值全树有多个时,先用第二个属性把目标单独圈出来(语义选择器,标 semantic:true + via): //*[@resource-id="v" and @text="我"]。抖音底部导航同 id 的几个 tab 靠这个解决—— 灰度版 tab 数量会变(4 个 ↔ 3 个),序号必然错位,文字不会
  3. 两个属性组合仍分不开(列表里同 id 同文字)→ 才退回序号 (…)[k](标 indexed:true + occ/total,前端打黄标 ⚠ 序号)
  4. 都没有属性、但有"最近的有属性祖先" → {祖先}/*[@class="android.widget.ImageView"][n]
  5. 连祖先都没有 → //hierarchy/*[i]/*[j](按子节点位置逐层,标 broad:true,脆弱、前端会提示)
  6. 类名也是空 → 标 invalid:true(提示无法生成可靠选择器)

两个必须记住的 XPath 坑(都实测踩过):

  • dump 的 XML 标签名一律是 <node>,class 在 @class 属性上。所以结构步进只能写 *[@class="…"] 或位置 *[i];写成 //FrameLayout[1] 这种"类名当标签名"的路径永远零命中 (2026-09-13 修:248 个元素里曾有 26 条死选择器)
  • 同级节点的 index 属性会重复(状态栏/内容区/导航栏三个兄弟的 index 全是 0), 兜底结构路径要按位置 *[i+1] 定位,不能按 @index

5.5 抓取与验证(编辑器里的两个按钮)

  • 「▶ 点一下」:按元素 bounds 中心在设备上真点一次(POST /api/screen/tap,snap=1 自动吸附到可点元素中心),返回吸附结果并刷新截图——确认位置是否可达
  • 「✓ 测选择器」:用将填入的选择器真跑一次 click(POST /api/steps/test)→ 返回 命中 / 未找到 / 已执行——确认回填的选择器在真实界面能命中
  • 「测试此步骤」:在编辑器里对任意步骤做真机试执行(不占用设备池、不影响运行中的任务)

5.6 u2 辅助函数(core/u2_helper.py)

函数 用途
ensure_app_running(d, package, max_restart=3, home_check=None) 确保 App 在前台,必要时重启
wait_for_app_home(d, package, home_check, timeout=40) 等 App 首页就绪。注意:主页 Activity 名可能是 SplashActivity,不能靠 Activity 名判断,要用界面特征元素
random_sleep(min_s, max_s) 随机间隔(模拟人类)
safe_click(el, timeout=1) / safe_set_text(el, text, timeout=1) 存在才点/才输,不抛异常
find_and_click(d, timeout=1, **selectors) 找元素并点击
random_swipe_up(d, ...) 按比例上滑,时长可随机

6. 自定义动作

自定义动作(custom_action 表)把一串常用步骤打包成可复用动作,供任何 generic_steps 任务拖入:

  • 编辑器里 ☑ 多选步骤 → 「打包选中步骤」 即可生成
  • 拖入画布时展开为 group 节点(执行语义等同顺序执行一次)
  • 「任务 → 自定义动作」子分栏可查看/编辑/删除

想做"引用型"节点(改一处、处处生效)目前不支持,见 backlog/TODO.md。


7. 步骤编辑器(前端)

static/admin/editor.js 的 _stepEditor 单例,能力:

能力 说明
操作库拖拽 左侧分类库(interact / screen / flow),拖到画布;支持跨层级嵌套(循环套循环)、拖拽排序
参数表单 按步骤类型渲染对应字段(选择器行、时长、概率、循环模式等)
条件分支 if_el 的 then/else 两个分支区;OCR 模式下多出"命中后点击"复选框
多选与打包 ☑ 多选 → 打包成自定义动作
元素抓取 「抓取元素」打开抓取弹窗(选设备 → 截图 + 元素树 → 点选回填),见 §5.5
测试 「测试此步骤」真机试执行
草稿自动保存 编辑中的步骤会存草稿,避免误关丢失

后端 STEP_TYPES 与前端 STEP_LIB 是两份定义,新增/修改步骤类型必须同时改,否则会出现"编辑器里有、后端不认识"(静默跳过)或反之。


8. 新增任务类型

平台当前只有 generic_steps。先确认是否真的需要:绝大多数 App 操作直接用步骤编辑器编排即可,新建任务类型的成本与维护负担都不低。

8.1 目录结构

tasks/<app>/
├── __init__.py        # from . import task  触发注册
├── task.py            # DEFAULT_PARAMS + Worker + Task + @register_task
└── actions/           # 该任务专属操作(可选)
    ├── __init__.py    # 先 from .base import ACTIONS,再 from . import xxx
    ├── base.py        # ACTIONS = create_action_registry()
    └── like.py        # @register_action(ACTIONS) LikeAction

参考实现:tasks/generic/task.py(步骤引擎)。

8.2 骨架

# tasks/<app>/task.py
from tasks.base import BaseTask, register_task
from core.device_worker import BaseWorker, _update_status
from core.logger import get_logger

log = get_logger("task.<app>")          # 写入 logs/task.log

DEFAULT_PARAMS = {
    "count": 20,                        # 任务自身参数放这里,不放 config.py
    "max_duration": 0,                  # 0 = 不限时(时长上限由基类实现)
}

class MyWorker(BaseWorker):
    """单设备执行体:只实现 run_task(d),d 是已连好的 u2.Device。"""
    def __init__(self, serial, params=None):
        super().__init__(serial, params)
        p = {**DEFAULT_PARAMS, **(params or {})}
        self.count = int(p.get("count", 20))

    def run_task(self, d):
        self._start_timer()
        self.set_progress(done=0, total=self.count, unit="次")
        for i in range(self.count):
            if self.stopped() or self.is_time_up():     # 必须检查,否则无法停止/超时
                break
            self.set_action(f"第 {i+1}/{self.count} 次")  # 上报当前动作(同时刷新心跳)
            # ... 业务操作(用 d 做点击/滑动/输入)...
            self.set_progress(done=i + 1, total=self.count, unit="次",
                              action_counts={}, elapsed=self.elapsed())

@register_task
class MyTask(BaseTask):
    task_type = "my_task"
    name = "我的任务"
    description = "…"
    default_params = dict(DEFAULT_PARAMS)

    @classmethod
    def list_action_types(cls):
        return []                       # 有专属操作时返回其清单

    @classmethod
    def get_action_class(cls, action_type):
        return None

    def create_worker(self, serial, params, ctx=None):
        merged = {**DEFAULT_PARAMS, **(params or {})}
        return MyWorker(serial, params=merged, ctx=ctx)
# tasks/<app>/__init__.py
from . import task  # noqa: F401  触发 @register_task 注册
# tasks/__init__.py —— 加一行
from .base import BaseTask, register_task, list_task_types, get_task_class
from .generic import task
from .myapp import task      # ← 新增

重启 Web 后,「新建任务」的任务类型下拉会出现新类型。

8.3 签名约定

  • BaseWorker.__init__(self, serial, params=None, daemon=True, ctx=None)
  • Task.create_worker(self, serial, params, ctx=None) —— 不要带 stf_client / stf 形参(STF 已摘除,历史签名已清理)
  • ctx 是本次运行的上下文(run_id / job_id / job_name / device_name),由 TaskManager._run_with_retry 传入;只用于结构化记录(如步骤明细 core/step_log.py),不参与业务逻辑。不关心就原样透传给 BaseWorker 即可。

8.4 拟人化:滑动与设备节奏(core/humanize.py)

自动化最容易被识别的地方不是"慢",是每次都一模一样:同一坐标、同一时长、 13 台设备整齐划一地做同一个动作。core/humanize.py 从两个层次解决:

层次 机制 谁决定
每次不同 起点/终点/幅度/时长抖动,弧线方向随机 每次调用现算(jitter 控制幅度,0~0.4)
每台设备不同 手速(0.821.32)、幅度(0.861.16)、弧度、常用横坐标(±9%屏宽)、停顿(0.80~1.35) crc32(serial) 播种,同设备恒定

用法(同步步骤里用):

from core import humanize
humanize.swipe(d, "up", serial=self.serial, duration_min=0.25, duration_max=0.4,
               distance_ratio=0.6, jitter=0.15, humanize=True)
humanize.pace(self.serial, 5.0, enabled=True)   # wait 步骤的"按设备节奏微调"

要点与坑:

  • 性格稳定、抖动随机:稳定才像"不同的人",随机才不像"机器"。性格按 serial 而不是设备名播种——改名不改风格,换地址会换风格(地址代表"这一台")。
  • 内部用独立的 random.Random 实例,不碰全局 random:worker 是多线程的, 全局 RNG 的取值顺序会被别的线程打乱,性格就串味了。
  • 屏幕尺寸别用 d.info:部分设备上一次要 14 秒(实测),改用 humanize.screen_size(d, serial)——它走 d.window_size()(同设备 0.6s)并缓存 120 秒。这让"加了弧线反而更快"成为可能。
  • 曲线点数固定为 4(5 个点):d.swipe_points 在慢设备上每多一个点约多 1 秒 (实测 2 点 1.4s / 6 点 6.0s / 10 点 10.6s),而设备自己会在点之间插值几十步, 4 个点的贝塞尔已经足够弯。不要为了"更平滑"把点数调大。
  • swipe_points 抛异常时自动退回直线 d.swipe(老 agent 兜底),日志会留一行 warning。
  • 想关掉:步骤参数 humanize: false(完全还原老行为);想调轻:jitter: 0.05。

9. Action 框架(App 专属操作)

core/actions/ 提供跨任务的通用操作抽象:BaseAction 接口、概率触发、注册机制;每个 App 有自己的注册表(create_action_registry()),互不污染。

# tasks/<app>/actions/like.py(示例)
from core.actions import BaseAction, register_action, should_trigger
from . import ACTIONS          # 本 App 的注册表

@register_action(ACTIONS)
class LikeAction(BaseAction):
    action_type = "like"
    name = "点赞"
    default_params = {"rate": 0.3}

    def execute(self, ctx):
        if not should_trigger(self.params):     # 按概率决定是否本次触发
            return False
        ...                                     # ctx 提供 device/worker/进度等
        return True

循环导入坑:actions/__init__.py 必须先 from .base import ACTIONS,再 from . import like。

当前 generic_steps 不使用 Action 框架(它直接分发到 _exec_<type>);Action 框架留给"专属任务类型"使用。


10. 测试与排查

场景 做法
试执行单个步骤 编辑器「测试此步骤」(POST /api/steps/test)——真跑一次,返回 命中 / 未找到 / 已执行
试执行整个任务 建一条只含该步骤的临时任务,目标选一台设备,enabled=false +「执行任务」
看任务日志 logs/task.log(业务)、logs/core.log(调度/adb)
看设备错误 监控页「最近错误」列;失败原因会带真实错误(不再只有"重试N次失败")
步骤没生效 检查是否有 WARNING:未知 type / 缺必填参数只告警不报错
选择器失效 看设备 last_warning("选择器连续 N 次未命中")→ 重新抓取

没有步骤的任务:执行时会明确报错,不会静默空跑。


11. 开发检查清单

新增/修改任务与步骤时,逐条打勾:

  • 后端 STEP_TYPES 与前端 STEP_LIB 一致(type 名、字段、默认值、分类)
  • 新步骤在 _exec_<type> 里实现了,且缺参数时有明确日志
  • 长循环里调用了 self.stopped() / self.is_time_up() / self.heartbeat()
  • 用文字/id 定位,没有滥用坐标
  • 抓元素是在目标任务运行的那台设备上、停在同一界面时做的
  • 新增任务类型已注册进 tasks/__init__.py
  • 文档同步:TASK_DEV.md(步骤表)、API.md(若接口有变)、必要时 AI_CONSOLE.md(AI 沉淀动作的白名单)
  • 临时测试数据已清理(任务/分组/自定义动作)