Browse Source

fix: preserve early process cancellation and sample stream markers promptly

07akioni 2 weeks ago
parent
commit
4c024cf252

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.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-08-28-subprocess-native-containment.md
-2026-08-28-subprocess-native-containment.md: 0c03884bba67dab6e2e38f96ce2e874ed62ba00f
-2026-08-28-subprocess-native-containment.zh.md: b7850e1c4fee06dfeb1a05c968d132de5994686f
+2026-08-28-subprocess-native-containment.md: ed1f127f6d03106339472549101f3c089bc05bbe
+2026-08-28-subprocess-native-containment.zh.md: 7a0c07b140a4559f4abefbaf0f201050b4776161

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md

@@ -22,7 +22,7 @@ The first eligible Linux ordinary or PTY call in one runtime deeply checks the e
 
 The parent creates one 0700 directory with a complete 0600 `launch-request.json` containing the final target cwd and environment. The private `DSH_SUBPROCESS_RUNNER` value locates that request while the runner starts from the provider cwd and a bootstrap-safe environment. `systemd-run --user --scope --quiet --collect --expand-environment=no` registers its process in the scope, then the one-shot bootstrap removes and validates the request, changes to the target cwd, restores the complete target environment, resolves a bare executable with the target PATH rules, clears `FD_CLOEXEC` on fd 0 through fd 2, and calls libc `execve()` with the original argv. The bootstrap becomes the target in place and preserves its inherited stdio; it does not remain as a supervisor.
 
-Request consumption or a manager observation of a loaded unit establishes scope ownership. Unit absence before either fact remains unresolved while the direct launcher is running. If that launcher exits while the request remains unconsumed, the direct result rejects with the startup failure while range observation records that the scope never existed and resolves the empty-range wait. The parent checks this unresolved interval every 50 milliseconds; after establishment, state queries back off exponentially to the existing 5-second systemctl bound. Each query reads both `LoadState` and `ActiveState`: loaded `inactive` or `failed`, or an established unit becoming `not-found`/`inactive` or otherwise collected away, proves the range empty. `active`, `activating`, `reloading`, and `deactivating` remain nonterminal. Unknown or malformed combinations and unreadable manager results reject `waitForExit()` instead of claiming quiescence. `terminate()` wakes a sleeping observer for an immediate recheck, and settlement cancels the losing backoff sleep. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion.
+Request consumption or a manager observation of a loaded unit establishes scope ownership. Unit absence before either fact remains unresolved while the direct launcher is running. If that launcher exits without a signal while the request remains unconsumed, the direct result rejects with the startup failure. A signal exit preserves the observed signal even before request consumption: cancellation and deadlines can stop the bootstrap before the target executes. An explicit bootstrap error still takes precedence over the signal. Range observation independently proves the scope empty; an absent unit plus a stopped launcher resolves the empty-range wait. The parent checks this unresolved interval every 50 milliseconds; after establishment, state queries back off exponentially to the existing 5-second systemctl bound. Each query reads both `LoadState` and `ActiveState`: loaded `inactive` or `failed`, or an established unit becoming `not-found`/`inactive` or otherwise collected away, proves the range empty. `active`, `activating`, `reloading`, and `deactivating` remain nonterminal. Unknown or malformed combinations and unreadable manager results reject `waitForExit()` instead of claiming quiescence. `terminate()` wakes a sleeping observer for an immediate recheck, and settlement cancels the losing backoff sleep. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion.
 
 The ordinary target result still comes from the same child process. The PTY path uses the same request and bootstrap without a resident runner, so the `node-pty` PID, process group, session leader, controlling terminal, foreground `inputWaiting`, `/dev/tty`, readiness, and direct terminal outcome retain their existing meanings while scope membership covers `setsid` and reparented descendants.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md

@@ -22,7 +22,7 @@ detached POSIX 进程组、Windows direct-parent 遍历与 PTY 后代扫描只
 
 parent 创建一个 0700 目录,其中的完整 0600 `launch-request.json` 保存最终 target cwd 与环境。私有 `DSH_SUBPROCESS_RUNNER` 值负责定位该 request,runner 则从 provider cwd 与 bootstrap-safe 环境启动。`systemd-run --user --scope --quiet --collect --expand-environment=no` 先把自身进程注册到 scope,再由 one-shot bootstrap 删除并校验 request、切换到 target cwd、恢复完整 target 环境、按 target PATH 规则解析裸可执行文件、清除 fd 0 至 fd 2 的 `FD_CLOEXEC`,并使用原始 argv 调用 libc `execve()`。bootstrap 会原地成为 target 并保留继承的 stdio,不作为常驻 supervisor。
 
