Skip to content

fix(core): 重启成功后恢复 worker 就绪状态 - #1032

Open
xiaoxueSunn wants to merge 1 commit into
deepcoldy:masterfrom
xiaoxueSunn:fix/restart-worker-ready-lifecycle
Open

fix(core): 重启成功后恢复 worker 就绪状态#1032
xiaoxueSunn wants to merge 1 commit into
deepcoldy:masterfrom
xiaoxueSunn:fix/restart-worker-ready-lifecycle

Conversation

@xiaoxueSunn

Copy link
Copy Markdown
Contributor

问题

存量会话执行 /restart 时,daemon 会先把 workerReady 设为 false,以阻止 fork、relay 等生命周期操作与 CLI 重启并发。

CLI 在同一个 worker 进程内恢复后只发送 prompt_readyrestart_result,不会再次发送进程级 ready。原逻辑虽然结算了 restart coordinator,却没有恢复 workerReady。因此终端和卡片已经显示 idle,fork/relay 仍会永久返回 worker_busy

复现顺序:

  1. 在正常会话执行 /restart
  2. 等待 replacement CLI 回到输入提示符。
  3. 执行 /fork --create ...
  4. fork 被误判为 mid-turn;新建群随后回滚解散。

修复

处理当前 worker 的 restart_result 时:

  • 先由 restart coordinator 校验并结算匹配的 attempt;
  • 仅当匹配成功且状态为 succeeded 时恢复 ds.workerReady = true
  • 失败、超时、重复回执和旧 worker 回执仍保持 fail-closed。

影响面

改动位于 core/worker-pool 的公共生命周期状态处理,覆盖所有采用 in-worker restart 的本地 CLI 和普通群/话题会话。

不改变 transcript、消息路由、fork 数据复制方式或远端 backend 的失败策略。失败重启不会被误标为 ready。

验证

  • 聚焦重启测试:5 个测试文件,56 个测试通过。
  • /restart 命令与 transfer/fork 组合回归:2 个测试文件,359 个测试通过。
  • 合计:7 个测试文件,415 个测试通过。
  • pnpm build 通过:TypeScript、脚本类型检查、Dashboard bundle、dist audit 全部完成。
  • git diff --check 通过。

@xiaoxueSunn
xiaoxueSunn requested a review from deepcoldy as a code owner August 27, 2026 04:39
@deepcoldy

Copy link
Copy Markdown
Owner

感谢这个修复 🙏 方向和病理分析都准确,我核实后确认问题真实存在,且守卫的设计比表面看起来更细致。以下是初步评审意见,供参考。

✅ 已核实成立的部分

病理链条:我确认 worker 侧 type: 'ready' 全仓只有一处(src/worker.ts:18278,在 case 'init' 内),所以 in-worker respawn 确实不会二次发 ready;master 上 ds.workerReady = true 也只有一个写点(ready handler)。栅栏被 requestSessionRestart 清掉后确实无人放开,isSessionLifecycleInFlight 三项之一就是 workerReady === false,fork/relay 都 gate 在它上面 —— 你描述的复现路径成立。

守卫形状值得肯定:接 restartCoordinator.resolve() 的返回值而不是只看 msg.status,一次兜住了三类边界。我用真 handler(__testOnly_setupWorkerHandlers + fake worker)驱动 IPC 逐条验证:

场景 结果
succeeded 收据 workerReady=true、fence 释放 ✅
failed 保持 fenced ✅
不匹配的 attemptId 保持 fenced,随后真收据才放开 ✅
failed → 重复 succeeded 保持 fenced ✅
timed_out → 迟到 succeeded 保持 fenced ✅
stale generation 收据 保持 fenced ✅

🟠 建议修改:新测试目前无法证明这个修复

test/restart-worker-ready-lifecycle.test.tsreadFileSync 读源码文本 + 断言 indexOf 顺序,也就是在测「源码长什么样」而不是「行为对不对」。

我做了一次反向变异来量化:在 restartCoordinator.resolve 前插一行 if (true) break;,让整段修复变成完全不可达的死代码(源码文本顺序全部保留,所以 source-pin 断言依然满足)——

  • 新增的 2 个测试:全绿
  • PR 描述里引用的回归套:521 tests / 13 files 全绿(restart 相关 8 套 110、fork+transfer 98、command-handler + restart-report + mojo-cross-boundary 313)
  • 我上面那个行为化探针:expected false to be true

也就是说 bug 完全回归了但测试察觉不到,PR 描述里的 415 个测试对这次修复没有约束力(全仓只有 3 个测试文件提到 restart_result,没有一个断言 workerReady)。

建议改成行为化测试,至少覆盖「succeeded 释放栅栏」+「failed / stale 不释放」。harness 是现成的,可以直接照 test/worker-ready-display-mode.test.ts 的写法(__testOnly_setupWorkerHandlers + fake worker EventEmitter emit('message', …)),成本不高。

