摘要
@wxg-prc-cpg/browser-skill-dsh-plugin@0.2.1 的 browser_session { action: "start" } 只在 bsk session start 成功返回之后 才把会话登记进插件注册表。一旦这次调用以任何方式失败/超时/被取消,而 daemon 侧
已经创建了会话并弹出了 Agent Window ,该窗口就成为"孤儿":插件不知道它存在,session list 看不到它,
session stop 直接报错,插件卸载和会话归档清理也都基于注册表、碰不到它。用户只能手动关掉那个 Chrome 窗口,
或用 CLI bsk session stop <id> 收尾。
在 Windows 上这不是罕见竞争,而是近乎必现 :配合 #180 / #183 ,凡是"需要自动拉起 daemon 的那次 start"
都会永久挂起 → 必然走进这条无清理的失败路径。
运行环境
项目
值
操作系统
Windows 10 22H2 (19045),x64
浏览器
Google Chrome 152.0.0.0 (Chromium),扩展 0.2.1 (Chrome 应用商店版)
bsk CLI / daemon
0.2.1 (协议 1.1),安装在 ~/.local/bin/bsk.exe,BSK_HOME=~/.bsk
插件
@wxg-prc-cpg/browser-skill-dsh-plugin 0.2.1
宿主
DeepSeek Harness Desktop 2.0.10,profile 为 desktop
健康状态
bsk doctor 全部 ok(exit 0),browsers connected 1,daemon 监听 127.0.0.1:52800
上报前已排除的可能原因:
端口不是本次的原因。 52800 最初落在 Hyper-V/HNS 的动态保留段内(52710–52809 → 绑定时 EACCES);
该问题已单独修复:把 52800 登记为 administered exclusion 并重启 winnat。现在 doctor 全绿、扩展稳定在线。
"daemon 已经忘记该会话"这条路径是被幂等处理的 :bsk session stop <unknown-id> --json 返回
{"code":"not_found", ...},而 isSessionNotFoundError() 恰好匹配这个码,因此 stop 会成功。
下面报的缺陷走的是另一条 路径。
复现步骤
路径 A —— Windows 上最常见的一种(daemon 尚未运行):
bsk daemon stop(或干脆重启系统 / 重启 dsh,让 daemon 不在运行)。
调用技能,然后调用 browser_session { action: "start", url: "https://example.com" }。
Agent Window 正常弹出、daemon 侧会话已创建 ,但工具调用永不返回
(即 [Bug][dsh-plugin] browser_session start hangs forever on Windows - bsk child exits fine, but plugin close event never fires; its own 120s timeout never surfaces #180 / fix(dsh-plugin): settle runner promises when the child never emits close #183 :runner 只在子进程 close 事件上 settle,而 Windows 上被 detach 的 daemon 继承了
插件的 stdout/stderr 管道句柄,导致 close 永不触发)。
中断该回合(点 Stop),或等待宿主放弃这次调用。
这时再试图关闭它:
browser_session { action: "list" } → no active browser sessions
browser_session { action: "stop" } → 报错 (见下)
browser_session { action: "stop", session: "<从 daemon 日志里读到的 id>" } → 报错 (外来 id)
卸载插件 / 归档该会话,同样都关不掉这个窗口
路径 B —— 任何其他导致 start 不返回的原因 :bsk session start 执行期间回合被取消、宿主侧工具超时、
调用中途应用重启。孤儿窗口的后果完全相同。
本次实测中两条路径各出现一次(同一会话内产生两个孤儿):
孤儿会话
触发方式
结果
apig
Agent Window 弹出后回合被中断;当时 daemon 已在运行,因此不是 #180 的自动拉起挂起(具体中断原因未进一步定位)
成为孤儿;该窗口活得比之后所有 browser_session 调用都久
tdwu
需要自动拉起 daemon 的那次 start(路径 A / #180 )
成为孤儿;会话在 daemon 中存活 02:32:35Z → 02:38:05Z,插件完全看不见它
所以路径 A 在 Windows 上不是理论推测 —— 重启系统或重启 dsh 之后,daemon 必然是停的,第一次 start 就会走这条路 。
实测现象
$ browser_session { action: "stop" }
Error: browser_session action=stop needs a session but none is active — use action=start first
以及用 daemon 日志里读到的 id 去停(原文逐字验证):
$ browser_session { action: "stop", session: "tdwu" }
Error: browser_session(action=stop): session "tdwu" does not belong to this plugin — only sessions created
by browser_session action=start are visible and operable here
与此同时 daemon 里仍然留着该会话,Agent Window 也还在屏幕上。下面是路径 A 中由自动拉起产生的那个孤儿
在 daemon 日志里的记录:
{"timestamp":"2026-09-15T02:32:35Z","level":"INFO","fields":{"message":"daemon ready","pid":38232,"ws_port":52800,...}}
... # ← 插件的 start 从未返回;没有任何 session id 到达模型
{"timestamp":"2026-09-15T02:38:05Z","level":"INFO","fields":{"message":"session removed: user closed Agent Window","session":"tdwu"}}
{"timestamp":"2026-09-15T02:38:05Z","level":"INFO","fields":{"message":"idle session stopped","session":"tdwu"}}
也就是说:创建它的那次工具调用从未返回,而这个会话在 daemon 里存在了约 5.5 分钟 。在这段时间里
DSH 侧没有任何路径能关掉它 —— list 看不到、两种 stop 都失败、插件卸载与会话归档清理都基于注册表。
(我们未能确认窗口最终消失是因为用户手动关闭,还是 daemon 侧回收;无论如何,它熬过了所有插件侧尝试。)
放大因素
回合被中断后,六个 browser_* 工具会立刻从对话里消失(unknown tool "browser_session"),必须重新调用
技能才会回来,于是 agent 连"尝试 stop"都做不到。该现象观察到两次,都紧跟在被中断的 start 之后。这可能属于
宿主侧行为(懒加载的工具注册绑定在被中止的回合上),但它抹掉了唯一的软件兜底手段,建议一并确认。
根因
src/session.ts / session.start(0.2.1 构建产物 lib/index.mjs,第 2415–2435 行):
async execute ( args , exec ) {
...
registry . reserveStart ( ) ;
const startArgs = [ "session" , "start" ] ;
...
let reply ;
try {
reply = await runBsk ( deps , exec , startArgs , "session start" ) ;
} catch ( error ) {
registry . abandonStart ( ) ; // ← 只是把名额还回去
throw error ;
}
registry . completeStart ( { // ← 只有成功返回后才登记
sessionId : reply . session_id , ...
} ) ;
registry . trackOwner ( reply . session_id , ownerSessionIds ( deps . ctx , exec . agent ?. id ) ) ;
deps . observation . addSession ( reply . session_id , args . url ) ;
...
}
以及那个"预留名额"本身:
/** Give back a reservation after a start that never produced a session. */
abandonStart ( ) {
this . pendingStarts = Math . max ( 0 , this . pendingStarts - 1 ) ;
}
可见 runBsk("session start") 的失败路径完全不做 daemon 侧对账 。对比之下,登记之后 的任何失败都有清理
(emulate/navigate 失败 → observation.stopSession(reply.session_id),第 2451–2458 行),而其余所有清理路径
都是"注册表驱动"的,因此全部绕开孤儿:
路径
位置
为什么碰不到孤儿
browser_session action=stop
registry.resolveForStop()(L4089)
抛 needs a session but none is active
显式传 session 参数
registry.resolve()(L4074)
抛 "does not belong to this plugin"
插件卸载
registry.ownedIds()(L4222)
只遍历注册表
DSH 对话归档清理
registry.ownedByDsh()(L54,archive-cleanup)
只看注册表
会话 id 只能从成功的 JSON 回包 (reply.session_id)里拿到;所以当调用被取消、或 CLI 在回包落地之前被杀,
插件手里根本没有该会话的任何句柄 —— 尽管 daemon 已经把它创建出来、窗口也已经弹出。
注意与 #180 / #183 的关系:#183 修的是挂起 (close 永不触发),但任何其它 原因导致的取消或超时仍然
会走这条无清理的路径。要让用户可见的症状("起了个浏览器会话,然后关不掉了")真正消失,两者都必须成立。
期望行为
一次没有完成的 start,不应该有可能留下无法管理的 Agent Window:
要么会话根本不被创建;
要么它被登记/可归因,从而事后能被对账并停掉;
且 browser_session 应当能够上报并关闭本插件自己导致创建 的会话,同时绝不触碰共享同一 daemon 的
其它程序创建的会话。
修复建议
首选 —— 让 start 可由调用方归因。 给 CLI 增加一个可选的调用方标识,例如
bsk session start --label <opaque-id>(或 --request-id),并由 bsk session list --json /
bsk session stop --label 回显。这样在 start 被拒绝/超时后,插件就能精确停掉带自己 label 的那个会话:
既不破坏现有的归属约束(README → "Ownership boundary"),也不存在竞态。
次选 —— 在失败尝试前后做对账。 在 catch 路径上,把 spawn 之前的 bsk session list --json 快照与
拒绝之后的快照做 diff,停掉"新增且不在插件注册表里"的会话。这是个启发式方案(别的程序可能恰好同时创建
会话),只有在 bsk 能告诉插件"这次 CLI 调用创建了哪些会话"时才可接受;否则请优先采纳方案 1。
最小兜底。 让 start 进行中的"预留名额"被记住,并提供一个对账时机(技能被调用时、插件重载时、或下一次
browser_session 调用时),使孤儿不可能无限期存活。哪怕只是让 browser_session { action: "list" }
把 daemon 侧、它无权操作的会话也列出来,agent 就能解释当前状态,并给用户一条可用的
bsk session stop <id>。
临时绕过(用户当下可用)
bsk session list # daemon 全局视角,能看到孤儿会话
bsk session stop < session_id> # 关掉它以及它的 Agent Window
或者直接在浏览器里关掉那个 Agent Window;如果被中断的回合让 browser_* 工具消失了,重新调用一次技能
(/browser-skill)即可恢复。
摘要
@wxg-prc-cpg/browser-skill-dsh-plugin@0.2.1的browser_session { action: "start" }只在bsk session start成功返回之后才把会话登记进插件注册表。一旦这次调用以任何方式失败/超时/被取消,而 daemon 侧已经创建了会话并弹出了 Agent Window,该窗口就成为"孤儿":插件不知道它存在,
session list看不到它,session stop直接报错,插件卸载和会话归档清理也都基于注册表、碰不到它。用户只能手动关掉那个 Chrome 窗口,或用 CLI
bsk session stop <id>收尾。在 Windows 上这不是罕见竞争,而是近乎必现:配合 #180 / #183,凡是"需要自动拉起 daemon 的那次 start"
都会永久挂起 → 必然走进这条无清理的失败路径。
运行环境
bskCLI / daemon~/.local/bin/bsk.exe,BSK_HOME=~/.bsk@wxg-prc-cpg/browser-skill-dsh-plugin0.2.1desktopbsk doctor全部ok(exit 0),browsers connected 1,daemon 监听127.0.0.1:52800上报前已排除的可能原因:
52800最初落在 Hyper-V/HNS 的动态保留段内(52710–52809→ 绑定时EACCES);该问题已单独修复:把
52800登记为 administered exclusion 并重启winnat。现在doctor全绿、扩展稳定在线。bsk session stop <unknown-id> --json返回{"code":"not_found", ...},而isSessionNotFoundError()恰好匹配这个码,因此 stop 会成功。下面报的缺陷走的是另一条路径。
复现步骤
路径 A —— Windows 上最常见的一种(daemon 尚未运行):
bsk daemon stop(或干脆重启系统 / 重启 dsh,让 daemon 不在运行)。browser_session { action: "start", url: "https://example.com" }。(即 [Bug][dsh-plugin] browser_session start hangs forever on Windows - bsk child exits fine, but plugin close event never fires; its own 120s timeout never surfaces #180 / fix(dsh-plugin): settle runner promises when the child never emits close #183:runner 只在子进程
close事件上 settle,而 Windows 上被 detach 的 daemon 继承了插件的 stdout/stderr 管道句柄,导致
close永不触发)。browser_session { action: "list" }→no active browser sessionsbrowser_session { action: "stop" }→ 报错(见下)browser_session { action: "stop", session: "<从 daemon 日志里读到的 id>" }→ 报错(外来 id)路径 B —— 任何其他导致 start 不返回的原因:
bsk session start执行期间回合被取消、宿主侧工具超时、调用中途应用重启。孤儿窗口的后果完全相同。
本次实测中两条路径各出现一次(同一会话内产生两个孤儿):
apigbrowser_session调用都久tdwu02:32:35Z → 02:38:05Z,插件完全看不见它所以路径 A 在 Windows 上不是理论推测 —— 重启系统或重启 dsh 之后,daemon 必然是停的,第一次 start 就会走这条路。
实测现象
以及用 daemon 日志里读到的 id 去停(原文逐字验证):
与此同时 daemon 里仍然留着该会话,Agent Window 也还在屏幕上。下面是路径 A 中由自动拉起产生的那个孤儿
在 daemon 日志里的记录:
也就是说:创建它的那次工具调用从未返回,而这个会话在 daemon 里存在了约 5.5 分钟。在这段时间里
DSH 侧没有任何路径能关掉它 ——
list看不到、两种stop都失败、插件卸载与会话归档清理都基于注册表。(我们未能确认窗口最终消失是因为用户手动关闭,还是 daemon 侧回收;无论如何,它熬过了所有插件侧尝试。)
放大因素
回合被中断后,六个
browser_*工具会立刻从对话里消失(unknown tool "browser_session"),必须重新调用技能才会回来,于是 agent 连"尝试 stop"都做不到。该现象观察到两次,都紧跟在被中断的 start 之后。这可能属于
宿主侧行为(懒加载的工具注册绑定在被中止的回合上),但它抹掉了唯一的软件兜底手段,建议一并确认。
根因
src/session.ts/session.start(0.2.1 构建产物lib/index.mjs,第 2415–2435 行):以及那个"预留名额"本身:
可见
runBsk("session start")的失败路径完全不做 daemon 侧对账。对比之下,登记之后的任何失败都有清理(emulate/navigate 失败 →
observation.stopSession(reply.session_id),第 2451–2458 行),而其余所有清理路径都是"注册表驱动"的,因此全部绕开孤儿:
browser_session action=stopregistry.resolveForStop()(L4089)needs a session but none is activesession参数registry.resolve()(L4074)registry.ownedIds()(L4222)registry.ownedByDsh()(L54,archive-cleanup)会话 id 只能从成功的 JSON 回包(
reply.session_id)里拿到;所以当调用被取消、或 CLI 在回包落地之前被杀,插件手里根本没有该会话的任何句柄 —— 尽管 daemon 已经把它创建出来、窗口也已经弹出。
注意与 #180 / #183 的关系:#183 修的是挂起(
close永不触发),但任何其它原因导致的取消或超时仍然会走这条无清理的路径。要让用户可见的症状("起了个浏览器会话,然后关不掉了")真正消失,两者都必须成立。
期望行为
一次没有完成的 start,不应该有可能留下无法管理的 Agent Window:
browser_session应当能够上报并关闭本插件自己导致创建的会话,同时绝不触碰共享同一 daemon 的其它程序创建的会话。
修复建议
首选 —— 让 start 可由调用方归因。 给 CLI 增加一个可选的调用方标识,例如
bsk session start --label <opaque-id>(或--request-id),并由bsk session list --json/bsk session stop --label回显。这样在 start 被拒绝/超时后,插件就能精确停掉带自己 label 的那个会话:既不破坏现有的归属约束(README → "Ownership boundary"),也不存在竞态。
次选 —— 在失败尝试前后做对账。 在
catch路径上,把 spawn 之前的bsk session list --json快照与拒绝之后的快照做 diff,停掉"新增且不在插件注册表里"的会话。这是个启发式方案(别的程序可能恰好同时创建
会话),只有在 bsk 能告诉插件"这次 CLI 调用创建了哪些会话"时才可接受;否则请优先采纳方案 1。
最小兜底。 让 start 进行中的"预留名额"被记住,并提供一个对账时机(技能被调用时、插件重载时、或下一次
browser_session调用时),使孤儿不可能无限期存活。哪怕只是让browser_session { action: "list" }把 daemon 侧、它无权操作的会话也列出来,agent 就能解释当前状态,并给用户一条可用的
bsk session stop <id>。临时绕过(用户当下可用)
或者直接在浏览器里关掉那个 Agent Window;如果被中断的回合让
browser_*工具消失了,重新调用一次技能(
/browser-skill)即可恢复。