Repository navigation
Description
Session 结束前的"还有没有未处理消息"判定,与"转为 idle"的状态转换不在同一个原子段里。两者之间可以插进一次消息落库,于是那条消息不会被任何执行消费——直到下一次有别的东西触发这个 Session 跑起来。
现象
后台 subagent 完成后,父 Session 有时不会自己醒来处理那条完成通知;用户下次发消息时它才和新消息一起冒出来。子 Agent 跑得越快越容易出现。
竞态
runLoop(packages/opencode/src/session/prompt.ts):
while (true) {
let msgs = yield* MessageV2.filterCompactedEffect(sessionID) // R:读快照
const { user: lastUser, assistant: lastAssistant } = MessageV2.latest(msgs)
...
if (lastAssistant?.finish && !hasToolCalls && lastAssistantBelongsToLatestTurn) break // E:用 R 的快照判定
...provider 请求...
}转为 idle 在另一处 —— Runner.finishRun(packages/opencode/src/effect/runner.ts),由 work 结束后的 Effect.onExit 触发:
const fiber = yield* work.pipe(Effect.onExit((exit) => finishRun(id, done, exit)), Effect.forkIn(scope))
const finishRun = (id, done, exit) =>
SynchronizedRef.modify(ref, (st) => [ ..., 命中则 { _tag: "Idle" } ])设消息落库时刻为 T:
| T 的位置 | 结果 |
|---|---|
| T < R | 该轮读到,退出条件不成立,继续处理 ✅ |
| R < T < 置 Idle | 判定用的是过期快照,看不到它 → 退出。写入方随后的 ensureRunning 若仍看到 Running,会走 join 分支丢弃传入的 work(runner.ts 的 case "Running": return [awaitDone(st.run.done), st]),于是无人消费 ❌ |
| T > 置 Idle | ensureRunning 走 Idle 分支新起一轮 ✅ |
消息不会丢失(已落库),但没有执行会去读它,要等下一次有东西触发该 Session。
为什么注入路径才实际撞上
用户输入和子 Agent 通知走同一条代码路径(都是 SessionPrompt.prompt → createUserMessage + loop() → ensureRunning),结构上同一个缺陷。差别在到达时刻的分布:
- 人打字的时刻与会话内部状态无关,要正好落进 R 与置 Idle 之间(其中 R→E 只有内存计算)基本是随机撞;撞上了也看得见——
prompt返回的是上一轮的 assistant 消息,用户会再发一次 - 注入由子 Agent 结算因果触发,而子 Agent 结算与父 Agent 收尾高度相关:父调完
task(background)立即返回、说一句就要结束这一轮,子若很快,注入正好落在收尾窗口
根因
判定与状态转换被拆在两层,中间隔着 work 结束、Effect.onExit、SynchronizedRef.modify 数个异步边界。
之所以如此,是因为 Runner 是泛型的(Runner.make<A, E>(scope, { onIdle, onBusy, onInterrupt })),对任意 work: Effect<A, E> 工作,不知道"消息"为何物;而"有没有未处理消息"只存在于 Session 侧的 runLoop 里。
对照:V2 的 SessionRunCoordinator(packages/core/src/session/run-coordinator.ts)没有这个问题——它的 settle 里,检查 entry.pendingWake 与 active.delete(key) 处在同一个同步块,中间只有 makeEntry / Map 操作 / Deferred.doneUnsafe 这些同步调用,wake 插不进去。该协调器刻意使用 Unsafe 系列同步 API,正是为了让"检查 + 转换"落在一个原子段里。
但 V2 帮不到这里:V2 core 没有 subagent 概念(packages/core/src/session/ 全目录只有 max-steps.ts 的提示词正文里出现过 task 字样),runner/ 也没有工具循环;tool/task.ts 用的是 V1 的 SessionPrompt。
修法
把判定搬进转换所在的临界区,不引入标志位——V1 的信号是持久化的消息行,可以现读现判,不需要像 V2 那样记住一个瞬时事件。
Runner.make增加一个由使用方提供的谓词钩子,例如shouldReArm: Effect<boolean>finishRun的SynchronizedRef.modify换成modifyEffect(start已在用这个形式),在临界区内yield*该谓词:为真则不转 Idle、重启work;为假才转 Idle- Session 侧传入的谓词复用退出条件的判据:最新 user 消息是否晚于最新 assistant
写入方不需要参与加锁。finishRun 与 ensureRunning 在同一个 SynchronizedRef 上串行,写入方的顺序是"落库 → ensureRunning",三种交错都被覆盖:
| 交错 | 结果 |
|---|---|
| 落库在临界区那次读之前 | 读到 → 不转 Idle,继续 ✅ |
| 落库在读之后、置 Idle 之前 | 转 Idle;写入方的 ensureRunning 排队拿到 ref 时看到 Idle → 起新一轮 ✅ |
| 落库在置 Idle 之后 | ensureRunning 看到 Idle → 起新一轮 ✅ |
必须一并加的两个守卫
照 V2 settle 的 Exit.isSuccess(exit) && !entry.stopping:
- 中断时不重整。否则
task取消、用户中断之后会把刚停下的 Session 复活 - 只在成功退出时重整。失败退出重整会变成失败循环
终止性
runLoop 在发 provider 请求之前就已创建 assistant 消息(const msg: SessionV1.Assistant = { id: MessageID.ascending(), parentID: lastUser.id, ... }),故每重整一轮必产生一条比 lastUser 更新的 assistant 消息,谓词随即变假,最多多跑一轮。配合上述守卫,中断与失败路径也不会转圈。
影响面
修复覆盖同一条投递渠道上的全部写入方:用户输入、subagent 完成/失败通知,以及后续 agent-management 引入的取消通知与 Agent 间消息。
改动集中在 effect/runner.ts、session/run-state.ts、session/prompt.ts,与 agent-management 无耦合,可独立落地与回归。
回归
- 目标 Session 处于 R 与置 Idle 之间时写入一条消息,该消息必须被消费,而非等待下一次外部触发
- 中断中的 Session 收到写入不得被重整复活
- 失败退出的 Session 不得进入重整循环
- 无未处理消息时不得产生额外的空转轮次
Activity
yixiao5428 commentedon Sep 11, 2026
根因确认无误,已按本 issue 的 re-arm 谓词方案修复,代码落地于分支
fix/session-idle-race(commit 832bbae225),PR 随后创建。对照当前代码逐条验证:issue 的论断全部成立——join 分支丢 work(
runner.tscase "Running"返回awaitDone(st.run.done))、R→E 之间无挂起点而 E→置 Idle 隔着 work 完成 /Effect.onExit/SynchronizedRef排队多个异步边界、assistant 在 provider 请求前落库的终止性论据。另确认实际窗口更宽:从最后一次读一直到置 Idle(中间还有 prune fork 与返回值读取),毫秒到几百毫秒级,与"子 Agent 越快越易复现"的因果分布吻合。实现与 issue 修法的对应:
Runner.make新增可选shouldReArm谓词;finishRun从modify改modifyEffect,临界区内Exit.isSuccess(exit)且谓词为真 → 重启同一 work 而非转 Idle。中断(interrupt-only failure)与失败退出被同一守卫挡住——issue 要求的两个守卫合流为一个条件,对应 V2settle的isSuccess && !stoppinglatest()后user && isAfter(user, assistant),而非退出条件的完整否定——后者会在 compaction stop / processor stop 等"成功退出但末条 assistant 无 finish"的状态下重整出多余 provider 请求SynchronizedRef全序穷尽,无人接管的交错不再存在顺带修复了一般形式:run 中途、长流式期间 join 的并发 prompt 同样依赖过期快照退出;"退出最后一刻重读持久化状态"把这些一并覆盖(含未来不调
ensureRunning的写入方)。回归测试(修复前 RED / 修复后 GREEN 均已实测):
parentID指向注入消息),并断言无第三次空转轮次test:httpapi三段门禁 208/0完整根因分析、V2 对照、正确性论证与验证记录见
docs/fixes/session-runner-fix-idle-race.md(workflow §7.1 八部分)。fix(session): re-arm finished runs when messages land in the idle win…
docs: session idle-race 修正方案文档与 devlog
Merge the session idle-race fix (#34) into agent management
fix(session): keep one runner per session across idle transitions (#89)