🟡 仅供参考(master 既有问题,不必在本 PR 处理)

in-worker restart IPC 全仓有 4 个生产者,只有 requestSessionRestart 铸了 attemptId

生产者 attemptId 清 workerReady
requestSessionRestart(/restart、卡片)
crash auto-restart(reason:'cli_crash'
dashboard 重启按钮(reason:'operator'
dashboard cwd-move(updateWorkingDir

后三条清了栅栏但永远收不到匹配收据(worker 侧 activeRestartAttemptId 只在 case 'restart'msg.attemptId 赋值),会落到同一个 fence-stuck。我实测 crash respawn 后 prompt_ready 到达时 workerReady 仍是 false

不过这三条在 master 上就已如此,本 PR 没有引入也没有加重,所以只是提一下,是否要一起收敛完全由维护者决定。缓解事实:用户之后手动 /restart 可以自愈(那条路径带 attemptId)。

其它

  • tsc --noEmit exit 0;上述回归套在未变异状态下全绿
  • 分支目前不含最新 origin/master,合入前需要 rebase(或走 merge queue)

以上是自动评审的初步意见,可能有误判,最终以维护者审阅结论为准。核心只有一条建议:把 source-pin 测试换成行为化测试,其余都是可选项。再次感谢你定位到这个问题 🙏

@deepcoldy

Copy link
Copy Markdown
Owner

补充与更正(第二轮交叉评审后)——两条实证结论,一条是对我上一条评论的更正。

⚠️ 更正:我上面说的「合入前需要 rebase」过强,请忽略

实测三项:

  • 分支落后 origin/master3 个 commit
  • git merge-tree --write-tree origin/master <branch>0 冲突
  • 分支保护 required_status_checks.strict = null未要求分支 up-to-date)

所以 rebase 是可选的,不是合入前置。mergeStateStatus=BLOCKED 的真实原因是缺 approval(required_approving_review_count=1 + require_code_owner_reviews=true),跟分支新旧无关。抱歉造成误解。

✅ 守卫强度的一个补充结论(对你有利)

交叉评审时有人注意到:restart_result 的守卫是 ds.worker !== worker,比 ready handler 的 ownsLifecycleMutation()(额外含 !isSessionTransferring)弱。我实证核实后确认无害,而且理由比「恰好安全」更强 —— 这个组合态结构上不可达

  • requestSessionRestartworker-pool.ts:3396)首行就是 if (isSessionTransferring(ds)) return undefined → transfer 在飞时 restart 直接被拒,压根铸不出 attemptId
  • transfer 的唯一入口(beginTransferInputGate,全仓仅 1 个调用点 :6910)前置 isSessionLifecycleInFlight:6891)→ restart 在飞时 transfer 报 worker_busy(我实测 inFlight=true

即「restart 在飞 + transfer 生效」两者互斥,弱守卫在 transfer 场景下走不到。所以这一点不需要你改。(未来若两处守卫要收紧,对齐即可,属长期整理。)

📌 关于那三条 master 既有的裸发路径(仍然不要求你在本 PR 处理)

补一个优先级信息:三条里 cli_crash auto-restart 是生产环境最高频的重启路径,实际影响面比 dashboard 两处大。如果后续开 follow-up,建议它排最前。修法不复杂:三条改走 coordinator 即可(cwd-move 需要给 requestSessionRestart 加一个 updateWorkingDir 透传参数,小改)。

另外建议在 PR 描述里补一句覆盖范围说明:本修复只覆盖 coordinator 路径(/restart + 卡片按钮),另三条裸发路径的栅栏卡死需靠用户手动 /restart 自愈(因为那条路径带 attemptId)。写明这点能让后来读 git 历史的人不误以为 fence-stuck 已全部收敛。

测试模板(可直接转正,已实测通过)

const ds = makeDs({ workerReady: true });
__testOnly_setupWorkerHandlers(ds, ds.worker);

const { attemptId } = requestSessionRestart(ds, { source: 'slash', notify: vi.fn() })!;
expect(ds.workerReady).toBe(false);            // 栅栏落下

(ds.worker as any).emit('message', { type: 'restart_result', attemptId, status: 'succeeded' });
expect(ds.workerReady).toBe(true);             // 栅栏抬起

// 反例三条,断言保持 false:伪 attemptId / status:'failed' / 超时后迟到的 succeeded

mock 清单照抄 test/worker-ready-display-mode.test.ts 即可(__testOnly_setupWorkerHandlers + fake worker EventEmitter)。

以上仍是自动评审的初步意见,最终以维护者审阅结论为准。综合两轮:改动方向和守卫设计都没有正确性问题,唯一建议就是把 source-pin 测试换成行为化测试 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants