Skip to content

Session 结束判定与转 idle 不原子,注入的消息可能无人消费 #32

Closed

Description

@lihaokun

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 那样记住一个瞬时事件。

  1. Runner.make 增加一个由使用方提供的谓词钩子,例如 shouldReArm: Effect<boolean>
  2. finishRun 的 SynchronizedRef.modify 换成 modifyEffect(start 已在用这个形式),在临界区内 yield* 该谓词:为真则不转 Idle、重启 work;为假才转 Idle
  3. Session 侧传入的谓词复用退出条件的判据:最新 user 消息是否晚于最新 assistant

写入方不需要参与加锁。finishRun 与 ensureRunning 在同一个 SynchronizedRef 上串行,写入方的顺序是"落库 → ensureRunning",三种交错都被覆盖:

交错 结果
落库在临界区那次读之前 读到 → 不转 Idle,继续 ✅
落库在读之后、置 Idle 之前 转 Idle;写入方的 ensureRunning 排队拿到 ref 时看到 Idle → 起新一轮 ✅
落库在置 Idle 之后 ensureRunning 看到 Idle → 起新一轮 ✅

必须一并加的两个守卫

照 V2 settle 的 Exit.isSuccess(exit) && !entry.stopping:

  1. 中断时不重整。否则 task 取消、用户中断之后会把刚停下的 Session 复活
  2. 只在成功退出时重整。失败退出重整会变成失败循环

终止性

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 无耦合,可独立落地与回归。

回归

  1. 目标 Session 处于 R 与置 Idle 之间时写入一条消息,该消息必须被消费,而非等待下一次外部触发
  2. 中断中的 Session 收到写入不得被重整复活
  3. 失败退出的 Session 不得进入重整循环
  4. 无未处理消息时不得产生额外的空转轮次

Activity

  1. self-assigned this
    on Sep 11, 2026
  2. yixiao5428 commented on Sep 11, 2026

    @yixiao5428
    Collaborator

    根因确认无误,已按本 issue 的 re-arm 谓词方案修复,代码落地于分支 fix/session-idle-race(commit 832bbae225),PR 随后创建。

    对照当前代码逐条验证:issue 的论断全部成立——join 分支丢 work(runner.ts case "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 要求的两个守卫合流为一个条件,对应 V2 settle 的 isSuccess && !stopping
    • Session 谓词复用退出判据的窄形式:latest() 后 user && isAfter(user, assistant),而非退出条件的完整否定——后者会在 compaction stop / processor stop 等"成功退出但末条 assistant 无 finish"的状态下重整出多余 provider 请求
    • 谓词 DB 读失败 fail-closed(退回修复前悬置语义),错误不进泛型 Runner 层
    • 写入方无需参与加锁:判定依据是持久化消息行,凡落库之后的 Idle 决策必能看到该消息——两种交错(写入方先拿 ref / finishRun 先拿 ref)被 SynchronizedRef 全序穷尽,无人接管的交错不再存在

    顺带修复了一般形式:run 中途、长流式期间 join 的并发 prompt 同样依赖过期快照退出;"退出最后一刻重读持久化状态"把这些一并覆盖(含未来不调 ensureRunning 的写入方)。

    回归测试(修复前 RED / 修复后 GREEN 均已实测):

    • runner 级 5 例:成功重整 / 谓词假零空转 / 失败不重整 / 中断不复活 / join 方拿旧结果
    • session 级 1 例(真 HTTP LLM server + 真 SQLite 全链路):以 hold 的 content-filter 响应制造"最后一次读之后不再重读"的真窗口,窗口内落库一条 user 消息,释放后断言第二次 provider 请求消费它(assistant parentID 指向注入消息),并断言无第三次空转轮次
    • 复现要点:必须选迭代内直接 break 的路径(content-filter)。第一版用正常 stop 复现是假绿——processor 返回 continue、循环自带一轮重读把注入顺便消费了;这条教训已记入 devlog
    • 触及套件 128/128 全绿;全套件 3576 pass / 28 fail,失败集合与修复前基线逐名一致(均为既有失败,非本修复引入);test:httpapi 三段门禁 208/0

    完整根因分析、V2 对照、正确性论证与验证记录见 docs/fixes/session-runner-fix-idle-race.md(workflow §7.1 八部分)。

  3. added a commit that references this issue on Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions