Skip to content

[BUG] 0.4.1:session-shells 把每次 sandbox/mode 变化当成全表重建信号,新建会话要为每个存活 agent 重新注册一遍 ops_bash #382

Open

Description

@Wuyunonjiji

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

操作系统与架构

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 出现」这一段受影响,进入会话后发消息正常。

复现与量化方法

  1. 同一 Host 进程内连续新建 N 个会话(工作区不限),不发送任何消息。

  2. 读会话记录 ~/.dsh/sessions/<workspace>/<session>/session.v4.jsonl.zstd。它是多帧追加
    zstd:每个帧以 magic 28 b5 2f fd 开头,需逐帧 zstdDecompressSync 再按行 JSON 解析;
    只解第一帧会漏掉后面的事件。

  3. 新建会话的事件序列固定为:

    session (createdAt)
      → permission/preset   seq 0
      → sandbox/mode        seq 1
      → approval/policy     seq 2
    
  4. 量 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) 次「卸载 + 重建工具定义」,且全部在宿主主线程同步完成。

放大三处:

  1. :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 是同一类开销。
  2. :190-195 attach() 里的 child.inject(['tools'], …) 回调也调 reconcile(),agent/created
    时必然触发一次;:197-205 detach() 里也调一次 → 一次新建会话至少 2 次全表重建。
  3. 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 的关系

本 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 又避开这条路径」的配置开关。

建议修复

  1. 只重算受影响的 agent:把 reconcile() 改成 reconcile(targets?),session/event 时只处理
    该 session 对应的 agent(agents.list() 的项带 session 归属,或用现有的 ownership 映射反查),
    agent/created / agent/disposed 只处理那一个 agent。
  2. 去掉 :175 的深拷贝:判断「当前是否还带着 bash」用
    ctx.get('tools')?.view?.(agent)?.visible?.has(BASH_TOOL),取不到再回退到
    schemas(agent).some(...)(dsh-tools 的 view(scope) 返回 Map,get() 内部每次都会重建它,
    schemas() 还要再深拷贝一遍)。
  3. 定义未变就不重注册:entry.definition 已保存上一次成功注册的定义,把它与新定义做结构比较,
    相同则跳过 clear() + register()(同时保留 fix: 修复 Linux 上原生 worker 完全不可用,并补上四条关停路径 (#26) #30 的语义:注册失败时回退到 entry.definition)。
  4. 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-ops 0.4.1 的 pristine tarball(npm dsh-ops-0.4.1.tgz)与本地 node_modules 逐文件
    SHA-256 比对:315/315 相同 → 下面所有行号都对应未改动的上游代码
  • 宿主进程由 Electron 拉起,CDP 9222 只开在渲染进程;宿主侧用事件时间戳法定位,未注入代码

Activity

  1. Wuyunonjiji commented on Oct 24, 2026

    @Wuyunonjiji
    Author

    补充说明:本处已被 @NaiyxYin 在 #333 的评论里指出过,本 issue 提供的是行级定位与修复方向

    为免重复计功,先说明来源:#333 下 @NaiyxYin 于 2026-10-23 的评论已明确记录

    剩余的 65.2% 时间全在 dsh-ops 的 session-shells.reconcile() 里:每新建一个会话,它就把所有已开会话(当时 169 个)的 ops_bash 工具销毁再重建一遍,而重建时每次都要跑一遍 JSON Schema 合法性校验(assertSupportedJsonSchema → checkSchemaNode,占 17.9%),还要走一次 Cordis effect 包装(又占 12.5%)。

    本 issue 的作用是把这条观察固化成可定位、可修复的条目,并补齐两点该评论未展开的内容:

    1. 行级因果链:lib/session-shells.js:213 的 session/event → reconcile()(:161),:162-163 对全部 entries clear(),:165-171 逐个 bashToolDefinition() + hostBashDefinition() + tools.register();并且 permission/preset 应用时必然 session.append("sandbox/mode"),所以「每新建一个会话」必然触发。
    2. 为什么是「至少两次」全表重建: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) 即可。

    修复方向与最小补丁片段已写在正文,欢迎在原评论基础上补充或指正。

  2. milu7 commented on Oct 24, 2026

    @milu7

    补一条同一触发点、不同失败模式的观察,已单独开 #386(不是重复:本 issue 讲的是全表重建的 O(N) 成本,那边讲的是这批重建整批被宿主拒绝)。

    在我们这台机器上,agent/created → attach() → reconcile() 里的 clear() / entry.ctx.tools.register() 全部被宿主拒绝:

    session append cannot reenter while another append is being published
    

    原因就是这里的 reconcile 是同步跑在新建会话的 append 窗口里,而 dispose/register 都要写会话事件。于是全表重建不是「白做一遍」,而是一遍都没成:

    • 每个已注册过 bash 的存活 agent 留 3 行 warn(teardown / publication / restoration),即 2 次白烧的追加;
    • 新建会话那条 entry 的 definition 还是 undefined,fix: 修复 Linux 上原生 worker 完全不可用,并补上四条关停路径 (#26) #30 兜底直接 return,新建会话当下没有 ops_bash;
    • 实测每行 ≈ 47 ms:warn 39 行时新建会话 2 041 ms,重启进程(存活 agent 剩 2 个、6 行)后 381 ms。

    所以即使按本 issue 的方向改成增量发布,只要 register 仍在追加窗口内同步调用,新建会话自己那一次发布还是会撞上同一个拒绝。建议和 #386 里那条一起做:把 reconcile 推迟到 queueMicrotask / setTimeout(0) 并合并同一 tick 的多次触发。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions