排队不等于执行;超时之前,先问这只钟到底在给谁计时。

1. 计时器很准,判决却错了⌗

设想一个自动修复 Agent:改完代码,提交测试,测试机正在忙。它拿着队列票据,等待前面的任务释放资源。

这时,一个从任务启动就开始走的计时器到点了。

如果系统把这次结束解释成“修复任务失败”,甚至据此更换模型,问题就来了:模型没有得到验证自己工作的机会,系统却已经给它打了分。

这不是计时精度问题,而是计时对象问题。墙钟准确记录了事情拖了多久,却没有回答事情为什么拖。

本文讨论的改动很小:移除测试机的固定排队上限,让调用者默认保留队列位置,并沿用已有的排队时间补偿机制。它不是发明一种新调度算法,而是把一条已经存在的例外规则变成默认语义:基础设施没有给你工作机会的时间,不该直接拿来证明你工作太慢。

2. 从“特殊通行证”变成默认规则⌗

改动前,共享测试任务默认最多排队 30 分钟;落地流水线没有这个排队上限。自动修复任务则通过 MAXCLAW_TEST_QUEUE=follow 显式选择继续等。

也就是说,同样是在等待同一类资源,调用方式不同,系统对等待的解释就不同。

机制改动前改动后
共享任务排队默认固定上限,可选择持续等待默认持续等待
调用接口可传 queueTimeoutMs删除这个参数
远端协议支持 --queue-seconds删除这个选项
客户端保护另有排队计时器删除排队计时器
命令执行超时保留仍然保留
自动修复的等待补偿已有继续使用,不再要求环境变量前缀

变化覆盖 11 个文件,新增 26 行、删除 116 行。数字来自提交差异,不是性能收益。

值得注意的不是“代码少了”,而是少了一组必须跨层保持一致的政策:调用参数、环境开关、远端参数、退出码、错误分类、任务提示词,不再分别维护“等多久就算失败”的版本。

如果持续排队本来就是正确语义,就不该靠模型记住一张特殊通行证才能得到它。

3. 不要一只钟包办所有失败⌗

实际实现比“执行才计时”稍细一些。

提交远端运行
    │
    ├── 传输准备、等待槽位
    │      不启动客户端执行兜底计时器
    │
    ├── 已取得槽位
    │      启动客户端兜底:命令预算 + 90 秒
    │
    ├── 准备工作目录与依赖
    │
    └── 命令开始
           启动远端命令 timeout
           启用运行期无输出检测(若调用者配置)

这里至少有三种不同的问题:

问题对应边界
尚未得到资源队列状态,不是命令执行超时
已占用资源,运行迟迟不结束命令预算与客户端兜底
命令已开始,长时间没有输出可选的无输出 watchdog

remote-tests.ts 收到“已取得测试机槽位”标记后,才启动客户端兜底计时器。远端 runner.sh 则在准备完成、启动命令时使用 timeout。两者并不是完全相同的起点:客户端兜底还约束取得槽位后的准备阶段。

无输出检测又是另一回事。OutputWatch 在识别到命令启动行之前,让 idleMs() 返回零,因此不会把等待槽位或启动前的安静误判成测试卡住。

这才是合理的多层保护:每层回答不同的问题,而不是每层都从请求发出那一刻倒数。

4. 下层肯等,上层也得认账⌗

只删除测试执行器的排队上限还不够。它肯继续等,包着它的 Agent 任务如果照样到点终止,改动只是把误杀往外挪了一层。

已有的 queue-waits.ts 用等待记录解决这个问题。每次等待记录进程、工作目录、开始时间和结束时间;任务运行器可以按自己的工作目录,查询本轮以来可补偿的等待。

当任务期限到达时,处理逻辑大致如下:

查询本轮等待记录
    │
    ├── 有尚未补偿的等待 → 延后期限,记住已补偿量
    ├── 仍有等待进行中   → 稍后再次检查
    └── 无新增等待       → 原来的终止逻辑继续生效

自动修复任务把工作目录通过 queueCreditDir 交给任务运行器,接入这条路径。这里不能扩大成“所有 Agent 的所有等待都会自动退款”:代码补偿的是明确接入的、可归属到该目录的等待记录,不是一个通用的 CPU 活跃时间计量器。

