Skip to content

feat(web-shell): show shell and monitor task output - #10906

Open
BZ-D wants to merge 34 commits into
mainfrom
feat/web-shell-task-output
Open

BZ-D wants to merge 34 commits into
mainfrom
feat/web-shell-task-output

Conversation

@BZ-D

@BZ-D BZ-D commented Sep 3, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

This PR makes captured Shell and Monitor output directly readable in the Web Shell task detail panel. Monitor stdout and stderr are now persisted alongside the existing Shell capture, and the daemon exposes a live-session-owner-scoped endpoint that returns a sanitized tail of at most 64 KiB.

The task detail shows live and terminal output, explicit empty/unavailable/truncated states, and a copy action. Web Shell and TUI show capture failures without hiding output already captured, and clear the warning after capture recovers. A shared streaming filter removes terminal control sequences without deleting adjacent log text. When an overflowing output box is already at the bottom, new output keeps it pinned there; once the user scrolls upward, refreshes preserve that position. Older daemons retain the existing metadata-only view through capability detection.

Why it's needed

Web Shell users could previously see task metadata and an output-file path, but not the output itself. Monitor notifications are not rendered as running output in the main Web Shell conversation, and a daemon-local file path is often not useful to a remote browser. This gives users a direct, bounded, session-scoped way to inspect long-running process output without depending on notification rendering or arbitrary filesystem access.

Reviewer Test Plan

How to verify

  1. Start a background Shell that emits distinct stdout and stderr lines over time, open it from Environment → Background tasks, and confirm the output appears while running and remains after completion.
  2. Start a Monitor that emits enough lines for notification throttling, then confirm the task detail retains every emitted stdout/stderr line even when the dropped-notification counter increases.
  3. Let the output box overflow, leave it at the bottom, and confirm the next refresh follows the new output. Scroll to the top and confirm a later refresh does not move the scroll position.
  4. Use the copy button beside Output and confirm the clipboard exactly matches the currently displayed output and the button briefly shows a success check.
  5. Simulate a capture write failure and confirm Web Shell and TUI display a warning while preserving captured output; allow writes to recover and confirm the warning disappears. Cancel a Monitor during a final capture write and confirm its terminal output includes the last line.
  6. Connect a Web Shell client to a daemon that does not advertise task-output support and confirm Shell and Monitor details remain metadata-only.

Evidence (Before & After)

Before After
Shell detail showed metadata and an output-file path; Monitor detail showed metadata and event counters. Both details show the captured output directly, including stdout and stderr, while retaining their existing metadata and controls.
Running Monitor lines were absent from the main conversation DOM, so the browser had no direct output surface. Output is loaded independently through the owning live session and remains available after the task reaches a terminal state.
There was no live-log scroll behavior or output copy action. In a real browser run, an overflowing box followed from scrollTop=1156 to 1192; after manually scrolling up it stayed at scrollTop=0, and the copied 2567-character value exactly matched the visible snapshot.

Additional real-session evidence: completed Shell and Monitor tasks retained stdout/stderr lines 01–45; a Monitor with eventCount=49 and droppedLines=41 still displayed all 90 captured output lines.

Tested on

OS Status
🍏 macOS ✅
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

macOS with the production Web Shell build, an isolated local daemon, system Chrome in headless mode, and real Shell/Monitor tool invocations. npm run build, npm run typecheck, npm run lint, targeted Core/ACP/CLI/SDK/Web Shell tests, and the focused Web Shell task-detail suite all passed.

Risk & Scope

  • Main risk or tradeoff: An open process-task detail performs a bounded output-tail read on task snapshot refreshes. Restored tabs from another live session poll on the same cadence while running. Responses are capped at 64 KiB; terminal Monitor reads wait for capture to finish so the final output is included.
  • Not validated / out of scope: Manual UI runs on Windows and Linux; full-log archival, search, download, pagination, and Agent/Workflow output; 64 KiB truncation and cross-session/symlink cases were covered by automated tests rather than browser E2E.
  • Breaking changes / migration notes: None. The feature is capability-gated, so older daemons keep the metadata-only UI.

Linked Issues

N/A

中文说明

本 PR 做了什么

本 PR 让 Web Shell 任务详情面板可以直接查看捕获的 Shell 和 Monitor 输出。Monitor 的 stdout 和 stderr 现在会像现有 Shell 输出一样持久化,daemon 新增一个严格绑定到实时会话 owner 的接口,最多返回经过清理的最近 64 KiB 输出。

任务详情会展示运行中和终态输出,并提供空输出、不可用、已截断等明确状态以及复制操作。Web Shell 和 TUI 会显示捕获失败提示,同时保留已捕获输出,并在捕获恢复后清除提示。共享的流式过滤器会移除终端控制序列,同时保留相邻日志文本。当输出框已经溢出且位于底部时,新输出会继续跟随到底部;用户向上滚动后,后续刷新会保持其滚动位置。通过 capability 检测,旧 daemon 继续使用原有的仅元数据界面。

为什么需要

此前 Web Shell 用户只能看到任务元数据和输出文件路径,无法直接看到输出。Monitor 通知不会作为运行中输出呈现在 Web Shell 主对话里,而 daemon 本地文件路径对远程浏览器通常也没有用。该改动提供了一种直接、有大小上限且受会话约束的方式来查看长时间运行的进程输出,不依赖通知渲染,也不会开放任意文件系统读取能力。

评审测试计划

如何验证

  1. 启动一个持续输出独特 stdout 和 stderr 行的后台 Shell,从“环境信息 → 后台任务”打开详情,确认运行中能看到输出,并且完成后输出仍然保留。
  2. 启动一个输出足够多行、会触发通知限流的 Monitor,确认即使已丢弃通知计数增加,任务详情仍保留所有 stdout/stderr 行。
  3. 让输出框产生溢出并停留在底部,确认下一次刷新会跟随新输出;随后滚动到顶部,确认再一次刷新不会改变滚动位置。
  4. 点击“输出”旁的复制按钮,确认剪贴板内容与当前显示的输出完全一致,并且按钮会短暂显示成功勾。
  5. 模拟捕获写入失败,确认 Web Shell 和 TUI 显示警告且保留已捕获输出;恢复写入后确认警告消失。在 Monitor 最后一次捕获写入期间取消任务,确认终态输出包含末尾行。
  6. 将 Web Shell 客户端连接到未声明任务输出能力的 daemon,确认 Shell 和 Monitor 详情仍保持仅元数据界面。

证据(改动前后)

改动前 改动后
Shell 详情只显示元数据和输出文件路径;Monitor 详情只显示元数据和事件计数。 两类详情都直接显示捕获的 stdout 和 stderr,同时保留原有元数据和控制操作。
运行中的 Monitor 行不会出现在主对话 DOM 中,因此浏览器没有直接查看输出的界面。 输出通过所属实时会话独立加载,并在任务进入终态后继续保留。
没有实时日志滚动行为,也没有输出复制操作。 真实浏览器验证中,溢出输出框从 scrollTop=1156 跟随到 1192;手动向上滚动后保持在 scrollTop=0;复制出的 2567 个字符与可见快照逐字一致。

其他真实会话证据:已完成的 Shell 和 Monitor 均保留 stdout/stderr 第 01–45 行;一个 eventCount=49、droppedLines=41 的 Monitor 仍显示了全部 90 行捕获输出。

测试系统

系统 状态
🍏 macOS ✅
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

macOS,使用生产 Web Shell 构建、隔离的本地 daemon、系统 Chrome 无头模式以及真实 Shell/Monitor 工具调用。npm run build、npm run typecheck、npm run lint、Core/ACP/CLI/SDK/Web Shell 定向测试和 Web Shell 任务详情专项测试均通过。

风险与范围

  • 主要风险或取舍:打开进程任务详情时,任务快照刷新会触发有界的输出尾部读取。来自其他实时会话的恢复标签页在运行期间以相同间隔轮询。响应最大为 64 KiB;终态 Monitor 读取会等待捕获结束,确保包含末尾输出。
  • 未验证 / 不在范围内:Windows 和 Linux 手动 UI 验证;完整日志归档、搜索、下载、分页以及 Agent/Workflow 输出;64 KiB 截断和跨会话/symlink 场景由自动化测试覆盖,而非浏览器 E2E。
  • 破坏性变更 / 迁移说明:无。该功能受 capability 控制,因此旧 daemon 继续使用仅元数据 UI。

关联 Issue

无

@BZ-D

BZ-D commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

E2E Test Report

Environment

  • macOS, production Web Shell build, isolated local daemon, system Chrome headless, real model/tool execution.
  • Disposable sessions, task processes, daemon ports, and output directories were removed after each run.

Process output visibility

  • Started live background Shell and Monitor tasks with distinct stdout/stderr markers.
  • While running, Shell output grew from 139 to 279 characters across two real reads, and Monitor output grew from 279 to 419 characters; every request returned HTTP 200 and the DOM contained the latest marker.
  • After natural completion, reopening both details returned HTTP 200 with truncated=false; stdout/stderr markers 01–45 remained visible from first to last line.
  • A Monitor finished with eventCount=49 and droppedLines=41, while its output detail still contained all 90 stdout/stderr lines. Notification throttling therefore did not drop captured UI output.
  • Running output markers were absent from the main conversation DOM before the task panel opened; the output detail did not depend on running Monitor notifications being rendered there.

Auto-scroll and copy

  • With an overflowing output box at the bottom, a live refresh increased scrollHeight from 1514 to 1550 and moved scrollTop from 1156 to 1192, leaving the box at the bottom.
  • After manually setting scrollTop=0, another live refresh increased scrollHeight to 1604 while scrollTop remained 0, confirming that refreshes preserve a user's upward scroll position.
  • Clicking the copy action produced a 2567-character clipboard value that exactly matched the displayed output snapshot, and the button showed the success check icon.

Automated verification

  • npm run build
  • npm run typecheck
  • npm run lint
  • Targeted Core, ACP bridge, CLI serve/ACP, TypeScript SDK, and Web Shell tests.
  • Focused task-detail suite: 20/20 tests passed, including overflow-at-bottom, scrolled-away, non-overflowing, copy, truncation, empty, unavailable, refresh, and old-daemon capability fallback cases.

Not manually covered

  • Windows and Linux UI runs.
  • Browser E2E for the 64 KiB truncation boundary, symlink refusal, cross-session ownership rejection, and missing-file state; these paths have automated coverage.

丁炳智 added 5 commits September 3, 2026 20:03
…utput

# Conflicts:
#	packages/cli/src/acp-integration/acpAgent.ts
#	packages/cli/src/serve/routes/session.ts
#	packages/cli/src/serve/server.test.ts
#	packages/cli/src/serve/server/telemetry-catalog.test.ts
#	packages/cli/src/serve/server/telemetry.test.ts
#	packages/sdk-typescript/test/unit/DaemonClient.test.ts
#	packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx
qqqys
qqqys previously approved these changes Sep 4, 2026

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical-only review at head b03ea9d.

Previously blocking issue — fixed. R1-10 reported that _qwen/session/tasks/output was dispatchable but absent from ALL_QWEN_VENDOR_METHODS, so initialize never advertised it and feature-detecting ACP clients would skip the method. Commit 5e8d3bd adds the entry (dispatch.ts:286) alongside the three sibling session/tasks* methods and pins it with a transport assertion.

No new Critical found. Checked the blocking-risk surfaces:

  • Route scope. GET /session/:id/tasks/:taskId/output goes through withOwnerReadSession; the WS case uses requireOwned (read path, matching sibling session/tasks) and is registered in WS_READ_METHODS. No primary-runtime or cross-session fallback.
  • File access. Output is resolved by taskId inside the owning session's own backgroundShellRegistry / monitorRegistry — no client-supplied path reaches the filesystem. The read reuses readTaskOutputTail with its O_NOFOLLOW + fstat identity guard, control/bidi sanitization, and the 64 KiB clamp; fs errors map to a generic Task output is unavailable. rather than leaking the path or errno.
  • Reader refactor. The new maxBytes parameter defaults to MAX_NOTIFICATION_OUTPUT_TAIL_BYTES, and the length is clamped by Math.min(stat.size, maxBytes, MAX_TASK_OUTPUT_TAIL_BYTES), so the pre-existing notification caller keeps its 8 KiB behavior unchanged.
  • Monitor writer lifecycle. The write stream is created before spawn and torn down on every exit path — spawn failure (both catch blocks), abort handler, and normal cleanup — with an error listener attached, so a failed or late write degrades to a warning instead of an unhandled crash.
  • UI. ProcessTaskOutput calls all hooks before its if (!supported) return null capability gate, so hook order stays stable across daemons with and without session_task_output; the remount key and effect deps prevent stale cross-task output.

Remaining review-round items are Suggestions (untested in-band error path, uncapped monitor output growth, read-permission roots for the advertised monitor path, first-mount scroll anchoring, terminal-task retry) and are not merge blockers.

CI at this head is still settling after the merge from main; no failure is attributable to this PR. The earlier red integration run came from the missing session_task_output capabilities baseline, which eaaa16d added.

@BZ-D

BZ-D commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@BZ-D
BZ-D dismissed a stale review September 4, 2026 06:38

outdated

qqqys
qqqys previously approved these changes Sep 4, 2026

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at head acd56a4d; my earlier approval was dismissed by this push, so this covers the new commit as well as the unchanged production diff.

Previously blocking issue — still fixed. The one Critical from the prior round (R1-10: _qwen/session/tasks/output was dispatchable but absent from ALL_QWEN_VENDOR_METHODS, so initialize never advertised it and feature-detecting ACP clients would skip the method) remains resolved on this head — the entry is present at dispatch.ts:286 alongside the three sibling session/tasks* methods, with the transport assertion from 5e8d3bd.

The new commit is docs-only and accurate. acd56a4d changes one line in docs/developers/daemon/00-index.md, moving the registered-capability count from 151 to 152 to account for the session_task_output tag this PR adds. Verified against the code: SERVE_CAPABILITY_REGISTRY on this head has exactly 152 top-level tag entries with no duplicates and includes session_task_output. No production file changed, so the diff reviewed below is byte-identical to the previously approved revision.