-request 被消费或 manager 已观察到 loaded unit 都能建立 scope ownership。在这两项事实出现前,只要 direct launcher 仍在运行,unit absence 就保持未决。如果 launcher 退出时 request 仍未消费,direct result 会以 startup failure reject,而 range observation 会记录 scope 从未存在,并成功结算 empty-range wait。parent 每 50 毫秒检查一次这段未决区间;建立后,状态查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。每次查询同时读取 `LoadState` 与 `ActiveState`:loaded `inactive` 或 `failed`,以及已经建立的 unit 变为 `not-found`/`inactive` 或被 collect 卸载,都能证明 range 为空。`active`、`activating`、`reloading` 与 `deactivating` 仍是非终态。未知或 malformed 组合以及不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。`terminate()` 会唤醒正在休眠的 observer 立即复查,结算时会取消未胜出的退避 sleep。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。
+request 被消费或 manager 已观察到 loaded unit 都能建立 scope ownership。在这两项事实出现前,只要 direct launcher 仍在运行,unit absence 就保持未决。如果 launcher 非信号退出时 request 仍未消费,direct result 会以 startup failure reject。信号退出即使发生在 request 消费前,也保留观察到的信号:取消与 deadline 可以在 target 执行前停止 bootstrap。明确的 bootstrap error 仍优先于信号。range observation 独立证明 scope 为空;unit 不存在且 launcher 已停止时,empty-range wait 成功结算。parent 每 50 毫秒检查一次这段未决区间;建立后,状态查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。每次查询同时读取 `LoadState` 与 `ActiveState`:loaded `inactive` 或 `failed`,以及已经建立的 unit 变为 `not-found`/`inactive` 或被 collect 卸载,都能证明 range 为空。`active`、`activating`、`reloading` 与 `deactivating` 仍是非终态。未知或 malformed 组合以及不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。`terminate()` 会唤醒正在休眠的 observer 立即复查,结算时会取消未胜出的退避 sleep。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。
 
 普通 target result 仍来自同一个 child process。PTY 路径复用同一 request 与 bootstrap,但不增加常驻 runner,因此 `node-pty` PID、进程组、session leader、控制终端、前台 `inputWaiting`、`/dev/tty`、readiness 与 direct terminal outcome 保留既有含义,同时 scope membership 覆盖 `setsid` 与 reparent 后代。
 

+ 2 - 2
.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.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/testing/2026-09-06-frontend-performance-budgets.md
-2026-09-06-frontend-performance-budgets.md: 4c1a99e10c9b2d7ba7ecbd38f2249ac84e9d330b
-2026-09-06-frontend-performance-budgets.zh.md: f563c55e6440de85d71110cbfb2533c7f99218ce
+2026-09-06-frontend-performance-budgets.md: 4d69dda8a04d4e9807c18c8f1b978ca7fc87a883
+2026-09-06-frontend-performance-budgets.zh.md: c9a174df55a2c175232eb2f4d8468b054e532b9c

+ 1 - 1
.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md

