停一个活只有杀进程:界面上少了「说点要求」

7 阅读1分钟

停一个活只有杀进程:界面上少了「说点要求」

结论先给:一个任务上有 worker 在跑的时候,「接着办」和「说点要求」这两个按钮不该存在,因为它们会花掉一个回合却改不了任何东西。现在它们被换成了「停止 dsh / 办完了 / 放下」,而关闭任务之前先问一句那个 worker 怎么办。

Andee 是一个住在 Android 手机里的 agent,靠无障碍服务读屏并自己点,不需要 App 配接口;他还能把活派给电脑上那个会读写文件的 agent(dsh)去跑。派出去的活在电脑上是独立进程,手机只是等它回话的那一端。

判断「还在跑」靠什么

不是靠一个状态字段,而是看这个任务有没有一个还开着的、kind 是 worker 的等待,并且最近收到过它的进度:

fun runningWorker(t: Tasks.Task): String? =
    t.waits.lastOrNull { it.open && it.kind == "worker" }?.name
        ?.takeIf { RemoteProgress.of(it) != null }

RemoteProgress 是内存里的一张表,只有收到 remote.progress 才会往里写。所以这一行的意思很窄:还有一条等待没被回答,并且这条等待对应的 worker 最近有过进度。两个条件都要满足,缺一个就当没有。

这么写的原因很实际。任务本身有 active,但 active 会在交付之后还留着,用来等你确认;拿它当「还在跑」,会在报告已经回来之后继续显示停止按钮。

三个按钮是怎么来的

跑着的时候走一个分支,没跑的时候走原来那个:

task.active -> {
    val running = TaskRows.runningWorker(task)
    if (running != null && onStopWorker != null) buildRunningActions(body, task, running)
    else body.addView(buttons(...四个...))
}

buildRunningActions 里有个三态 asking:null 是正常三个按钮,"done" 和 "dropped" 是等你回答那个问题。点「办完了」时先不关,把 asking 置成 "done",重画一次,画面换成那一句问话。

问话的原文是这么写的:

dsh #7 还在跑。先把它停掉吗?不停的话它会跑完,花的是你的额度,报告回来时这件事已经关了,没人接。

这句话把代价摊开来说。以前不是这样:点「放下」,tasks.close(id, "dropped", ...) 直接执行,worker 在电脑上继续跑,报告回来时任务已经关了,那条等待没人接,而额度照扣。

停的那一条路径

onStopWorker 在 service 里接的是 dsh.cancel,走 RemoteAgentHost.dispatch,和模型自己调 delegate_cancel 是同一条路:

onStopWorker = { worker ->
    val dev = peers.tasks.agentDevice()
    dev != null && RemoteAgentHost(peers.remote, RemoteAgentTasks { ... }, { dev }, File(filesDir, "agent_inbox"))
        .dispatch("dsh.cancel", JSONObject().put("id", worker))
        .let { (it as? JSONObject)?.optBoolean("cancelled") == true }
}

同一条路的理由:如果界面另外开一条取消通道,那条等待会以「没被看过」的状态留在账上,半夜又拉起一个回合把活重新开一遍。走模型那条路,等待被标记成已经看过,没人会再去重启它。

电脑侧的 cancel 只有一句注释,说的是全部:

/** Stop a running job: its process is killed, which is the protocol's only way to abandon a turn. */

没有暂停,没有中途改参数。要放弃就 destroyForcibly(),连它的子进程一起。所以「停止」这个字在界面上很轻,落到系统里是一次进程被杀,写到一半的文件停在半句上。

返回值也是两态:cancelled: true,或者 cancelled: false 加一句 Worker #7 is not running.。UI 把它翻成两句人话,第二句仍然带着不确定:

没能叫停电脑上的 dsh #7(够不着电脑,或它已经结束了)

这句是刻意不合并的。够不着和已经结束,在手机这一端看到的是同一种沉默:链路没开,或者进程没了,都拿不到回话。要么连上去问一句才知道是哪种,要么就照实说不知道。

一次点击为什么不能立刻有答案

onStopWorker 会阻塞在链路上。所以按下去不同步等结果:另开一条线程去问,回来后把一句话写进对话记录,再重画卡片:

Thread {
    val ok = runCatching { stop(worker) }.getOrDefault(false)
    Handler(Looper.getMainLooper()).post {
        ChatHistory.addEvent(Kind.STOPPED.name, if (ok) "你叫停了…" else "没能叫停…", "")
        if (root != null) rebuild()
    }
}.start()

失败也写一行,这一点是故意的。一个按钮按下去没有任何反应,用户只会认为它坏了,然后反复按。写一行「没能叫停」至少说明请求发出去了,是电脑那边没答应。

阻塞还有一个副作用:那条线程在等的时候,卡片上的状态没变。所以重画放在 post 里,等结果回来才改界面,避免按钮先变灰、结果又失败,来回闪。

顺带说一个只有自己会碰到的细节:asking 这个状态在截图工具里要能预置,否则那张「正在问」的卡片永远拍不到。桌面侧为此加了个 TaskPreview.asking,只在截图时写值。这类东西不进产品路径,但少一行就没法验证那句话真的显示出来了。

代价

  • 停是不可逆的。已经产出的文件留在电脑上那个 worker 目录里,半截的 .part 也在,没有清理。
  • 同时最多两个 worker,单个活最长两小时(MAX_RUNNING = 2、JOB_MAX_MS = 2h)。到上限时第三个请求直接抛错,说明在等哪一个,不排队。
  • 「还在跑」来自最近一次进度。链路断久一点,那一行会安静下来,不会翻成「已结束」,因为代码里没有能证明它结束了的证据。
  • Mac 侧是同一套判断,desktop/TasksUi.kt 里也是三个按钮加一次确认,文案一致。两个身体说法不一样,人就会以为自己点错了。

一句话能记住的

给一个已经在跑的东西加需求,是在骗自己。要么让它跑完,要么杀进程。界面上那个被删掉的按钮,删的理由就这一条。