Просмотр исходного кода

fix(browser-use): await existing Agent creation lifecycle

Tianyi Cui 2 недель назад
Родитель
Сommit
6caeb505c5

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-12-browser-use-provider-registration.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-12-browser-use-provider-registration.md
-2026-09-12-browser-use-provider-registration.md: 1e8ce789754291eef5eaf16750c8460369dccead
-2026-09-12-browser-use-provider-registration.zh.md: 7efb054a08db690e27ce45af178b63803ca202b5
+2026-09-12-browser-use-provider-registration.md: aef60b83a0caf796ea186aaf54c0972988d03c70
+2026-09-12-browser-use-provider-registration.zh.md: e4f936818c6b06f71eec78b052159a1bf8862595

+ 4 - 4
.agents/notes/implemented/architecture/2026-09-12-browser-use-provider-registration.md

@@ -22,17 +22,17 @@ Stagehand's launcher inherits its process environment, and SDK initialization ca
 
 Stagehand uses its supported custom-model callback to request structured results through the Session's selected DSH model. A provider-local adapter requests one result tool call and validates its arguments against Stagehand's requested JSON Schema; it does not execute additional model tool calls. Auxiliary requests are logged and flushed before model dispatch, and settled responses are logged and flushed before automation continues. These events remain separate from the main conversation, preserving the browser operation's model input without altering the agent loop.
 
-MCP client activation waits for connection and tool discovery, but provider activation can finish before any Session exists. That activation promise cannot represent the clients owned by future Sessions. Each MCP browser provider observes future `agent/created` publications and immediately claims the Agent's existing `runMaintenance` phase for one client startup attempt. Maintenance runs outside a turn and holds queued input until discovery settles, so ordinary prompt assembly sees the completed catalog. Direct callers await `agent.whenIdle()` before inspecting prompt assembly or scoped tools.
+MCP client activation waits for connection and tool discovery, but provider activation can finish before any Session exists. That activation promise cannot represent the clients owned by future Sessions. Each MCP browser provider awaits one client startup attempt within the existing serial `agent/created` event. The [awaited Agent creation decision](2026-09-09-awaited-agent-creation.md) owns queued-input ordering and creation rollback. Successful creation or resume exposes the completed catalog to prompt assembly and direct callers.
 