@@ -16,7 +16,7 @@ The existing serial benchmark inventory includes two frontend owners: [active re
 
 The browser input contains 240 closed turns, 40 tool results, and 20 code fences, plus mixed-language prose and reasoning. Historical Assistant records carry matching compact streams built through the production accumulator with 12-character reasoning/text deltas and 8-character tool-argument deltas; empty streams would omit stored and transferred payload costs. Nine older-page actions exhaust this input from its observed 25-turn initial window; the readiness probe follows mounted turn growth rather than duplicating the pagination algorithm. Each sample uses a fresh scaffold and browser. Setup, seeding, browser launch, initial shell load, and sidebar expansion are excluded from open timing. Open ends at transcript availability and an editable composer; page and navigation timings end at their target DOM state. Two animation frames include a rendering opportunity, not hardware presentation or a guarantee that every offscreen node painted.
 
-The continuation sends 120 text deltas at 16 ms replay pacing. The input witness is installed before Enter submission from the focused composer; typing retains focus without a mouse-refocus action or a separate pre-input animation-frame wait. First/final marker lookups stay inside the latest Assistant step and retain visible-state waits. Diagnostics capture reply markers and focus immediately after the first-visible wait, plus browser-clock timestamps and focus at the first actual input event. Replay never waits for input; starvation can still fail overlap. The synchronous input witness reads that same bounded reply. Whole-history text and accessibility queries add observer CPU and garbage collection to the measured interval, so reducing that observer work is benchmark repair, not product optimization. It records Enter-to-first-visible-reply, trusted draft typing whose first actual input event observes the first reply but no completion marker, complete reply wall time through settled persistence and the new rendered turn-tail, and Chromium main-thread task duration. The complete wall budget adds the fixed 1984 ms scripted pacing to a scaled overhead allowance; input and completion have their own enforced budgets. Post-GC browser heap and DOM counts remain diagnostics because one endpoint does not prove a leak.
+The continuation sends 120 text deltas at 16 ms replay pacing. The input witness is installed before Enter submission from the focused composer; typing retains focus without a mouse-refocus action or a separate pre-input animation-frame wait. First/final marker lookups sample visible text inside the latest Assistant step on animation frames, avoiding selector retry backoff. Hidden text and markers in older steps cannot satisfy the observer. The first observation captures reply markers and focus in the browser; diagnostics are retrieved after typing to avoid an additional pre-input round trip. Diagnostics also capture browser-clock timestamps and focus at the first actual input event. Replay never waits for input; starvation can still fail overlap. The synchronous input witness reads that same bounded reply. Whole-history text and accessibility queries add observer CPU and garbage collection to the measured interval, so reducing that observer work is benchmark repair, not product optimization. It records Enter-to-first-visible-reply, trusted draft typing whose first actual input event observes the first reply but no completion marker, complete reply wall time through settled persistence and the new rendered turn-tail, and Chromium main-thread task duration. The complete wall budget adds the fixed 1984 ms scripted pacing to a scaled overhead allowance; input and completion have their own enforced budgets. Post-GC browser heap and DOM counts remain diagnostics because one endpoint does not prove a leak.
 
 Reconnect uses three fresh compiled plain-Node children. Each creates a 100,000-delta reasoning prefix with distinct timestamps and two compact records before timing `ClientAssistantStream.replace()`. GC precedes the baseline and follows replacement while the result remains reachable; replacement time excludes both collections. The report consumes the result after collection and checks that the next dense live frame remains accepted. This measures reconstruction, not transport, rendering, or an entire reconnect workflow.
 

+ 1 - 1
.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md

@@ -16,7 +16,7 @@ Node 对话折叠很快,并不能证明浏览器能绘制长对话或在流式
 
 浏览器输入包含 240 个已关闭轮次、40 个工具结果和 20 个代码块,以及混合语言正文和推理。历史 Assistant 记录携带匹配的紧凑 stream,通过生产 accumulator 按 12 字符推理/文本 delta 和 8 字符工具参数 delta 构建;空 stream 会遗漏存储与传输负载成本。从观察到的初始 25 轮窗口开始,九次更早分页操作读完该输入;就绪探针跟踪已挂载轮次增长,不复制分页算法。每个样本使用全新 scaffold 和浏览器。环境准备、数据播种、浏览器启动、初始 shell 加载及侧栏展开不计入打开时间。打开测量在对话可用且输入框可编辑时结束;分页与导航测量在目标 DOM 状态出现时结束。两次动画帧包含一次渲染机会,不代表硬件显示或保证每个屏幕外节点都已绘制。
 
-续接以 16 ms 重放间隔发送 120 个文本 delta。输入观察器在从已聚焦输入框按 Enter 提交前安装;键入保留焦点,不执行鼠标重新聚焦,也不单独等待输入前动画帧。首段/最终标记查找限制在最新 Assistant step,并保留可见状态等待。诊断在首段可见等待后立即记录回复标记和焦点,并记录首个实际输入事件的浏览器时钟时间戳与焦点。重放从不等待输入;响应阻塞仍可能导致重叠失败。同步输入证据读取同一个受限回复。全历史文本与无障碍查询会向测量区间加入观察器 CPU 和垃圾回收成本,因此减少此类观察工作属于基准修正,而非产品优化。它记录 Enter 提交到首段可见回复的时间、首个实际输入事件观察到首段回复且完成标记尚未出现时的真实草稿键入、直到持久化结算并渲染新 turn-tail 的完整回复壁钟时间,以及 Chromium 主线程任务时间。完整壁钟预算在缩放后的额外开销额度上加固定的 1984 ms 脚本节奏;输入和完成均有独立执行的预算。强制 GC 后的浏览器 heap 和 DOM 数量仍仅供诊断,因为单个终点不能证明泄漏。
+续接以 16 ms 重放间隔发送 120 个文本 delta。输入观察器在从已聚焦输入框按 Enter 提交前安装;键入保留焦点,不执行鼠标重新聚焦,也不单独等待输入前动画帧。首段/最终标记查找在动画帧上采样最新 Assistant step 内的可见文本,避免选择器重试退避。隐藏文本和较早步骤中的标记均不能满足观察条件。首次观察在浏览器内记录回复标记与焦点;诊断在键入后取回,避免增加输入前的通信往返。诊断还记录首个实际输入事件的浏览器时钟时间戳与焦点。重放从不等待输入;响应阻塞仍可能导致重叠失败。同步输入证据读取同一个受限回复。全历史文本与无障碍查询会向测量区间加入观察器 CPU 和垃圾回收成本,因此减少此类观察工作属于基准修正,而非产品优化。它记录 Enter 提交到首段可见回复的时间、首个实际输入事件观察到首段回复且完成标记尚未出现时的真实草稿键入、直到持久化结算并渲染新 turn-tail 的完整回复壁钟时间,以及 Chromium 主线程任务时间。完整壁钟预算在缩放后的额外开销额度上加固定的 1984 ms 脚本节奏;输入和完成均有独立执行的预算。强制 GC 后的浏览器 heap 和 DOM 数量仍仅供诊断,因为单个终点不能证明泄漏。
 
 重连使用三个全新编译后的纯 Node 子进程。各进程在计时 `ClientAssistantStream.replace()` 前创建包含不同时间戳、两条紧凑记录和 100,000 个 delta 的推理前缀。在基线前执行 GC,并在结果仍可达时于替换后再次 GC;替换时间不含两次回收。报告在回收后消费结果,并检查下一个稠密序号的实时 frame 仍被接受。这测量重建,不测量传输、渲染或完整重连工作流。
 

+ 2 - 2
benchmarks/long-session-browser/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 benchmarks/long-session-browser/README.md
-README.md: 009a7a61d9a1a0d4b198ae017def5d3126b713a2
-README.zh.md: df1da8fd76c2f4fa926b6348fa631530fb98181a
+README.md: d6bd253dedb2c6cdea7fec1019a14bfcc3c4c56f
+README.zh.md: fe593b833002ece8efd18d07707c10708cb76e2e

+ 1 - 1
benchmarks/long-session-browser/README.md

@@ -10,7 +10,7 @@ The required Chromium workflow in [long-session.bench.ts](long-session.bench.ts)
 
 ## Measurements
 
-Three fresh browser processes and scaffold worlds produce raw samples and median verdicts. Open and paging end after the expected transcript state and two animation frames; this includes a rendering opportunity, not a hardware presentation timestamp. Paging reports every page and gates the median of each sample’s slowest page. Stream reports first visible reply, trusted draft typing, complete reply wall time, and Chromium main-thread task duration. Enter submits from the focused composer; draft typing retains that focus without a mouse click. Reply-marker lookups and the input-event text witness read only the latest Assistant step, avoiding repeated whole-history text and accessibility scans. The input witness is installed before submission, and draft typing starts as soon as the first marker is visible, without an extra pre-input animation-frame wait. Diagnostics report marker state and focus after the first-visible wait, and browser-clock timestamps and focus at the first input event. They do not pause replay; a delayed first observation or input can still fail overlap. The actual first input event must observe an unfinished reply; completion waits for the new rendered turn-tail after Host settlement. After measurement, a trusted keystroke after DONE must fail the same overlap assertion. Open, the slowest older page, and first Trajectory use standard-hosted expectations of 900/700/500 ms. Shared 1.25× headroom gives limits of 1125/875/625 ms respectively; stream endpoint overhead budgets are unchanged. Heap after forced GC and DOM counts are diagnostics, not leak budgets.
+Three fresh browser processes and scaffold worlds produce raw samples and median verdicts. Open and paging end after the expected transcript state and two animation frames; this includes a rendering opportunity, not a hardware presentation timestamp. Paging reports every page and gates the median of each sample’s slowest page. Stream reports first visible reply, trusted draft typing, complete reply wall time, and Chromium main-thread task duration. Enter submits from the focused composer; draft typing retains that focus without a mouse click. Reply-marker lookups and the input-event text witness read only the latest Assistant step, avoiding repeated whole-history text and accessibility scans. The input witness is installed before submission, and draft typing starts as soon as the first marker is visible, without an extra pre-input animation-frame wait. Reply markers are sampled on animation frames, with visible text required inside the latest step. The first observation captures marker state and focus in the browser; its diagnostics are retrieved after typing so they add no pre-input round trip. Diagnostics also include browser-clock timestamps and focus at the first input event. They do not pause replay; a delayed first observation or input can still fail overlap. The actual first input event must observe an unfinished reply; completion waits for the new rendered turn-tail after Host settlement. After measurement, a trusted keystroke after DONE must fail the same overlap assertion. Open, the slowest older page, and first Trajectory use standard-hosted expectations of 900/700/500 ms. Shared 1.25× headroom gives limits of 1125/875/625 ms respectively; stream endpoint overhead budgets are unchanged. Heap after forced GC and DOM counts are diagnostics, not leak budgets.
 
 The fixture reserves an empty system head before the first user message, with each user message inside its step. It contains mixed-language prompts, prose, reasoning, 20 code fences, and 40 synthetic tool results. Every historical Assistant includes a compact stream built by the production accumulator from matching reasoning, text, tool arguments, usage, and finish chunks. No model, tool, external network, recorded Session, or private Harness home supplies its content. Streaming uses 120 text deltas at 16 ms replay pacing through the real composer, agent loop, transport, and persistence.
 

+ 1 - 1
benchmarks/long-session-browser/README.zh.md

@@ -10,7 +10,7 @@
 
 ## 测量
 
-三个全新浏览器进程与 scaffold 环境产生原始样本及中位数判定。打开和分页在预期 transcript(文本记录)状态出现且经过两次动画帧后结束;这包含一次渲染机会,而非硬件显示时间戳。分页报告每一页,并对各样本最慢分页时间的中位数执行预算检查。流式报告首次可见回复、受信任的草稿键入、完整回复壁钟时间和 Chromium 主线程任务时间。Enter 从已聚焦的输入框提交;草稿键入保留该焦点,不执行鼠标点击。回复标记查找与输入事件文本观察器仅读取最新 Assistant 步骤,避免重复进行全量历史文本扫描和无障碍扫描。输入事件文本观察器在提交前安装,首个标记可见后立即开始草稿键入,不额外等待输入前动画帧。诊断报告等待首次可见回复后的标记状态与焦点,以及首个输入事件的浏览器时钟时间戳与焦点。诊断不会暂停回放;首次观察或输入延迟仍可能导致重叠失败。实际首个输入事件必须观察到未完成的回复;完整回复的测量会在 Host 结算后等待新 turn-tail 渲染完成。测量后,在 DONE 之后发送的受信任按键必须无法通过同一个重叠断言。打开、最慢的较早页面和首次 Trajectory 使用标准托管预期 900/700/500 ms。共享的 1.25× 余量分别产生 1125/875/625 ms 上限;流式终点的额外开销预算不变。强制 GC 后的 heap 与 DOM 数量仅供诊断,不作为泄漏预算。
+三个全新浏览器进程与 scaffold 环境产生原始样本及中位数判定。打开和分页在预期 transcript(文本记录)状态出现且经过两次动画帧后结束;这包含一次渲染机会,而非硬件显示时间戳。分页报告每一页,并对各样本最慢分页时间的中位数执行预算检查。流式报告首次可见回复、受信任的草稿键入、完整回复壁钟时间和 Chromium 主线程任务时间。Enter 从已聚焦的输入框提交;草稿键入保留该焦点,不执行鼠标点击。回复标记查找与输入事件文本观察器仅读取最新 Assistant 步骤,避免重复进行全量历史文本扫描和无障碍扫描。输入事件文本观察器在提交前安装,首个标记可见后立即开始草稿键入,不额外等待输入前动画帧。回复标记在动画帧上采样,要求文本在最新步骤内可见。首次观察在浏览器内记录标记状态与焦点;诊断在键入后取回,不增加输入前的通信往返。诊断还包含首个输入事件的浏览器时钟时间戳与焦点。诊断不会暂停回放;首次观察或输入延迟仍可能导致重叠失败。实际首个输入事件必须观察到未完成的回复;完整回复的测量会在 Host 结算后等待新 turn-tail 渲染完成。测量后,在 DONE 之后发送的受信任按键必须无法通过同一个重叠断言。打开、最慢的较早页面和首次 Trajectory 使用标准托管预期 900/700/500 ms。共享的 1.25× 余量分别产生 1125/875/625 ms 上限;流式终点的额外开销预算不变。强制 GC 后的 heap 与 DOM 数量仅供诊断,不作为泄漏预算。
 
 fixture(测试前置数据)在首条用户消息前保留空 system 头节点,每条用户消息都位于其步骤内。它包含混合语言提示词、正文、推理(reasoning)、20 个围栏代码块和 40 个合成工具结果。每条历史 Assistant 都含紧凑流,由生产 accumulator 从匹配的推理、文本、工具参数、usage 和 finish 分片构建。其内容不来自模型、工具、外部网络、录制会话或私有 Harness 主目录。流式回复以 16 ms 回放间隔发送 120 个文本 delta,经过真实输入框、agent loop(智能体循环)、传输与持久化。
 

+ 36 - 7
benchmarks/long-session-browser/long-session.bench.ts

@@ -52,6 +52,22 @@ function expectInputOverlap(value: boolean): void {
   expect(value).toBe(true)
 }
 
+async function waitForReplyMarker(page: Page, marker: string, timeout = 30000) {
+  return page.waitForFunction(({ marker, first, done }) => {
+    const reply = Array.from(document.querySelectorAll('[data-chat-flow-kind="assistant-step"]')).at(-1)
+    if (!reply) return false
+    const text = document.createTreeWalker(reply, NodeFilter.SHOW_TEXT)
+    let node: Node | null
+    while ((node = text.nextNode())) {
+      if (!node.textContent?.includes(marker) || !node.parentElement?.checkVisibility({ checkVisibilityCSS: true })) continue
+      const composer = Array.from(document.querySelectorAll('[data-composer-input][contenteditable="true"]')).at(-1)
+      const transcript = reply.textContent ?? ''
+      return { atMs: window.performance.now(), focused: document.activeElement === composer, first: transcript.includes(first), done: transcript.includes(done) }
+    }
+    return false
+  }, { marker, first: FIRST, done: DONE }, { polling: 'raf', timeout })
+}
+
 async function watchInputOverlap(composer: Locator): Promise<void> {
   await composer.evaluate((element, markers) => {
     element.removeAttribute('data-benchmark-input-witness')
@@ -95,6 +111,22 @@ it('accepts recorded hosted paging and Trajectory medians and rejects slower end
   }
 })
 
+it('waits for visible marker text in the latest Assistant step', async () => {
+  const browser = await chromium.launch({ headless: true })
+  try {
+    const page = await browser.newPage()
+    await page.setContent(`<div data-chat-flow-kind="assistant-step">${FIRST}</div><div data-chat-flow-kind="assistant-step"><span style="visibility:hidden">${FIRST}</span></div>`)
+    await expect(waitForReplyMarker(page, FIRST, 100)).rejects.toThrow('Timeout')
+    await page.locator('span').evaluate(element => { element.style.visibility = 'visible' })
+    const observation = await waitForReplyMarker(page, FIRST)
+    expect(await observation.jsonValue()).toMatchObject({ first: true, done: false })
+    await observation.dispose()
+    await expect(waitForReplyMarker(page, DONE, 100)).rejects.toThrow('Timeout')
+  } finally {
+    await browser.close()
+  }
+})
+
 it('opens, pages, navigates and streams into a 240-turn browser history', async () => {
   if (webSnapshotMode() !== 'replay') throw new Error('browser benchmarks require keyless replay mode')
   const samples: { open: number; page: number; trajectory: number; first: number; streamTask: number; streamWall: number; input: number; inputOverlapped: boolean; heapMb: number; nodes: number }[] = []
@@ -157,22 +189,19 @@ it('opens, pages, navigates and streams into a 240-turn browser history', async
           await watchInputOverlap(composer)
           const started = performance.now()
           await page.keyboard.press('Enter')
-          const reply = page.locator('[data-chat-flow-kind="assistant-step"]').last()
-          await reply.getByText(FIRST, { exact: false }).last().waitFor()
+          const firstMarker = await waitForReplyMarker(page, FIRST)
           const first = performance.now() - started
-          const firstObservation = await composer.evaluate((element, markers) => {
-            const transcript = Array.from(document.querySelectorAll('[data-chat-flow-kind="assistant-step"]')).at(-1)?.textContent ?? ''
-            return { atMs: window.performance.now(), focused: document.activeElement === element, first: transcript.includes(markers.first), done: transcript.includes(markers.done) }
-          }, { first: FIRST, done: DONE })
           // Keep focus across submission; mouse actionability must not delay the input probe.
           const input = await measure(page, async () => {
             await page.keyboard.type('next synthetic question')
             await expect.poll(() => composer.textContent()).toBe('next synthetic question')
           })
           const inputOverlapped = await composer.getAttribute('data-benchmark-input-overlap') === 'true'
+          const firstObservation = await firstMarker.jsonValue()
+          await firstMarker.dispose()
           console.log(JSON.stringify({ benchmark: 'long-session-browser/input', sample, first, input, firstObservation, witness: await composer.getAttribute('data-benchmark-input-witness'), inputTiming: await composer.getAttribute('data-benchmark-input-timing') }))
           expectInputOverlap(inputOverlapped)
-          await reply.getByText(DONE, { exact: false }).last().waitFor()
+          await (await waitForReplyMarker(page, DONE)).dispose()
           const settlement = await settled
           if (!settlement.ok) throw settlement.error
           await page.waitForFunction(({ selector, expected }) => document.querySelectorAll(selector).length === expected, { selector: TAIL, expected: HISTORY_TURNS + 1 })

+ 10 - 2
packages/shell/bash-sandbox/tests/sandbox.spec.ts

@@ -661,8 +661,16 @@ describe('background sandbox facts', () => {
 
   it('disposal kills wrapped background jobs (inherited HMR safety)', async () => {
     const { ctx, bash } = await setup()
-    const task = bash.start(bash.resolve({ command: 'sleep 30' }))
-    await ctx.fiber.dispose()
+    const task = bash.start(bash.resolve({ command: 'echo ready; sleep 30' }))
+    let output = ''
+    try {
+      await expect.poll(() => {
+        output += task.readOutput().delta
+        return output
+      }).toContain('ready')
+    } finally {
+      await ctx.fiber.dispose()
+    }
     expect(task.status).toBe('killed')
   })
 })

+ 2 - 2
packages/subprocess/subprocess-local/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/subprocess/subprocess-local/README.md
-README.md: 63d4d5d403d3559778dcad76a8c96cf677f1a977
-README.zh.md: b876ca9d3a33b37557229390b7f6039ba4498b96
+README.md: 9cd8544e2d5970ec8ca9828f886099279db0ecfd
+README.zh.md: 86897e47dcb65dd59ec3143a6f5cb7a8fa30dd1a

+ 2 - 0
packages/subprocess/subprocess-local/README.md

@@ -92,6 +92,8 @@ Each spawn selects one owner for both signalling and quiescence. Supported Linux
 
 A spawn synchronously validates the final argv, cwd, and environment, selects containment before the user command can run, and returns a handle while target identity remains private. Linux ordinary and terminal launches use a private one-shot request whose scoped bootstrap restores the target cwd and environment, resolves the executable, clears close-on-exec on fd 0 through fd 2, and enters libc `execve()` with the original argv. Windows ordinary launches isolate runner fd 0 through fd 2, reserve fd 3 for IPC, and carry target stdio on fd 4 through fd 6; the runner resolves those CRT descriptors to OS handles, creates the target suspended, assigns it to the Job, resumes it, and closes only the carrier descriptors. `done` settles the direct command after its stdio barrier, while `waitForExit()` separately waits for the selected scope, Job, process group, or observed session to become empty.
 
+A Linux launcher terminated by a signal reports that signal even before its bootstrap consumes the target request; an explicit bootstrap error still rejects `done`. This preserves cancellation and timeout results without treating an unsignaled bootstrap failure as success.
+
 ### Safety invariants
 
 Spill files are opened `0600` with `O_EXCL` and random names under a `0700` per-process directory, defeating symlink planting in shared temp dirs; a failed final close withholds the spill path. Fallback process identities carry start times, so cleanup never follows PID reuse. A selected native failure is reported instead of replaying argv through fallback, and a range is removed from the live set only after cleanup completes or the failure remains observable. Host-exit finalization creates no promises or timers, preserves the host exit code and diagnostic, contains each target's failure, and does not claim quiescence.

+ 2 - 0
packages/subprocess/subprocess-local/README.zh.md

@@ -92,6 +92,8 @@ kind: "package-reference"
 
 一次 spawn 会同步校验最终 argv、cwd 与环境,在用户命令可能运行前选择 containment,并在目标身份保持私有的情况下返回句柄。Linux 普通命令与终端启动使用私有的一次性请求;scope 内的 bootstrap 会恢复目标 cwd 与环境、解析可执行文件、清除 fd 0 至 fd 2 的 close-on-exec 标记,再以原始 argv 进入 libc `execve()`。Windows 普通命令会隔离 runner 的 fd 0 至 fd 2、把 fd 3 留给 IPC,并用 fd 4 至 fd 6 承载 target stdio;runner 把这些 CRT 描述符解析成 OS handle,以 suspended 状态创建 target,将其加入 Job、恢复运行,再只关闭 carrier 描述符。`done` 会在 direct command 及其 stdio 屏障结算后完成,`waitForExit()` 则分别等待所选 scope、Job、进程组或已观察会话变空。
 
+Linux launcher 被信号终止时,即使 bootstrap 尚未消费 target request,也会报告该信号;明确的 bootstrap error 仍会让 `done` reject。这会保留取消与超时结果,同时不会把非信号退出的 bootstrap failure 当作成功。
+
 ### 安全不变式
 
 spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目录下创建,可抵御共享临时目录中的符号链接植入;最终关闭失败时不公布 spill 路径。fallback 进程身份携带启动时间,因此清理绝不会跟随 PID 复用。选定的 native 路径失败时会报告错误,而不会通过 fallback 重放 argv;受管范围只有在清理完成后才从存活集合移除,否则失败仍保持可观察。宿主退出最终清理不创建 Promise 或定时器,保留宿主退出码与诊断,分别包含每个目标的失败,也不会声称已经完全停稳。

+ 2 - 2
packages/subprocess/subprocess-local/src/linux-scope.ts

@@ -376,7 +376,7 @@ function directOutcome(
           rejectOutcome(deserializeRunnerError(startup.error))
           return
         }
-        if (existsSync(files.requestPath)) {
+        if (existsSync(files.requestPath) && signal === null) {
           rejectOutcome(new Error('subprocess scope exited before its bootstrap consumed the launch request'))
           return
         }
@@ -441,7 +441,7 @@ export function prepareLinuxTerminalScope(
     resolveOutcome: (outcome) => {
       const startup = readLinuxStartupError(files.startupErrorPath)
       if (startup !== undefined) throw deserializeRunnerError(startup.error)
-      if (existsSync(files.requestPath)) {
+      if (existsSync(files.requestPath) && outcome.signal === null) {
         throw new Error('terminal scope exited before its bootstrap consumed the launch request')
       }
       return outcome

+ 19 - 1
packages/subprocess/subprocess-local/tests/linux-scope.spec.ts

@@ -205,7 +205,7 @@ describe('Linux scope establishment and quiescence', () => {
     expect(spawnSync).toHaveBeenCalledWith('/bin/systemctl', expect.arrayContaining([
       'kill', '--kill-whom=all', '--signal=SIGTERM',
     ]), expect.anything())
-    const direct = expect(result.direct).rejects.toThrow('before its bootstrap consumed')
+    const direct = expect(result.direct).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
     child.exit(null, 'SIGTERM')
     await direct
     await expect(waiting).resolves.toBeUndefined()
@@ -248,6 +248,15 @@ describe('Linux scope establishment and quiescence', () => {
     result.owner.cleanup?.()
   })
 
+  it.each(['SIGTERM', 'SIGKILL'] as const)('reports %s before bootstrap consumption as a signal outcome', async (signal) => {
+    const { child, result, requestPath } = launch(async () => missingUnit())
+    expect(existsSync(requestPath)).toBe(true)
+    child.exit(null, signal)
+    await expect(result.direct).resolves.toEqual({ exitCode: null, signal })
+    await expect(result.owner.waitForExit()).resolves.toBeUndefined()
+    result.owner.cleanup?.()
+  })
+
   it('uses manager-observed unit existence as establishment proof', async () => {
     const { child, result } = launch(async () => activeUnit('inactive'))
     await expect(result.owner.waitForExit()).resolves.toBeUndefined()
@@ -572,6 +581,15 @@ describe('Linux PTY bootstrap reuse', () => {
     scope.cleanup()
   })
 
+  it.each(['SIGTERM', 'SIGKILL'] as const)('preserves PTY %s before bootstrap consumption', (signal) => {
+    const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' })
+    try {
+      expect(scope.resolveOutcome({ exitCode: null, signal })).toEqual({ exitCode: null, signal })
+    } finally {
+      scope.cleanup()
+    }
+  })
+
   it('uses default owner dependencies and rejects an unconsumed request', () => {
     const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' })
     const requestPath = scope.env[SUBPROCESS_RUNNER_ENV]

+ 3 - 1
packages/subprocess/subprocess-local/tests/local.spec.ts

@@ -393,6 +393,7 @@ describe('LocalSubprocessRuntime', () => {
       const ctx = new Context()
       const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
       const service = ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
+      service.internals = { platform: 'darwin' }
       const handle = await ctx.subprocess.spawnTerminal({
         argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1,
       })
@@ -600,6 +601,7 @@ describe('LocalSubprocessRuntime', () => {
       ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
       const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
       const alive = new Set([124])
+      ;(ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>).internals = { platform: 'darwin' }
       ;(ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>).terminalInspector = {
         foregroundPgid: () => 123,
         isStdinWaiting: () => false,
@@ -617,7 +619,7 @@ describe('LocalSubprocessRuntime', () => {
       })
       exitListener?.({ exitCode: 0 })
       await handle.done
-      await new Promise(resolve => setTimeout(resolve, 10))
+      await expect(handle.terminate()).rejects.toThrow('surviving pids: 124')
       expect((ctx.subprocess as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.size).toBe(1)
       await fiber.dispose()
       expect(disposalErrors).toHaveLength(1)