[Bug] session/fork inherits the source's pending inbox — a branch's first new message is answered as the source's queued question | fork 继承未消费收件箱:分支首条新消息被当成源分支排队提问回答 #6277
Replies: 3 comments
|
这是来自zhangyueQQ邮箱的自动回复邮件。您好,您的邮件已收到,谢谢。
|
|
你发现的「每 fork 一代就多继承一条幽灵消息」是这条缺陷最有说服力的一面 —— 它是可累积的,不是一次性异常。我在 master 上复现并做了个可挂载的修复。 已验证的修复:
|
| 运行 | 子会话 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.
中文版
[Bug]
session/fork继承源会话未消费的收件箱,分支首条新消息被当成源会话的排队提问回答版本:
@deepseek-ai/dsh0.1.5-rc.1 · Windows · Web GUI(端口 3080)摘要
对会话点「创建分支」后,在分支里输入一个全新问题并发送 —— 分支实际跑的却是源会话在该节点的下一条排队提问,我真正的新问题被挤到队列尾部(或被降级为回合内注入)。而且这个问题会累积:每 fork 一代就多继承一条幽灵消息,多次分支后队列里全是幽灵。
根因:
session/fork选取种子前缀时,从最后一个turn/end向后推进到下一个turn/start才截断,于是把属于下一回合的agent/inbox/spliced「插入」事件一起继承了;而待处理收件箱正是由这类事件折叠重建的持久投影。继承了「插入」却不继承它的「消费」,子会话重放后队列里就凭空多出源会话的提问,子会话首个新回合按 FIFO 从下标 0 取料,取到的就是幽灵。不是客户端串会话:请求确实发到了子会话 id,是子会话的收件箱投影被污染。
修复建议:切点停止条件除
turn/start外,再加上「向下一回合的收件箱插入」;并补一条「种子前缀收件箱不平衡」的错误日志便于回归。已在真实日志上离线复算:6 个分支开局残留 1+2+3+4+5+1=16 条 → 全部归 0,且不会丢弃任何已完成回合的历史(v3 事件的surfaceOp引用只向前指,截尾安全)。当前可用规避:在已污染的分支里把「排队」面板的条目全部取消,投影清空后行为即恢复正常;或只在源会话空闲且队列为空时创建分支。
现象
对会话点「创建分支」(fork)后打开该分支,输入一个全新问题并发送。分支并不是从这个新问题开始的:
而且它会累积:每多 fork 一代就多继承一条陈旧条目,fork 几次之后队列里就只剩幽灵了。
复现步骤
atSeq)。hello)并回车。期望:分支的首个新回合里只有
hello。实际:分支的首个新回合里是源会话那条陈旧提问;
hello被排在它后面。证据(真实会话日志,
~/.dsh/sessions/<workspace>/)fork 的切点正好落在一次收件箱插入与消费它的那个回合之间:
子(fork 出来的)会话从被继承的插入事件重建出了非空收件箱;队列深度随 fork 代数递增:
start=1;该回合跑的是陈旧提问start=2;user/message= 源会话的陈旧提问hello→ splice 到start=5;回合回答的是陈旧提问;手动取消 5 次(outcome=canceled)后才恢复正常(分支标签已匿名化为 A–F;这六个 fork 会话是同一个本地 workspace 里的一条真实链条,队列深度序列 1→2→3→4→5 正是逐代累积的体现。提示词文本已做脱敏。)
根因
session/fork选取种子前缀时一路推进到下一个turn/start,这就把下一回合的agent/inbox/spliced插入事件一并纳入了继承前缀:而待处理收件箱恰恰是一个由这些事件折叠出来的持久投影(
@deepseek-ai/dsh-agent-loop/inbox,key: "inbox"):于是,一个在被继承前缀里找不到对应消费的插入事件,就把源会话的队列实体化到了子会话中;子会话的首个回合按 FIFO 从下标 0 取料 —— 取到的就是这个幽灵。
两点范围说明:
atSeq)和从最后已完成回合 fork(不带atSeq,只要源会话有排队提示词即可触发)。修复建议
切点应当停在任何属于下一回合的东西之前 —— 不只是
turn/start,还包括向下一回合收件箱的插入 —— 同时加一条种子不平衡断言以便诊断:用新的切点规则在上述六个真实分支上离线重放折叠过程验证:分支开局待处理数 16 → 0(分别为 1、2、3、4、5、1 → 全部为 0)。该规则只会丢弃被舍弃回合末尾的下一回合插入,因此不会丢失任何已完成回合的历史 —— v3 的
surfaceOp引用只向后指,截尾是安全的。替代/补充方案:在子会话的第一条自有事件里发一条补偿性的
agent/inbox/spliced(removedCount = pending,outcome: "canceled"),这样任何不平衡的种子都能自愈,且丢弃动作仍然可审计。当前规避方式
环境
@deepseek-ai/dsh0.1.5-rc.1(npx 安装),Windows 11,Web GUI 服务在 127.0.0.1:3080session/fork)English version
[Bug]
session/forkinherits the source's pending inbox, so a branch's first new message is answered as the source's queued questionVersion:
@deepseek-ai/dsh0.1.5-rc.1 · Windows · Web GUI (port 3080)Summary
After forking a session, type a brand-new question in the branch and send it. The turn that actually runs is the source's queued prompt at that node, and the real question is pushed to the back of the queue (or downgraded to a mid-turn injection). It compounds: every additional fork generation inherits one more ghost message, so after a few forks the queue is nothing but ghosts.
Root cause: when
session/forkpicks the seed prefix, it advances from the lastturn/endup to the nextturn/startbefore cutting — so it also inherits theagent/inbox/splicedinsert events that belong to the next turn. The pending inbox is a durable projection folded from exactly those events. Inheriting an insert without inheriting its consume makes the source's queued prompt reappear inside the child; the child's first new turn claims FIFO from index 0 and picks up the ghost.This is not client-side session mixing: the request really does reach the child session id — it is the child's inbox projection that is poisoned.
Proposed fix: make the cut stop not only at
turn/start, but also at any "insert into the next turn's inbox"; plus an unbalanced-seed error log for diagnosability. Verified offline against real logs: the six branches start with 1+2+3+4+5+1 = 16 residual entries → all 0, without dropping any completed-turn history (v3 eventsurfaceOpreferences point backwards only, so trimming the tail is safe).Workaround today: in a polluted branch, cancel every entry in the queued panel — once the projection is empty, behaviour returns to normal; or only fork while the source session is idle with an empty queue.
Symptom
Create a branch (fork) of a session, open the branch, type a brand-new question and send it. The branch does not start with that question:
It compounds: every extra fork generation inherits one more stale item, so after a few forks the queue is nothing but ghosts.
Reproduction
atSeq).hello) and press Enter.Expected: the branch's first new turn contains exactly
hello.Actual: the branch's first new turn contains the source's stale question;
hellois queued behind it.Evidence (real session logs,
~/.dsh/sessions/<workspace>/)The fork cut sits between an inbox insert and the turn that consumes it:
Child (forked) sessions reconstructed a non-empty inbox from the inherited insert; queue depth grows per fork generation:
start=1; the turn ran the stale questionstart=2;user/message= source's stale questionhello→ spliced atstart=5; turn answered the stale question; only after 5 manual cancels (outcome=canceled) did it behave(Branch labels are anonymized to A–F; the six forked sessions are a real chain in one local workspace, and the queue-depth sequence 1→2→3→4→5 is what shows the per-generation accumulation. Prompt texts are redacted.)
Root cause
session/forkpicks the seed prefix by advancing to the nextturn/start, which includes the next turn'sagent/inbox/splicedinserts in the inherited prefix:Meanwhile the pending inbox is a durable projection folded from exactly those events (
@deepseek-ai/dsh-agent-loop/inbox,key: "inbox"):An insert with no matching consume inside the inherited prefix therefore materialises the source's queue inside the child, and the child's first turn claims FIFO from index 0 — i.e. the ghost.
Two notes on scope:
atSeq) and fork-from-last-completed-turn (noatSeq, source merely has a queued prompt).Proposed fix
Stop the cut before anything that belongs to the next turn — not only
turn/start, but also next-turn inbox inserts — plus an unbalanced-seed assertion for diagnosability:Verified offline by replaying the fold with the new cut rule over the six real branches above: pending-at-branch-start 16 → 0 (1, 2, 3, 4, 5, 1 each → 0). The rule only ever drops trailing next-turn inserts of the discarded turn, so no completed-turn history is lost — v3
surfaceOpreferences point backwards only, so trimming the tail is safe.Alternative / complementary: emit a compensating
agent/inbox/spliced(removedCount = pending,outcome: "canceled") as the child's first own event, so any unbalanced seed self-heals and the discard stays auditable.Workaround today
Environment
@deepseek-ai/dsh0.1.5-rc.1 (npx install), Windows 11, Web GUI served on 127.0.0.1:3080session/fork), i.e. "创建分支".All reactions