No Critical found in the production diff. Checked the blocking-risk surfaces:

  • Route scope. GET /session/:id/tasks/:taskId/output goes through withOwnerReadSession; the WS case uses requireOwned (read path, matching sibling session/tasks) and is registered in WS_READ_METHODS. No primary-runtime or cross-session fallback.
  • File access. Output is resolved by taskId inside the owning session's own backgroundShellRegistry / monitorRegistry — no client-supplied path reaches the filesystem. The read reuses readTaskOutputTail with its O_NOFOLLOW + fstat identity guard, control/bidi sanitization, and the 64 KiB clamp; fs errors map to a generic Task output is unavailable. rather than leaking the path or errno.
  • Reader refactor. The new maxBytes parameter defaults to MAX_NOTIFICATION_OUTPUT_TAIL_BYTES, and the length is clamped by Math.min(stat.size, maxBytes, MAX_TASK_OUTPUT_TAIL_BYTES), so the pre-existing notification caller keeps its 8 KiB behavior unchanged.
  • Monitor writer lifecycle. The write stream is created before spawn and torn down on every exit path — spawn failure (both catch blocks), abort handler, and normal cleanup — with an error listener attached, so a failed or late write degrades to a warning instead of an unhandled crash.
  • UI. ProcessTaskOutput calls all hooks before its if (!supported) return null capability gate, so hook order stays stable across daemons with and without session_task_output; the remount key and effect deps prevent stale cross-task output.

Remaining review-round items are all Suggestions (untested in-band error path, uncapped monitor output growth, read-permission roots for the advertised monitor path, first-mount scroll anchoring, terminal-task retry) and are not merge blockers.

CI on this head is 23 pass with no failures; the remaining pending checks are not attributable to this PR and are not treated as blockers.

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at acd56a4 across correctness and security.

Most of the surface checks out: the output routes gate on session ownership at both layers (task ids are internally generated, no cross-session path), output rendering is plain React text with no dangerouslySetInnerHTML/anchor synthesis, the read path is capped at 64 KiB, and the web-shell panel state handles task completion/panel-close transitions without stale updates.

One issue worth addressing before merge — the new monitor capture file has no size cap, details inline.

Comment thread packages/core/src/tools/monitor.ts Outdated
qqqys
qqqys previously approved these changes Sep 4, 2026

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at head 941d16b; my earlier approval was dismissed by this push, so this covers the new commit plus the unchanged remainder of the diff.

Previously blocking issue — still fixed. The one Critical from the prior round (R1-10: _qwen/session/tasks/output dispatchable but absent from ALL_QWEN_VENDOR_METHODS, so initialize never advertised it) remains resolved. This push does not touch dispatch.ts; the entry is still present at line 286 with the transport assertion from 5e8d3bd.

New commit 941d16b (bound monitor output capture) — no Critical found. It replaces the unbounded createWriteStream sink with an in-memory rolling tail capped at MAX_TASK_OUTPUT_TAIL_BYTES + 1, flushed through atomicWriteFile. Checked the blocking-risk surfaces:

  • The new cap constant resolves correctly. monitor.ts now imports MAX_TASK_OUTPUT_TAIL_BYTES from services/backgroundShellRegistry.js; that module imports only node:fs, task types, atomicFileWrite, debugLogger, no-follow-open, promptIdContext, terminalSafe, and xml — nothing from tools/monitor. No cycle, so the module-level + 1 cannot evaluate against a TDZ binding. This mattered: an undefined constant would have made bytesToKeep NaN and silently re-introduced the unbounded growth the commit exists to fix.
  • No stuck-in-running path from the deferred settle. onClose/onError now pass settleFromExit / registry.fail as a close callback instead of calling them inline, so terminal status waits on the final flush. Every scheduling gap is covered: flushOutputCapture's .finally() clears outputWritePromise and re-runs finishOutputCapture; writeOutputCapture is invoked only from the child stdio data handler (a macrotask), while that .finally() runs in the preceding microtask drain, so outputDirty can never be left true with no flush scheduled; and once outputCloseRequested is set, writeOutputCapture early-returns so no new dirty state can appear. settleFromExit also self-guards on registration.status !== 'running', so the deferral cannot double-settle.
  • Close is requested on all exit paths — spawn throw, abort handler, early-spawn-error, and cleanup (including the already-cleaned-up branch, which forwards the callback). Write failures are caught inside the flush loop and logged, so an unreadable target degrades rather than rejecting out of execute().
  • Truncation signal preserved. The file is created empty up front (atomicWriteFileSync with noFollow: true), so a monitor with no output yields size 0 and the reader returns undefined → "No output yet". Once the cap is crossed the size is exactly 64 KiB + 1, which keeps readTaskOutputTail's truncated: start > 0 true. noFollow on both write paths also refuses a pre-planted symlink at the output path.
  • Behavior change is documented, not silently divergent. docs/developers/tools/monitor.md and the design doc were updated in the same commit: the prior claim that rate-limited lines "remain in the output file" is corrected to the bounded rolling tail, so the docs no longer promise full retention the code does not provide.
  • The new behavior is pinned by a real assertion, not a smoke test: it emits a cap-crossing stdout/stderr stream and asserts the exact resulting file size, the exact retained bytes, and { truncated: true } from readTaskOutputTail.

Rest of the production diff is byte-identical to the previously approved revision (route ownership via withOwnerReadSession / requireOwned, task-id-only lookup with no client-supplied path, O_NOFOLLOW + fstat tail reader, 64 KiB clamp, generic error text, monitor writer teardown, and hook order ahead of the !supported capability gate). Remaining review-round items are all Suggestions and are not merge blockers.

CI was freshly triggered by this push: 8 passing, no failures, the rest still pending. Pending checks and review-pr are not treated as blockers.

yiliang114
yiliang114 previously approved these changes Sep 4, 2026

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Light review pass on head 941d16b. No blocking findings. The new GET /session/:id/tasks/:taskId/output endpoint is owner-gated, and the output path is resolved from the session-scoped shell/monitor registry by taskId rather than client input, so there is no arbitrary-path read; readTaskOutputTail keeps the O_NOFOLLOW + identity check and is hard-capped at 64 KiB, failing closed to an 'unavailable' state. Monitor capture is a bounded tail (64 KiB + 1 byte to preserve the truncated signal) written via atomic noFollow writes with settle deferred until the capture closes, and the web-shell side is capability-gated, renders output as text in a

 (no injection surface), and copies the exact displayed value. All checks are green at review time (only the automatic review-pr job still running).

…utput

# Conflicts:
#	docs/developers/daemon/00-index.md
@BZ-D
BZ-D dismissed stale reviews from yiliang114 and qqqys via 2533b1d September 4, 2026 08:21
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 14/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 14/100 轮)。改动内容与我反驳保留之处如下:

Autofix round summary — PR #10906

Feedback disposition

[rc:3970931518] R13-1 [Critical] — escape-sequence payload leaks through the capture grammar — Addressed

