Repository navigation
[BUG] 0.4.1:session-shells 把每次 sandbox/mode 变化当成全表重建信号,新建会话要为每个存活 agent 重新注册一遍 ops_bash #382
Description
dsh-ops 版本与安装通道
dsh-ops 0.4.1(npm 安装,dist-tags.latest 仍是 0.4.1,2026-10-20 发布)。安装在
~/.dsh/profiles/desktop/node_modules/dsh-ops,未做任何本地修改(下面提到的本地热修复只用于
确认因果,不在生产环境长期保留)。
DSH 版本与 profile
- DSH Desktop
0.2.1-rc.2,profile =desktop,dsh.profile.bundles共 32 个 - Host Node
v24.21.0 - 工具规模(同一 profile):
- 默认(项目级 MCP 走
defer)88 个工具,request/header中tools合计 102,759 bytes - 接入 REA MCP 后 258 个工具,仅
parameters合计 691,714 bytes(量级与 [BUG] 0.4.1:visibleTools/PTC snapshot 用 tools.schemas() 深拷贝 258 个工具 schema,新建会话随活跃 Agent 数线性变慢 #369 一致)
- 默认(项目级 MCP 走
操作系统与架构
Windows 11 专业版,build 28000,x64。
用户可见症状
在同一个 Host 进程内反复「新建会话」,每建一个会话,下一次新建就更慢一档;重启 DSH 归零。
64 个存活 agent 时:
/api/session/create往返 1,758–2,612 ms- 同期宿主主线程单核 100% 跑满约 1.9–2.5 s
- 渲染端主线程只占 64–86 ms(script 36–39 ms、layout 6–7 ms),可排除前端
只有「click → 新会话 UI 出现」这一段受影响,进入会话后发消息正常。
复现与量化方法
-
同一 Host 进程内连续新建 N 个会话(工作区不限),不发送任何消息。
-
读会话记录
~/.dsh/sessions/<workspace>/<session>/session.v4.jsonl.zstd。它是多帧追加
zstd:每个帧以 magic28 b5 2f fd开头,需逐帧zstdDecompressSync再按行 JSON 解析;
只解第一帧会漏掉后面的事件。 -
新建会话的事件序列固定为:
session (createdAt) → permission/preset seq 0 → sandbox/mode seq 1 → approval/policy seq 2 -
量
approval/policy.time - sandbox/mode.time得到「创建窗口」。实测同一进程内该窗口随存活
agent 数线性增长(2 ms → 77 → 182 → 199 → 227 → 323 ms …),重启进程后回到 2 ms。
也可用 CDP 直接量:--remote-debugging-port=9222 打开渲染进程,点「新建会话」并按时间记录
/api/session/create 的 start / ttfb / total —— 结果与上面的窗口法一致(total ≈ 窗口 + 常数)。
根因
lib/session-shells.js:161 的 reconcile() 是全表重建:
const reconcile = () => {
for (const entry of entries.values()) // :162-163 先把所有存活 agent 的 ops_bash 卸掉
clear(entry);
for (const [agent, entry] of entries) { // :165-171 再逐个重建
if (!entry.ctx || !allowed(agent)) continue;
const base = bashToolDefinition({ ... });
const definition = hostBashDefinition(ctx, base, ..., sessionWorkdir, jobs);
publish(entry, definition); // → entry.ctx.tools.register(definition)
}
for (const [agent, entry] of entries) { // :172-183 受限 agent 还要逐个判断
if (!entry.ctx || allowed(agent) || stopped) continue;
if ((ctx.get('tools').schemas(agent) ?? []).some(tool => tool.name === BASH_TOOL)) { ... }
}
};触发点:
// :213
ctx.on('session/event', (_session, event) => { if (event.type === 'sandbox/mode')
reconcile(); });而每新建一个会话必然 append 一次 sandbox/mode:@deepseek-ai/dsh-permission-presets
应用预设时依次调用 setSandboxMode() / setApprovalPolicy(),前者就是
session.append("sandbox/mode", { mode })。
于是「新建第 N+1 个会话」= 把已经存活的 N 个 agent 的 ops_bash 全部 clear() 掉、再逐个
bashToolDefinition() + hostBashDefinition()(每次都要重建一套完整 JSON Schema 与执行闭包)
tools.register()。这是 O(N) 次「卸载 + 重建工具定义」,且全部在宿主主线程同步完成。
放大三处:
:175对每个受限(workspace-write)agent 调ctx.get('tools').schemas(agent)—— 这是一次
全量 schema 深拷贝,258 个工具时约 691 KB/agent,和 [BUG] 0.4.1:visibleTools/PTC snapshot 用 tools.schemas() 深拷贝 258 个工具 schema,新建会话随活跃 Agent 数线性变慢 #369 是同一类开销。:190-195attach()里的child.inject(['tools'], …)回调也调reconcile(),agent/created
时必然触发一次;:197-205detach()里也调一次 → 一次新建会话至少 2 次全表重建。reconcile()在ctx.inject(['sandbox'])(:215)、ctx.inject(['jobs'])(:216-220)、
ctx.inject(['sandboxPolicy','subprocess'])(:221-225)里也各调一次,服务挂载/卸载时会额外整表重建。
实测成本(逐项关闭后的差值)
在同一台机器、同一点击序列上,把三处 O(N) 源逐个修掉后量「创建窗口」的斜率:
| 状态 | 斜率 |
|---|---|
| 0.4.1 原样(#369 ptc + #333 discovery + 本文件都未修) | 窗口 2 ms → 数百 ms,64 存活 agent 时 create total 1,758–2,612 ms |
修掉 #369(ptc 的 tools.schemas() 深拷贝)与 #333(discovery reconcile) |
19.86 ms / 每存活会话 |
再把本文件 :175 的深拷贝换成 tools.view(agent).visible(Map)判断,并把 clear()+publish() 限定到确实需要重建的 entry,同时修掉 file/result-provider 的每次事件重建视图 |
2.78 ms / 每存活会话 |
说明:最后一行为两处改动之和(本文件 + result-provider),单独归因本文件需要上游做 A/B;
但前一行的 19.86 ms/会话 与「本文件未修」这一状态是一一对应的。
用户在 64 会话时的体感是「点一下要等 2 秒」,与上表一致。
与既有 issue 的关系
- [BUG] 0.4.1:visibleTools/PTC snapshot 用 tools.schemas() 深拷贝 258 个工具 schema,新建会话随活跃 Agent 数线性变慢 #369 ——
lib/ptc/index.js的visibleTools()/snapshot()用tools.schemas()深拷贝 + 逐工具tools.get() - [BUG] discovery 的 reconcile 每次对全部活跃 agent 重算 presentation(完整 systemPrompt.assemble),新建会话耗时随活跃会话数线性增长 #333 ——
lib/discovery/index.js的reconcile()对全部 agent 重算 presentation(prompt.assemble) - [BUG] 0.4.1:ptc 的全局串行链 + 强制 reconcile 使所有会话在提示词装配阶段同时卡死(33 插件 + 10 MCP) #359 —— ptc 的模块级全局串行链 +
tools/change的force全量拆装
本 issue 报的是第三个独立源:lib/session-shells.js 把「sandbox/mode 变化」当成全表重建信号。
已在仓库现有 issue 与评论中检索 session-shells / sandbox/mode / ops_bash 的 reconcile,
未见覆盖此处的报告;三处可以叠加,叠加时体感最差。
为什么不能靠配置规避
publishBashTool: false 确实会让 mountSessionBash() 整体跳过(lib/session-shells.js:151),
监听器也不再注册。但 Windows 上原生 bash 工具 @deepseek-ai/dsh-tool-bash 被
@deepseek-ai/dsh-base/cordis.patch.yml 显式 disabled: true,关掉 publishBashTool 等于让人
彻底没有 bash(与 #23 的结论一致)。所以在当前实现下没有「既保留 bash 又避开这条路径」的配置开关。
建议修复
- 只重算受影响的 agent:把
reconcile()改成reconcile(targets?),session/event时只处理
该 session 对应的 agent(agents.list()的项带 session 归属,或用现有的 ownership 映射反查),
agent/created/agent/disposed只处理那一个 agent。 - 去掉 :175 的深拷贝:判断「当前是否还带着 bash」用
ctx.get('tools')?.view?.(agent)?.visible?.has(BASH_TOOL),取不到再回退到
schemas(agent).some(...)(dsh-tools的view(scope)返回 Map,get()内部每次都会重建它,
schemas()还要再深拷贝一遍)。 - 定义未变就不重注册:
entry.definition已保存上一次成功注册的定义,把它与新定义做结构比较,
相同则跳过clear()+register()(同时保留 fix: 修复 Linux 上原生 worker 完全不可用,并补上四条关停路径 (#26) #30 的语义:注册失败时回退到entry.definition)。 attach()/detach()已有天然的「单个 agent」语义,不必再走整表reconcile()。
最小改动的补丁片段(相对 0.4.1,仅示意方向):
const carriesBash = (agent) => {
const shellTools = ctx.get('tools');
try {
const visible = shellTools?.view?.(agent)?.visible;
if (visible instanceof Map) return visible.has(BASH_TOOL);
}
catch { /* fall through */ }
return (shellTools?.schemas(agent) ?? []).some(tool => tool.name === BASH_TOOL);
};
// :175
if (carriesBash(agent)) { ... }改动后我对 6 个文件做了 node --check 与逐字节还原比对,ops_bash 的前台/后台/超时转后台、
以及 workspace-write 下 restrict({deny:[BASH_TOOL]}) 的行为都正常(本地验证,未提交 PR ——
需要的话我可以把这个方向整理成 PR,含回归测试)。
附:验证环境
dsh-ops0.4.1 的 pristine tarball(npmdsh-ops-0.4.1.tgz)与本地node_modules逐文件
SHA-256 比对:315/315 相同 → 下面所有行号都对应未改动的上游代码- 宿主进程由 Electron 拉起,CDP 9222 只开在渲染进程;宿主侧用事件时间戳法定位,未注入代码
Activity
Wuyunonjiji commentedon Oct 24, 2026
补充说明:本处已被 @NaiyxYin 在 #333 的评论里指出过,本 issue 提供的是行级定位与修复方向
为免重复计功,先说明来源:#333 下 @NaiyxYin 于 2026-10-23 的评论已明确记录
本 issue 的作用是把这条观察固化成可定位、可修复的条目,并补齐两点该评论未展开的内容:
lib/session-shells.js:213的session/event→reconcile()(:161),:162-163对全部 entriesclear(),:165-171逐个bashToolDefinition()+hostBashDefinition()+tools.register();并且permission/preset应用时必然session.append("sandbox/mode"),所以「每新建一个会话」必然触发。attach()(:190-195的child.inject(['tools'], …)回调)与detach()(:203)也各调一次reconcile(),即agent/created一次 +sandbox/mode一次。与 @NaiyxYin 的两个子成本对应关系:
assertSupportedJsonSchema的开销来自:169每次重建hostBashDefinition();Cordis effect 包装来自tools.register()→:146。所以「定义未变就不重注册」能同时压掉这两项 ——entry.definition已经缓存了上一次成功注册的定义(:147),把它与新定义做结构比较即可短路。另外补一条该评论未涉及的独立成本:
:175对每个受限(workspace-write)agent 还会调ctx.get('tools').schemas(agent),258 工具时这是约 691 KB 的全量 schema 深拷贝/agent,与 #369 同类;改成tools.view(agent)?.visible?.has(BASH_TOOL)即可。修复方向与最小补丁片段已写在正文,欢迎在原评论基础上补充或指正。
milu7 commentedon Oct 24, 2026
补一条同一触发点、不同失败模式的观察,已单独开 #386(不是重复:本 issue 讲的是全表重建的 O(N) 成本,那边讲的是这批重建整批被宿主拒绝)。
在我们这台机器上,
agent/created→attach()→reconcile()里的clear()/entry.ctx.tools.register()全部被宿主拒绝:原因就是这里的 reconcile 是同步跑在新建会话的 append 窗口里,而 dispose/register 都要写会话事件。于是全表重建不是「白做一遍」,而是一遍都没成:
definition还是undefined,fix: 修复 Linux 上原生 worker 完全不可用,并补上四条关停路径 (#26) #30 兜底直接 return,新建会话当下没有ops_bash;所以即使按本 issue 的方向改成增量发布,只要
register仍在追加窗口内同步调用,新建会话自己那一次发布还是会撞上同一个拒绝。建议和 #386 里那条一起做:把 reconcile 推迟到queueMicrotask/setTimeout(0)并合并同一 tick 的多次触发。