[Bug] 分叉(fork)会继承父会话"已入队未执行"的消息并在子会话自动重跑,且没有任何干预窗口 #6197
Replies: 4 comments
Correction: an intervention window does exist — it is just undiscoverableFollow-up to the original report. One conclusion in it was wrong, and it is worth correcting rather than leaving a misleading premise in the thread. What the report got wrong. It states the user has no intervention window. That is incorrect. The window exists — it simply sits in a session state the UI never guides you into. The three states. The queue panel is fed by the live control stream, so visibility depends entirely on whether the agent is alive — and being alive is not the same as having a prompt submitted:
The middle row is the window. Why a permission switch opens it. Reproduction (2026-09-11). Fork from a turn whose successor message is still queued → do not send anything in the child → toggle the approval policy once → the queue panel now shows the inherited message, and "Edit queued message" opens it with the full original text. Both Edit and Remove work from there. Sending a prompt first closes the window permanently. What does not change. The root cause stands: What should change is the framing. The defect is better described as "the intervention window exists but is undiscoverable" than "there is no window". That arguably makes the visibility half more important, not less: the only way to reach the window is a permission toggle — an action that has nothing to do with the message you are trying to edit, and that no user would think to try. |
|
你强调的「没有任何干预窗口」是关键判断 —— 从子会话被创建到用户第一次提交之间确实没有可操作的时机,所以清理只能发生在 已验证的修复:
|
| 运行 | 子会话 pendingNextTurn |
子会话自有事件 | 父会话队列 |
|---|---|---|---|
| 未挂插件 | 含父会话排队消息 id | [] |
未受影响 |
| 挂上插件 | [] |
一条持久 agent/inbox/spliced { removedCount: 1, outcome: "canceled" } |
未受影响 |
关键代码路径(与各位的定位一致)
packages/api/session-controller/src/commands.ts:243-246— cut 从boundary.seq + 1一直推进到下一个turn/start,把区间内的agent/inbox/spliced一起扫进子会话 seedpackages/api/session-controller/src/commands.ts:263-268— 该前缀作为seed与inheritedEventCount,并打上meta.isSeeded: truepackages/core/agent-loop/src/inbox.ts:27-56—inboxProjectionDefinition折叠日志里全部 splice,完全不看inheritedEventCount;而packages/core/session/src/index.ts:607其实已经在切点写了session/end-seed { inherited: true }标记,只是投影没读它
插件怎么修
在 agent/created 时,对 header.isSeeded 的会话,只重放 session.inheritedEventCount 之前的事件(按 inbox splice 规则折叠),得到「分叉那一刻正在排队」的精确 id 集合,再用 agent.inbox.remove(id) 持久地移除它们 —— 写入的是 agent/inbox/spliced,不是只改内存。子会话在切点之后为自己排队的新输入不受影响;非 seeded 会话一律不碰,所以 resume 回来的会话保留自己的队列。仓库里另有 12 个契约测试。
边界说明:这只是把解决方案打包成可挂载守护插件,不是上游补丁 —— bundle 补丁够不到 commands.ts 与 inbox 投影内部。真正的 upstream 修复仍应落在那处 cut 条件(或给 inboxProjectionDefinition 加 inheritedEventCount 判断)。在官方合入前,插件让现有部署立刻受益。
Verified fix: dsh-plugin-fork-inbox-guard
- npm: https://www.npmjs.com/package/dsh-plugin-fork-inbox-guard
- Source / raw reproduction transcripts: https://github.com/Robin1987China/dsh-plugin-fork-inbox-guard (
docs/verification.md)
Reproduced end to end on master aa8262ec091 through the real sessionController.fork() (no mock), then re-run with the fix mounted:
| Run | Child pendingNextTurn |
Child own events | Parent queue |
|---|---|---|---|
| No guard | parent's queued id present | [] |
intact |
| Guard mounted | [] |
one durable agent/inbox/spliced { removedCount: 1, outcome: "canceled" } |
intact |
The relevant code paths are packages/api/session-controller/src/commands.ts:243-246 (the cut walks to the next turn/start, sweeping the agent/inbox/spliced into the seed), commands.ts:263-268, and packages/core/agent-loop/src/inbox.ts:27-56 (the projection folds every splice and never consults inheritedEventCount, even though packages/core/session/src/index.ts:607 already wrote a session/end-seed { inherited: true } marker at the cut).
The guard, on agent/created for a header.isSeeded session, replays only the events before session.inheritedEventCount, then durably removes exactly the identities that were pending at the cut. Input the child queued for itself after the cut is untouched, and non-seeded sessions are never touched.
Scope note: this is a mountable guard, not an upstream patch — a bundle patch cannot reach commands.ts or the inbox projection. The real fix still belongs in the cut condition above.
|
补充:安装方式有更新。 这个修复现在也被 umbrella bundle dsh plugin --profile <你的 profile> add dsh-community-fixes再把 |
Uh oh!
There was an error while loading. Please reload this page.
Issue type: Bug(行为 + 可见性)
Component:
@deepseek-ai/dsh-api-session-controller(Host 侧fork());次要:dsh-client-ui-chat/dsh-client-ui-conversationVersion:
@deepseek-ai/dsh0.1.5-rc.1(npmlatest/next),会话格式 V3Platform: Windows / Node v24.19.0(与平台无关,纯逻辑缺陷)
TL;DR (English)
SessionController.fork()seeds the child with every event between the boundary turn'sturn/endand the nextturn/start. That interval containsagent/inbox/spliced(target: "next-turn") — a user message the parent had already submitted but not yet executed. So the child silently inherits it and runs it as its own next turn, re-executing the same user message in both sessions.The user cannot intervene: the queue panel (
QueueDock) is fed by the live control stream and renders nothing for a cold session, while any moment the session becomes live the inherited item is already consumed as FIFO head and becomes an immutable historicaluser/message(DSH has no edit entry point for historical messages).1. 根因
SessionController.fork()——@deepseek-ai/dsh-api-session-controller/lib/types/commands.js(约 L187–L275):那个
while循环的设计意图是合理的:把turn/end之后、下一个turn/start之前的包级状态事件一并继承,否则子会话会丢失权限/沙箱/标题等状态:permission/preset、sandbox/mode、approval/policysession/titleagent/inbox/spliced(target: "next-turn")缺陷本质:切点逻辑没有区分事件语义,把"状态"和"待办动作"一视同仁地塞进了 seed。
2. 用户可见症状
3. 复现步骤
条件:在下一轮消息已提交、但尚未执行完毕的时刻分叉。
turn/end turn=N)。agent/inbox/spliced {target:"next-turn"}入队,随后才产生turn/start turn=N+1)。forkAt(closing.finalNode.seq))。触发条件极窄但完全确定性:
atSeq落在第 N 轮内 →boundary= 第 N 轮turn/end→cut推进到第 N+1 轮turn/start之前 → 恰好吞掉那条agent/inbox/spliced。4. 为什么用户"看不到也改不了"
这里有两条互不相通的数据通路,它们的错位才是问题最糟的部分:
通路 A(分叉复制):持久化日志 → seed 里含
agent/inbox/spliced→ 会执行。通路 B(队列面板):运行时 live agent → 独立 control 流 → 客户端
SessionQueueMirror:dsh-api-session-controller/lib/types/client/sessions/session.jsL387:replaceControl(queue)→queueMirror.replace(queue);服务端数据由control.js的queueItems(agent)生成,直接读 live agent 的inbox。冷会话没有 live agent → control 流没有 queue 数据 → 客户端
queue为空 →dsh-client-ui-conversation/lib/client.jsL14096:而 README 明确:history pages / log following 可以在不激活 Agent 的前提下进行。
结果:
user/message而历史消息在 DSH 里没有编辑入口 —— 全库唯一的编辑能力是
queue.edit("编辑排队消息"),它只作用于placement === "queued"的项。→ 两个状态之间不存在可编辑窗口。
5. 实测证据(真实日志)
工作区
<workspace>,2026-09-10 21:26–21:43 (UTC+8)。日志<DSH_HOME>/sessions/<workspace-slug>/<id>/session.v3.jsonl.zstd(多帧 zstd,需按 magic28 B5 2F FD分帧解压)。父会话
session-05705bef…(6 轮):子会话
session-f5b07ce6…(parentSession = 05705bef,inheritedEventCount = 262):同一批数据里另外两个切点(
inheritedEventCount可直接从投影缓存identity读出):inheritedEventCountsession-93312d71…turn 5@257session-f5b07ce6…turn 6@265session-9ecab245…session-9ecab245的投影缓存inbox行(冷 projection 里它在,印证通路 A 与 B 的错位):6. 建议修复(按推荐度排序)
A2(推荐,Host 侧):保留 seed,但在子会话追加一条清理继承待办的动作
fork()在agents.create()之后,检测 seed 中未消费的 inbox 项并追加:优点:同时保留包级状态(权限/沙箱/标题照旧继承),又不让待办动作跨会话复制;语义上也更正确——"新会话不应替父会话执行它没跑完的事"。
A3(次选,Host 侧):把"队列非空"变成分叉的前置条件
在
fork()里,若源会话inbox.nextTurn/inbox.nextStep非空,返回一个明确的错误码(复用session/fork-unavailable的形态,如session/fork-queue-pending),由客户端提示用户"队列中还有未执行的消息,请先处理"。不建议直接截断
cut—— 因为cut是连续前缀长度,截断会连带丢掉紧随其后的session/title等状态事件。B(应与 A 同时做,Client 侧):让不可用状态可见
dsh-client-ui-chat/lib/client.jsL3667 已有"分叉不可用"机制:把"队列有待执行消息"纳入该条件,按钮置灰并给出原因提示。当前的问题是:行为发生了,UI 却完全不解释。
附:一处值得一并审视的不一致
同为 projection,
dsh-schedule与dsh-subagent都显式跳过 seed 区间:而 inbox 的 fold 不跳过,所以 seed 里的排队消息会进入运行时队列并被消费。这个不一致正是"日志里有、UI 里没有"的源头,建议明确 inbox 在 fork 语义下应遵循哪一种。
附:定位索引
dsh-api-session-controller/lib/types/commands.jsL187–275fork(),缺陷本体(L224–227 的 cut 推进)dsh-api-session-controller/lib/types/control.jsL151–172queueItems()读 live agent 的 inboxdsh-api-session-controller/lib/types/client/sessions/queue-mirror.jsL12–16textOf():content 含非 text 块 →null→ 编辑按钮置灰dsh-api-session-controller/lib/types/client/sessions/session.jsL387, L422–427dsh-client-ui-conversation/lib/client.jsL14071, L14096, L14247QueueDock;空队列直接return null;编辑可用条件dsh-client-ui-chat/lib/client.jsL3664–3667, L8338–8346forkAt(closing.finalNode.seq)、branchUnavailabledsh-client-ui-workspace/lib/client.jsL73–75atSeq→ 从最后一个已完成轮次分叉(带走全部历史)相关讨论
All reactions