All five entrances reproduced on the pre-round head with focused failing tests, then fixed at the grammar level in packages/core/src/tools/monitor.ts and packages/core/src/services/backgroundShellRegistry.ts:

  • (a) Writer hold-back vs stripper mismatch — the OSC hold class accepted only printable ASCII while the stripper accepts [^\x07\x1b\n\r]*. The hold class now mirrors the stripper's payload class for both OSC and DCS/SOS/PM/APC, so a chunk boundary inside a UTF-8 window title or OSC 8 hyperlink no longer defeats the hold.
  • (b) Hold-back cap dropped the hold — an over-PARTIAL_LINE_BUFFER_CAP string sequence (multi-KB OSC 52 / inline image, reachable by accumulation across individually small chunks) now engages a per-stream discardingStringPayload flag: the arrived partial is dropped from the chunk and the remaining payload is discarded until BEL, ST (ESC \), a fresh ESC, LF, or CR — mirroring the stripper's own terminators — instead of being persisted as fabricated output. A chunk-final ESC during the discard stays held with the discard so an ST straddling the boundary cannot leak its backslash. A teardown mid-discard drops the decoder residue as payload, not text. The cap itself was not raised, per the finding's constraint.
  • (c) Reader window boundary — readTaskOutputTail now peeks one byte ahead of the window; when the window opens immediately after an ESC, everything up to the first line break is leaderless sequence residue (a sequence can never cross a line break), so the served text starts at the first line that begins inside the window. This covers the shell-writer carrier (a >64 KiB output file holding a chunk-final lone ESC) without touching the shell writer, whose grammar unification the finding explicitly scopes as longer-term.
  • (d) String-terminator split — both string hold alternatives now accept a bounded in-flight ST suffix (a trailing lone ESC is held with the sequence), so a boundary between the ST's ESC and its backslash reconstitutes the ST instead of persisting \ as text.
  • (e) Close flush — TAIL_CSI_REGEX and TAIL_FE_ESC_REGEX gained the end-of-input arm the OSC and string rules already had, so a teardown whose last chunk ends mid-CSI (ESC [ 3 1) no longer persists the bracket and parameters. The Fe rule still requires an intermediate byte, and \x1b\\ stays first in the string terminator group; MAX_MONITOR_OUTPUT_CAPTURE_BYTES = MAX_TASK_OUTPUT_TAIL_BYTES + 1 is unchanged.

[rc:3970931527] R14-1 [Critical] — CR frame selector treats a whitespace-only erase pad as the frame — Addressed

normalizeOutputCarriageReturns now skips frames whose trim() is empty when choosing kept, so a progress-bar erase pad (spaces/tabs) no longer discards the real frame — and no longer turns a window's only content into undefined (which both consumers surfaced as "task produced no output"). droppedFrames still requires a second non-blank frame, so a real frame followed by a pad reports truncated: false; the doc comment now says non-blank to match. Both pinned collapse fixtures (rec 1\rrec 2\rrec 3, frame 10%…done) stay green.

Deferred / declined

None this round. The review body's deferred non-Critical list is explicitly "recorded, not requested in this round" under the convergence posture, and the Deferred non-Critical feedback brake section is an audit record — per the round rules I did not act on those items.

Conflict notes

--conflict false; no merge performed.

Verification

Reproduction first (pre-fix, at head 7c511421d4): the six new tests fail for the reasons the findings describe —

  • backgroundShellRegistry.test.ts: serves the real frame when a redraw erases it with a whitespace pad red (returned undefined); drops the first line when the served window opens mid-escape red (served [31m… residue); pins the multi-segment carriage-return collapse shape green pre-fix (a pin, per the finding green today and red under the mutants it names).
  • monitor.test.ts: strips an OSC with a UTF-8 payload split across stdout chunks red; discards an over-cap string payload that grew across small chunks red (payload remainder persisted); strips a DCS split inside its ST terminator across stdout chunks red (stray \ captured); strips a dangling CSI leader flushed at close from the capture red (tail [31); discards an over-cap payload whose ST straddles a chunk boundary red.

Post-fix checks actually run:

  • cd packages/core && npx vitest run src/services/backgroundShellRegistry.test.ts src/tools/monitor.test.ts — 176 passed.
  • cd packages/cli && npx vitest run src/serve/server.test.ts src/serve/acp-http/transport.test.ts src/acp-integration/acpAgent.test.ts — 2309 passed (downstream consumers of the reader against the rebuilt dist).
  • npm run build — passed. npm run typecheck — passed. npm run lint — passed. npx prettier --check on the four touched files — passed.
  • Mutation probes (each: revert guard → run → confirm red → restore → green): (1) trim() frame-skip revert → pad test red; (2) hold-regex revert → UTF-8 OSC and DCS-ST tests red; (3) discard-flag revert → over-cap test red; (4) CSI/Fe end-of-input-arm revert → dangling-CSI test red; (5) window-peek revert → mid-escape test red; (6) ST-straddle-branch revert → straddle test red. All restored and re-verified green.
中文说明

Autofix 本轮总结 — PR #10906

反馈处置

[rc:3970931518] R13-1 [Critical] — 转义序列 payload 透过捕获文法泄漏 —— 已处理

五个入口全部先在修复前的 head 上用聚焦的失败测试复现,然后在 packages/core/src/tools/monitor.ts 与 packages/core/src/services/backgroundShellRegistry.ts 中按文法层面修复:

  • (a) 写入侧 hold-back 与 stripper 不一致 —— OSC 的 hold 字符类此前只接受可打印 ASCII,而 stripper 接受 [^\x07\x1b\n\r]*。现在 OSC 与 DCS/SOS/PM/APC 的 hold 字符类都与 stripper 的 payload 类一致,UTF-8 窗口标题或 OSC 8 超链接中间的 chunk 边界不再能使 hold 失效。
  • (b) hold-back 超限后放弃 hold —— 超过 PARTIAL_LINE_BUFFER_CAP 的字符串序列(多 KB 的 OSC 52 / 内联图片,可由多个各自很小的 chunk 累积触发)现在会置起按流独立的 discardingStringPayload 标志:当前 chunk 中已到达的部分被丢弃,后续 payload 一直丢弃到 BEL、ST(ESC \)、新的 ESC、LF 或 CR 为止——与 stripper 自己的终结符一致——而不再作为伪造输出持久化。discard 期间若 chunk 末尾是孤立 ESC,则保持 hold 并继续 discard,ST 跨边界不会漏出反斜杠。discard 中途收尾时,decoder 残留按 payload 丢弃而不是当文本。上限本身未按发现的要求改动。
  • (c) 读取侧窗口边界 —— readTaskOutputTail 现在会向前多看一个字节;当窗口恰好开在 ESC 之后时,第一个换行符之前的内容都是没有 leader 的序列残留(序列不可能跨行),因此对外文本从窗口内第一个完整行开始。这覆盖了 shell 写入侧的载体(超过 64 KiB、含有 chunk 末尾孤立 ESC 的输出文件),同时不改动 shell 写入侧——其文法统一被该发现明确划入更长期的范围。
  • (d) 字符串终结符被切开 —— 两个字符串 hold 分支现在都接受有界的在途 ST 后缀(序列末尾的孤立 ESC 随序列一起 hold),ESC 与反斜杠之间的边界会重组出完整 ST,而不再把 \ 持久化为文本。
  • (e) 关闭时的 flush —— TAIL_CSI_REGEX 与 TAIL_FE_ESC_REGEX 补上了 OSC 与字符串规则已有的 end-of-input 分支,最后一个 chunk 结束于 CSI 中间(ESC [ 3 1)的拆解不再持久化方括号与参数。Fe 规则仍要求至少一个中间字节,终结符组里 \x1b\\ 仍在最前,MAX_MONITOR_OUTPUT_CAPTURE_BYTES = MAX_TASK_OUTPUT_TAIL_BYTES + 1 未改动。

[rc:3970931527] R14-1 [Critical] —— 回车帧选择器把纯空白擦除填充当成所绘帧 —— 已处理

normalizeOutputCarriageReturns 在选择 kept 时现在跳过 trim() 为空的帧,进度条擦除填充(空格/制表符)不再丢弃真实帧——也不再让窗口唯一内容变成 undefined(两个消费者此前都将其表现为"任务没有产出")。droppedFrames 仍要求存在第二个非空白帧,所以"一个真实帧加一块填充"报告 truncated: false;文档注释已同步改为非空白。两个已钉住的折叠夹具(rec 1\rrec 2\rrec 3、frame 10%…done)保持绿色。

延后 / 拒绝

本轮无。评审正文中的延后非 Critical 列表在收敛姿态下明确为"已记录,本轮不要求修改",而 Deferred non-Critical feedback 刹车区是审计记录——按本轮规则我未对这些条目采取动作。

冲突说明

--conflict false;未执行合并。

验证

先复现(修复前,head 7c511421d4):六个新测试按发现所述原因失败——

  • backgroundShellRegistry.test.ts:serves the real frame when a redraw erases it with a whitespace pad 红(返回 undefined);drops the first line when the served window opens mid-escape 红(返回 [31m… 残留);pins the multi-segment carriage-return collapse shape 修复前为绿(按发现所述,该用例今天为绿、在其列出的变异体下变红,属于钉住行为的用例)。
  • monitor.test.ts:strips an OSC with a UTF-8 payload split across stdout chunks 红;discards an over-cap string payload that grew across small chunks 红(payload 残余被持久化);strips a DCS split inside its ST terminator across stdout chunks 红(捕获到多余 \);strips a dangling CSI leader flushed at close from the capture 红(tail [31);discards an over-cap payload whose ST straddles a chunk boundary 红。

修复后实际运行的检查:

  • cd packages/core && npx vitest run src/services/backgroundShellRegistry.test.ts src/tools/monitor.test.ts —— 176 通过。
  • cd packages/cli && npx vitest run src/serve/server.test.ts src/serve/acp-http/transport.test.ts src/acp-integration/acpAgent.test.ts —— 2309 通过(基于重新构建的 dist 验证读取侧的下游消费者)。
  • npm run build —— 通过。npm run typecheck —— 通过。npm run lint —— 通过。对四个改动文件执行 npx prettier --check —— 通过。
  • 变异探针(每个:移除守护 → 运行 → 确认变红 → 还原 → 复绿):(1) 还原 trim() 帧跳过 → 填充用例红;(2) 还原 hold 正则 → UTF-8 OSC 与 DCS-ST 用例红;(3) 还原 discard 标志 → 超限用例红;(4) 还原 CSI/Fe end-of-input 分支 → dangling-CSI 用例红;(5) 还原窗口前窥 → mid-escape 用例红;(6) 还原 ST 跨边界分支 → 对应用例红。全部还原并复验为绿。

Deferred non-Critical feedback

Critical-only mode is active: 5 change-producing rounds are complete. The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback is deferred only after its author has used 2 regular feedback batches in this window's Critical-only tail; authors at that budget, if any, are named below. (@qwen-code /retry starts a fresh counting window.)

  • @wenshao is at this window's regular-feedback budget — to continue: start your comment with [Critical], submit a Request changes review, or comment @qwen-code /retry for a fresh window. / @wenshao 本窗口常规反馈预算已用完——继续请以 [Critical] 开头评论、提交 Request changes、或评论 @qwen-code /retry 开新窗口。
中文说明

已进入仅处理 Critical 的模式:已完成 5 个产生改动的轮次。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。维护者反馈仅在其本人于本窗口 Critical-only 阶段已使用 2 批常规反馈预算后才会延后;达到预算的作者(如有)在下方点名。(评论 @qwen-code /retry 可开启新的计数窗口。)

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

🧵 Resolved all 2 selected review thread(s). · 已关闭全部选中的 2 条评审线程。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 kimi-k3 · CLI 0.23.2

@wenshao

wenshao commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Round-4 local verification on a real stack (maintainer) — head 4661b40aa4

Follow-up to my round-1 (5c41a581a6), round-2 (14ef3e198e) and round-3 (0d2ee20037) reports. Rebuilt this head in a fresh worktree, re-ran the real qwen serve + real Chrome rig, and focused on the three substantive commits added since round 3: c491511461 (teardown join), 7c511421d4 (one escape grammar for writer and reader) and 4661b40aa4 (the remaining grammar gaps).

Result: the Reviewer Test Plan is still 5/5, the safety guards still hold, and the two escape-grammar commits are a large, real improvement for the Monitor path — I measured four distinct payload leaks that they close. Two things need a decision before merge:

  1. My round-3 F1 is not fixed, and 7c511421d4 propagated it from the reader into the writer. The Monitor capture now strips through stripOutputControlChars, which still carries the TAIL_FE_ESC_REGEX + bug — so the wrong byte is now written permanently into the capture file, where no reader-side fix can recover it. The one-character half-revert I proposed in round 3 fixes it and, at this head, is strictly better in three distinct cases.
  2. F2 (new): the leaderless-residue window rule can serve nothing at all for a >64 KiB single-record stream, rendering as No output yet for a task that has 70 KB of output.

Neither serves wrong data; both are display-level. Everything else about these commits that I could measure came out well — the mutation matrix is 11 killed of 12, up from 4 of 8 at round 3.

Rig — worktree at 4661b40aa4, lockfile-matched node_modules, npm run build; daemon node packages/cli/dist/index.js serve --port 8941 --workspace <tmp git repo>, isolated HOME; ~60-line fake OpenAI provider so real run_shell_command/monitor calls execute; system Chrome; Web Shell client bundle index-CHsT9Vdj.js (recorded on every measurement). npm run typecheck and npm run lint both pass at this head.


0. Scope — what this PR contributes that moved since round 3

Comparing the PR's own contribution (the added/removed line set of git diff <merge-base> <head> per file, so the two main merges do not register):

packages/core/src/services/backgroundShellRegistry.ts  + .test.ts   the grammar work
packages/core/src/tools/monitor.ts                     + .test.ts   capture writer, hold-back, over-cap discard
packages/core/src/services/monitorRegistry.ts                       +8 lines: outputCaptureClosed?: Promise<void>
packages/cli/src/ui/AppContainer.test.tsx                           the QWEN_HOME pin now also pins HOME
packages/web-shell/client/i18n.tsx                                  2 lines: "Showing the latest 64 KiB" -> "Showing the latest output"
packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx   1 line: the matching assertion

The PR's contribution to the other 39 files is byte-identical to round 3, so that report still covers them. TasksStatusMessage.tsx itself did not move, which is why items 3 and 4 below only needed re-confirming.


1. Reviewer Test Plan — 5 / 5 at this head

Fig 1 — monitor and the live leak

# Item Result at 4661b40aa4
1 Shell output while running and after completion 91 lines retained after exitCode 0 — stdout line 01…45, stderr line 01…45, SHELL-DONE-MARKER
2 Monitor with notification throttling Events 6, Dropped 115, and all 121 captured lines present
3 Follow at bottom / preserve after scrolling up Pinned: scrollTop 1246 -> 3082 with scrollHeight - scrollTop = clientHeight = 358 on both samples, content line 044 -> 095. Scrolled to top: scrollTop 0 -> 0 while scrollHeight 3440 -> 4304, content 095 -> 119
4 Copy button Icon lucide-copy -> lucide-check -> lucide-copy; clipboard pasted with a real Cmd+V = 62 chars, byte-identical to the visible <pre>
5 Daemon without the capability Same bundle index-CHsT9Vdj.js against a daemon with session_task_output removed (141 -> 140 features): metadata-only, 0 copy buttons, no Output section

Route validation and the safety guards, re-measured on this head:

kind missing / kind=bogus         -> 400 VALIDATION_FAILED
no Authorization header           -> 401
unknown taskId                    -> 200 in-band {error:"Task output is unavailable."}
symlink planted over the path     -> refused, the target's secret never served
FIFO planted over the path        -> HTTP 200 in 10 ms, daemon not wedged

2. What the two grammar commits genuinely fix — four leaks, measured live on the Monitor path

One probe writes ESC and its final byte in separate write() calls 250 ms apart (an ordinary pipe-chunk split), run once as a background Shell and once as a Monitor.

Fig 2 — escape grammar

On the Monitor path these four all went from leaking to clean, and the capture file now carries 0 ESC bytes:

case served at this head
DCS terminated by ST, payload straddling a chunk J-dcs KEPT END
DCS terminated by BEL K-dcs-bel KEPT END
unterminated DCS leader then a newline L-dcs-unterm , next line kept
ST split across chunks (ESC in one chunk, the backslash in the next) O-st KEPT END

The mechanism the commit names is real — strip-ansi matches only the two ESC P bytes and leaves the payload:

stripAnsi("a <ESC> P 1$r0m <ESC> \ KEPT")   ->   "a1$r0m\KEPT"
stripAnsi("a <ESC> P 1$r0m <BEL> KEPT")     ->   "a1$r0mKEPT"

But shell.ts:3871 is untouched by this PR — still outputStream.write(stripAnsi(event.chunk)) with no hold-back — and the same route serves kind=shell. So all four leaks are still live there, visible in the browser:

J-dcs 1$r0m\KEPT END      K-dcs-bel 1$r0mKEPT END      L-dcs-unterm 1$rpayload      O-st 1$r0m\KEPT END

The commit message's "the capture now strips through the same TAIL_* grammar the reader serves with" holds for Monitor only. Closing the Shell half is the natural follow-up — either share the Monitor writer, or leave it, but the design doc should not claim both halves are covered.


3. F1 is not fixed, and it is now written to disk

TAIL_FE_ESC_REGEX is still /\x1b[\x20-\x2f]+(?:[\x30-\x7e]|$)/g. At round 3 that leaked the final byte of split two-byte Fe escapes on the reader side only. Now the Monitor writer uses the same function, so the wrong byte is persisted:

round-3 head   monitor capture file   b'G-split-fe decsc decrc crix END'     (correct)
THIS head      monitor capture file   b'G-fe 7decsc 8decrc ccrix END'        (0 ESC bytes - the 7/8/c are real text on disk now)

Same one-character half-revert as round 3, and at this head it is strictly better in three distinct cases, not one. Pure-function comparison on the real dist:

input head arm C (+ to *)
teardown chunk tail ESC [ plus a lone 0xe6 'tail [<U+FFFD>' 'tail <U+FFFD>' arm C — head leaks the CSI introducer
split two-byte Fe ESC 7 'a7b' 'ab' arm C
ST as its own fragment ESC \ 'x\\y' 'xy' arm C — head leaks the ST backslash
stray ESC W 'aW313 b' 'a313 b' ambiguous (ESC W is a valid Fe = EPA)

Over 4 000 modelled Shell capture files (identical bytes on both arms): real characters lost 8 254 on both; garbage characters leaked 10 644 head vs 9 406 arm C; of 1 078 disagreements, arm C correct 108, head correct 0.

Arm C costs exactly two assertions, and both pin a leak rather than a behaviour:

  • readTaskOutputTail > deletes only the ESC of a residual lone ESC, keeping the byte after it — the ambiguous ESC W case.
  • MonitorTool > flushes held escape and decoder tails into the capture at close — this one pins 'tail [<U+FFFD>', i.e. it asserts that the CSI introducer is persisted. Its own comment says "the readable bytes that followed it stay", but [ is the sequence's introducer, not a byte the program wrote.
-const TAIL_FE_ESC_REGEX = /\x1b[\x20-\x2f]+(?:[\x30-\x7e]|$)/g;
+const TAIL_FE_ESC_REGEX = /\x1b[\x20-\x2f]*(?:[\x30-\x7e]|$)/g;

4. F2 (new, Suggestion) — the leaderless-residue rule can serve nothing at all

readTaskOutputTail now peeks one byte before the 64 KiB window; if it is ESC, everything up to the first line break is dropped as residue. With files laid out so the byte before the window is exactly the one named:

Fig 4 — F2 and the mutation matrix

tail shape boundary byte served
has newlines ESC 65 493 chars — the real first line RED-TEXT real words… is dropped
has newlines ordinary 65 531 chars — first line intact
no newline at all ESC 0 chars — readTaskOutputTail returns undefined, so a 71 537-byte capture renders as No output yet
no newline at all ordinary 65 531 chars, normal

Reachable for any single-record stream over 64 KiB — a JSON log line, a base64 blob, minified bundle output — whose window boundary happens to land on an ESC. Bounded probability, total consequence, and the empty result is indistinguishable from a task that genuinely produced nothing. Dropping one line when a break exists is defensible; the firstBreak === -1 branch should fall back to serving the window rather than discarding it.


5. The new truncated semantics fire on ordinary progress bars

Folding droppedFrames into truncated is honest — the CR collapse really does destroy records — and the notice text was softened to match. Worth knowing what it costs in practice: a 104-byte progress bar now carries the truncation notice even though its entire output is on screen.

Fig 3 — progress notice

capture file (104 bytes)   downloading pkg-a <CR> downloading pkg-a 35% <CR> downloading pkg-a 100% done <LF> installed 3 packages <LF> PROGRESS-DONE
served                     truncated = true, 3 lines, all of them shown
pane                       "Showing the latest output"

Not a defect — just a call worth making deliberately, since npm, pip, curl -# and most build tools will now show it.


6. Mutation matrix on the new commits — 12 mutants, 11 killed

The bot's own test-efficacy probe still reports no measurement for this PR, so this is measured rather than assumed. Both new test files are much stronger than at round 3 (which was 4 killed of 8).

Mutation Verdict
M1 FE + to * (arm C) KILLED (2 tests)
M2 string rule loses its BEL terminator KILLED
M3 string rule loses its newline lookahead KILLED
M4 CSI rule loses its end-of-input arm KILLED
M6 leaderless-residue window rule disabled KILLED
M7 CR erase-pad rule: trim() dropped KILLED
M8 droppedFrames no longer folded into truncated KILLED (4 tests)
M9 monitor writer reverts to stripAnsi KILLED (4 tests)
M10 monitor hold-back reverts to the round-3 grammar KILLED (3 tests)
M11 over-cap string payload kept, not discarded KILLED (2 tests)
M12 findStringPayloadEnd loses its BEL arm KILLED (2 tests)
M5 Fe rule loses its end-of-input arm SURVIVED

M5 is a coverage gap, not an equivalent mutant: I rebuilt it and a teardown chunk ending in ESC ( serves 'a' at head and 'a(' under the mutant.


7. Suites at this head (macOS — the lane CI skips outside merge_group)

Suite Result
npm run typecheck / npm run lint pass / pass
packages/core PR-touched (backgroundShellRegistry, monitor) 176 passed
packages/web-shell PR-touched (4 files) 1 125 passed
packages/sdk-typescript daemon (3 files) 616 passed
packages/acp-bridge full (37 files) 1 994 passed / 2 failed
packages/cli PR-touched (7 files) 2 570 passed / 2 failed

Both failures are load-dependent flakes in areas this PR does not touch, and both are green when their file runs alone (bridge.test.ts 920 passed twice; server.test.ts 1 259 passed): preheat > clamps explicit keep-alive to the setTimeout maximum / arms idle timer on preheated channel, and GET /workspace/:id/sessions > reports organized session truncation. They appeared only while the batch ran alongside the daemon and a browser. Round 3 saw the same class in server.test.ts and reproduced it on the merge base.

CI at this head: Qwen Code CI success — Test (ubuntu-latest, Node 22.x), Lint & Static, web-shell E2E Smoke, Integration Tests (no-AK) and both Desktop Shell lanes green; Test (macos-latest) and Test (windows-latest) skipped, which is the gap these local macOS runs fill.


Verdict

The direction of these two commits is right and the Monitor half is materially better than it was at round 3 — four real payload leaks closed, pinned by tests a same-tree revert kills. Before merge I would like the one-character F1 half-revert in section 3, because that defect is now persisted to disk rather than applied at read time, and its two pinning assertions both encode a leak. F2 and the Shell-side writer asymmetry are fine as follow-ups. Nothing here serves wrong data or affects the session-owner binding, the 64 KiB ceiling, or the symlink/FIFO guards, all of which I re-measured and they hold.

中文说明

第四轮本地真实环境验证(维护者)—— head 4661b40aa4

这是对我此前第一轮(5c41a581a6)、第二轮(14ef3e198e)、第三轮(0d2ee20037)报告的续篇。我在新的 worktree 重建了当前 head,重跑了真实 qwen serve + 真实 Chrome 的验证台,重点验证第三轮以来新增的三个实质提交:c491511461(teardown join)、7c511421d4(写入端与读取端共用一套转义语法)、4661b40aa4(补齐剩余语法缺口)。

结论:评审测试计划仍然 5/5,安全护栏仍然成立,两个转义语法提交对 Monitor 链路是一次实质且可观的改进——我实测到它们关闭了四处不同的泄漏。但有两点需要在合入前做决定:

  1. 我第三轮的 F1 没有被修复,而且 7c511421d4 把它从读取端传播到了写入端。 Monitor 的捕获现在经 stripOutputControlChars 处理,而该函数里 TAIL_FE_ESC_REGEX 的 + 缺陷依旧——于是错误的字节现在被永久写进捕获文件,读取端再怎么修也救不回来。我第三轮提出的一字符「只回退一半」方案能修好它,而且在当前 head 上它在三处不同用例上严格更优。
  2. F2(新发现):新的「无引导符残留」窗口规则在超过 64 KiB 的单记录流上可能什么都不返回,导致一个有 70 KB 输出的任务在界面上显示为「暂无输出」。

两者都不会返回错误数据,都属于显示层面。这两个提交其余我能测到的部分表现都很好——变异矩阵为 12 杀 11,第三轮是 8 杀 4。

验证台 —— worktree 位于 4661b40aa4,node_modules 与 lockfile 一致,npm run build;daemon 用 node packages/cli/dist/index.js serve --port 8941 --workspace <临时 git 仓库>,隔离 HOME;约 60 行假 OpenAI provider 使真实 run_shell_command/monitor 调用可以执行;系统 Chrome;Web Shell 客户端 bundle 为 index-CHsT9Vdj.js(每次测量都记录)。该 head 上 npm run typecheck、npm run lint 均通过。

0. 范围 —— 本 PR 自身贡献中自第三轮以来发生变化的部分

按「本 PR 自身贡献」(每个文件 git diff <merge-base> <head> 的增删行集合,这样两次主干合并不会被计入)对比:

packages/core/src/services/backgroundShellRegistry.ts  + .test.ts   语法改动
packages/core/src/tools/monitor.ts                     + .test.ts   捕获写入器、hold-back、超限丢弃
packages/core/src/services/monitorRegistry.ts                       +8 行:outputCaptureClosed?: Promise<void>
packages/cli/src/ui/AppContainer.test.tsx                           QWEN_HOME 之外再钉住 HOME
packages/web-shell/client/i18n.tsx                                  2 行:「仅显示最近 64 KiB」→「仅显示最新输出」
packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx   1 行:对应断言

本 PR 对其余 39 个文件的贡献与第三轮逐字节一致,那份报告依然覆盖它们。TasksStatusMessage.tsx 本身没有变化,所以下文第 3、4 项只需复核。

1. 评审测试计划 —— 本 head 上 5/5

# 项目 4661b40aa4 上的结果
1 Shell 运行中与完成后的输出 exitCode 0 后仍保留 91 行——stdout line 01…45、stderr line 01…45、SHELL-DONE-MARKER
2 触发限流的 Monitor Events 6、Dropped 115,全部 121 行都在
3 底部跟随 / 上滚后保持 跟随:scrollTop 1246 -> 3082,两次采样 scrollHeight - scrollTop = clientHeight = 358,内容 line 044 -> 095。滚到顶部:scrollTop 0 -> 0,scrollHeight 3440 -> 4304,内容 095 -> 119
4 复制按钮 图标 lucide-copy -> lucide-check -> lucide-copy;真实 Cmd+V 粘贴得到 62 字符,与可见 <pre> 逐字节一致
5 不声明该能力的 daemon 同一 bundle index-CHsT9Vdj.js 连到移除 session_task_output 的 daemon(141 -> 140 个 feature):仅元数据,0 个复制按钮,无 Output 区块

路由校验与安全护栏,在本 head 上重新实测:

缺少 kind / kind=bogus            -> 400 VALIDATION_FAILED
无 Authorization 头               -> 401
未知 taskId                       -> 200 带内 {error:"Task output is unavailable."}
在输出路径放置符号链接            -> 拒绝,目标文件的秘密内容从未被返回
在输出路径放置命名管道            -> HTTP 200,耗时 10 ms,daemon 未被楔死

2. 两个语法提交真正修好的部分 —— Monitor 链路上四处泄漏,均为实测

一个探针把 ESC 与其终止字节分两次 write() 写出,间隔 250 ms(普通的管道分块),分别以后台 Shell 和 Monitor 各跑一次。

在 Monitor 链路上,下列四项从泄漏变为干净,且捕获文件中 ESC 字节数为 0:

用例 本 head 返回
以 ST 结尾、payload 跨分块的 DCS J-dcs KEPT END
以 BEL 结尾的 DCS K-dcs-bel KEPT END
未终止的 DCS 引导符后接换行 L-dcs-unterm ,下一行保留
ST 跨分块(ESC 在前一块,反斜杠在后一块) O-st KEPT END

提交信息所述机理属实——strip-ansi 只匹配 ESC P 两个字节,把 payload 留下:

stripAnsi("a <ESC> P 1$r0m <ESC> \ KEPT")   ->   "a1$r0m\KEPT"
stripAnsi("a <ESC> P 1$r0m <BEL> KEPT")     ->   "a1$r0mKEPT"

但 shell.ts:3871 未被本 PR 触及——仍然是逐分块 outputStream.write(stripAnsi(event.chunk)),没有 hold-back——而同一个路由也服务 kind=shell。所以这四处泄漏在 shell 链路上依然存在,并且在浏览器里可见:

J-dcs 1$r0m\KEPT END      K-dcs-bel 1$r0mKEPT END      L-dcs-unterm 1$rpayload      O-st 1$r0m\KEPT END

提交信息里「捕获现在使用与读取端相同的 TAIL_* 语法」只对 Monitor 成立。补上 shell 那一半是自然的后续——要么共用 Monitor 的写入器,要么保持现状,但设计文档不应声称两半都已覆盖。

3. F1 未修复,而且现在被写进了磁盘

TAIL_FE_ESC_REGEX 仍然是 /\x1b[\x20-\x2f]+(?:[\x30-\x7e]|$)/g。第三轮时它只在读取端泄漏被切分的双字节 Fe 转义的终止字节;现在 Monitor 写入端用的是同一个函数,于是错误字节被持久化:

第三轮 head    monitor 捕获文件   b'G-split-fe decsc decrc crix END'   (正确)
本 head        monitor 捕获文件   b'G-fe 7decsc 8decrc ccrix END'      (ESC 数为 0——7/8/c 已成为磁盘上的真实文本)

与第三轮相同的一字符半回退,而在当前 head 上它在三处不同用例上严格更优,而不只是一处。基于真实产物的纯函数对比:

输入 head arm C(+ 改 *)
收尾分块 tail ESC [ 加上单独的 0xe6 'tail [<U+FFFD>' 'tail <U+FFFD>' arm C——head 泄漏了 CSI 引导符
被切分的双字节 Fe ESC 7 'a7b' 'ab' arm C
作为独立片段出现的 ST ESC \ 'x\\y' 'xy' arm C——head 泄漏了 ST 的反斜杠
游离 ESC W 'aW313 b' 'a313 b' 有歧义(ESC W 是合法的 Fe = EPA)

在 4 000 个模拟 shell 捕获文件上(两臂字节完全相同):丢失的真实字符两臂均为 8 254;泄漏的垃圾字符 head 10 644,arm C 9 406;1 078 处分歧中,arm C 正确 108 次,head 正确 0 次。

arm C 恰好只让两条断言变红,而这两条钉住的都是泄漏本身,而非行为:

  • readTaskOutputTail > deletes only the ESC of a residual lone ESC, keeping the byte after it——即有歧义的 ESC W 用例。
  • MonitorTool > flushes held escape and decoder tails into the capture at close——它钉住的是 'tail [<U+FFFD>',也就是断言 CSI 引导符会被持久化。该用例自己的注释写着「其后可读的字节保留」,但 [ 是序列的引导符,不是程序写出的可读字节。

4. F2(新发现,建议级)—— 无引导符残留规则可能什么都不返回

readTaskOutputTail 现在会窥探 64 KiB 窗口之前的一个字节;若为 ESC,则把直到第一个换行为止的内容全部当作残留丢弃。构造文件使窗口前一字节恰好是指定字节:

尾部形态 边界字节 返回
含换行 ESC 65 493 字符——真实的首行 RED-TEXT real words… 被丢弃
含换行 普通字节 65 531 字符——首行完整
完全没有换行 ESC 0 字符——readTaskOutputTail 返回 undefined,于是 71 537 字节的捕获文件在界面上显示为「暂无输出」
完全没有换行 普通字节 65 531 字符,正常

任何超过 64 KiB 的单记录流都可能触发——一行 JSON 日志、一段 base64、压缩后的打包产物——只要窗口边界恰好落在 ESC 上。概率有界,但后果是全丢,而且这个空结果与「任务确实没有输出」无法区分。存在换行时丢掉一行是可以接受的;firstBreak === -1 这一分支应当回退为照常返回该窗口,而不是整体丢弃。

5. 新的 truncated 语义会在普通进度条上触发

把 droppedFrames 折进 truncated 是诚实的——CR 折叠确实会销毁记录——提示文案也相应做了弱化。值得知道它在实践中的代价:一个 104 字节的进度条现在也会带上截断提示,尽管它的全部输出都在屏幕上。

捕获文件(104 字节)   downloading pkg-a <CR> downloading pkg-a 35% <CR> downloading pkg-a 100% done <LF> installed 3 packages <LF> PROGRESS-DONE
返回                   truncated = true,3 行,全部显示
面板                   「仅显示最新输出」

这不是缺陷,只是一个值得有意识做出的取舍,因为 npm、pip、curl -# 以及大多数构建工具从此都会显示它。

6. 新提交的变异矩阵 —— 12 个变异体,杀死 11 个

机器人自己的 test-efficacy 探针对本 PR 仍无测量结果,所以这部分是实测而非推断。两个新测试文件都比第三轮(8 杀 4)强得多。

变异 判定
M1 FE + 改 *(arm C) KILLED(2 条用例)
M2 string 规则丢掉 BEL 终止符 KILLED
M3 string 规则丢掉换行前瞻 KILLED
M4 CSI 规则丢掉输入结束分支 KILLED
M6 停用无引导符残留窗口规则 KILLED
M7 CR 擦除填充规则去掉 trim() KILLED
M8 droppedFrames 不再折进 truncated KILLED(4 条)
M9 monitor 写入器退回 stripAnsi KILLED(4 条)
M10 monitor hold-back 退回第三轮语法 KILLED(3 条)
M11 超限 string payload 保留而非丢弃 KILLED(2 条)
M12 findStringPayloadEnd 丢掉 BEL 分支 KILLED(2 条)
M5 Fe 规则丢掉输入结束分支 SURVIVED

M5 是覆盖缺口而非等价变异体:我重新构建后实测,以 ESC ( 结尾的收尾分块在 head 上返回 'a',在变异体上返回 'a('。

7. 本 head 上的本地套件(macOS —— 正是 CI 在 merge_group 之外会跳过的车道)

套件 结果
npm run typecheck / npm run lint 通过 / 通过
packages/core 本 PR 触及(backgroundShellRegistry、monitor) 176 通过
packages/web-shell 本 PR 触及(4 个文件) 1 125 通过
packages/sdk-typescript daemon(3 个文件) 616 通过
packages/acp-bridge 全量(37 个文件) 1 994 通过 / 2 失败
packages/cli 本 PR 触及(7 个文件) 2 570 通过 / 2 失败

两处失败都是本 PR 未触及区域的负载相关抖动,且各自文件单独运行时全绿(bridge.test.ts 两次均 920 通过;server.test.ts 1 259 通过):preheat > clamps explicit keep-alive to the setTimeout maximum / arms idle timer on preheated channel,以及 GET /workspace/:id/sessions > reports organized session truncation。它们只在与 daemon 和浏览器同机批量运行时出现。第三轮在 server.test.ts 上见过同一类抖动,并在 merge base 上复现过。

本 head 的 CI:Qwen Code CI success——Test (ubuntu-latest, Node 22.x)、Lint & Static、web-shell E2E Smoke、Integration Tests (no-AK) 以及两条 Desktop Shell 车道全绿;Test (macos-latest) 与 Test (windows-latest) 被跳过,而这正是本地 macOS 跑批所填补的缺口。

结论

这两个提交的方向是对的,Monitor 那一半相比第三轮有实质提升——关闭了四处真实的 payload 泄漏,并且有同树回退能打红的测试钉住。合入前我希望先落地第 3 节那个一字符的 F1 半回退,因为该缺陷现在是被持久化到磁盘、而不再是读取时才产生,而且钉住它的两条断言编码的都是泄漏本身。F2 与 shell 侧写入器不对称可以作为后续处理。这里没有任何一项会返回错误数据,也不影响会话归属绑定、64 KiB 上限或符号链接/FIFO 护栏——这些我都重新测量过,全部成立。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 15/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 15/100 轮)。改动内容与我反驳保留之处如下:

Autofix round summary — PR #10906

Addressed all four actionable Critical findings. Each fix ships with the red-test
witness the finding named, and every new guard/branch was mutation-probed
(removed → pinned test fails → restored → green).

[rc:3973840767] R13-1 — escape-grammar class (monitor.ts / backgroundShellRegistry.ts)

All five measured entrances are closed, sharing the one stripper grammar:

  1. Reader window (e1) — readTaskOutputTail no longer peeks one byte and
    drops the first line. It scans back a bounded prefix (≤ 4096 bytes, stopping
    at the previous line break) for the nearest ESC before the window and
    prepends from it, so the stripper removes the reconstituted sequence whole
    and the real first line survives. Pinned by the flipped mid-escape test plus
    new fixtures whose windows open 2 and 9 bytes into ESC[38;5;208m.
  2. Teardown (e2) — flushPartialLineBuffers now strips the held escape and
    the decoder residue independently, so a U+FFFD can no longer defeat the
    end-of-input arm; the capture records tail instead of tail [.
    The sibling test's expectation was flipped deliberately, as the finding
    instructed.
  3. Over-cap non-string leader (e3) — the discard state now carries the
    leader kind (string | csi | fe) with per-class terminator scans, so an
    over-cap CSI or Fe payload is discarded until its final byte instead of
    fabricating that byte into the capture and the event stream.
  4. Line-break cut (e4) — the CSI and Fe stripper rules gained the
    (?=[\x1b\n\r]) newline-lookahead arm their OSC/string siblings already
    carried; a leader cut by LF/CR strips whole instead of leaking its bracket
    and parameters.
  5. Two-byte escapes (e5) — a new rule strips the assigned no-intermediate
    escapes (SS2/SS3, the Fe single functions D/E/H/M/N/O/Z, the DEC private
    pairs 6–9/=/>, RIS ESC c, the locking shifts ESC n/o/|/}/~). Unassigned
    finals still cost only the ESC byte, so the pinned lone-ESC contract
    (ESC W keeps W) is intact.

Not done, with reasons:

  • The suggested writer rewrite (retain raw bytes, re-strip the whole retained
    window per flush) was considered and declined: it would leak the tail of any
    payload larger than the 64 KiB capture window (today's discard state handles
    those at any size) and would re-strip the whole window on every chunk,
    amplifying the already-reported per-chunk rewrite cost (R2-5). The
    per-entrance closures above close every measured witness without that
    regression.
  • The producer-side follow-up named in the finding (giving the background-shell
    capture writer in packages/core/src/tools/shell.ts the monitor writer's
    cross-chunk hold-back) is outside this PR's footprint; recorded in
    deferred-findings.json for the follow-up queue.

[rc:3973840774] R15-1 — mid-escape drop destroyed real output (backgroundShellRegistry.ts)

Fixed by the same reader rewrite: the drop-to-first-line branch and its
empty-the-window arm are gone. A window that opens mid-sequence keeps its real
line; a window with no line break at all is served instead of collapsing to
undefined; a cut landing on a CRLF no longer serves a leading blank line (one
leading \n is trimmed on the prepend path only, so the full-window leading
\n pin is untouched). truncated stays start > 0 || droppedFrames and the
read stays bounded by Math.min(N, start).

[rc:3973840778] R15-2 — over-cap branch sliced away the terminator ESC (monitor.ts)

The over-cap branch now keeps a string payload's trailing ESC held
(held = '\x1b', slice one byte less), so the next chunk's backslash
reconstitutes the ST and the real line after the sequence survives. The
existing straddle test (ST ESC arriving in a later payload chunk) still passes;
a new test with the ESC as the cap-crossing chunk's final byte pins the fix.

[rc:3973840783] R15-4 — silent capture write failures (monitor.ts / monitorRegistry.ts / tasksSnapshot.ts / status types)

  • The first write failure is recorded on the registration as
    outputCaptureError; the writer gives up after 3 consecutive failures
    (success resets the counter) instead of re-attempting a doomed full-tail
    write on every chunk.
  • The failure is surfaced beside droppedLines: in the terminal notification
    (display text and <summary>) and in the served status
    (tasksSnapshot.ts → ServeSessionMonitorTaskStatus →
    DaemonSessionMonitorTaskStatus), so readers can tell a stale tail from a
    complete one.
  • My own probe of this path exposed a latent race in the PR's flush loop: a
    chunk dirtied between the loop's last check and the promise reset left the
    dirty flag set with no writer running — the capture would silently stop
    advancing and the close join could hang. The .finally now restarts the
    loop when the tail is dirty. (The give-up branch forces the flag false, so
    this never re-arms a dead disk.)

Mutation probes

probe removed guard result
1 trailing-ESC keep in over-cap branch keeps an over-cap string payload terminator ESC held… red
2 CSI/Fe discard arming both over-cap CSI/Fe tests red
3 split-strip at teardown flushes held escape and decoder tails… red
4 failure record + give-up records a capture write failure… red
5 dirty restart in flush .finally records a capture write failure… red (writer wedges at 2 attempts)
6 reader scan-back prepend all three reconstitution tests red
6b leading-\n trim on prepend path CRLF test red
7 CSI/Fe newline-lookahead arms strips a CSI leader cut by a line break whole red
8 two-byte escape rule strips two-byte escapes… red
9 notification surfacing includes an output capture failure… red
10 snapshot field serves a monitor output capture failure red

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • npx prettier --check on all 10 changed files — passed (2 files auto-formatted with --write first, then re-verified)
  • vitest packages/core: src/tools/monitor.test.ts (101), src/services/monitorRegistry.test.ts (59), src/services/backgroundShellRegistry.test.ts (86) — 246 passed
  • vitest packages/cli: tasksSnapshot.test.ts (20), serve/server.test.ts + serve/acp-http/transport.test.ts (1647), acp-integration/acpAgent.test.ts (682) — passed
  • vitest packages/acp-bridge — 1996 passed
  • vitest packages/sdk-typescript (DaemonClient, DaemonSessionClient, acpRouteTable) — 616 passed
  • vitest packages/web-shell (TasksStatusMessage, daemon session actions) — 229 passed
  • Integration: npm run bundle then QWEN_SANDBOX=false npx vitest run cli/qwen-serve-routes.test.ts in integration-tests/ — 38 passed
中文说明

Autofix 本轮总结 — PR #10906

本轮处理了全部四条可执行的 Critical 发现。每项修复都附带了发现中所点名的
"移除应变红"测试见证,并且每个新增的守卫/分支都做了变异探针验证
(移除 → 钉住它的测试变红 → 恢复 → 变绿)。

[rc:3973840767] R13-1 —— 转义文法类别(monitor.ts / backgroundShellRegistry.ts)

五个实测入口全部关闭,读写两侧共享同一套 stripper 文法:

  1. 读取窗口(e1)——readTaskOutputTail 不再只peek一个字节然后丢弃首行。
    现在向窗口前有界回扫(≤ 4096 字节,遇到上一处换行即停),找到距窗口最近
    的 ESC 并从它开始拼接,使 stripper 能把重新拼合的序列整段移除,真实首行
    得以保留。由翻转后的 mid-escape 测试以及两个分别在 ESC[38;5;208m
    内部第 2、第 9 字节开窗的新夹具钉住。
  2. 收尾(e2)——flushPartialLineBuffers 现在把保留的转义序列与解码器
    残余分别 strip,U+FFFD 无法再破坏 end-of-input 分支;捕获文件记为
    tail 而不是 tail [。按发现的要求,刻意翻转了兄弟测试的期望。
  3. 超限的非字符串 leader(e3)——丢弃状态现在携带 leader 类别
    (string | csi | fe),并按类别使用各自的终止符扫描,因此超限的
    CSI 或 Fe 载荷会被丢弃到其终止字节为止,而不是把该终止字节伪造成输出
    写进捕获文件和事件流。
  4. 换行截断(e4)——CSI 与 Fe 规则补上了 OSC/字符串兄弟规则已有的
    (?=[\x1b\n\r]) 换行前瞻分支;被 LF/CR 截断的 leader 会整段剥离,不再
    把方括号与参数泄漏成文本。
  5. 两字节转义(e5)——新增规则剥离已赋值的"无中间字节"转义
    (SS2/SS3、Fe 单功能 D/E/H/M/N/O/Z、DEC 私有两字节 6–9/=/ >、RIS ESC c、
    锁定位移 ESC n/o/|/}/~)。未赋值的终止字节仍然只损失 ESC 本身,因此
    既有钉住的 lone-ESC 契约(ESC W 保留 W)不受影响。

未做,及原因:

  • 发现中建议的写入端重写(保留原始字节、每次 flush 对整个保留窗口重新
    strip)经评估后未采纳:它会把任何大于 64 KiB 捕获窗口的载荷尾部泄漏成
    文本(今天的丢弃状态对任意大小都能处理),并且每个 chunk 都要重扫整个
    窗口,放大已报告的逐 chunk 整段重写开销(R2-5)。上述逐入口修复在
    不引入该回归的前提下关闭了所有实测见证。
  • 发现中点名的生产方后续项(把 monitor 写入端的跨 chunk 保留机制同样给到
    packages/core/src/tools/shell.ts 的后台 shell 捕获写入端)超出本 PR 的
    足迹范围;已记录到 deferred-findings.json,进入后续队列。

[rc:3973840774] R15-1 —— 转义中途丢弃破坏真实输出(backgroundShellRegistry.ts)

由同一处读取端重写修复:丢弃至首个换行的分支及其"清空整个窗口"的支路已
删除。窗口开在序列中途时保留真实行;窗口内完全没有换行时照常服务,不再
坍缩为 undefined;切点落在 CRLF 上时服务出的尾部不再以空行开头(仅在
拼接路径上 trim 一个前导 \n,整窗读取时的前导 \n 钉住行为不受影响)。
truncated 仍为 start > 0 || droppedFrames,读取仍被
Math.min(N, start) 夹住。

[rc:3973840778] R15-2 —— 超限分支把终止符 ESC 连同载荷一起切掉(monitor.ts)

超限分支现在把字符串载荷的尾部 ESC 保留下来(held = '\x1b'、少切一个
字节),于是下一个 chunk 的反斜杠能重新拼合出 ST,序列之后的真实行得以
保留。现有 straddle 测试(ST 的 ESC 在之后的载荷 chunk 到达)保持通过;
新增一个以 ESC 作为超限 chunk 最后一字节的测试钉住该修复。

[rc:3973840783] R15-4 —— 捕获写入失败静默(monitor.ts / monitorRegistry.ts / tasksSnapshot.ts / status 类型)

  • 首次写入失败记录到注册项的 outputCaptureError;连续失败达到 3 次后
    写入端放弃重试(成功会重置计数),不再每个 chunk 重试注定失败的整段
    尾部写入。
  • 该失败像 droppedLines 一样被呈现:终态通知(展示文本与 <summary>)
    以及服务状态(tasksSnapshot.ts → ServeSessionMonitorTaskStatus →
    DaemonSessionMonitorTaskStatus),读取方由此能区分陈旧尾部与完整输出。
  • 我对该路径做探针时发现本 PR flush 循环里的一个潜在竞态:在循环最后一次
    检查与 promise 复位之间到达的 chunk 会留下 dirty 标志却无人重启写入——
    捕获会静默停止推进,close join 可能永久悬挂。.finally 现在在尾部仍脏时
    重启循环。(放弃分支已强制把该标志清零,因此不会对死掉的磁盘重新武装。)

变异探针

探针 移除的守卫 结果
1 超限分支保留尾部 ESC keeps an over-cap string payload terminator ESC held… 变红
2 CSI/Fe 丢弃武装 两个超限 CSI/Fe 测试变红
3 收尾分段 strip flushes held escape and decoder tails… 变红
4 失败记录 + 放弃重试 records a capture write failure… 变红
5 flush .finally 的脏位重启 records a capture write failure… 变红(写入端停在 2 次尝试)
6 读取端回扫拼接 三个重拼测试全部变红
6b 拼接路径上的前导 \n trim CRLF 测试变红
7 CSI/Fe 换行前瞻分支 strips a CSI leader cut by a line break whole 变红
8 两字节转义规则 strips two-byte escapes… 变红
9 通知呈现 includes an output capture failure… 变红
10 快照字段 serves a monitor output capture failure 变红

验证

  • npm run build —— 通过
  • npm run typecheck —— 通过
  • npm run lint —— 通过
  • 对全部 10 个改动文件执行 npx prettier --check —— 通过(其中 2 个文件先经 --write 自动格式化,再复核通过)
  • vitest packages/core:src/tools/monitor.test.ts(101)、src/services/monitorRegistry.test.ts(59)、src/services/backgroundShellRegistry.test.ts(86)—— 246 通过
  • vitest packages/cli:tasksSnapshot.test.ts(20)、serve/server.test.ts + serve/acp-http/transport.test.ts(1647)、acp-integration/acpAgent.test.ts(682)—— 通过
  • vitest packages/acp-bridge —— 1996 通过
  • vitest packages/sdk-typescript(DaemonClient、DaemonSessionClient、acpRouteTable)—— 616 通过
  • vitest packages/web-shell(TasksStatusMessage、daemon session actions)—— 229 通过
  • 集成测试:先 npm run bundle,再在 integration-tests/ 下运行 QWEN_SANDBOX=false npx vitest run cli/qwen-serve-routes.test.ts —— 38 通过

Deferred non-Critical feedback

Critical-only mode is active: 5 change-producing rounds are complete. The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback is deferred only after its author has used 2 regular feedback batches in this window's Critical-only tail; authors at that budget, if any, are named below. (@qwen-code /retry starts a fresh counting window.)

中文说明

已进入仅处理 Critical 的模式:已完成 5 个产生改动的轮次。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。维护者反馈仅在其本人于本窗口 Critical-only 阶段已使用 2 批常规反馈预算后才会延后;达到预算的作者(如有)在下方点名。(评论 @qwen-code /retry 可开启新的计数窗口。)

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

🧵 Resolved all 4 selected review thread(s). · 已关闭全部选中的 4 条评审线程。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 kimi-k3 · CLI 0.23.2

@wenshao

wenshao commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Round-5 local verification on a real stack (maintainer) — head 5ef3c95258

Follow-up to round-3 (0d2ee20037) and round-4 (4661b40aa4). One commit landed after my round-4 report — 5ef3c95258 — and it addresses both of the things I asked about.

Result: F1 and F2 are both fixed, and F1 by a better mechanism than the one I proposed. The new capture-failure surface works end to end. My round-4 blocking ask is resolved; I have no blocking item at this head. Two new Suggestion-level notes below (F3, F4), plus the Shell-side writer asymmetry from round 4 that is still open by design.

Rig — worktree at 5ef3c95258, lockfile-matched node_modules, npm run build; daemon node packages/cli/dist/index.js serve --port 8951 --workspace <tmp git repo>, isolated HOME; ~60-line fake OpenAI provider so real run_shell_command/monitor calls execute; system Chrome; Web Shell client bundle index-CHsT9Vdj.js. npm run typecheck and npm run lint both pass.


1. F1 is fixed — with an enumerated allowlist, which is the right call

I asked for TAIL_FE_ESC_REGEX + → *. The commit did something better: it left the Fe rule alone and added

const TAIL_TWO_BYTE_ESC_REGEX = /\x1b(?:[DEHMNOZ]|[6-9=>]|[cno|}~])/g;

That strips the assigned two-byte escapes without touching the byte after a genuinely stray ESC — so it resolves the ambiguity I flagged in round 3 instead of trading one side of it for the other. Measured on the real dist:

Fig 2 — F1, F2, F3

input round-4 head this head
split two-byte Fe ESC 7 / 8 / c / D / M / = / > 'a7b', 'a8b', … 'ab' — all seven
stray ESC W (a valid Fe = EPA, ambiguous) 'aW313 b' 'aW313 b' — unchanged, correctly
unassigned ESC Q / ESC z — 'aQb' / 'azb' — byte kept

Live on the daemon, same probe as round 4 (a child writing ESC and its final byte in separate write() calls 250 ms apart):

SHELL   served   "G-fe decsc decrc crix END"      (round 4: "G-fe 7decsc 8decrc ccrix END")
MONITOR served   "G-fe decsc decrc crix END"      and the capture file holds 0 ESC bytes

The monitor capture file being clean is the part that mattered most — round 4's version had written 7decsc to disk, where no reader-side fix could recover it.

Fig 1 — monitor and shell details


2. F2 is fixed — and better than I asked for

I suggested the firstBreak === -1 branch fall back to serving the window. The commit instead looks back up to 4 096 bytes for the sequence's leader and prepends it so the stripper can remove the whole sequence, which keeps the real first line as well:

tail shape, byte before the window = ESC round-4 head this head
tail has newlines real first line dropped (65 493 chars) 65 532 chars, first line intact, only the [31m residue removed
tail has no newline at all 0 chars — a 71 537-byte capture rendered as No output yet 65 532 chars served

3. The new capture-failure surface works end to end

I drove it live: a running monitor, then chmod 500 on its capture directory so every write fails.

Fig 3 — capture failure and mutants

served status        outputCaptureError = 'atomicWriteFile("…"): EACCES: permission denied…'
model notification   <summary>… Output capture failed: atomicWriteFile(&quot;…&quot;): EACCES …</summary>
give-up latch        the capture froze at 144 bytes / "cap line 12" and stayed there even after
                     I restored the directory permissions (MAX_OUTPUT_WRITE_FAILURES = 3)
final task           status completed, eventCount 25, served output 12 lines

So the tail really is stale — but it is no longer silently stale, which is the whole point of the change. Both the browser and the model are told.


4. F3 (new, Suggestion) — the lookback can serve past the documented 64 KiB ceiling

When the nearest ESC before the window is a stray one on the same line, everything between it and the window is prepended and served, so the response exceeds the cap by that distance:

ESC..window gap      0     100    1000    4000    4095    4096    5000
served chars     65536   65636   66536   69536   69631   65536   65536
over the cap         0     100    1000    4000    4095       0       0

Bounded at MAX_TAIL_SEQUENCE_LOOKBACK_BYTES = 4 096, so the served tail is really "at most 64 KiB + 4 095 bytes". The design doc and the reviewer test plan both say 64 KiB. Either clamp after prepending or state the real bound — this is a one-line call, not a defect.

5. F4 (new, Suggestion) — the capture-failure message is 746 chars and repeats the absolute path

The recorded error embeds the daemon-absolute capture path twice — once in the atomicWriteFile(…) prefix and once in the EACCES body — and it ships to the browser client and into the model-facing <summary>. This PR exists partly because "a daemon-local file path is often not useful to a remote browser"; trimming to the errno plus a basename would fit that better and cost the model far fewer tokens.

6. Still open from round 4 (not a regression, and not introduced here)

shell.ts:3871 is still outputStream.write(stripAnsi(event.chunk)) with no hold-back, and strip-ansi eats ESC P as a two-byte escape — so on kind=shell the DCS payloads still reach the browser, while the Monitor path (which this PR hardened) is clean:

SHELL     J-dcs 1$r0m\KEPT END      K-dcs-bel 1$r0mKEPT END      L-dcs-unterm 1$rpayload
MONITOR   J-dcs KEPT END            K-dcs-bel KEPT END           L-dcs-unterm

Fine as a follow-up; I only ask that the design doc not claim both halves share one grammar.


7. Mutation matrix on the new commit — 12 mutants, 8 killed

Mutation Verdict
N1 drop the two-byte rule from the strip chain KILLED
N2 two-byte rule loses the DEC private pairs [6-9=>] KILLED
N3 two-byte rule loses the locking shifts `[cno }~]`
N4 CSI rule loses its new newline lookahead KILLED
N6 sequence lookback disabled KILLED (4 tests)
N8 leading-newline trim after a reconstituted sequence removed KILLED
N10 writer never gives up after repeated failures KILLED
N11 first capture-write failure is not recorded KILLED
N5 Fe rule loses its new newline lookahead SURVIVED — "a ESC ( LF b" → head 'a\nb', mutant 'a(\nb'
N7 lookback no longer stops at a line break SURVIVED — an ESC on an earlier line: head serves 65 536 chars starting WINDOW-START, mutant serves 65 776 starting EARLIER LINE THAT MUST NOT BE PULLED IN
N12 discard grammar loses its CSI branch SURVIVED — a 6 000-byte over-cap CSI through the real monitor: head X-csi REAL-TEXT-AFTER END, mutant X-csi m REAL-TEXT-AFTER END
N9 shrunk-file guard removed SURVIVED — defensive branch, I did not construct a witness

The three witnessed survivors are coverage gaps, not equivalent mutants. N7 is the one worth a line of test — it is exactly what keeps F3 bounded to a single line instead of running back across the whole 4 KiB lookback.


8. Reviewer Test Plan and guards at this head

item result
1 — Shell after completion 91 lines, 45 stdout + 45 stderr + SHELL-DONE-MARKER, truncated: false
2 — Monitor with throttling Events 6, Dropped 115, all 121 captured lines present
4 — Copy button present and enabled on the detail (1 copy button)
5 — Capability gate unchanged since round 4 (client contribution byte-identical)
kind missing / kind=bogus   -> 400 VALIDATION_FAILED
no Authorization header     -> 401
symlink over the path       -> in-band error, the target's secret never served
FIFO over the path          -> HTTP 200 in 12 ms, daemon not wedged

Suites at this head, all green, no flakes this run:

Suite Result
npm run typecheck / npm run lint pass / pass
packages/core (backgroundShellRegistry, monitor, monitorRegistry) 246 passed
packages/web-shell PR-touched (3 files) 1 061 passed
packages/sdk-typescript daemon (3 files) 616 passed
packages/cli (tasksSnapshot, server, acpAgent) 1 961 passed
packages/acp-bridge (status, bridge) 938 passed

Verdict

My round-4 blocking ask is resolved and I have no blocking item at this head. F1 was fixed with a better mechanism than I proposed, F2 was fixed more thoroughly than I asked, and the capture-failure surface does what it says on a real daemon. F3, F4, the N5/N7/N12 coverage gaps and the Shell-side writer asymmetry are all follow-up sized. From my side this is ready to merge.

中文说明

第五轮本地真实环境验证(维护者)—— head 5ef3c95258

这是对第三轮(0d2ee20037)与第四轮(4661b40aa4)的续篇。我第四轮报告之后又落地了一个提交 5ef3c95258,它把我提的两点都处理了。

结论:F1 与 F2 都已修复,而且 F1 用的机制比我提议的更好。新增的捕获失败上报链路端到端可用。我第四轮的阻断项已解决,当前 head 上我没有阻断项。 下文有两条新的建议级说明(F3、F4),以及第四轮那条按设计仍未处理的 shell 侧写入器不对称。

验证台 —— worktree 位于 5ef3c95258,node_modules 与 lockfile 一致,npm run build;daemon 用 node packages/cli/dist/index.js serve --port 8951 --workspace <临时 git 仓库>,隔离 HOME;约 60 行假 OpenAI provider 使真实 run_shell_command/monitor 调用可以执行;系统 Chrome;Web Shell 客户端 bundle 为 index-CHsT9Vdj.js。npm run typecheck、npm run lint 均通过。

1. F1 已修复 —— 用枚举白名单,这是更正确的做法

我要求的是把 TAIL_FE_ESC_REGEX 的 + 改成 *。提交做了更好的处理:保留 Fe 规则不动,新增

const TAIL_TWO_BYTE_ESC_REGEX = /\x1b(?:[DEHMNOZ]|[6-9=>]|[cno|}~])/g;

它剥离的是已分配的双字节转义,而不碰真正游离 ESC 之后的那个字节——也就是说,它把我第三轮指出的歧义解决掉了,而不是在两侧之间做交换。基于真实产物实测:

输入 第四轮 head 本 head
被切分的双字节 Fe ESC 7 / 8 / c / D / M / = / > 'a7b'、'a8b' …… 'ab' —— 七个全部
游离 ESC W(合法 Fe = EPA,有歧义) 'aW313 b' 'aW313 b' —— 保持不变,正确
未分配的 ESC Q / ESC z — 'aQb' / 'azb' —— 字节保留

在真实 daemon 上跑与第四轮相同的探针(子进程把 ESC 与终止字节分两次 write(),间隔 250 ms):

SHELL   返回   "G-fe decsc decrc crix END"      (第四轮:"G-fe 7decsc 8decrc ccrix END")
MONITOR 返回   "G-fe decsc decrc crix END"      且捕获文件中 ESC 字节数为 0

monitor 捕获文件变干净是最要紧的一点——第四轮那一版已经把 7decsc 写进了磁盘,读取端再怎么修也救不回来。

2. F2 已修复 —— 而且比我要求的更彻底

我建议 firstBreak === -1 那个分支回退为照常返回窗口。提交改为向前回看最多 4 096 字节找到序列引导符并前置拼接,让剥离器可以整段移除该序列,因此真实的首行也保住了:

尾部形态,窗口前一字节 = ESC 第四轮 head 本 head
尾部含换行 真实首行被丢弃(65 493 字符) 65 532 字符,首行完整,只移除了 [31m 残留
尾部完全没有换行 0 字符——71 537 字节的捕获文件显示为「暂无输出」 返回 65 532 字符

3. 新增的捕获失败上报链路端到端可用

我实测驱动了它:一个运行中的 monitor,然后对其捕获目录 chmod 500 使每次写入都失败。

返回状态       outputCaptureError = 'atomicWriteFile("…"): EACCES: permission denied…'
模型通知       <summary>… Output capture failed: atomicWriteFile(&quot;…&quot;): EACCES …</summary>
放弃闩锁       捕获文件冻结在 144 字节 /「cap line 12」,即使我恢复目录权限后也不再前进
               (MAX_OUTPUT_WRITE_FAILURES = 3)
最终任务       status completed、eventCount 25、返回输出 12 行

也就是说尾部确实是陈旧的——但不再是静默陈旧,而这正是本次改动的意义所在。浏览器和模型都被告知了。

4. F3(新发现,建议级)—— 回看拼接可能返回超出文档所述 64 KiB 上限的内容

当窗口之前最近的 ESC 是同一行上的游离 ESC 时,它与窗口之间的所有内容都会被前置拼接并返回,于是响应按该距离超出上限:

ESC 到窗口的间距      0     100    1000    4000    4095    4096    5000
返回字符数        65536   65636   66536   69536   69631   65536   65536
超出上限              0     100    1000    4000    4095       0       0

上界为 MAX_TAIL_SEQUENCE_LOOKBACK_BYTES = 4 096,所以返回的尾部实际上是「最多 64 KiB + 4 095 字节」。设计文档与评审测试计划写的都是 64 KiB。要么在拼接后再裁剪,要么把真实上界写清楚——这是一行的取舍,不是缺陷。

5. F4(新发现,建议级)—— 捕获失败消息长 746 字符且重复了绝对路径

记录的错误把 daemon 的绝对捕获路径嵌入了两次——一次在 atomicWriteFile(…) 前缀里,一次在 EACCES 正文里——并且会送到浏览器客户端和面向模型的 <summary>。本 PR 的立意之一就是「daemon 本地文件路径对远程浏览器通常没有用」;裁剪成 errno 加上文件基名会更契合,也能为模型省下大量 token。

6. 第四轮遗留、非回归、也不是本次引入

shell.ts:3871 仍然是 outputStream.write(stripAnsi(event.chunk)) 且没有 hold-back,而 strip-ansi 会把 ESC P 当作双字节转义吃掉——所以在 kind=shell 上 DCS payload 仍会送到浏览器,而本 PR 加固过的 monitor 链路是干净的:

SHELL     J-dcs 1$r0m\KEPT END      K-dcs-bel 1$r0mKEPT END      L-dcs-unterm 1$rpayload
MONITOR   J-dcs KEPT END            K-dcs-bel KEPT END           L-dcs-unterm

作为后续处理没问题;我只希望设计文档不要声称两半共用同一套语法。

7. 新提交的变异矩阵 —— 12 个变异体,杀死 8 个

变异 判定
N1 从剥离链中删掉双字节规则 KILLED
N2 双字节规则丢掉 DEC 私有对 [6-9=>] KILLED
N3 双字节规则丢掉锁定移位 `[cno }~]`
N4 CSI 规则丢掉新增的换行前瞻 KILLED
N6 停用序列回看 KILLED(4 条用例)
N8 去掉重建序列后的前导换行裁剪 KILLED
N10 写入器在反复失败后永不放弃 KILLED
N11 不记录首次捕获写入失败 KILLED
N5 Fe 规则丢掉新增的换行前瞻 SURVIVED —— "a ESC ( LF b" → head 'a\nb',变异体 'a(\nb'
N7 回看不再在换行处停止 SURVIVED —— 上一行存在 ESC 时:head 返回 65 536 字符、起始为 WINDOW-START,变异体返回 65 776 字符、起始为 EARLIER LINE THAT MUST NOT BE PULLED IN
N12 丢弃语法丢掉 CSI 分支 SURVIVED —— 6 000 字节的超限 CSI 经真实 monitor:head 得 X-csi REAL-TEXT-AFTER END,变异体得 X-csi m REAL-TEXT-AFTER END
N9 去掉文件被截短的守卫 SURVIVED —— 防御性分支,我没有构造出证人

三个有证人的存活者都是覆盖缺口而非等价变异体。其中 N7 最值得补一条用例——正是它把 F3 限制在一行之内,而不是让回看跑遍整个 4 KiB。

8. 本 head 上的评审测试计划与护栏

项目 结果
1 —— Shell 完成后 91 行,45 stdout + 45 stderr + SHELL-DONE-MARKER,truncated: false
2 —— 触发限流的 Monitor Events 6、Dropped 115,全部 121 行都在
4 —— 复制按钮 详情上存在且可用(1 个复制按钮)
5 —— 能力门 自第四轮以来未变(客户端贡献逐字节一致)
缺少 kind / kind=bogus   -> 400 VALIDATION_FAILED
无 Authorization 头      -> 401
在路径上放置符号链接     -> 带内错误,目标文件的秘密内容从未被返回
在路径上放置命名管道     -> HTTP 200,耗时 12 ms,daemon 未被楔死

本 head 上的套件,全绿,本次无抖动:

套件 结果
npm run typecheck / npm run lint 通过 / 通过
packages/core(backgroundShellRegistry、monitor、monitorRegistry) 246 通过
packages/web-shell 本 PR 触及(3 个文件) 1 061 通过
packages/sdk-typescript daemon(3 个文件) 616 通过
packages/cli(tasksSnapshot、server、acpAgent) 1 961 通过
packages/acp-bridge(status、bridge) 938 通过

结论

我第四轮的阻断项已解决,当前 head 上我没有阻断项。 F1 用了比我提议更好的机制修复,F2 修得比我要求的更彻底,捕获失败上报链路在真实 daemon 上确实做到了它所声称的事。F3、F4、N5/N7/N12 的覆盖缺口以及 shell 侧写入器不对称都是后续量级的事项。从我这边看,可以合入。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 AutoFix stopped: this counting window now contains 3 agent time-budget exhaustions (pushed rounds in between included; this round itself may have failed differently). That is 3 full agent runs that pushed nothing. A human should split or reduce the PR (or raise the agent time budget AND its step backstop together), then comment @qwen-code /retry to re-arm. Until then future scans will skip this PR.

⚠️ This change was NOT pushed — any commit referenced below was made only in the runner workspace and has been discarded. What the agent reported:
Qwen failed during address-review: timeout (7200000ms).

See the Qwen Autofix agent step logs for model/tool output.

中文说明

🤖 AutoFix 已停止:当前计数窗口内已累计 3 次时间预算耗尽(含其间推送过的轮次;本轮本身可能以别的方式失败)。即 3 次完整 agent 运行没有推送任何内容。应由人工拆分或缩减该 PR(或同时提高 agent 时间预算与其步骤兜底),然后评论 @qwen-code /retry 重新武装。在此之前,后续扫描将跳过本 PR。

Run log: https://github.com/QwenLM/qwen-code/actions/runs/34451132365


🧠 Handled by Qwen Code · model/模型 kimi-k3 · CLI 0.23.2

@qwen-code-dev-bot qwen-code-dev-bot added the autofix/needs-human The autofix loop stopped on this PR — a human must re-arm, split, merge, or close it label Sep 10, 2026
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

⏸️ Takeover paused: this PR reached its round cap (100/100). Comment @qwen-code /takeover to re-arm a fresh window and continue management, or @qwen-code /takeover stop to release.

中文说明

⏸️ 托管已暂停:本 PR 达到轮次上限(100/100)。评论 @qwen-code /takeover 可重新武装、开启新窗口继续托管;或评论 @qwen-code /takeover stop 释放。

@wenshao

wenshao commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

Both sides grew legacySessionTelemetryRoutes from the same 69-route base,
so the drift-guard counts collided:

- this PR added GET /session/:id/tasks/:taskId/output  -> 70 routes / 68 handler_resolved
- main added GET+POST /session/:id/sources and
  DELETE /session/:id/sources/:sourceId               -> 72 routes / 70 handler_resolved

The route table itself auto-merged with all four entries, so the assertions
take the union: 73 routes, 71 handler_resolved, 2 pre_resolved (unchanged).
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

Root cause

Main's 471b6e5ef5 (PR #11262, session sources) collided with this PR. Both append to the same legacySessionTelemetryRoutes catalog and rewrite the same derived counters: ours added GET /session/:id/tasks/:taskId/output (69→70), theirs the three /session/:id/sources routes (69→72).

Semantic, not merely adjacent

The route table auto-merged: the four entries sit in disjoint regions and all survived. The counters did not — each side wrote a total that must include the other's routes, so taking either parent under-counts and fails the guard. Resolution is the union, verified by counting the merged table:

// telemetry.test.ts
it('contains 73 unique routes with the audited 71/2 attribution split', ...)
expect(keys).toHaveLength(73);   expect(new Set(keys).size).toBe(73);
/* handler_resolved */ ).toHaveLength(71);  // 67 base + 1 ours + 3 theirs
/* pre_resolved     */ ).toHaveLength(2);   // untouched by either side
// telemetry-catalog.test.ts
expect(registered).toHaveLength(73);  expect(registered).toEqual(catalog);

Load-bearing

  • registered comes from the live Express router stack, so 73 holds only if both sides' registrations survived too. They did (session.ts:6243/:6349/:6368/:6393). Never relax toEqual(catalog) into a subset check to make a count pass — that equality is the guard.
  • Catalog order is not load-bearing for the new routes: the matcher rejects on segment-count mismatch first, so the 3-, 4- and 5-segment additions cannot shadow each other. Pairwise-checking all 73, the only first-match-wins ambiguity is pre-existing: turns/current vs turns/:promptId, untouched here, but that one is order-sensitive — keep current first.

Not verified

No build, typecheck, lint or tests were run. Two non-conflicted dependents, checked statically:

  • integration-tests/cli/qwen-serve-routes.test.ts auto-merged an exact capability-key list both sides extended (session_task_output ours; session_sources, web_shell_brand theirs). All three are in the merged test and in SERVE_CAPABILITY_REGISTRY.
  • telemetry.test.ts's "matches every catalog entry…" loop is catalog-driven, so it now runs theirs' three routes through this PR's resolver for the first time. The resolver builds from the same catalog, so no break is expected.
中文说明

根因:main 的 471b6e5ef5(PR #11262 会话来源)与本 PR 冲突。两者都向同一 legacySessionTelemetryRoutes 目录追加条目并改写同一组计数:本 PR 加 GET /session/:id/tasks/:taskId/output(69→70),对方加三条 /session/:id/sources 路由(69→72)。

语义冲突,非单纯相邻:路由表自动合并成功(四条条目区域不重叠,全保留),冲突在计数——双方的总数都须含对方路由,取任一父提交都会少算并使守卫失败。故取并集,已统计表核实:73 条路由、71 条 handler_resolved(67+1+3)、pre_resolved 仍为 2。

关键约束:registered 取自真实 Express 路由栈,故 73 成立前提是双方路由注册也保留;切勿为让计数通过而把 toEqual(catalog) 放宽成子集比较,该断言即守卫。新路由不依赖目录顺序——匹配器先比段数,3/4/5 段新增路由互不遮蔽;两两检查 73 条,唯一「首个匹配胜出」歧义是既有的 turns/current 与 turns/:promptId,current 须在前。

未能验证:未跑构建、类型检查、lint 或测试。仅静态核查两个非冲突依赖:integration-tests/cli/qwen-serve-routes.test.ts 的精确 capability 列表由双方各自扩展(session_task_output / session_sources / web_shell_brand),三键在测试与注册表中均在;telemetry.test.ts 的「matches every catalog entry…」由目录驱动,会首次用本 PR 解析器跑对方三条路由。均未运行。

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — head 508b31b1

Note on which commit this is against. The head moved while I was reviewing (from 5ef3c95 to 508b31b1), and I re-anchored everything below to 508b31b1. If it moves again, treat the line references as needing re-checking rather than as findings.

Reviewed by reading the diff and the affected files at this SHA. No local test run.

Verdict: ESCALATE to maintainer, with no Critical from me. 49 files across packages/core/src/{services,tools,utils}, packages/cli/src/serve/**, packages/acp-bridge, packages/sdk-typescript and packages/web-shell — cross-package, and it adds a new daemon session route plus a capability flag, so it needs maintainer sign-off on the route contract rather than mine. What I could verify myself, I did, and it held up; details below so the next reader does not have to redo it.

The O_NONBLOCK hardening is correct, and the reasoning is the good kind

packages/core/src/utils/no-follow-open.ts, both variants:

-  const baseFlags = fs.constants?.O_RDONLY ?? 0;
+  const baseFlags =
+    (fs.constants?.O_RDONLY ?? 0) | (fs.constants?.O_NONBLOCK ?? 0);

A FIFO planted under the workspace's own .qwen/ would otherwise block open(2) until a writer appears, and the !stat.isFile() guard only runs after the open returns — so the guard cannot save you, and the stall is daemon-wide. O_NONBLOCK is a no-op for regular files and makes the FIFO open return immediately so the existing fstat check fails closed. The flag does persist on the returned fd, but since non-regular files are rejected post-open, no caller can end up reading EAGAIN off a socket or chardev. This is a shared core utility, though, which is part of why I am escalating rather than approving: every existing caller inherits the flag change.

Web Shell conventions — checked, clean

ProcessTaskOutput in packages/web-shell/client/components/messages/TasksStatusMessage.tsx is a plain function component that owns its refs (outputElementRef, outputValueRef, followNextOutputRef) and renders no portal, so the React.forwardRef requirement for ref-sensitive wrappers and the useWebShellPortalRoot() requirement for portalled components do not apply here. No data-web-shell-* attribute or --web-shell-* variable is disturbed.

The polling effect is well-behaved: an active flag guards every then/catch/finally, the interval is cleared in the cleanup, and the parent remounts via key={${currentTask.kind}:${currentTask.id}} so state cannot leak across tasks. I did stop on the deps list including task.runtimeMs, which changes on every tasks poll and therefore tears down and re-creates the interval before it can fire — but the comment there already says exactly that, and states that the interval exists for cross-session restored tabs which never receive a snapshot advance. Intentional and documented; no finding.

Suggestion — loading is never cleared on the unsupported path

Same component: loading initialises to true, the effect returns early on if (!supported) return, and nothing resets it. It is unobservable today because if (!supported) return null; short-circuits the render before any consumer sees it, so this is not a bug — but it is a state field whose invariant is maintained by a return null several lines away. Initialising loading to supported would make the two agree locally and survive someone later rendering a placeholder instead of null.

CI picture at this head

Mostly green, including Lint & Static, Integration Tests (no-AK, No Sandbox) and Real daemon E2E. Still outstanding: Serve A/B (ubuntu-latest) and Test (ubuntu-latest, Node 22.x) are pending, and Test (macos-latest) / Test (windows-latest) report skipping. Given this adds a daemon route and a capability flag, I would want Serve A/B green before merge, and I would not read the skipped platform lanes as cross-platform coverage for the no-follow-open.ts change — O_NONBLOCK and O_NOFOLLOW availability is exactly where platforms differ.

wenshao
wenshao previously approved these changes Sep 11, 2026
@wenshao

wenshao commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao
wenshao disabled auto-merge September 11, 2026 22:53

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical-only review pass at head 508b31b1ec1c0c145988540a17b2904ebb6f0ff6. Every distinct Critical still open on this PR is verified fixed by reading the current head code, and the independent scan found no new merge-blocking defect. The outstanding automated change request cites the findings below; each is addressed at this head, so those threads can be closed against this evidence.

Open Critical threads, re-verified on this head

  • R2-1 - monitor capture decoded each pipe chunk with a raw toString('utf-8'), baking U+FFFD into the persisted file - fixed. packages/core/src/tools/monitor.ts imports StringDecoder and gives each stream its own instance (decoder: new StringDecoder('utf8') on both the stdout and stderr buffers, with the comment that a shared one would corrupt the other), decoding through buffer.decoder.write(data) so a multi-byte codepoint split across pipe chunks is reassembled. On teardown it flushes buf.decoder.end() and strips the concatenation heldEscape + decoderResidue as one unit, so a codepoint or sequence straddling the final chunk is not corrupted either.
  • R2-4 - ANSI sequences straddling two chunks survived per-chunk stripAnsi and were persisted literally - fixed. The capture now holds back a trailing incomplete escape sequence per buffer (buffer.heldEscape) and prepends it to the next chunk, with the comment that a chunk-final fragment would otherwise persist the reassembled sequence's debris; the terminator-straddling case keeps the ESC held so the next chunk's first byte decides what it starts, and an over-cap leader is dropped from that chunk rather than persisted. On the read side stripOutputControlChars removes whole OSC/DCS/CSI/Fe/two-byte sequences before the per-character backstop, and readTaskOutputTail reconstitutes a window that opens mid-sequence through a bounded 4 KiB lookback.
  • R2-2 - the telemetry route drift guard pinned stale counts, failing two existing tests - fixed. packages/cli/src/serve/server/telemetry.test.ts (+4/-4) and telemetry-catalog.test.ts (+1/-1) carry the bumped pins at this head, and Test (ubuntu-latest, Node 22.x), which runs that suite, concludes success on this commit.
  • R2-3 - an unknown or evicted taskId threw RequestError.invalidParams, surfacing a client miss as a 5xx / -32603 server fault - fixed. buildSessionTaskOutputStatus in packages/cli/src/acp-integration/acpAgent.ts now returns { v, sessionId, taskId, kind, output: '', truncated: false, error: 'Task output is unavailable.' } when the registry has no entry, and the same fail-closed payload when readTaskOutputTail reports an error. The remaining RequestError.invalidParams throws in the ext-method arm are reserved for genuinely malformed request parameters (missing sessionId/taskId, a taskKind outside shell | monitor).
  • R3-1 - cross-session restored task tabs read output from the active session - fixed. packages/web-shell/client/App.tsx now overrides getTaskOutput: (taskId, kind) => workspace.client.sessionTaskOutput(tab.sourceSessionId, taskId, kind) in crossSessionActions alongside getTasks and cancelTask, so a restored tab reads through its owning session.
  • R4-1 - the client-triggerable read opened the task-owned file without O_NONBLOCK, so a planted FIFO could stall the daemon's main thread - fixed. packages/core/src/utils/no-follow-open.ts now builds baseFlags as (fs.constants?.O_RDONLY ?? 0) | (fs.constants?.O_NONBLOCK ?? 0) in both openSyncNoFollow and openNoFollow, including the fallback branch used where O_NOFOLLOW is unavailable. readTaskOutputTail opens through openSyncNoFollow(outputFile) and only then reaches fs.fstatSync(fd) with if (!stat.isFile() || stat.size <= 0) return undefined;, so the FIFO open returns immediately and the guard fails closed instead of blocking upstream of it.

Current scan

  • Route ownership and scoping. GET /session/:id/tasks/:taskId/output in packages/cli/src/serve/routes/session.ts is wrapped in withOwnerReadSession, validates taskId presence and kind against shell | monitor with a 400 VALIDATION_FAILED before any read, sets Cache-Control: no-store, and delegates to the resolved runtime's own bridge (runtime.bridge.getSessionTaskOutputStatus(sessionId, taskId, kind)), so there is no primary-runtime fallback and no cross-session lookup path.
  • Bounded, race-aware tail read. readTaskOutputTail caps the window at Math.min(stat.size, maxBytes, MAX_TASK_OUTPUT_TAIL_BYTES) (64 KiB), detects the file shrinking under the read (bytesRead <= lookback returns undefined rather than serving a short or wrong window), and reports a lossy carriage-return frame collapse through droppedFrames folded into truncated instead of silently discarding records.
  • Client surface. getTaskOutput in packages/web-shell/client/daemon/session/actions.ts runs through withActionTimeout, the action type is declared on DaemonSessionActions, the capability gate keeps older daemons on the metadata-only view, and the four new UI strings exist in both EN and ZH.

CI

Every non-skipped check on this head concluded success, including Test (ubuntu-latest, Node 22.x), Lint & Static, Serve A/B, web-shell E2E Smoke, Capture web-shell visuals, both Desktop Shell lanes, Integration Tests (no-AK), Live Host and the real-daemon E2E lanes. Nothing is failing, cancelled or still running.

Still open, non-blocking

The remaining unresolved threads are Suggestion-level and were not treated as gates: test pinning for the hard-cap operand, the copy button's unavailable and clipboard-failure paths, the capability-fallback render gate, the unavailable-to-recovered transition, the truncation notice's negative case, WS_READ_METHODS read classification, and the unpinned lockedIn-style label widths.

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🔓 Takeover auto-released: the autofix loop paused on this PR 3 day(s) ago (🤖 AutoFix stopped: this counting window now contains 3 agent time-budget exhaustions (pushed rounds in between included;) and no re-arm followed, so the autofix/takeover label is removed to keep the managed pool honest. autofix/needs-human stays as the reminder that this PR needs a human decision: merge it, close it, or split/reduce it and comment @qwen-code /takeover to re-engage with a fresh round window.

中文说明

🔓 已自动释放接管:autofix 循环在 3 天前暂停于此 PR(🤖 AutoFix stopped: this counting window now contains 3 agent time-budget exhaustions (pushed rounds in between included;),此后无人重新武装,现移除 autofix/takeover 标签以保持托管池真实可用。保留 autofix/needs-human 作为待办提醒 —— 本 PR 需要人工决策:合并、关闭,或拆分/缩小后评论 @qwen-code /takeover 以全新轮次窗口重新接管。

@qwen-code-dev-bot qwen-code-dev-bot removed the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Sep 13, 2026

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review corrects our own prior approval at this head and flips our verdict. It files no new finding: the four [Critical] threads named below were filed by the automated reviewer on 2026-09-10 and are still open. Nothing here re-argues them, and no equivalent feedback is being duplicated.

State as read immediately before posting this review: head 508b31b1ec1c0c145988540a17b2904ebb6f0ff6 (unmoved since 2026-09-10), reviewDecision=CHANGES_REQUESTED, mergeable_state=dirty.

1. What is void in our APPROVED review of 2026-09-11T23:00:58Z (id 5184081272)

Two claims, both falsified at the same head they were made about:

  • "Every distinct Critical still open on this PR is verified fixed by reading the current head code, and the independent scan found no new merge-blocking defect."
  • "Still open, non-blocking — The remaining unresolved threads are Suggestion-level and were not treated as gates."

A complete review-thread census at this head (cursor-paginated, totalCount=111 fetched=111 MATCH=True) reads 87 unresolved threads, 21 of which open with a [Critical] comment. Four are anchored at this head with isResolved=false, isOutdated=false:

thread location
R13-1 packages/core/src/services/backgroundShellRegistry.ts:73
R15-3 packages/acp-bridge/src/status.ts:840
R17-1 packages/core/src/services/backgroundShellRegistry.ts:172
R17-2 packages/core/src/services/backgroundShellRegistry.ts:84

That approval enumerated six findings from earlier rounds (R2-1, R2-4, R2-2, R2-3, R3-1, R4-1) and never mentioned these four. Its "remaining unresolved threads are Suggestion-level" sentence is therefore wrong as written: it covers 21 unresolved [Critical] threads, not only Suggestions.

2. What we independently re-measured this round: R17-1 is real, and it is a regression

We are not taking the automated finding on faith — we executed it against a checkout of this head.

Location. readTaskOutputTail in packages/core/src/services/backgroundShellRegistry.ts. The window itself is correctly capped at :166 (length = Math.min(stat.size, maxBytes, MAX_TASK_OUTPUT_TAIL_BYTES)), but :205-211 then scans the lookback prefix (:171, bounded by MAX_TAIL_SEQUENCE_LOOKBACK_BYTES = 4096, :44) for a preceding ESC and prepends buffer.subarray(i, lookback) on top of the window. Between there and the return { text: trimmed } at :228-231 the only reductions are stripOutputControlChars (:217), normalizeOutputCarriageReturns (:217), a conditional single leading \n removal and a trimEnd() (:223-225). There is no re-clamp to maxBytes. When the stripper does not consume the prepended bytes — because that ESC leads a sequence that already closed before the window, or leads no recognised sequence at all — every real byte between it and the window start is served in addition to the full window.

Trigger. Any capture file with an ESC in the ≤4096 bytes before the served window and no line break between them. That is ordinary ANSI-coloured child output, not an adversarial input.

Impact. The served text exceeds the caller's byte budget by up to 4096 bytes. Measured on this head:

case file bytes maxBytes served over cap
ESC [ W + 19 real bytes + 11-byte window 33 11 30 yes
zz\n + ESC[31m + b×4091 + a×65536 69635 65536 69627 yes
control: same file, no ESC before the window 69630 65536 65536 no
control: default caller (:674, 8192), no ESC 9003 8192 8192 no

Both controls stay exactly at their cap, so the overflow is caused by the prepend and not by the harness. The second row breaks the 64 KiB bound (MAX_TASK_OUTPUT_TAIL_BYTES, :38) that the task-output endpoint documents, and the first row's shape reaches the pre-existing shell <output-tail> notification at :674, which calls with the default MAX_NOTIFICATION_OUTPUT_TAIL_BYTES = 8192.

Note one correction to the automated thread: its first witness quotes 31 bytes for an 11-byte request; our execution measures 30. The mechanism and the second witness (69627) agree exactly — we derive that figure from the constants alone before running anything (3+5+4091+65536 = 69635 file bytes ⇒ length = 65536, start = 4099, lookback = 4096, prepend ESC[31m+b×4091 ⇒ strip leaves 4091 + 65536 = 69627) — but where the two differ, 30 is the measured value.

Base arm — this is a regression, not pre-existing surface. At merge base 779cfe913bd1dd8063cc822fecd5128ccacc2769 the function is readOutputTail(outputFile) with no maxBytes parameter: MAX_TAIL_SEQUENCE_LOOKBACK_BYTES does not exist anywhere in the file, and the read is Buffer.allocUnsafe(length) → fs.readSync(fd, buffer, 0, length, start) → buffer.subarray(sliceOffset, bytesRead). A subarray of a length-sized buffer, passed only through a stripper and a trimEnd, cannot exceed length. So the base served text was structurally within budget and this diff introduces the over-cap path.

Why the existing test cannot see it. backgroundShellRegistry.test.ts:741 (it('limits output-tail to the retained byte budget')) builds its fixture at :745 as 'prefix-' + 'a'.repeat(MAX_NOTIFICATION_OUTPUT_TAIL_BYTES) + '\nlast line\n' and asserts not.toContain('prefix-') at :757. There is no ESC before prefix-, so the fixture exercises exactly our third control row — the bounded path — and passes whether or not the prepend is clamped.

Fix direction. Either clamp the served text to maxBytes after stripping (take the trailing maxBytes bytes of trimmed, and keep truncated true when that clamp bites), or bound the prepend itself so prependLength + windowLength <= maxBytes by shrinking length before the read. Then extend the :741 fixture with a second case that places an ESC immediately before prefix-, which is the mutation the current pin cannot detect.

3. What is unaffected

The six fix verifications in that approval — R2-1 (StringDecoder per stream in monitor.ts), R2-4 (held-escape reassembly plus stripOutputControlChars/lookback on the read side), R2-2 (telemetry pin bumps), R2-3 (buildSessionTaskOutputStatus failing closed instead of throwing invalidParams), R3-1 (crossSessionActions.getTaskOutput reading through tab.sourceSessionId), R4-1 (O_NONBLOCK in no-follow-open.ts plus the isFile() guard) — are each cited to a specific file and line on a different code path from the one above. They were not re-measured this round and we do not retract them; the author and maintainers can still rely on them.

4. What must be read with caution

That approval's "Bounded, race-aware tail read" bullet says readTaskOutputTail "caps the window at Math.min(stat.size, maxBytes, MAX_TASK_OUTPUT_TAIL_BYTES) (64 KiB)". Read as a statement about length at :166 it is literally true, and the race detection it describes (bytesRead <= lookback returning undefined) is real. It must not be quoted as bounding the served text, which is what section 2 falsifies.

5. What we did not assess

R13-1 (the hand-rolled ECMA-48 grammar being narrower than a real VT parser), R15-3 (outputCaptureError produced and serialized but rendered by no task-detail surface) and R17-2 (the five-pass .replace() chain not being closed under deletion-adjacency) remain the automated reviewer's findings at this head. We did not independently verify them this round and take no position on their severity. They are listed above only because our approval's universal claim covered them.

6. Gate state and scope of this action

reviewDecision is already CHANGES_REQUESTED and mergeable_state is dirty, so this review changes nothing about mergeability today. Its purpose is to remove a misleading signal: our APPROVED row is currently live at head against 21 unresolved [Critical] threads, and it is the only reasoned approval on the PR. We are superseding our own row only — the maintainer approval submitted at 2026-09-11T22:52:39Z is theirs and we say nothing about it. We are not withdrawing or re-filing any dismissed review, and we are not asking for the four threads to be re-litigated; they are open and, for R17-1, now independently confirmed.

@wenshao

wenshao commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code attempted to resolve merge conflicts but the run did not complete successfully.

Check the workflow run for full logs.

@wenshao

wenshao commented Sep 27, 2026

Copy link
Copy Markdown
Collaborator

Review follow-up and verification report

Commit: 84402dff7a. Merged current main and resolved the bridge split, sandbox runtime, telemetry catalog, and test integration conflicts.

The follow-up fixes the confirmed control-filter grammar/adjacency issues, strict returned-byte bound, capture-failure visibility and recovery, and a terminal Monitor capture-flush race. The full build, typecheck, bundle, lint, targeted Core/CLI/ACP/SDK/Web Shell tests and 41 daemon-route integration tests passed locally. Two clean self-audit passes and independent review found no remaining confirmed Critical. Remote CI is running separately.

E2E and regression verification

Verified the rebuilt CLI with a real Monitor child process and a localhost mock model. Split escapes, embedded controls, oversized OSC payloads terminated by C1 ST/CAN, and ordinary Unicode text produced the expected captured output and served tail. All seven output lines survived even when event notifications were throttled.

The original parser/reader witnesses now pass: no measured escape residue, no ordinary text lost through adjacent escapes, and exactly 65,536 bytes returned for the former 69,627-byte overflow case.

Capture failures show a notice in Web Shell and TUI while retaining captured text; recovery clears the warning. A cancelled monitor's task-output request stays pending until the final capture flush completes, then returns the final bytes.

Results: 45 focused regression tests passed, plus the bundled-CLI capture assertion. This combines headless CLI E2E with component and ACP endpoint tests; it does not claim interactive browser E2E or a physical disk-full test.

Tested bundle SHA-256: c2e08955908a194b4b041c0a7897e0ed0c9c38d5e1abaea3919cfe86991dcace.

中文:已合并 main 并处理冲突,修复控制序列清理、输出字节上限、捕获失败提示及取消时末尾输出读取竞态。本地构建、类型检查、打包、lint、相关定向测试及 41 项路由集成测试通过;最终实测和回归验证结果如下述报告。远端 CI 正在运行,未将本地测试结果等同于远端 CI 通过。

@wenshao

wenshao commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

Review follow-up scope

Tracked in #12849.

This PR has already passed through more than 17 review rounds. Following AGENTS.md’s review-convergence rule, this update fixes confirmed correctness defects and merge regressions. The following remaining Suggestion families are explicitly deferred rather than represented as fixed. Repeated round-2/3/4 copies are grouped under their original id; each linked thread retains the detailed request.

Review family Follow-up
R1-1 (5 thread(s)) Additional resolved-with-error coverage for daemon and UI
R1-3 (4 thread(s)) Output-file creation failure coverage
R1-4 (4 thread(s)) Missing task id / invalid kind ACP validation coverage
R1-6 (4 thread(s)) Explicit notification-throttle versus capture-retention coverage
R1-7 (4 thread(s)) Remaining shared TaskBase comments about reserved output paths
R1-8 (4 thread(s)) Retry UX after a terminal task read fails
R1-9 (4 thread(s)) Monitor output path access through generic file tools
R1-11 (4 thread(s)) Initial-mount log scroll position
R2-5 (3 thread(s)) Capture write-amplification/performance tuning
R2-6 (3 thread(s)) Registry idle/cancel settlement ordering (terminal-read flush race is fixed here; broader lifecycle policy remains separate)
R2-7 (3 thread(s)) Superseded output response guard coverage
R2-8 (3 thread(s)) Explicit WebSocket read-tier classification coverage
R2-9 (3 thread(s)) Stronger old-daemon render-gate negative assertion
R2-10 (3 thread(s)) Copy button empty/unavailable-state coverage
R2-12 (3 thread(s)) Clipboard failure coverage
R2-13 (3 thread(s)) Unavailable-read to recovered-read UI transition coverage
R2-14 (3 thread(s)) Truncation notice negative assertion

R1-5 (CI collection) is covered by the current no-AK integration script and the 41-test integration run. R1-12 (unbounded file) is already fixed by the rolling tail; R2-11 (hard-cap coverage) is addressed in this update.

中文:本 PR 已经历超过 17 轮审查,按仓库约定,本轮只修复已确认的正确性问题和合并回归。上表逐项记录仍需后续处理的建议,并保留原评论链接;这些建议不冒充已修复。R1-12 已由滚动尾部解决,R2-11 的硬上限测试已补齐。

@wenshao

wenshao commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

Fixed the Lint & Static failure in d27482b4e9. The capture-failure warning was missing its required Traditional Chinese translation; the standalone i18n parity gate reproduced the CI failure with exactly that error. Added the missing translation without changing validation rules or runtime logic.

Validation: the exact npm run check-i18n command now passes, as do build, typecheck, bundle, formatting/ESLint, and 161 focused i18n/task-detail tests. Independent verification confirmed the same translation in source and bundled locale assets. The latest main merge is preserved. The remote Lint & Static job has now completed successfully, including the originally failing i18n check and every subsequent static gate. Other test/review jobs are still running; no other failure is currently reported.

中文:已补齐遗漏的繁体中文文案,修复严格 i18n 检查失败。原始失败命令、本地构建与类型检查、打包和 161 项相关测试均通过,打包产物也已独立复核。远端 Lint & Static 已完整通过,包含原先失败的 i18n 检查及后续全部静态检查;其他测试与评审尚在运行,当前没有其他失败项。

This branch has not been deployed

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

Labels

autofix/needs-human The autofix loop stopped on this PR — a human must re-arm, split, merge, or close it

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants