还没轮到它,就判它超时?

排队不等于执行;超时之前,先问这只钟到底在给谁计时。
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。