Replies: 4 comments
|
对着现在的 TypeScript 源码(不是编译后的 lib/index.js)在 upstream master 上核实过,完全成立。packages/api/session-controller/src/commands.ts 里还是这样: let cut = SessionLogOffset(boundary.seq + 1) 而这段代码正好在 fork() 自己的文档注释下面,注释写的是 Create a new ordinary Session from one completed-turn prefix。这个 while 循环破坏了这个约定,它会一直走到下一个 turn/start 之前,正好把排队中的 agent/inbox/spliced(下一条 prompt)也扫进去。 也核实了 fold 那一侧:packages/core/agent-loop/src/inbox.ts 里 inboxProjectionDefinition 对每个 agent/inbox/spliced 都是 inbox.toSpliced(splice.start, removedCount, ...splice.inserted),完全不区分这是继承下来的历史,还是真正待处理的输入。所以只要 seed 里带了这条 splice,子会话打开后就会像你日志里那样,把父会话排队中的下一条 prompt 当成自己的待办直接执行。 你建议的修法,去掉这个 while,直接用 boundary.seq + 1,和函数自己的文档注释是一致的,也不会影响 seeded header 的约束,因为 inheritedEventCount 本来就是设成 cut。这看起来是一个真实存在、目前仍在生产代码里的正确性缺陷,不是设计取舍的问题,文档注释本身已经写明了预期行为。 |
|
补充一下:我把这个 bug 的修复写好了,推到了我的 fork 上,分支在这里: https://github.com/Mide69/deepseek-harness/tree/fix/session-fork-seed-boundary 修法和你在补充建议里说的一致:把 agent/inbox/spliced(带非空 inserted 的那种)当成扫描的硬边界,而不是完全去掉那个 while 循环。原因是仓库里已经有一个测试依赖这个循环把 turn/end 之后的 request/header(模型选择变更)也带进子会话种子里,如果直接删掉循环会让那个测试失败。所以正确的修法是继续扫到下一个 turn/start,但一遇到带 inserted 内容的 agent/inbox/spliced 就停。 加了一个回归测试,复现你日志里的那种事件顺序(turn/end → agent/inbox/spliced 带排队 prompt → turn/start),确认新代码下这条 splice 不会出现在子会话种子里,同时原来那条 request/header 的测试也照样通过。跑过完整的 typecheck 和 lint,都是干净的。 |
|
你对 cut 机制描述得最精确 —— 已验证的修复:
|
| 运行 | 子会话 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.
session.forkcuts its seed by walking forward from the boundaryturn/enduntil the nextturn/start. Everything between those two events — notably the durableagent/inbox/splicedthat admits the next user prompt — is therefore included in the child's seed. Because pending input is rebuilt by foldingagent/inbox/spliced(dsh-agent-loopinbox projection), the forked child starts with the parent's next prompt already queued and re-runs a task the parent already completed.环境
dsh0.1.5-rc.1(全局安装,Homebrew node_modules)@deepseek-ai/dsh-api-session-controller0.1.5-rc.1,缺陷在lib/index.js:683-685(fork())dsh-client-ui-chat的TurnTailNodeView.onBranch→ctx.sessions.fork({ sessionId, atSeq, increaseTitle: true }))dsh-client-ui-workspace,不带atSeq)同样复现复现步骤
预期:子会话的 seed = 「到 turn N 的
turn/end为止」的一个完整已完成轮次前缀。实际:seed 额外包含 turn N+1 的用户 prompt(以及
turn/end与下一个turn/start之间的其他事件)。侧边栏「分叉会话」(取最后一个
turn/end)在父会话存在排队/进行中的下一条提交时同样中招;对子会话再次分叉会累积多条多出来的 prompt。根因
@deepseek-ai/dsh-api-session-controller/lib/index.js:slice(0, cut)于是包含boundary.seq + 1 … (下一个 turn/start - 1)的全部事件。而 DSH 的日志顺序(实测)是:也就是说,分叉点之后的下一条用户提交恰好落在这个被扫入的窗口里。
子会话的待处理输入不是快照,而是 fold 出来的:
dsh-agent-loop的inboxProjectionDefinition对每个agent/inbox/spliced做所以被继承的那条 splice 会把父会话的下一条 prompt 变成子会话的
next-turn待处理项;子会话 resume / 打开后循环 claim 该输入并开一轮,于是父会话已跑过的任务被重跑。fork()自己的文档注释写的是 “Create a new ordinary Session from one completed-turn prefix”,boundary.seq + 1才符合该语义。证据(本机真实日志)
父会话
session-892cd3e7-3a93-48a9-982b-0072d486201c(2026-09-10T23:07:06,isSeeded:false):在 turn 3 的尾部(
atSeq≈ 693 的 assistant/message)分叉 →boundary = 695,cut被while推到 698,seed =events[0..697],最后一条正是 697 的那条 splice。子会话
session-54d4d9dd-…(2026-09-11T13:00:34,isSeeded:true,parentSession: session-892cd3e7-…):另一个子会话
session-6715d80f-…(2026-09-11T13:09:53,同一父会话)完全同构:inherited 标记在 698,首个user/message在 seq 707,内容与父会话 turn 4 的 prompt 逐字相同。两个 fork 子会话各把父会话的下一条任务重跑了一遍。fork 链上的叠加效应:
session-1b17eb0b-…(父为上面那个已是 fork 的54d4d9dd)的 inherited 标记在 701,其 seed 尾部是 696/697/698(继承的 splice + 标记)+ 699session/title+ 700 又一条agent/inbox/spliced,即一次分叉继承了两条排队 prompt。最小验证(对任意 v3 日志):
建议修复
即删掉那个
while。boundary.seq + 1本身就是完整的已完成轮次前缀,子会话的session/end-seed {inherited:true}标记仍会落在同一位置(inheritedEventCount = cut),不违反 v2/v3 seeded-header 的 “last inherited end-seed marker === cut” 约束。如果确实需要在 seed 里保留
turn/end之后的若干非轮次事件(session/title、sandbox/approval 状态等),也请显式排除带inserted的agent/inbox/spliced—— 那一条不是历史,而是「未来的输入」,继承它必然改变子会话的执行语义。补充建议:即便保留扫描,也应当把
agent/inbox/spliced视为 cut 的硬边界(或对继承的 pending 输入做一次outcome:"canceled"的显式清空),否则用户无法通过 UI 区分「历史」与「会被执行的待办」。临时规避(用户侧)
turn/end,cut等于日志长度,分叉干净。相关
session.fork。dsh-better-sidebar的「保存为新会话」显式判断threadTrailingPending(最后一条user/message未被turn/end回答)并不把待处理追问带进新会话,可作为期望语义的参考实现。All reactions