取消是一项动作;停止是一组后置条件。
这两个概念在 Agent 系统里很容易被一枚"已取消"图标合并。用户点击取消,运行时发出终止命令,命令返回成功,于是页面显示"已停止"。但如果旧子进程仍在写文件、某个孙进程已经脱离原进程组,或者结果文件只是暂时不可读,那么"停止成功"其实把几种完全不同的事实压成了一个结论。
真正重要的问题不是取消按钮有没有生效,而是:系统到底证明到了哪一层?
1. Anywhere Agents 先给出了一条很严格的观察规则:看不到,不等于不存在
Yue Zhao 在 Anywhere Agents 的提交 570c89f 中处理了一批被中断后留下的 prun-task-* 工作单元。作者报告:有 27 个工作单元已经记录结果路径,但对应结果文件已经消失;同时仍保留 24.3 MiB 的尾部输出。这些数字来自该提交的工程现场报告,本文没有独立复现。
更值得注意的不是数量,而是它对"观察失败"的处理方式。report-state 没有把工作单元简化成"可恢复 / 已失败 / 可重派",而是拆成两个正交字段:
result_path_state:记录的结果路径是否能被解析;result:目标文件当前到底是present / empty / missing / unknown。
只有明确收到 FileNotFoundError,工具才允许把结果写成 missing。权限拒绝、I/O 错误、非普通文件、入口过长或根目录不可读,都不会被偷换成"不存在"。提交里的原则可以直接翻成中文:
观察失败,不能自动变成执行结论。
这条规则对"取消 Agent"同样适用。根进程 PID 看不到,最多说明这个 PID 当前不可观察;只有拥有更强的进程约束证据,系统才有资格把结论扩大到整个执行树。
2. Issue #29 更进一步:四轮更强的"已回收"都被反例推翻
Anywhere Agents 的开放 Issue #29 很有研究价值,因为它不是只提出"孤儿进程难处理",而是记录了四轮逐步加强的 reap-orphans 方案,以及每一轮为什么仍然不足。
| 轮次 | 当时想证明什么 | 被什么反例推翻 |
|---|---|---|
| 第 1 轮 | 已发送信号,所以可以说 REAPED |
kill 全部失败,仍然报告 REAPED |
| 第 2 轮 | 记录的根进程已消失 | 后代进程忽略 TERM 后继续存活 |
| 第 3 轮 | 进程组已经为空 | 后代进程用 setsid 离开原进程组 |
| 第 4 轮 | 多轮快照已经收敛到固定点 | 一个短寿命中间进程在两次枚举之间生成孙进程后退出,孙进程从未进入已观察集合 |
第 4 轮尤其关键:它不需要 PID 复用、不需要查询失败,也不需要扫描超时。纯粹依靠时序,就可以出现:回收器认为已经收敛、打印 REAPED,但孙进程仍然活着。
因此,Issue 最后的结论不是"再多扫几遍",而是把证明责任前移到派发时:如果工作进程在启动时没有被放进一个受内核约束的封闭集合,之后的回收器只能通过 PID、父子关系和进程组去推断成员,而这些关系都可以逃逸。
作者提出的方向是:Windows 使用 Job Object(作业对象) ,POSIX 保证独立会话或使用 setsid;只有在进程启动时先建立内核级约束,后续回收器才有资格对整个执行树作更强的后置条件声明。该 Issue 甚至把 REAPED 保留给内核级进程约束,在这一前提真正实现之前不让它成为可达结果。
这不是 CodeFlowMu 的实现依据,也不证明我们的运行时存在同样缺陷。但它给出一个非常有用的审计原则:
停止范围越大,证明责任越不能只靠"我现在没看见它"。
3. 一个"已停止"状态至少压着六种不同事实
为了避免把动作和后置条件混在一起,可以把取消现场拆成下面几层:
| 事实层 | 能证明什么 | 不能证明什么 |
|---|---|---|
| 已请求取消 | 运行时确实发出了取消意图 | 操作系统已经停止任何进程 |
| 终止命令成功 | 终止工具返回了成功结果 | 所有后代进程都已退出 |
| 已观察到根进程退出 | 已知根进程或外层进程 PID 不再可观察 | 子进程、孙进程、句柄、端口都清理完毕 |
| 已观察到已知子进程退出 | 已知直接子进程已退出 | 没有逃逸的后代进程 |
| 已证明进程约束成立 | 执行单元属于可被整体约束的封闭集合 | 工作区一定没有异步尾部写入 |
| 已具备重新派工资格 | 调度规则允许新一轮执行 | 上一轮所有操作系统资源都已被证明消失 |
这六层可以互相引用,但不能互相代签。
尤其要避免两种跳跃:
taskkill 返回 0 → 整棵执行树已经消失
以及:
PM 允许重试 → 上一轮执行一定已经完全结束
前者把工具结果扩写成操作系统级进程约束事实;后者把治理许可扩写成进程事实。
4. 我们自己的 Windows 探针到底证明了什么
CodeFlowMu 是我们正在开发的本地多 Agent 运行时。第一方代码路径在 Windows 上使用:
text
taskkill /PID <pid> /T /F
来终止受管命令的外层进程,以及 Windows 对该 PID 可识别的进程树。
我们没有直接把这条命令写成"进程树已安全收拢"。相反,我们做了一个故意很窄的实验:在新的临时目录里启动一个外层进程,再由它启动一个长寿命直接子进程;确认两个 PID 都存在后,对外层进程执行同一条 taskkill /T /F,随后分别观察两个 PID。
那次受控 Windows 主机记录为:
taskkill退出码 =0;- 外层进程退出已观察 =
true; - 直接子进程退出已观察 =
true; kernel_containment_proven = false。
因此这次 PASS 只能支持一句很窄的话:
在这个 Windows 主机、这个"外层进程 + 直接子进程"的样本中,
/T没有只结束外层进程;两个已知 PID 都被观察到退出。
它不能支持:
"任意深度的 Windows Agent 进程树都已被证明收拢。"
目前公开证据包已经把这个实验合同物化成 Windows 专用探针,并公开了脱敏已记录结果和记录检查脚本。读者可以在自己的 Windows 机器上重新运行同一二层合同;非 Windows 主机则直接拒绝运行,不会返回假 PASS。
这比只给一行"1/1 PASS"更重要,因为可重跑不等于扩大结论。即使别的 Windows 主机再次 PASS,也仍然没有覆盖逃逸的后代进程、短寿命中间进程、跨权限、更深层级、容器或远程工作进程。
5. cancelled 更适合被理解成控制面结果,而不是执行树收拢证明
这里还有一个值得我们自己继续收紧的语义问题。
当前第一方受管命令路径在终止工具成功后,可以把本次命令记录进入 cancelled。这个字段对控制面是有价值的:它说明这次取消操作已经按当前机制执行完 。但如果界面或后续调度把 cancelled 直接解释成"所有后代进程都不存在",语义就超出了当前证据。
更稳健的停止记录应该拆成独立字段或独立证据轴,例如:
text
cancel_request sent
termination_command exit_0
root_exit observed
known_child_exit observed
containment unverified
workspace_quiescence unverified
redispatch_eligibility decided_by_existing_rule
这些英文名称在这里保留,是因为它们代表建议的机器可读字段;对应中文含义分别是:取消请求、终止命令、根进程退出、已知子进程退出、执行树约束、工作区静止状态和重新派工资格。
这样,"未知"不会被强行塞进"失败",也不会因为控制命令成功就被涂成全绿。
6. 静默也不能被写成死亡
停止证据还有另一面:不仅不能把"看不到"写成"已经死了",也不能把"暂时没有输出"写成"已经失活"。
我们重跑过一个既有的受管命令测试样本:受管作业经历模拟两小时静默、101 次观察,以及索引丢失后的重启恢复,仍然保持为可诊断记录。这个定向测试是 1/1 PASS。
它回答的不是进程树约束,而是另一条语义边界:
静默 ≠ 死亡。
和前面的:
已请求取消 ≠ 整棵执行树已消失。
其实属于同一种纪律------运行时应记录它真正观察到的事实,而不是用缺少新信号来补一个更方便的结论。
7. 真正危险的是取消和重派之间的空白区
为什么要这么较真?因为最昂贵的事故往往不是第一次执行失败,而是第二次执行启动得太早。
旧外层进程已经退出,但某个后代进程仍在写文件;PM 根据业务规则批准重试;新的 Agent 又拿到同一个工作区。此时执行租约可以阻止同一调度路径上的两个正式执行轮次同时占有执行权,但租约本身不能杀掉一个已经逃逸到运行时观察范围之外的操作系统进程。
所以"能否重派"至少要明确区分两种判断:
- 治理与调度资格:这项任务是否允许产生新的执行轮次;
- 执行环境安全证据:旧执行现场是否已经达到当前要求的收拢程度。
前者是任务治理问题,后者是运行环境问题。一个正确的调度许可不能替操作系统签字。
8. 下一批反例比再加一个绿色状态更有价值
如果继续研究这条问题,我认为最值得补的不是一个更漂亮的"已停止"标记,而是更具攻击性的反例:
- 外层进程退出,直接子进程继续运行;
- 子进程主动脱离原进程组或父子关系;
- 短寿命中间进程在两次观察之间生成孙进程;
- 旧结果文件在根进程退出后继续增长;
- 取消完成与重新派工同时竞争;
- 进程退出但端口、文件锁或外部辅助进程仍未释放。
每个实验都应该输出同一套结构:
发生了什么动作 → 观察到哪些对象 → 哪些后置条件已证明 → 哪些仍未知 → 因此允许什么下一步。
这比把所有结果压成 cancelled=true 更适合长期运行的 Agent 运行时。
结论:停止不是一个按钮结果,而是一份有范围的证明
Anywhere Agents 的 report-state 提醒我们:观察失败不能变成结果。 Issue #29 又进一步说明:如果执行树从一开始就不是一个被内核约束的封闭集合,事后枚举很难证明它已经完全消失。
我们的 Windows 二层探针则给出一个更小、更具体的事实:taskkill /T /F 在一个"外层进程 + 直接子进程"样本上确实观察到了两者退出。这个事实值得保留,但也必须停在这里。
取消请求是一项动作;停止是一组后置条件;执行树收拢是一项更强的证明。
可靠的 Agent 运行时不需要假装知道所有事。它只需要在重新派工之前,明确告诉下一位执行者:什么已经停下,什么只是没看见,什么仍然没有被证明。
公开证据
来源与证据边界
Anywhere Agents
- commit
570c89f:本文引用其report-state对结果路径观察与结果结论的拆分,以及作者报告的 27 个遗留工作单元、24.3 MiB 尾部输出。数字为作者工程现场报告,本文未独立复现。 - Issue #29:本文引用四轮孤儿进程回收方案被实机反例逐步推翻,以及将整棵执行树的证明前移到派发时内核级进程约束的设计讨论。该 Issue 仍为开放状态,不能视为已发布完成方案。
Anywhere Agents 只作为公开研究与工程参照;本文不据此声称 CodeFlowMu 与其共享根因、实现或已达到同一进程约束设计。
第一方证据
R1 只覆盖一台 Windows 主机上的一个"外层进程 + 直接子进程"样本。公开探针让相同二层合同可以被重跑,但并不把历史 PASS 升级成 Windows Job Object 或其他内核级进程约束证明。本文也不声称所有 Windows 进程树、独立运行时、网络文件系统、容器或远程工作进程已获得同样保证。
原文与可复核证据:joinwell52-ai.github.io/joinwell52/...
English edition:joinwell52-ai.github.io/joinwell52/...