makecindy / makecindy/cindy

「用量恢复后继续任务」(#1162) 自 #1281 起 100% 失效:定时任务改为无条件入队后被 state.recovery 判据永久挡住

Open
#2,704 2 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.