-Startup failure remains failed for that live activation and rejects prompt assembly and model requests. A busy attachment skips startup permanently for the activation while its other work continues; a newly created or resumed Agent can acquire the attachment after release. Late installation and reload apply only to future activations, following the [Schedule mounting policy](../../../../packages/schedule/schedule/README.md#use-this-package). Browser tools and resource requests for a successful client share the Session queue; other Sessions cannot execute those requests or receive that server's instructions.
+Startup failure or cancellation rejects creation or resume and triggers rollback of the Agent and its client resources. A busy attachment skips startup permanently for the activation while its other work continues; a newly created or resumed Agent can acquire the attachment after release. Late installation and reload apply only to future activations, following the [Schedule mounting policy](../../../../packages/schedule/schedule/README.md#use-this-package). Browser tools and resource requests for a successful client share the Session queue; other Sessions cannot execute those requests or receive that server's instructions.
 
 ## Alternatives considered
 
 **Unified browser action API.** Playwright, Chrome DevTools, and Stagehand have different native semantics. No current consumer requires interchangeable action methods, so provider-owned tools retain those semantics.
 
-**A new core startup API.** Existing Agent maintenance already prevents turns during Session-owned setup; another initialization API would duplicate that ownership.
+**A provider-owned startup phase.** Existing serial `agent/created` awaits Session-owned setup and makes failures visible to the creator. A separate maintenance task would duplicate that lifecycle ownership.
 
-**Discovery during prompt assembly.** Prompt and tool collection need ready registrations. Starting discovery there either exposes an incomplete catalog or requires another collection pass; maintenance completes discovery before a turn begins.
+**Discovery during prompt assembly.** Prompt and tool collection need ready registrations. Starting discovery there either exposes an incomplete catalog or requires another collection pass; awaited creation completes discovery before a turn begins.
 
 **One shared browser across Sessions.** Browser tabs, navigation, and login state can be isolated per Session. Sharing them would introduce cross-Session interference that the desktop integrations cannot generally avoid.
 

+ 4 - 4
.agents/notes/implemented/architecture/2026-09-12-browser-use-provider-registration.zh.md

@@ -22,17 +22,17 @@ Stagehand 的启动器继承其进程环境,SDK 初始化可能在清理完成
 
 Stagehand 使用受支持的自定义模型回调,通过 Session 选定的 DSH 模型请求结构化结果。提供方内的适配器请求一个结果工具调用,并依据 Stagehand 请求的 JSON Schema 验证其参数;它不执行额外的模型工具调用。辅助请求在模型分派前记录并刷新,已结算响应在自动化继续前记录并刷新。这些事件保持独立于主对话,在不改变 agent loop(智能体循环)的情况下保留浏览器操作的模型输入。
 
-MCP 客户端激活会等待连接和工具发现,但提供方可能在任何 Session 存在之前就完成激活。该激活 promise 无法代表未来各 Session 拥有的客户端。每个 MCP 浏览器提供方观察后续的 `agent/created` 发布,并立即占用 Agent 现有的 `runMaintenance` 阶段,执行一次客户端启动尝试。维护在轮次之外运行,并将输入保持在队列中直到发现结束,使常规提示词组装读取完整目录。直接调用方在检查提示词组装或作用域工具前等待 `agent.whenIdle()`。
+MCP 客户端激活会等待连接和工具发现,但提供方可能在任何 Session 存在之前就完成激活。该激活 promise 无法代表未来各 Session 拥有的客户端。每个 MCP 浏览器提供方在现有的串行 `agent/created` 事件中等待一次客户端启动尝试。[等待 Agent 创建的决策](2026-09-09-awaited-agent-creation.zh.md)负责排队输入顺序与创建回滚。创建或恢复成功后,提示词组装与直接调用方即可读取完整目录。
 
-启动失败会保留为本次激活的失败状态,并拒绝提示词组装和模型请求。附加连接被占用时,本次激活永久跳过启动,但其他工作继续运行;连接释放后,新创建或恢复的 Agent 可以获取连接。较晚安装和重新加载只作用于后续激活,与 [Schedule 的挂载策略](../../../../packages/schedule/schedule/README.zh.md#use-this-package)一致。成功客户端的浏览器工具与资源请求共享 Session 队列;其他 Session 不能执行这些请求,也不能收到该服务器的指导。
+启动失败或取消会拒绝创建或恢复,并触发 Agent 及其客户端资源的回滚。附加连接被占用时,本次激活永久跳过启动,但其他工作继续运行;连接释放后,新创建或恢复的 Agent 可以获取连接。较晚安装和重新加载只作用于后续激活,与 [Schedule 的挂载策略](../../../../packages/schedule/schedule/README.zh.md#use-this-package)一致。成功客户端的浏览器工具与资源请求共享 Session 队列;其他 Session 不能执行这些请求,也不能收到该服务器的指导。
 
 ## 考虑过的替代方案
 
 **统一浏览器动作 API。** Playwright、Chrome DevTools 与 Stagehand 的原生语义不同。当前没有消费方要求可互换的动作方法,因此由提供方拥有工具以保留这些语义。
 
-**新增核心启动 API。** 现有 Agent 维护阶段已能在 Session 自有设置期间阻止轮次运行;另一个初始化 API 会重复这份所有权。
+**提供方自有的启动阶段。** 现有的串行 `agent/created` 等待 Session 自有设置,并向创建方报告失败。独立维护任务会重复这份生命周期所有权。
 
-**在提示词组装期间发现。** 提示词和工具收集需要已就绪的注册。此时启动发现要么暴露不完整目录,要么要求再次收集;维护在轮次开始前完成发现。
+**在提示词组装期间发现。** 提示词和工具收集需要已就绪的注册。此时启动发现要么暴露不完整目录,要么要求再次收集;等待创建会在轮次开始前完成发现。
 
 **多个 Session 共享一个浏览器。** 浏览器标签页、导航与登录状态可以按 Session 隔离。共享它们会引入桌面集成通常无法避免的跨 Session 干扰。
 

+ 2 - 2
docs/subsystems/browser-use.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/browser-use.md
-browser-use.md: 97397ec656970803331587347e7a1c4a487ff156
-browser-use.zh.md: 5c8b764a5aab17ac16050e2a68014cf8cc0378a4
+browser-use.md: d4ca48d5e0349c05231ba9e29cab118298f43567
+browser-use.zh.md: 76f46660284584926303600884f27c11846b9825

+ 1 - 1
docs/subsystems/browser-use.md

@@ -26,7 +26,7 @@ Provider shutdown stops tool admission and waits for owned work and resource cle
 
 ## MCP initialization
 
-An MCP provider initializes one client for each live Agent created after the provider loads. Existing Agent maintenance holds queued input until connection and discovery settle; the client then remains with the Session across turns. Direct callers inspecting prompt assembly or scoped tools first await `agent.whenIdle()`. A failed startup remains failed for that activation and rejects prompt assembly and model requests.
+An MCP provider initializes one client for each live Agent created after the provider loads. The existing serial `agent/created` event awaits connection and discovery before creation or resume completes and queued input runs. The client remains with the Session across turns. Startup failure or cancellation rejects creation or resume and triggers client cleanup.
 
 If an attachment is busy, that activation continues without the browser and does not retry on later turns. After release, a newly created or resumed activation can acquire it. Loading or reloading the provider does not adopt already active Sessions; the [shared runtime](../../packages/experimental/browser-use-runtime/README.md) owns these initialization rules.
 

+ 1 - 1
docs/subsystems/browser-use.zh.md

@@ -26,7 +26,7 @@
 
 ## MCP 初始化
 
-MCP 提供方为其加载后创建的每个活动 Agent 初始化一个客户端。现有 Agent 维护阶段将输入保持在队列中,直到连接和发现结束;客户端随后跨轮次归 Session 所有。直接检查提示词组装或作用域工具的调用方先等待 `agent.whenIdle()`。启动失败会保留为本次激活的失败状态,并拒绝提示词组装和模型请求。
+MCP 提供方为其加载后创建的每个活动 Agent 初始化一个客户端。现有的串行 `agent/created` 事件等待连接和发现结束后,创建或恢复才完成,排队输入才开始运行。客户端跨轮次归 Session 所有。启动失败或取消会拒绝创建或恢复,并触发客户端清理。
 
 如果附加连接已被占用,本次激活不使用浏览器,但继续运行,后续轮次不会重试。连接释放后,新创建或恢复的激活可以获取它。加载或重新加载提供方不会接管已经活动的 Session;[共享运行时](../../packages/experimental/browser-use-runtime/README.zh.md)拥有这些初始化规则。
 

+ 2 - 2
packages/experimental/browser-use-chrome-devtools-mcp/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/experimental/browser-use-chrome-devtools-mcp/README.md
-README.md: b81ecaf139cf4d25fa85f45ce081cd1badf346e7
-README.zh.md: 746dee351e73156c10c508f9f64c37d607253b5f
+README.md: b4ef51079d79429b570ec222f283b3c20112546b
+README.zh.md: 5ce248b9759e4fb942629fc10217bb055f1a0e65

+ 3 - 3
packages/experimental/browser-use-chrome-devtools-mcp/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Use Chrome DevTools MCP to inspect pages and operate Chromium through its upstream tools. The provider initializes a Session's MCP connection before queued input runs and retains it across turns. Launch a separate browser or attach one Session to an existing browser with its current tabs and login state. This published experimental package activates only when explicitly mounted.
+Use Chrome DevTools MCP to inspect pages and operate Chromium through its upstream tools. The provider initializes a Session's MCP connection before creation or resume completes and retains it across turns. Launch a separate browser or attach one Session to an existing browser with its current tabs and login state. This published experimental package activates only when explicitly mounted.
 
 ## Table of Contents
 
@@ -57,7 +57,7 @@ When configuring the system prompt's `toolOrder` for the whole process, leave br
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The provider resolves its pinned npm entry and starts it under the current Node executable. A temporary protocol probe may precede the serving process. The [shared runtime](../browser-use-runtime/README.md) owns initialization during Agent maintenance, per-Session serialization, and cleanup; the [MCP client](../../mcp/mcp-client/README.md) owns transport, discovery, and result projection. No runtime invariant companion is published because the provider maintains no independent connection observation.
+The provider resolves its pinned npm entry and starts it under the current Node executable. A temporary protocol probe may precede the serving process. The [shared runtime](../browser-use-runtime/README.md) owns awaited Agent initialization, per-Session serialization, and cleanup; the [MCP client](../../mcp/mcp-client/README.md) owns transport, discovery, and result projection. No runtime invariant companion is published because the provider maintains no independent connection observation.
 
 Browser state survives turns while its live Session remains attached. Disposal waits for server shutdown before releasing resources. Resume after reload starts fresh browser runtime state; stored conversation history does not restore cookies or pages.
 
@@ -98,7 +98,7 @@ An unchanged catalog preserves its tool-definition prefix. Results append to his
 The integration retains the pinned server's browser and tool restrictions.
 
 - Chromium only; Firefox and WebKit are not selectable.
-- Startup failure remains a failure for that live activation and rejects prompt assembly and model requests. A failed or disconnected client is not retried; after fixing the cause, create a new Session or unload and resume the existing one.
+- Startup failure or cancellation rejects Session creation or resume and triggers client cleanup. A disconnected client is not retried; after fixing the cause, create a new Session or unload and resume the existing one.
 - Attachment exclusivity is local to this provider instance. Other processes and browser users can still modify the same pages.
 - The shared resource-server inventory can show inherited server names; it does not grant access to another Session's browser.
 - Cancellation does not undo navigation, clicks, or other actions already delivered to the browser.

+ 3 - 3
packages/experimental/browser-use-chrome-devtools-mcp/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-通过 Chrome DevTools MCP 的上游工具检查网页并操作 Chromium。提供方在处理排队输入前初始化 Session 的 MCP 连接,并跨轮次保留连接。可以启动独立浏览器,也可以让一个 Session 接入已有浏览器,使用其现有标签页和登录状态。本包以实验状态发布,仅在显式挂载后启用。
+通过 Chrome DevTools MCP 的上游工具检查网页并操作 Chromium。提供方在 Session 创建或恢复完成前初始化其 MCP 连接,并跨轮次保留连接。可以启动独立浏览器,也可以让一个 Session 接入已有浏览器,使用其现有标签页和登录状态。本包以实验状态发布,仅在显式挂载后启用。
 
 ## 目录
 
@@ -57,7 +57,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节 — 点击展开</summary>
 
-提供方解析固定版本 npm 包的可执行入口,并使用当前 Node 启动。服务进程之前可能运行临时协议探测进程。[共享运行时](../browser-use-runtime/README.zh.md)负责 Agent 维护阶段的初始化、逐 Session 串行执行与清理;[MCP 客户端](../../mcp/mcp-client/README.zh.md)负责传输、发现和结果投影。提供方不维护独立的连接观测,因此不发布运行时不变量配套入口。
+提供方解析固定版本 npm 包的可执行入口,并使用当前 Node 启动。服务进程之前可能运行临时协议探测进程。[共享运行时](../browser-use-runtime/README.zh.md)负责等待 Agent 初始化、逐 Session 串行执行与清理;[MCP 客户端](../../mcp/mcp-client/README.zh.md)负责传输、发现和结果投影。提供方不维护独立的连接观测,因此不发布运行时不变量配套入口。
 
 只要活动 Session 保持连接,浏览器状态就会跨轮次保留。销毁会等待服务器关闭,再释放资源。重新加载后恢复 Session 会创建新的浏览器运行状态;已保存的对话历史不会还原 Cookie 或页面。
 
@@ -98,7 +98,7 @@ kind: "package-reference"
 本集成保留固定版本服务器的浏览器与工具限制。
 
 - 仅支持 Chromium;不可选择 Firefox 或 WebKit。
-- 启动失败会保留为本次激活的失败状态,并拒绝提示词组装和模型请求。失败或断开的客户端不会重试;修复原因后,创建新 Session,或卸载并恢复已有 Session。
+- 启动失败或取消会拒绝 Session 创建或恢复,并触发客户端清理。断开的客户端不会重试;修复原因后,创建新 Session,或卸载并恢复已有 Session。
 - 连接独占仅在此提供方实例内有效。其他进程与浏览器用户仍可修改相同页面。
 - 共享资源服务器目录可以显示继承的服务器名称,但不会授予对其他 Session 浏览器的访问权限。
 - 取消不会撤销已发送给浏览器的导航、点击或其他操作。

+ 2 - 2
packages/experimental/browser-use-playwright-mcp/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/experimental/browser-use-playwright-mcp/README.md
-README.md: 51def18775c9fb638e65689258e3dc4f878067c9
-README.zh.md: 27da89137993feb395f3c0252f365c458d8eed83
+README.md: 6397c1f641e8c86ad9fd56886b04add1b89dfa3a
+README.zh.md: 41162e440a5a42e7a214d4fc078b8f80a786926d

+ 3 - 3
packages/experimental/browser-use-playwright-mcp/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Use Playwright MCP to inspect pages and operate Chromium through its upstream tools. The provider initializes a Session's MCP connection before queued input runs and retains it across turns. Launch a separate browser or attach one Session to an existing browser with its current tabs and login state. This published experimental package activates only when explicitly mounted.
+Use Playwright MCP to inspect pages and operate Chromium through its upstream tools. The provider initializes a Session's MCP connection before creation or resume completes and retains it across turns. Launch a separate browser or attach one Session to an existing browser with its current tabs and login state. This published experimental package activates only when explicitly mounted.
 
 ## Table of Contents
 
@@ -57,7 +57,7 @@ When configuring the system prompt's `toolOrder` for the whole process, leave br
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The provider resolves its pinned npm entry and starts it under the current Node executable. A temporary protocol probe may precede the serving process. The [shared runtime](../browser-use-runtime/README.md) owns initialization during Agent maintenance, per-Session serialization, and cleanup; the [MCP client](../../mcp/mcp-client/README.md) owns transport, discovery, and result projection. No runtime invariant companion is published because the provider maintains no independent connection observation.
+The provider resolves its pinned npm entry and starts it under the current Node executable. A temporary protocol probe may precede the serving process. The [shared runtime](../browser-use-runtime/README.md) owns awaited Agent initialization, per-Session serialization, and cleanup; the [MCP client](../../mcp/mcp-client/README.md) owns transport, discovery, and result projection. No runtime invariant companion is published because the provider maintains no independent connection observation.
 
 Browser state survives turns while its live Session remains attached. Disposal waits for server shutdown before releasing resources. Resume after reload starts fresh browser runtime state; stored conversation history does not restore cookies or pages.
 
@@ -98,7 +98,7 @@ An unchanged catalog preserves its tool-definition prefix. Results append to his
 The integration retains the pinned server's browser and tool restrictions.
 
 - Chromium only; Firefox and WebKit are not selectable.
-- Startup failure remains a failure for that live activation and rejects prompt assembly and model requests. A failed or disconnected client is not retried; after fixing the cause, create a new Session or unload and resume the existing one.
+- Startup failure or cancellation rejects Session creation or resume and triggers client cleanup. A disconnected client is not retried; after fixing the cause, create a new Session or unload and resume the existing one.
 - Attachment exclusivity is local to this provider instance. Other processes and browser users can still modify the same pages.
 - The shared resource-server inventory can show inherited server names; it does not grant access to another Session's browser.
 - Cancellation does not undo navigation, clicks, or other actions already delivered to the browser.

+ 3 - 3
packages/experimental/browser-use-playwright-mcp/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-通过 Playwright MCP 的上游工具检查网页并操作 Chromium。提供方在处理排队输入前初始化 Session 的 MCP 连接,并跨轮次保留连接。可以启动独立浏览器,也可以让一个 Session 接入已有浏览器,使用其现有标签页和登录状态。本包以实验状态发布,仅在显式挂载后启用。
+通过 Playwright MCP 的上游工具检查网页并操作 Chromium。提供方在 Session 创建或恢复完成前初始化其 MCP 连接,并跨轮次保留连接。可以启动独立浏览器,也可以让一个 Session 接入已有浏览器,使用其现有标签页和登录状态。本包以实验状态发布,仅在显式挂载后启用。
 
 ## 目录
 
@@ -57,7 +57,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节 — 点击展开</summary>
 
-提供方解析固定版本 npm 包的可执行入口,并使用当前 Node 启动。服务进程之前可能运行临时协议探测进程。[共享运行时](../browser-use-runtime/README.zh.md)负责 Agent 维护阶段的初始化、逐 Session 串行执行与清理;[MCP 客户端](../../mcp/mcp-client/README.zh.md)负责传输、发现和结果投影。提供方不维护独立的连接观测,因此不发布运行时不变量配套入口。
+提供方解析固定版本 npm 包的可执行入口,并使用当前 Node 启动。服务进程之前可能运行临时协议探测进程。[共享运行时](../browser-use-runtime/README.zh.md)负责等待 Agent 初始化、逐 Session 串行执行与清理;[MCP 客户端](../../mcp/mcp-client/README.zh.md)负责传输、发现和结果投影。提供方不维护独立的连接观测,因此不发布运行时不变量配套入口。
 
 只要活动 Session 保持连接,浏览器状态就会跨轮次保留。销毁会等待服务器关闭,再释放资源。重新加载后恢复 Session 会创建新的浏览器运行状态;已保存的对话历史不会还原 Cookie 或页面。
 
@@ -98,7 +98,7 @@ kind: "package-reference"
 本集成保留固定版本服务器的浏览器与工具限制。
 
 - 仅支持 Chromium;不可选择 Firefox 或 WebKit。
-- 启动失败会保留为本次激活的失败状态,并拒绝提示词组装和模型请求。失败或断开的客户端不会重试;修复原因后,创建新 Session,或卸载并恢复已有 Session。
+- 启动失败或取消会拒绝 Session 创建或恢复,并触发客户端清理。断开的客户端不会重试;修复原因后,创建新 Session,或卸载并恢复已有 Session。
 - 连接独占仅在此提供方实例内有效。其他进程与浏览器用户仍可修改相同页面。
 - 共享资源服务器目录可以显示继承的服务器名称,但不会授予对其他 Session 浏览器的访问权限。
 - 取消不会撤销已发送给浏览器的导航、点击或其他操作。

+ 2 - 2
packages/experimental/browser-use-runtime/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/experimental/browser-use-runtime/README.md
-README.md: b0c0378c13badffa8322bc0c70ed0071714af70c
-README.zh.md: 35f5e685220f0fbbaf682193e5e3cb99266eb87e
+README.md: 41c2b627d1ecfe6f3ebba974f717245220cc6246
+README.zh.md: 4b5a58e7d2772d7fc56a7d3f8572f5c84b5641e1

+ 5 - 5
packages/experimental/browser-use-runtime/README.md

@@ -29,11 +29,11 @@ This public experimental library is a dependency of the browser providers. It ha
 
 Native providers construct `SessionResources` from the package root, supplying resource acquisition and cleanup callbacks. Calls pass the exact live Agent to `run()`; stale owners and a second owner of an exclusive attachment fail before acquisition. Canceling an acquisition wait leaves initialization available to other callers in the same Session; Session disposal aborts and awaits that initialization. Providers keep their registration until `dispose()` finishes.
 
-MCP providers use `mountSessionMcp` from `@deepseek-ai/dsh-experimental-browser-use-runtime/mcp`, supplying their fixed server name, executable, arguments, and ownership policy. The helper observes future `agent/created` events and immediately claims each Agent's existing `runMaintenance` phase for one scoped client startup and discovery attempt. Queued input waits until maintenance settles; a successful client remains owned by that Session across turns.
+MCP providers use `mountSessionMcp` from `@deepseek-ai/dsh-experimental-browser-use-runtime/mcp`, supplying their fixed server name, executable, arguments, and ownership policy. The helper awaits one scoped client startup and discovery attempt within each future Agent's `agent/created` event. Agent creation or resume completes after discovery, before queued input runs; a successful client remains owned by that Session across turns.
 
-A busy attachment skips startup permanently for that live activation while its other work continues. Releasing the attachment does not retry skipped activations; a newly created or resumed Agent can acquire it. A failed startup remains failed for the activation and rejects prompt assembly and model requests. User cancellation during startup likewise leaves that activation failed, even if Session-owned acquisition later succeeds; disposal still closes the owned resource. Reconnection is disabled. Loading or reloading a provider applies only to future Agent activations.
+A busy attachment skips startup permanently for that live activation while its other work continues. Releasing the attachment does not retry skipped activations; a newly created or resumed Agent can acquire it. Startup failure or cancellation rejects Agent creation or resume and triggers creation rollback, including client cleanup. Reconnection is disabled. Loading or reloading a provider applies only to future Agent activations.
 
-Direct callers inspecting prompt assembly or scoped tool definitions after Agent creation must first await `agent.whenIdle()`. That wait establishes startup settlement, not success; assembly still reports a retained startup failure.
+Callers can inspect prompt assembly and scoped tool definitions after awaited Agent creation or resume completes.
 
 Browser tools and resource requests targeting this server use the same queue and require the calling Session's own connection. Other MCP servers remain usable. Inherited server instructions are omitted without ownership; the shared server-name inventory keeps its normal scope behavior.
 
@@ -49,7 +49,7 @@ The [resource manager](src/index.ts) keys ownership by live Agent identity and j
 
 Disposed-cause cancellation starts resource cleanup before AgentHandle waits for idle. Cleanup closes resources before waiting for running operations, allowing connection teardown to interrupt upstream APIs without abort support. A failed close rejects disposal and retains ownership. Agent-scoped cleanup prevents a resumed Session with the same durable id from inheriting a previous browser.
 
-The [MCP helper](src/mcp.ts) claims maintenance synchronously when the Agent is published, so input cannot race initial discovery. MCP client activation completes within that maintenance task; prompt assembly neither initiates startup nor recursively waits for it. The helper retains the provider registration through resource cleanup and uses the [MCP client](../../mcp/mcp-client/README.md) for transport, schema discovery, result conversion, and durable image admission.
+The [MCP helper](src/mcp.ts) connects within serial `agent/created`; [AgentLoop](../../core/agent-loop/README.md#understand-the-implementation) holds queued input and owns creation rollback. Prompt assembly reads the initialized catalog. The helper retains the provider registration through resource cleanup and uses the [MCP client](../../mcp/mcp-client/README.md) for transport, schema discovery, result conversion, and durable image admission.
 
 No runtime invariant companion is published: resource ownership and pending work are private lifecycle state, with no separately maintained runtime projection to compare. Owner tests cover isolation, disposal, and failed cleanup.
 
@@ -81,7 +81,7 @@ The library adds no prompt text. Discovered tool schemas and provider guidance d
 
 Providers remain responsible for the browser operations they supply.
 
-- **MCP activation** — providers do not adopt existing live Agents; skipped or failed startup is not retried within that activation.
+- **MCP activation** — providers do not adopt existing live Agents; a busy attachment is not retried within that activation.
 - **Attachment scope** — exclusive ownership applies to one resource manager, not separate providers, processes, or external browser clients.
 - **Cancellation** — abort signals and connection closure cannot undo browser actions already delivered. An upstream operation that ignores both can delay cleanup.
 - **Recovery** — a failed close retains ownership; this manager does not retry disposal or restore browser state from the Session log.

+ 5 - 5
packages/experimental/browser-use-runtime/README.zh.md

@@ -29,11 +29,11 @@ kind: "package-library"
 
 原生提供方从包根入口构造 `SessionResources`,提供资源获取与清理回调。调用向 `run()` 传递确切的实时 Agent;失效的所有者与独占附加的第二个所有者在获取资源前失败。取消资源获取等待不会终止初始化,同一 Session 的其他调用方仍可继续等待;释放 Session 会中止并等待该初始化结束。提供方将注册保留到 `dispose()` 完成。
 
-MCP 提供方使用 `@deepseek-ai/dsh-experimental-browser-use-runtime/mcp` 中的 `mountSessionMcp`,提供固定服务器名称、可执行文件、参数与所有权策略。辅助库观察后续的 `agent/created` 事件,并立即占用各 Agent 现有的 `runMaintenance` 阶段,执行一次有作用域的客户端启动与发现尝试。排队输入等待维护结束;成功的客户端跨轮次归该 Session 所有。
+MCP 提供方使用 `@deepseek-ai/dsh-experimental-browser-use-runtime/mcp` 中的 `mountSessionMcp`,提供固定服务器名称、可执行文件、参数与所有权策略。辅助库在每个后续 Agent 的 `agent/created` 事件中等待一次有作用域的客户端启动与发现尝试。Agent 创建或恢复在发现完成后结束,随后才运行排队输入;成功的客户端跨轮次归该 Session 所有。
 
-附加连接被占用时,本次激活永久跳过启动,但其他工作继续运行。连接释放不会触发被跳过激活的重试;新创建或恢复的 Agent 可以获取连接。启动失败会保留为本次激活的失败状态,并拒绝提示词组装和模型请求。用户在启动期间取消操作,也会使本次激活保持失败,即使 Session 自有的资源获取随后成功;最终释放仍会关闭该资源。重连已禁用。加载或重新加载提供方只作用于后续的 Agent 激活。
+附加连接被占用时,本次激活永久跳过启动,但其他工作继续运行。连接释放不会触发被跳过激活的重试;新创建或恢复的 Agent 可以获取连接。启动失败或取消会拒绝 Agent 创建或恢复,并触发包含客户端清理的创建回滚。重连已禁用。加载或重新加载提供方只作用于后续的 Agent 激活。
 
-Agent 创建后,直接检查提示词组装或作用域工具定义的调用方必须先等待 `agent.whenIdle()`。等待只表示启动已结束,不代表成功;组装仍会报告保留的启动失败。
+调用方在等待 Agent 创建或恢复完成后,即可检查提示词组装和作用域工具定义。
 
 指向此服务器的浏览器工具调用与资源请求使用同一队列,且要求调用 Session 自己拥有连接。其他 MCP 服务器仍可使用。没有所有权时会省略继承的服务器指导;共享服务器名称目录保留常规作用域行为。
 
@@ -49,7 +49,7 @@ Agent 创建后,直接检查提示词组装或作用域工具定义的调用
 
 因释放而取消时,在 AgentHandle 等待空闲前开始资源清理。清理先关闭资源再等待运行中的操作,使连接清理能够中断不支持 abort 的上游 API。关闭失败会拒绝释放并保留所有权。Agent 作用域清理防止使用同一持久 id 恢复的 Session 继承之前的浏览器。
 
-[MCP 辅助库](src/mcp.ts)在 Agent 发布时同步占用维护阶段,使输入无法与首次发现并发执行。MCP 客户端激活在该维护任务中完成;提示词组装既不发起启动,也不递归等待启动。辅助库在资源清理期间保留提供方注册,并通过 [MCP 客户端](../../mcp/mcp-client/README.zh.md)处理传输、schema 发现、结果转换与持久图像接纳。
+[MCP 辅助库](src/mcp.ts)在串行 `agent/created` 中建立连接;[AgentLoop](../../core/agent-loop/README.zh.md#understand-the-implementation)保留排队输入并负责创建回滚。提示词组装读取已初始化的目录。辅助库在资源清理期间保留提供方注册,并通过 [MCP 客户端](../../mcp/mcp-client/README.zh.md)处理传输、schema 发现、结果转换与持久图像接纳。
 
 不发布运行时不变量伴随入口:资源所有权与待处理工作是私有生命周期状态,没有可供比较的独立维护运行时投影。所属测试覆盖隔离、释放与清理失败。
 
@@ -81,7 +81,7 @@ Agent 创建后,直接检查提示词组装或作用域工具定义的调用
 
 提供方仍负责自己提供的浏览器操作。
 
-- **MCP 激活** — 提供方不接管已有的活动 Agent;跳过或失败的启动不会在本次激活中重试。
+- **MCP 激活** — 提供方不接管已有的活动 Agent;被占用的附加连接不会在本次激活中重试。
 - **附加范围** — 独占所有权作用于一个资源管理器,不约束独立提供方、进程或外部浏览器客户端。
 - **取消** — abort 信号与连接关闭无法撤销已交付的浏览器操作。同时忽略两者的上游操作可能延迟清理。
 - **恢复** — 关闭失败会保留所有权;此管理器不重试释放,也不从 Session 日志恢复浏览器状态。

+ 2 - 1
packages/experimental/browser-use-runtime/package.json

@@ -58,6 +58,7 @@
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
     "@deepseek-ai/cordis-plugin-include": "workspace:^",
-    "@deepseek-ai/dsh-attachment-local": "workspace:^"
+    "@deepseek-ai/dsh-attachment-local": "workspace:^",
+    "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^"
   }
 }

+ 11 - 30
packages/experimental/browser-use-runtime/src/mcp.ts

@@ -86,12 +86,12 @@ export interface SessionMcpOptions {
 }
 
 interface ClientState {
-  status: { kind: 'pending' | 'ready' | 'blocked' } | { kind: 'failed'; error: unknown }
+  status: 'ready' | 'blocked'
   mask?: Scope
 }
 
 /**
- * Initialize one MCP client during each future Agent's first maintenance task.
+ * Await one MCP client during each future Agent's creation.
  * A busy attachment leaves that activation without browser tools; its other turns continue.
  * Calls are serialized per Session; unload closes every server before releasing registration.
  * @param ctx - provider context supplying browser use, Agents, tools, and prompt assembly.
@@ -110,7 +110,7 @@ export function mountSessionMcp(ctx: Context, options: SessionMcpOptions): void
     refreshingMasks = true
     try {
       for (const [agent, state] of clients) {
-        if (state.status.kind !== 'blocked') continue
+        if (state.status !== 'blocked') continue
         const inherited = ctx.tools.schemas(agent).filter(tool => tool.name.startsWith(toolPrefix))
         if (inherited.length === 0) continue
         state.mask ??= createScope(ctx, agent)
@@ -174,38 +174,19 @@ export function mountSessionMcp(ctx: Context, options: SessionMcpOptions): void
       clients.clear()
     }
   }, `${options.name}.sessions`)
-  ctx.systemPrompt.tools(({ agent, scope }) => {
-    const status = clients.get((agent ?? scope) as Agent)?.status
-    if (status?.kind === 'pending') throw new Error(`${options.name}: browser initialization is pending`)
-    if (status?.kind === 'failed') throw status.error
-    return { schemas: [] }
-  })
-  ctx.on('agent/created', ({ agent }) => {
-    const state: ClientState = { status: { kind: resources.available(agent) ? 'pending' : 'blocked' } }
-    clients.set(agent, state)
+  ctx.on('agent/created', async ({ agent, signal }) => {
+    const state: ClientState = { status: resources.available(agent) ? 'ready' : 'blocked' }
     agent.ctx.effect(() => async () => {
       clients.delete(agent)
       await state.mask?.dispose()
     }, `${options.name}.activation`)
-    if (state.status.kind === 'blocked') {
+    if (state.status === 'blocked') {
+      clients.set(agent, state)
       refreshBlockedMasks()
       return
     }
-    try {
-      const startup = agent.runMaintenance(async (signal) => {
-        try {
-          await resources.run(agent, signal, async () => {})
-          state.status = { kind: 'ready' }
-        } catch (error) {
-          state.status = { kind: 'failed', error }
-          throw error
-        }
-      })
-      // The synchronous prompt guard reports the captured failure before model dispatch.
-      void startup.catch(() => {})
-    } catch (error) {
-      state.status = { kind: 'failed', error }
-    }
+    await resources.get(agent, signal)
+    clients.set(agent, state)
   }, { prepend: true })
   ctx.on('tools/change', refreshBlockedMasks)
   ctx.on('tools/execute', async (exec, next) => {
@@ -214,7 +195,7 @@ export function mountSessionMcp(ctx: Context, options: SessionMcpOptions): void
       && (exec.arguments as { server?: unknown }).server === options.name
     if (!exec.name.startsWith(toolPrefix) && !ownResource) return next()
     const agent = exec.agent
-    if (agent === undefined || clients.get(agent)?.status.kind !== 'ready') {
+    if (agent === undefined || clients.get(agent)?.status !== 'ready') {
       throw new Error(`${options.name}: browser tool belongs to another Session`)
     }
     return resources.run(agent, exec.signal, async (_scope, combined) => {
@@ -229,7 +210,7 @@ export function mountSessionMcp(ctx: Context, options: SessionMcpOptions): void
   })
   ctx.on('system-prompt/assemble', async (_assembly, { agent }, next) => {
     const assembly = await next()
-    if (agent === undefined || clients.get(agent)?.status.kind === 'ready') return assembly
+    if (agent === undefined || clients.get(agent)?.status === 'ready') return assembly
     return { ...assembly, sections: assembly.sections.filter(section => section.name !== `mcp:${options.name}`) }
   })
 }

+ 140 - 59
packages/experimental/browser-use-runtime/tests/mcp.spec.ts

@@ -14,6 +14,7 @@ import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime'
 import Llm, { LlmAdapter, ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm'
 import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
 import Sessions, { SessionId } from '@deepseek-ai/dsh-session'
+import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
 import Agents from '@deepseek-ai/dsh-agent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -197,7 +198,7 @@ describe('Session MCP Loader composition', () => {
   })
 
   it('keeps unrelated and child Sessions running without a busy attachment and admits a later owner', async () => {
-    const { ctx, root, model } = await load(true, 'gate')
+    const { ctx, root, model } = await load(true)
     registerIndependentTool(ctx)
     const first = await ctx.agents.create({ sessionId: SessionId('first') })
     const second = await ctx.agents.create({ sessionId: SessionId('second'), agentOptions: { provider: 'fixture', model: 'fixture' } })
@@ -205,7 +206,6 @@ describe('Session MCP Loader composition', () => {
       sessionId: SessionId('child'), parentAgent: first.agent, agentOptions: { provider: 'fixture', model: 'fixture' },
       setup: (_inner, agent) => { bindScopeParent(agent, first.agent) },
     })
-    await writeFile(join(root, 'release'), '')
     await warm(ctx, first.agent)
     expect(ctx.tools.schemas(child.agent).some(tool => tool.name === TOOL)).toBe(false)
     for (const { agent } of [second, child]) {
@@ -247,36 +247,50 @@ describe('Session MCP Loader composition', () => {
 
   it('allows another Session to answer while attached-browser discovery is pending', async () => {
     const { ctx, root, model, browser } = await load(true, 'hold')
-    const first = await ctx.agents.create({ sessionId: SessionId('first') })
-    const second = await ctx.agents.create({ sessionId: SessionId('second'), agentOptions: { provider: 'fixture', model: 'fixture' } })
+    const first = ctx.agents.create({ sessionId: SessionId('first') })
+    const rejected = expect(first).rejects.toThrow()
     await vi.waitFor(async () => { expect((await events(root)).some(event => event.event === 'probe')).toBe(true) })
-    await expect(ctx.systemPrompt.assemble({ agent: first.agent, scope: first.agent })).rejects.toThrow('initialization is pending')
+    const second = await ctx.agents.create({ sessionId: SessionId('second'), agentOptions: { provider: 'fixture', model: 'fixture' } })
     second.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Answer without using the browser.' }], source: { kind: 'user' } }))
     await second.agent.whenIdle()
     expect(model.requests).toHaveLength(1)
     expect(model.requests[0]?.tools ?? []).toEqual([])
     expect((await events(root)).filter(event => event.event === 'start')).toHaveLength(1)
     await browser.dispose()
-    await first.agent.whenIdle()
+    await rejected
+    expect(ctx.agents.get(SessionId('first'))).toBeUndefined()
+    expect(ctx.sessions.get(SessionId('first'))).toBeUndefined()
   })
 
   it('rolls back failed discovery and stops a child when unload interrupts discovery', async () => {
     const failed = await load(false, 'fail', undefined, [TOOL, '<unlisted-tools>'])
-    const owner = await failed.ctx.agents.create({ sessionId: SessionId('failure'), agentOptions: { provider: 'fixture', model: 'fixture' } })
-    owner.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit the fixture.' }], source: { kind: 'user' } }))
-    await expect(warm(failed.ctx, owner.agent)).rejects.toThrow('initial connection')
+    let failedAgent!: Agent
+    const laterListener = vi.fn()
+    await expect(failed.ctx.agents.create({
+      sessionId: SessionId('failure'), agentOptions: { provider: 'fixture', model: 'fixture' },
+      setup: (inner, agent) => {
+        failedAgent = agent
+        inner.on('agent/created', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit the fixture.' }], source: { kind: 'user' } })) }, { prepend: true })
+        inner.on('agent/created', laterListener)
+      },
+    })).rejects.toThrow('initial connection')
     expect(failed.model.requests).toEqual([])
-    expect(failed.ctx.tools.schemas(owner.agent)).toEqual([])
+    expect(laterListener).not.toHaveBeenCalled()
+    expect(failed.ctx.agents.get(failedAgent.id)).toBeUndefined()
+    expect(failed.ctx.sessions.get(failedAgent.id)).toBeUndefined()
+    expect(failed.ctx.tools.schemas(failedAgent)).toEqual([])
     const failedEvents = await events(failed.root)
     expect(failedEvents.filter(event => event.event === 'initialize')).toHaveLength(1)
     for (const { pid } of failedEvents.filter(event => event.event === 'start')) {
       expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' }))
     }
     const held = await load(false, 'hold')
-    const pendingOwner = await held.ctx.agents.create({ sessionId: SessionId('pending') })
+    const pendingOwner = held.ctx.agents.create({ sessionId: SessionId('pending') })
+    const interrupted = expect(pendingOwner).rejects.toThrow()
     await vi.waitFor(async () => { expect((await events(held.root)).some(event => event.event === 'probe')).toBe(true) })
     await held.browser.dispose()
-    await pendingOwner.agent.whenIdle()
+    await interrupted
+    expect(held.ctx.agents.get(SessionId('pending'))).toBeUndefined()
     for (const { pid } of (await events(held.root)).filter(event => event.event === 'start')) {
       expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' }))
     }
@@ -398,82 +412,149 @@ describe('Session MCP Loader composition', () => {
   it('holds an immediate PTC turn until the first SDK includes browser tools', async () => {
     const { ctx, root, model } = await load(false, 'gate', undefined, ['run_code', '<unlisted-tools>'])
     await ctx.plugin(PresentationRuntime)
-    const owner = await ctx.agents.create({
+    let created = false
+    let initializing!: Agent
+    const creation = ctx.agents.create({
       sessionId: SessionId('ptc-first-request'), agentOptions: { provider: 'fixture', model: 'fixture' },
-      setup: (inner) => { inner.tools.presentAs('ptc') },
+      setup: (inner, agent) => {
+        initializing = agent
+        inner.tools.presentAs('ptc')
+        inner.on('agent/created', () => {
+          agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Describe the available browser tools.' }], source: { kind: 'user' } }))
+        }, { prepend: true })
+      },
+    }).then((handle) => {
+      created = true
+      return handle
     })
-    owner.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Describe the available browser tools.' }], source: { kind: 'user' } }))
     await vi.waitFor(async () => { expect((await events(root)).some(event => event.event === 'probe')).toBe(true) })
+    expect(created).toBe(false)
     expect(model.requests).toEqual([])
+    expect((await execute(ctx, initializing)).isError).toBe(true)
     await writeFile(join(root, 'release'), '')
+    const owner = await creation
     await owner.agent.whenIdle()
     expect(model.requests).toHaveLength(1)
     expect(model.requests[0]?.tools?.map(tool => tool.name)).toEqual(['run_code'])
     expect(JSON.stringify(model.requests[0]?.messages)).toContain(TOOL)
   })
 
-  it('keeps a canceled startup failed and disposes its later-completing owned client', async () => {
-    const { ctx, root, model } = await load(false, 'gate')
-    const owner = await ctx.agents.create({ sessionId: SessionId('canceled-startup'), agentOptions: { provider: 'fixture', model: 'fixture' } })
+  it('cancels creation during discovery, closes its process, and releases the attachment', async () => {
+    const { ctx, root, model } = await load(true, 'gate')
+    const controller = new AbortController()
+    let initializing!: Agent
+    const creation = ctx.agents.create({
+      sessionId: SessionId('canceled-startup'), signal: controller.signal,
+      agentOptions: { provider: 'fixture', model: 'fixture' },
+      setup: (inner, agent) => {
+        initializing = agent
+        inner.on('agent/created', () => {
+          agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit the fixture.' }], source: { kind: 'user' } }))
+        }, { prepend: true })
+      },
+    })
+    const rejected = expect(creation).rejects.toThrow('cancel browser startup')
     await vi.waitFor(async () => { expect((await events(root)).some(event => event.event === 'probe')).toBe(true) })
-    owner.agent.cancel({ kind: 'user' })
-    await owner.agent.whenIdle()
-    await expect(warm(ctx, owner.agent)).rejects.toThrow('browser operation canceled')
-    await writeFile(join(root, 'release'), '')
-    await vi.waitFor(() => { expect(ctx.tools.schemas(owner.agent).some(tool => tool.name === TOOL)).toBe(true) })
-    owner.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit the fixture.' }], source: { kind: 'user' } }))
-    await owner.agent.whenIdle()
+    controller.abort(new Error('cancel browser startup'))
+    await rejected
     expect(model.requests).toEqual([])
-    expect((await execute(ctx, owner.agent)).isError).toBe(true)
-    await owner.dispose()
+    expect(ctx.agents.get(initializing.id)).toBeUndefined()
+    expect(ctx.sessions.get(initializing.id)).toBeUndefined()
+    expect(ctx.tools.schemas(initializing)).toEqual([])
+    expect((await execute(ctx, initializing)).isError).toBe(true)
     for (const { pid } of (await events(root)).filter(event => event.event === 'start')) {
       expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' }))
     }
+    await writeFile(join(root, 'release'), '')
+    const successor = await ctx.agents.create({ sessionId: initializing.id })
+    expect((await execute(ctx, successor.agent)).isError).toBe(false)
   })
 
-  it('retains a maintenance claim failure before dispatching queued model work', async () => {
-    const { ctx, model } = await load(false, undefined, undefined, [TOOL, '<unlisted-tools>'])
-    const release = Promise.withResolvers<undefined>()
-    try {
-      const owner = await ctx.agents.create({
-        sessionId: SessionId('claimed-maintenance'), agentOptions: { provider: 'fixture', model: 'fixture' },
-        setup: (inner) => {
-          inner.on('agent/created', ({ agent }) => { void agent.runMaintenance(() => release.promise) }, { prepend: true })
-        },
-      })
-      owner.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit the fixture.' }], source: { kind: 'user' } }))
-      release.resolve(undefined)
-      await owner.agent.whenIdle()
-      await expect(warm(ctx, owner.agent)).rejects.toThrow('already has active work')
-      expect(model.requests).toEqual([])
-      expect(ctx.tools.schemas(owner.agent)).toEqual([])
-      expect((await execute(ctx, owner.agent)).isError).toBe(true)
-    } finally {
-      release.resolve(undefined)
+  it('closes the discovered client when a later creation listener rejects', async () => {
+    const { ctx, root, model } = await load(false, undefined, undefined, [TOOL, '<unlisted-tools>'])
+    let initializing!: Agent
+    await expect(ctx.agents.create({
+      sessionId: SessionId('later-listener-failure'), agentOptions: { provider: 'fixture', model: 'fixture' },
+      setup: (inner, agent) => {
+        initializing = agent
+        inner.on('agent/created', () => {
+          expect(ctx.tools.schemas(agent).some(tool => tool.name === TOOL)).toBe(true)
+          agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit the fixture.' }], source: { kind: 'user' } }))
+          throw new Error('later initialization failed')
+        })
+      },
+    })).rejects.toThrow('later initialization failed')
+    expect(model.requests).toEqual([])
+    expect(ctx.agents.get(initializing.id)).toBeUndefined()
+    expect(ctx.sessions.get(initializing.id)).toBeUndefined()
+    expect(ctx.tools.schemas(initializing)).toEqual([])
+    expect((await execute(ctx, initializing)).isError).toBe(true)
+    for (const { pid } of (await events(root)).filter(event => event.event === 'start')) {
+      expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' }))
     }
   })
 
-  it('retains cancellation from a later creation listener before the startup operation runs', async () => {
+  it('cancels before browser discovery without starting a process', async () => {
     const { ctx, root, model } = await load(false, 'gate')
-    const cause = { kind: 'user' as const }
-    const owner = await ctx.agents.create({
-      sessionId: SessionId('canceled-at-creation'), agentOptions: { provider: 'fixture', model: 'fixture' },
+    const controller = new AbortController()
+    await expect(ctx.agents.create({
+      sessionId: SessionId('canceled-at-creation'), signal: controller.signal,
+      agentOptions: { provider: 'fixture', model: 'fixture' },
       setup: (inner) => {
-        inner.on('agent/created', ({ agent }) => { agent.cancel(cause) })
+        inner.on('agent/created', () => { controller.abort(new Error('cancel before discovery')) }, { prepend: true })
       },
-    })
-    await owner.agent.whenIdle()
-    await expect(warm(ctx, owner.agent)).rejects.toBe(cause)
-    owner.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit the fixture.' }], source: { kind: 'user' } }))
-    await owner.agent.whenIdle()
+    })).rejects.toThrow('cancel before discovery')
     expect(model.requests).toEqual([])
+    expect(ctx.agents.get(SessionId('canceled-at-creation'))).toBeUndefined()
+    await expect(readFile(join(root, 'events.ndjson'))).rejects.toMatchObject({ code: 'ENOENT' })
+  })
+
+  it('awaits discovery on persisted resume and releases a canceled resume before retry', async () => {
+    const { ctx, root, model } = await load(true, 'gate')
+    await ctx.plugin(JsonlSessionPersistence, { root: join(root, 'sessions') })
     await writeFile(join(root, 'release'), '')
-    await vi.waitFor(() => { expect(ctx.tools.schemas(owner.agent).some(tool => tool.name === TOOL)).toBe(true) })
-    expect((await execute(ctx, owner.agent)).isError).toBe(true)
-    await owner.dispose()
+    const sessionId = SessionId('persisted-browser')
+    const first = await ctx.agents.create({ sessionId, agentOptions: { provider: 'fixture', model: 'fixture' } })
+    first.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit the fixture.' }], source: { kind: 'user' } }))
+    await first.agent.whenIdle()
+    expect(model.requests).toHaveLength(2)
+    await first.dispose()
+    await rm(join(root, 'release'))
+
+    const controller = new AbortController()
+    const canceledResume = ctx.agents.resume({
+      resumeSessionId: sessionId, signal: controller.signal,
+      agentOptions: { provider: 'fixture', model: 'fixture' },
+      setup: (inner, agent) => {
+        inner.on('agent/created', () => {
+          agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Visit again.' }], source: { kind: 'user' } }))
+        }, { prepend: true })
+      },
+    })
+    const rejected = expect(canceledResume).rejects.toThrow('cancel browser resume')
+    await vi.waitFor(async () => { expect((await events(root)).filter(event => event.event === 'probe')).toHaveLength(2) })
+    expect(model.requests).toHaveLength(2)
+    controller.abort(new Error('cancel browser resume'))
+    await rejected
+    expect(ctx.agents.get(sessionId)).toBeUndefined()
+    expect(ctx.sessions.get(sessionId)).toBeUndefined()
     for (const { pid } of (await events(root)).filter(event => event.event === 'start')) {
       expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' }))
     }
+
+    let resumed = false
+    const resuming = ctx.agents.resume({ resumeSessionId: sessionId }).then((handle) => {
+      resumed = true
+      return handle
+    })
+    await vi.waitFor(async () => { expect((await events(root)).filter(event => event.event === 'probe')).toHaveLength(3) })
+    expect(resumed).toBe(false)
+    expect(model.requests).toHaveLength(2)
+    await writeFile(join(root, 'release'), '')
+    const owner = await resuming
+    expect(ctx.tools.schemas(owner.agent).some(tool => tool.name === TOOL)).toBe(true)
+    expect((await execute(ctx, owner.agent)).content).toEqual([{ type: 'text', text: 'Visit 1: direct' }])
+    await owner.dispose()
   })
 
   it('initializes only future activations after provider reload', async () => {

+ 26 - 25
packages/experimental/browser-use-runtime/tests/resources.spec.ts

@@ -12,7 +12,7 @@ async function fixture() {
   const ctx = new Context()
   contexts.push(ctx)
   await ctx.plugin(AgentRegistry)
-  function owner(id: string) {
+  async function owner(id: string) {
     const fiber = ctx.plugin(() => {})
     const session = Session.create(SessionId(id))
     const agent: Agent = {
@@ -23,7 +23,8 @@ async function fixture() {
       whenIdle: () => Promise.resolve(undefined),
     }
     const unregister = ctx.agents.register(agent)
-    return { agent, async dispose() { await fiber.dispose(); unregister() } }
+    await unregister
+    return { agent, async dispose() { await fiber.dispose(); await unregister() } }
   }
   return { ctx, owner }
 }
@@ -35,8 +36,8 @@ afterEach(async () => {
 describe('Session browser resource ownership', () => {
   it('acquires once across concurrent requests and gives another Session a different resource', async () => {
     const { ctx, owner } = await fixture()
-    const a = owner('a')
-    const b = owner('b')
+    const a = await owner('a')
+    const b = await owner('b')
     const close = vi.fn(async () => {})
     const open = vi.fn(async (agent: Agent) => ({ value: { id: agent.id }, close }))
     const resources = new SessionResources(ctx, { label: 'test', exclusive: false, open })
@@ -47,7 +48,7 @@ describe('Session browser resource ownership', () => {
     await a.dispose()
     expect(close).toHaveBeenCalledTimes(1)
     await expect(resources.get(a.agent)).rejects.toThrow('not a live browser owner')
-    const resumed = owner('a')
+    const resumed = await owner('a')
     expect(await resources.get(resumed.agent)).not.toBe(first)
     await resources.dispose()
     expect(close).toHaveBeenCalledTimes(3)
@@ -57,8 +58,8 @@ describe('Session browser resource ownership', () => {
 
   it('reserves an attached browser while acquisition or cleanup is pending', async () => {
     const { ctx, owner } = await fixture()
-    const a = owner('a')
-    const b = owner('b')
+    const a = await owner('a')
+    const b = await owner('b')
     const opened = Promise.withResolvers<undefined>()
     const released = Promise.withResolvers<undefined>()
     const closing = Promise.withResolvers<undefined>()
@@ -91,8 +92,8 @@ describe('Session browser resource ownership', () => {
 
   it('releases a failed acquisition and can acquire for another Session', async () => {
     const { ctx, owner } = await fixture()
-    const a = owner('a')
-    const b = owner('b')
+    const a = await owner('a')
+    const b = await owner('b')
     const open = vi.fn().mockRejectedValueOnce(new Error('browser unavailable')).mockResolvedValue({ value: 1, close: async () => {} })
     const resources = new SessionResources<number>(ctx, { label: 'test', exclusive: true, open })
     await expect(resources.get(a.agent, new AbortController().signal)).rejects.toThrow('browser unavailable')
@@ -102,7 +103,7 @@ describe('Session browser resource ownership', () => {
 
   it('retries failed acquisition for the same live owner without duplicating cleanup', async () => {
     const { ctx, owner } = await fixture()
-    const a = owner('a')
+    const a = await owner('a')
     const close = vi.fn(async () => {})
     const open = vi.fn().mockRejectedValueOnce(new Error('launch failed')).mockResolvedValue({ value: 1, close })
     const resources = new SessionResources<number>(ctx, { label: 'test', exclusive: false, open })
@@ -115,7 +116,7 @@ describe('Session browser resource ownership', () => {
 
   it('quiesces disposal when an in-flight acquisition fails during rollback', async () => {
     const { ctx, owner } = await fixture()
-    const a = owner('a')
+    const a = await owner('a')
     const entered = Promise.withResolvers<undefined>()
     const failed = Promise.withResolvers<never>()
     const resources = new SessionResources(ctx, {
@@ -132,8 +133,8 @@ describe('Session browser resource ownership', () => {
 
   it('serializes one Session while another proceeds and skips cancelled queued work', async () => {
     const { ctx, owner } = await fixture()
-    const a = owner('a')
-    const b = owner('b')
+    const a = await owner('a')
+    const b = await owner('b')
     const resources = new SessionResources(ctx, {
       label: 'test', exclusive: false,
       open: async () => ({ value: {}, close: async () => {} }),
@@ -162,8 +163,8 @@ describe('Session browser resource ownership', () => {
 
   it.each(['get', 'run'] as const)('cancels one %s caller while another waiter retains the same acquisition', async (kind) => {
     const { ctx, owner } = await fixture()
-    const a = owner('a')
-    const b = owner('b')
+    const a = await owner('a')
+    const b = await owner('b')
     const entered = Promise.withResolvers<undefined>()
     const release = Promise.withResolvers<undefined>()
     const close = vi.fn(async () => {})
@@ -202,7 +203,7 @@ describe('Session browser resource ownership', () => {
 
   it('honors caller cancellation between shared readiness and the waiting continuation', async () => {
     const { ctx, owner } = await fixture()
-    const a = owner('a')
+    const a = await owner('a')
     const ready = Promise.withResolvers<undefined>()
     const resources = new SessionResources(ctx, {
       label: 'test', exclusive: false,
@@ -227,8 +228,8 @@ describe('Session browser resource ownership', () => {
 
   it('retains a late initialization failure after its only caller canceled before waiting', async () => {
     const { ctx, owner } = await fixture()
-    const a = owner('a')
-    const b = owner('b')
+    const a = await owner('a')
+    const b = await owner('b')
     const ready = Promise.withResolvers<never>()
     const open = vi.fn().mockImplementationOnce(() => ready.promise).mockResolvedValue({ value: 1, close: async () => {} })
     const resources = new SessionResources<number>(ctx, { label: 'test', exclusive: true, open })
@@ -244,7 +245,7 @@ describe('Session browser resource ownership', () => {
 
   it('reports non-Error cancellation and acquisition failures through cancellable waits', async () => {
     const { ctx, owner } = await fixture()
-    const a = owner('a')
+    const a = await owner('a')
     const entered = Promise.withResolvers<undefined>()
     const ready = Promise.withResolvers<undefined>()
     const open = vi.fn().mockImplementationOnce(async () => {
@@ -262,7 +263,7 @@ describe('Session browser resource ownership', () => {
     ready.resolve(undefined)
     await resources.dispose()
 
-    const b = owner('b')
+    const b = await owner('b')
     const failed = new SessionResources<number>(ctx, { label: 'test', exclusive: false, open: vi.fn().mockRejectedValue('failed to connect') })
     await expect(failed.get(b.agent, new AbortController().signal)).rejects.toThrow('failed to connect')
     await failed.dispose()
@@ -270,7 +271,7 @@ describe('Session browser resource ownership', () => {
 
   it('closes a late acquisition and waits for its shutdown during racing disposals', async () => {
     const { ctx, owner } = await fixture()
-    const a = owner('a')
+    const a = await owner('a')
     const entered = Promise.withResolvers<undefined>()
     const release = Promise.withResolvers<undefined>()
     const closeEntered = Promise.withResolvers<undefined>()
@@ -298,7 +299,7 @@ describe('Session browser resource ownership', () => {
 
   it('interrupts resources before awaiting an operation that needs close to settle', async () => {
     const { ctx, owner } = await fixture()
-    const a = owner('a')
+    const a = await owner('a')
     const running = Promise.withResolvers<undefined>()
     const stopped = Promise.withResolvers<undefined>()
     const resources = new SessionResources(ctx, {
@@ -318,8 +319,8 @@ describe('Session browser resource ownership', () => {
 
   it('retains exclusive ownership when resource shutdown fails', async () => {
     const { ctx, owner } = await fixture()
-    const a = owner('a')
-    const b = owner('b')
+    const a = await owner('a')
+    const b = await owner('b')
     const resources = new SessionResources(ctx, {
       label: 'test', exclusive: true,
       open: async () => ({ value: {}, close: async () => { throw new Error('close failed') } }),
@@ -335,7 +336,7 @@ describe('Session browser resource ownership', () => {
 
 it('reports early disposal cleanup failure while retaining the owned resource', async () => {
   const { ctx, owner } = await fixture()
-  const a = owner('early-close-failure')
+  const a = await owner('early-close-failure')
   const entered = Promise.withResolvers<undefined>()
   const stopped = Promise.withResolvers<undefined>()
   const warning = vi.spyOn(ctx.logger, 'warn')

+ 3 - 0
pnpm-lock.yaml

@@ -5907,6 +5907,9 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-session-persistence-jsonl':
+        specifier: workspace:^
+        version: link:../../session/session-persistence-jsonl
       '@deepseek-ai/dsh-session-projection':
         specifier: workspace:^
         version: link:../../session/session-projection