[Bug] 子代理结算时 projection registration 失效会让整个进程致命退出(fatal load failure / exit 1) #8849
Replies: 5 comments
复发(第 2 次,同一 stack)— 13~18 分钟必崩同一部署在 13 分钟后再次崩溃,stack 完全一致,只有 agent id 不同: 两个实例的存活时长:
结论:这不是低频竞态,而是 13~18 分钟必现的路径。 两次都发生在 continuable subagent 结算期间,该部署当时在密集使用子代理。 一条原报告里的线索可以排除第一次崩溃前约 3 秒,我们编辑过 profile 的 patch 文件(该 profile 配了 对生产的影响进程反复退出 = 所有进行中的会话被反复中断 + Web access token 每次变更。目前仅靠 systemd |
这条是两层缺陷叠在一起——而且与文档承诺直接冲突1. 文档侧的承诺(
|
| 帖 | 坏状态 | 后果 |
|---|---|---|
#8792 |
两个插件同 id |
宿主崩溃,应用起不来 |
| 你这条 | 子代理结算时投影 registration 失效 | 整个进程退出(服务下线) |
#8846 |
历史里一个孤立代理项 | 该会话永久 400 |
⇒ 共同点:一个局部坏状态升级为整体不可用。建议把这三条写成同一条原则——"局部失效必须被局部化:不得升级为进程退出或整体视图失败"。写成一类比三条各自修更容易被排期。
4. 请补两样(决定是"必现"还是"偶发")
- 投影 registration 为什么会失效——是启动期竞态、还是卸载/重载后未重注册?(你已给出
reactLoopInbox.current()这一行,把失效那一刻的日志贴上); - 是否可重跑:重启后能否再次触发;若能,请给出最小步骤(你已有 17 分 31 秒这个现场,把之前的操作序列列出即可)。
5. 版本口径
你未写版本。0.2.1-alpha.1(latest/next = 0.2.0-rc.2);packages/core/agent-loop 在 0.1.7-rc.2 → 0.2.1-alpha.1 之间有改动,请补基线。
一条边界
我确认的是文档承诺"冷读取可用"这一句与异常逃逸导致进程退出的因果(后者来自你的描述)。registration 为何失效以你补的日志为准——我没有该实例的现场。
|
补两样(PerryLink 要的)。 1. 失效那一刻的日志 —— 不存在诚实回答:没有。日志里能看到的只有 throw 本身,紧接同一秒进程退出: throw 之前没有任何一行日志——投影 registration 的建立/失效在默认 verbosity 下不落日志。所以"是启动期竞态、还是卸载/重载后未重注册"我无法从现场判定,只能给出下面能确证的部分。 2. 热重载可以彻底排除(比初版更硬)初版报告里我留过一条线索:第一次崩溃前约 3 秒编辑过 patch 文件(该 profile 配了
第二次崩溃前没有任何配置改动。所以热重载既非充分条件也非必要条件,可以不再考虑这条线。 3. 可重跑:是,且间隔稳定
systemd 最小步骤:让一个 continuable subagent 走到结算路径——派生一个 continuable subagent,让它阻塞在后台任务上,父侧在它 idle 时结算。该部署当时在密集使用子代理(并发多个),两次崩溃都发生在 4. 版本基线
5. 关于分层同意你的两层拆法,并认同第 2 条(顶层异步边界兜底)更该优先。现场两次都是"一个子代理结算失败"升级成"整个服务下线",而我这边只能靠 补充一条现场观察,供你判断第 1 条:崩溃时该实例的 memory peak 只有 1.0G(上限 5G)、CPU 累计 3 分 16 秒,没有任何资源压力迹象。所以不是"被拖垮后状态错乱",更像一个正常路径上的状态假设被违反。 |
你那条栈在源码里逐层对得上——我把位置给你,并指出"没有日志"本身就是一条缺陷1. 栈到源码的映射(
|
| 你的产物栈 | 源码位置 | 内容 |
|---|---|---|
dsh-agent-loop/lib/index.js:176 |
packages/core/agent-loop/src/inbox.ts:191 |
错误原文逐字一致:agent "${…}" cannot read inbox state: its projection registration is not active |
dsh-subagent/lib/index.js:1204 settlementState |
packages/subagent/subagent/src/continuation-activation.ts:782 |
private settlementState(…) |
其中的 hasPending |
同文件 :788 |
if (activation.inbox.hasPending || activation.ownedChildren.size > 0) return 'wait' |
dsh-subagent/lib/index.js:685 hasPending |
packages/subagent/subagent/src/inbox.ts:37 |
get hasPending(): boolean |
⇒ 因果链是清楚的:settlementState 为了判断"还能不能继续"去读 inbox.hasPending ⇒ 而 hasPending 依赖投影 ⇒ 投影失效时 current() 抛出而不是返回一个"未知"值 ⇒ 异常沿着 settlementState 冒到无人捕获的异步边界 ⇒ 进程退出。
2. ⚠️ "没有日志"这件事本身就是缺陷(建议单列一条诉求)
你诚实回答"失效那一刻的日志不存在"——只有 throw 本身、同一秒进程退出。⇒ 这意味着:
投影为什么失效,在事后完全无法查证。
⇒ 建议把这条作为独立诉求提出(与"不要崩"并列):
投影注册状态发生变化(失效/重注册)时,应当留下一条可查的记录(何时、哪个 agent、原因);否则任何同类崩溃都只能停在"它抛了"这一层。
理由:即使把"抛"改成"降级",你这条报告仍然无法回答"为什么会失效"——而下一个报告者会遇到同样的问题。补日志是成本最低、对所有未来报告都有效的一步。
3. 修法的两个落点(建议分开写)
inbox.ts:191处不应抛:读不到投影时应当返回一个明确的"不可用"状态(或空),让调用方决定怎么处理 ⇒ 这样settlementState会把它当作"wait/unknown"而不是致命错误;settlementState的调用侧应有兜底:你说是"没有错误处理的 async IIFE"⇒ 无人await的异步边界必须捕获,把失败记成该子代理的失败,而不是让它变成进程级未处理拒绝。
第 2 条比第 1 条更该优先:即使第 1 条修了,别的未处理拒绝还会以同样方式带走整个服务(你给的是 exit=1/FAILURE、消耗 3min16s CPU 后退出)。
4. 你这条与另外两条同源(建议互引)
#8589(子 agent 脱离管理面、433 步无法中止)与 #8827(父 turn 已 interrupted 而第 5 阶段子会话仍在跑)⇒ 三条合起来指向同一个结构性缺口:
子代理的生命周期(含结算、取消、状态读取)没有和父级 turn 绑定。
⇒ 建议把三条互引,并在你这条里点明"结算期也是这个缺口的一部分"——这样维护者会当成一次结构性修复,而不是三个点补。
5. 版本
你未写版本;我核到的 HEAD 上三处符号都在(inbox.ts:191、continuation-activation.ts:782/:788、subagent/src/inbox.ts:37)⇒ 当前存活项。
一条边界
我确认的是栈到源码的逐层对应与错误原文一致。**"为什么投影会失效"**仍未知——这正是第 2 节要求补日志的原因;我没有该实例的现场。
|
按你的建议,把「缺日志」单列为一条与"不要崩"并列的诉求,并补上互引与一条本地复核。 1. 独立诉求:投影生命周期必须留痕
理由(用你的话,我认为它比修法本身更值得单列):
我这边现场再次佐证了这一点:两次崩溃( 2. 互引(按你的建议)
三条指向同一个结构性缺口:子代理的生命周期(含结算、取消、状态读取)没有和父级 turn 绑定。结算期是这个缺口的一部分,而不是一个孤立 bug——建议当作一次结构性修复排期,而不是三个点补。 3. 本地复核:不是单机环境问题(新增证据)在同一构建 0.2.0-rc.2 的另一台机器(macOS / Node 26)上,我核了代码路径,与你映射的 HEAD 位置逐条对应、且缺陷原样存在: // dsh-agent-loop/lib/index.js:176 —— 仍直接抛,无降级
current() {
const state = this.projections.stateOf(this.session, "inbox");
if (state === void 0) throw new Error(`agent "${this.session.id}" cannot read inbox state: its projection registration is not active`);
return state;
}
// dsh-subagent/lib/index.js:1155-1198 —— async IIFE 仍以 "})();" 收尾,无 .catch()
watchSettlement(activation) {
(async () => {
while (true) {
...
const readiness = await this.locks.run(activation.childId,
() => Promise.resolve(this.settlementState(activation, idleObservation))); // ← 这条链会抛
...
}
})();
}值得注意:该函数内确实有 2 个 版本口径:崩溃现场为 0.2.0-rc.2(升级自 0.1.5-rc.1,后者无此机制、从未出现)。按你的核对,HEAD 上三处符号都还在 ⇒ 当前存活项。 4. 一条边界本节第 3 点我确认的只是"同一构建在第二台机器上代码路径一致"。本地是否已经崩过,我无法查证—— |
Uh oh!
There was an error while loading. Please reload this page.
Summary
一个 continuable subagent 在结算(settlement)期间读取 inbox 状态时,若它的
inboxprojection registration 已失效,ReactLoopInbox.current()会抛异常。该异常发生在一个没有错误处理的 async IIFE 里,最终成为未处理拒绝,使整个 dsh 进程致命退出(dsh: fatal load failure,exit code 1)。不是单个会话失败,是整个服务下线。本机实测:实例存活 17 分 31 秒后崩溃。
Reproduction
观察到的一次完整记录(本地时区):
出事的子代理身份与当时状态:
即:一个 continuable subagent 正阻塞在等待后台任务,父侧在结算它。
关于触发时机的一条线索(相关性,非已证实的因果):崩溃前约 3 秒我们刚编辑过 profile 的 patch 文件,该 profile 配了
patchReload: live。若热重载会重建 projection 注册表,则正在结算的子代理就会读到失效的 registration。仅供参考——即使触发源是正常 teardown,进程也不应该死,这才是要修的点。Current behavior
链路如下:
watchSettlement()在whenIdle()返回后调用settlementState()settlementState()读activation.inbox.hasPendinghasPending→this.agent.inbox.nextTurn→ReactLoopInbox.current()current()在stateOf(this.session, "inbox")为undefined时直接抛异常而调用方是没有任何 catch 的 async IIFE:
于是这个 throw 变成未处理拒绝,被 harness 判定为
fatal load failure并直接结束进程。代价是:所有正在进行的会话中断、Web token 变更、需要等自动重启。Expected behavior
建议任一处收紧,都能把「整个服务下线」降级为「一个子代理结算失败」:
current()不在此处抛:registration 失效在 teardown 场景中是可预期的。返回undefined,或抛出调用方可捕获的具名错误。watchSettlement的 async IIFE 加 catch:结算循环内任何异常都应记录并终止该 activation,而不是冒泡成未处理拒绝。这一条能兜住所有同类问题。settlementState()已经有"closed"状态,这种情况天然属于它,不必走异常路径。Environment
@deepseek-ai/dsh0.2.0-rc.2(dsh-subagent、dsh-agent-loop同版本)Restart=always,崩溃后 10 秒自动重启fatal load failure仅此一次;无法判断是低频竞态还是必现路径All reactions