「用量恢复后继续任务」(#1162) 自 #1281 起 100% 失效:定时任务改为无条件入队后被 state.recovery 判据永久挡住
- Dominant language
- TypeScript
- Stars
- 2.7k
- Forks
- 395
- Avg merge
- 21h 48m
- Merged PRs (30d)
- 776
Description
## 摘要
「用量恢复后继续任务」(#1162,2026-07-31 合入)自 **#1281(2026-08-01 合入,commit `c5e89dbd1`)** 起 **100% 失效**,至今(0.1.46)无法使用。
失效不是概率性的:#1281 把定时任务的投递从「先判忙、不忙则直发」改为「无条件进 coordinator 队列」,而队列的派发条件里有一条 `if (state.recovery) return null`。**本功能的目标会话按定义必然带着 recovery 项**(正因为有中断的 turn 要续,才需要这个功能),因此每一次触发都必然被挡,等满 30 分钟后作废。
两者语义直接互斥,没有能跑通的路径。
## 时间线
| 日期 | PR | 内容 |
|---|---|---|
| 2026-07-31 | **#1162** `feat(desktop): 支持用量恢复后继续任务` | 撞用量上限时提供「恢复后继续」入口,创建一次性自动化,在额度恢复后把提示词发回原会话继续 |
| 2026-08-01 | **#1281** `fix(desktop): 自动续跑被容量中断的定时任务` (`c5e89dbd1`) | 把 Schedule 输入统一改走 coordinator 队列 |
#1281 合入后,#1162 再未成功执行过。
## 根因
### 改动前(#1162 合入时,`runner.ts:573`)
```ts
if (this.deps.schedulerQueue?.isSessionBusy(sessionId)) {
…
return await this.fireHeartbeatViaQueue(…) // 只有「忙」才排队
}
// 不忙 → 往下走,直发
```
排队是**有条件**的,判据 `isSessionBusy` = `isSessionInTurn`,只问「有没有 turn 正在跑」。
撞用量上限的 turn 已经以 `isTerminal: true` 终止,会话只剩一个待恢复项 → 判定不忙 → 直发 → 成功。这是本功能设计时依赖的行为。
### 改动后(#1281)
```diff
+ // 生产环境统一把 Schedule 输入交给与普通聊天相同的 coordinator。
+ // 测试/启动早期未注入 bridge 时保留下面的直发降级路径。
+ if (this.deps.schedulerQueue) {
+ return this.fireHeartbeatViaQueue(…)
```
前置的 `isSessionBusy` 判断被移除,生产环境一律进队列。
而队列能否派发由 `agent-input-coordinator.ts` 的 `getDrainableHead()` 决定,其中:
```ts
if (state.recovery) return null; // 有待恢复项 → 不派发
```
这条规则对普通聊天是正确的:用户还没处理中断的 turn 时,新消息理应排队等待。但对「专门来处理那个中断 turn」的自动化任务,它恰好是反的。
### 为什么是必然失效
本功能的触发条件就是「会话因用量上限中断」,而中断必然留下 recovery 项(红条上的「重试 / 恢复后继续」正是靠它渲染)。所以:
- 有 recovery 项 → 队列不派发 → 任务作废
- 没有 recovery 项 → 根本不会创建这个任务
命中率 100%。
> 补充:`if (state.recovery) return null` 这行从开源首个 commit(`bb3f71d08`,07-24)就存在,早于 #1162。变的不是这条规则,而是**本功能的投递路径被改道到了受这条规则约束的队列上**——改道之前根本碰不到它。
## 实测证据(0.1.46 / macOS arm64)
会话日志 `logs/sessions//2026-08-14.ndjson`:
```
00:59:26 rate_limit_event status:"allowed_warning" utilization:0.98 five_hour
01:00:10 rate_limit_event status:"allowed_warning" utilization:0.99
01:00:30 rate_limit_event status:"rejected" overageDisabledReason:"org_level_disabled"
01:00:30 SDK ◀ turn ended with error
stopReason: stop_sequence terminalReason: api_error
output: You've hit your session limit · resets 1:40am (Asia/Shanghai)
```
用户按提示创建了 3 条「用量恢复后继续任务」,定在额度重置后一分钟(01:41)。主日志:
```
01:41:00.833 scheduler: in-flight run registered ×3(准点触发)
01:41:00.857 [maker-ipc] send_to_session queued while target busy
01:41:00.857 [runner] heartbeat prompt queued behind busy session
01:41:00.857 scheduler: in-flight run entered pure queue wait (slot released)
02:11:00.933 [runner] queued heartbeat exceeded max dispatch wait, withdrawing
waitedMs: 1800070, maxWaitMs: 1800000
02:11:00.933 schedule fire failed
error: 'Session send failed before dispatch: … action=send-user-prompt (SESSION_RUNNING)'
```
三条任务分别等待 1800070 / 1800075 / 1800077 ms,全部卡在 30 分钟上限(`QUEUED_DISPATCH_MAX_WAIT_MS`,`runner.ts:132`)后被撤回。
注意:**额度在 01:40 已经恢复,触发时间也完全正确**。挡住投递的只有 recovery 项。
## 影响
1. **功能完全不可用**,且是静默的——用户以为任务已排好,实际上必然失败。
2. **撤回没有任何用户可见通知**。本例中用户次日早上才发现三条任务全废,中间空了六个多小时。
3. 30 分钟的等待上限对本场景无意义:recovery 项只有用户手动点「重试 / 恢复后继续」才会消解,等多久都等不到。
## 建议修复方向
核心是让「针对 recovery 项本身」的投递能穿过 recovery 判据,而不是改动排队机制本身:
1. 给 schedule heartbeat 增加允许在 recovery 态派发的标记,`getDrainableHead()` 对该类消息放行;
2. 或者本功能的投递不进普通队列,恢复 #1162 时的直发语义;
3. 无论走哪条,`withdrawing` 都应产生用户可见通知,不应静默失败。
具体取舍建议由 scheduler / coordinator 的 owner 决定。
## 关于评审环节的一点反馈
想诚实地提一个流程上的观察,对事不对人。
#1281 的评审并不算薄:**80 条评审记录、20 条讨论、3 个自动评审器(greptile-apps / copilot-pull-request-reviewer / chatgpt-codex-connector)参与,改动规模 +992/-123、涉及 8 个文件,最终有维护者 Approve。** 即便如此,它把两天前刚合入的一个完整功能整条打断,没有被任何一个环节发现。
这说明当前评审的强度主要落在**单个 PR 内部的正确性**上,对**跨 PR 的行为回归**基本没有覆盖——尤其是这类「不改目标功能一行代码,但把它的执行路径改道到另一套判据上」的改动。既有测试也帮不上忙:#1162 的测试验的是它自己那条链路,#1281 换的是链路之外的路由。
一个成本不高的改进建议:当 PR 改动**共享判据或投递路由**(如本例的 `fireHeartbeatViaQueue` 路由、`getDrainableHead` 判据)时,在描述里列出现有调用方并说明各自受到的影响。这类改动的爆炸半径不体现在 diff 里,只体现在调用方清单里。
作为 #1162 的作者,看到功能在合入两天后就被静默打断、并且三周内无人发现,坦白说挺遗憾的。提出来不是为了追责,而是希望这类跨 PR 的回归能在流程上有个抓手。
Contributor guide
Research direction
Trace the schedule heartbeat path in runner.ts, including the QUEUED_DISPATCH_MAX_WAIT_MS handling, then inspect getDrainableHead() in agent-input-coordinator.ts and the recovery-state condition. Reproduce the documented recovery-session schedule failure and define a regression test showing that the recovery-targeted task dispatches after reset and that withdrawal is visible to the user.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- desktop
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100