实现也有清晰的近似边界:

  • 共享远端运行的记录从发起远端过程前开始,到收到槽位标记结束,包含一部分槽位前的传输准备,并非纯粹的队列驻留时间。
  • 等待记录按区间逐条求和,而非合并重叠区间。如果同一目录并发发起多个验证,不能把这个和直接等同于“Agent 实际被阻塞的墙钟时长”。
  • 写入记录失败不会打断测试,但可能丢失补偿。

所以这是一种面向验证流程的预算修正,而不是精确的工作量审计。方向正确,不等于可以把口径说得比实现更大。

5. 可以一直排队,不等于可以失联⌗

删除上限之后,最合理的质疑是:会不会永远挂在那里?

答案不是“绝不可能”,而是要分清仍然存在的约束,以及仍然承担的风险。

远端执行器保留了队列票据与槽位锁。共享槽位按较早的存活票据优先分配;落地任务另有专用槽位规则,不是所有类别混成一个绝对 FIFO。读取较早票据时,已经死亡的等待进程会被清理。

等待期间,执行器约每 30 秒报告一次:已经等了多久、前面有多少任务、当前资源持有者以及机器负载。取得槽位时,再明确报告状态转换。

调用者也没有把工作丢给远端就撒手。连接的输入通道承担存活信号:调用侧消失后,远端等待或执行会走终止与清理路径。

这些机制保住的是可观察性、队列顺序和调用者生命周期,并不保证队列最终一定前进。如果资源永远不释放,或者取得槽位前存在仍未被识别的挂起,这次改动不再用一个固定排队期限替系统宣布结束。

代价是真实的:任务的总墙钟时间没有因此获得上界,容量问题也没有自动消失。

但把容量不足变成一张“模型失败”的成绩单,并没有解决容量问题。它只让报表看起来收尾了。

6. 测试抓住的是边界,不是耐心⌗

这里不需要真的等半小时来证明设计。最有价值的测试,是让“等待时间”明显超过“执行预算”,观察计时器是否仍在正确的位置启动。

remote-runner.test.ts 对测试运行和类型检查都做了这种安排:用占位进程持有共享槽位 6 秒,给待运行命令设置 1 秒预算。

测试断言包括:

场景规格中的断言
排队超过命令预算最终运行成功,timedOut: false
客户端计时器只出现取得槽位后启动的 91 秒兜底计时器
等待可见输出含排队状态、负载,记录到的排队时间至少 4 秒
队列顺序后来的等待者不越过先来的,死票据不阻塞
命令真的跑太久仍返回执行超时,并说明已取得槽位

另外,queue-waits.test.ts 检查等待区间与查询窗口的交集、存活进程的未结束等待,以及补偿不能重复领取:一段 300 秒等待补偿过之后,没有新增等待就不再延长期限;如果还在等,则按 60 秒的复查间隔继续检查。

这些是提交中的测试规格,不是本文重新运行的压测报告。它们支持“时间从哪里算”的判断,不能推出吞吐提升百分比、任意负载下的完成率,或永久无饥饿保证。

测试中被删除的“超过排队上限应退出”用例也不是简单减少覆盖:旧行为已不再是契约。新的关键断言是,排队时不应启动那只计时器。

7. 真正该删除的是错误归因⌗

这次改动并不意味着“超时越少越先进”。有外部交付期限的任务,当然可以有端到端截止时间;需要限制排队成本的系统,也可以有准入或取消政策。

但它们应该以自己的名字出现:

  • 交付期限到了,是交付期限到了。
  • 资源一直不可用,是基础设施没有给出结果。
  • 命令实际运行太久,才是执行超时。

三种情况都可能需要终止,却不应该自动导向同一个结论,更不应该都触发同一套模型重试。

相较于层层增加固定等待上限,这个设计更准确的地方,是让时间约束跟着状态边界走:没取得资源时保持队列位置,取得资源后约束执行,外层任务根据明确记录修正预算。

时钟只能告诉你多久了。系统还得负责说清楚:这段时间,究竟是谁在等谁。


源码依据:提交 8297f644 中的 src/testing/remote-tests.ts、src/testing/remote/runner.sh、src/testing/queue-waits.ts,以及对应的 remote-runner.test.ts、remote-watchdog.test.ts、queue-waits.test.ts;外层预算接入见 src/dispatch/task-runner.ts 与 src/improve/autofix.ts。