Skip to content

test(managed-agent): add FG6e hosted SSE gap gates - #12942

Merged
wenshao merged 2 commits into
mainfrom
codex/fg6e-hosted-sse-gap-gates
Sep 28, 2026
Merged

wenshao merged 2 commits into
mainfrom
codex/fg6e-hosted-sse-gap-gates

Conversation

@wenshao

@wenshao wenshao commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

Adds the FG6e real-process gate for disconnecting both public and WebShell event streams during one Hosted Workspace Edit, letting the original private Turn finish while observers are absent, and resuming without duplicate or missing events. The public stream uses Last-Event-ID over a conflicting query cursor; WebShell uses its existing afterSequence request field.

Why it's needed

D3 already supports durable replay. This gate connects that contract to a real packaged Harness, Broker, worker and SQL store, so reconnecting an observer cannot silently hide repeated inference, repeated file effects, cancellation or broken event identities.

Reviewer Test Plan

How to verify

Confirm both observers receive the initial event, then disconnect while a real Edit is blocked reading a FIFO. With both disconnected, the original execution must settle successfully once, change x to xx, and release ownership without cancellation. After resuming from each saved SSE ID, both observers must receive the committed assistant message followed by the actual terminal event, with exact sequence [1, 2, 3] and payloads matching independent SQL rows. Public JSON replay and the WebShell snapshot plus retained control events must agree.

A test-only relay projects actual private Harness frames through the production projector and SQL append path. It pauses only the real terminal projection until both resumed streams have caught up; it does not manufacture tool or terminal events or enable public Workspace Turn admission.

Evidence (Before & After)

N/A for UI; this change adds tests and design documentation. Installed global CLI 0.24.6 explicitly rejects the private Hosted profile, so that baseline cannot exercise FG6e. The packaged local artifact is tested with a deterministic local model and real MariaDB 10.11.19.

Local validation passed: build, typecheck, bundle, 170 Java unit tests (including the 20 D3 tests), all five Hosted Workspace integration methods, all six FG6c crash scenarios, 43 relevant CLI tests, Checkstyle, ESLint and formatting. Five replay mutations each failed its intended assertion; an injected failure before opening the FIFO writer left no fixture process behind. Production sources and artifacts were restored before the final normal rerun. Exact evidence is recorded in the separate E2E report.

Tested on

OS Status
🍏 macOS ✅
🪟 Windows N/A — FIFO gate
🐧 Linux ✅ Latest-head Hosted/MySQL and Broker gates

Environment (optional)

Node.js 22.22.2, JDK 21, packaged CLI, real Spring/Broker processes and MariaDB 10.11.19. No external model or production credentials.

Risk & Scope

  • Main risk or tradeoff: a test-only relay bridges private Hosted execution into the D3 read path because public Workspace Turn admission remains unavailable.
  • Not validated / out of scope: coordinator admission/batching, public Turn status transitions, browser UI, pagination, retention/resync and slow-consumer overflow. Existing D3 tests retain those replay checks.
  • Breaking changes / migration notes: none; no production behavior, protocol, dependency or migration changes.
  • Design: English · 简体中文. Both versions describe the same boundaries and acceptance criteria.

Linked Issues

Part of #12872 (FG6e only).

中文说明

本 PR 的内容

增加 FG6e 多进程门禁:在真实 Hosted Workspace Edit 中断开公开与 WebShell 事件流,让原私有 Turn 在观察者离线时完成,再续传并核对没有重复或缺失。公开流使用 Last-Event-ID,并验证其优先于冲突的查询游标;WebShell 使用现有请求字段 afterSequence。

为什么需要

D3 已支持持久化回放。本门禁将该契约与真实打包 Harness、Broker、worker 和 SQL Store 连通,防止观察者重连掩盖重复推理、重复文件效果、取消或事件身份错误。

审阅者测试计划

如何验证

确认两个观察者先收到初始事件,再在真实 Edit 阻塞于 FIFO 读取时断开。双方离线期间,原执行必须成功结算一次,把 x 改为 xx,没有取消并释放所有权。各自从保存的 SSE ID 续传后,双方须收到已提交助手消息及实际终态,序列严格为 [1, 2, 3],载荷与独立 SQL 记录完全一致。公开 JSON 回放和 WebShell 快照及保留控制事件也须一致。

仅测试使用的转发器把实际私有 Harness 帧经产品 projector 和 SQL 追加路径投影,仅将真实终态投影暂停至两端续传追上水位。它不伪造工具或终态事件,也不开放公开 Workspace Turn 准入。

证据(前后对比)

UI 对比不适用,本改动只有测试和设计文档。安装的全局 CLI 0.24.6 明确拒绝私有 Hosted 配置,所以该基线无法触达 FG6e。本地打包产物通过确定性本地模型和真实 MariaDB 10.11.19 验证。

本地验证通过:build、typecheck、bundle、170 项 Java 单测(包含 20 项 D3 测试)、五个 Hosted Workspace 集成测试方法、FG6c 六种崩溃场景、43 项相关 CLI 测试、Checkstyle、ESLint 及格式检查。五项回放突变分别命中预期断言;FIFO writer 打开前注入失败后,没有遗留夹具进程。最终正常重跑前已恢复产品源码及产物。确切证据记录于单独的 E2E 报告。

测试平台

OS 状态
🍏 macOS ✅
🪟 Windows 不适用 — FIFO 门禁
🐧 Linux ✅ 最新提交 Hosted/MySQL 与 Broker 门禁通过

环境

Node.js 22.22.2、JDK 21、打包 CLI、真实 Spring/Broker 进程及 MariaDB 10.11.19,不使用外部模型或生产凭据。

风险与范围

  • 主要取舍:公开 Workspace Turn 准入尚不可用,因此通过仅测试使用的转发器把私有 Hosted 执行接入 D3 读取路径。
  • 未验证及范围外:coordinator 准入与批处理、公开 Turn 状态转换、浏览器 UI、分页、保留窗口/resync、慢消费者溢出。现有 D3 测试继续覆盖相关回放契约。
  • 破坏性改动与迁移:无;不改变产品行为、协议、依赖或数据库迁移。
  • 设计:English · 简体中文,两版边界及验收标准一致。

关联 Issue

属于 #12872,仅完成 FG6e。

@wenshao

wenshao commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

FG6e E2E report

Current commit: 769f48bf4f80905faa7ac9acc46d0bf065ad3280, based on origin/main at 92f4d4f6455bf02a41e0d856afe63b79108f936f. Full local behavioral validation below ran on cc0e2e3aa262465e52d5c1f1d41b9453449307ce. The follow-up adds exactly two explanatory comments; Maven test-compile and Checkstyle pass, and javap -c -p instructions before/after are byte-identical. macOS, Node.js 22.22.2, JDK 21, real MariaDB 10.11.19, deterministic local model, packaged CLI. Latest-head SDK Java and main CI are both successful.

The installed global CLI 0.24.6 explicitly rejects the private Hosted profile. Its baseline made zero model requests and was terminated after 30 s; this is an unreachable baseline, not a reproduced SSE defect.

Normal and control runs

  • Final npm run build, npm run typecheck, npm run bundle: passed. Relevant CLI tests: 43 passed. Changed-file ESLint/Prettier and Java Checkstyle: passed.
  • Final Java -Phosted-workspace-tools verify checkstyle:check: 170 unit tests and 5 integration methods, zero failures/errors/skips. The 20 focused D3 replay/stream/projector tests also passed; they are included in 170, not additional unique tests. The 5 integration methods retain the ordinary flow and all FG6a/FG6b/FG6d cases plus FG6e.
  • Final FG6e: 2.358 s; complete Hosted class: 36.157 s. Both raw public and WebShell prefix+suffix ID sequences were [1,2,3], with exact payloads matching independent SQL rows. Public header precedence and WebShell native body cursor were exercised. No client sorting/deduplication is used.
  • Both observers disconnected during real FIFO-blocked Edit. With observers absent, the original private Turn completed once, file x became xx, and the control-directory decoy stayed unchanged. Model calls 2; acquire/prepare/start/release each 1; cancel 0.
  • Separate SQL inspection verified one original successful execution, dispatch generation 1, released storage owner/Runtime, 14 journal transactions, 15 contiguous unique journal events and 19 immutable resources with valid hashes. The projected message and terminal refer to actual private source sequences 12 and 14. All 18 migration-history rows were unchanged.
  • FG6c six crash scenarios passed earlier in this task (96.838 s), with independent SQL/hash/owned-PID checks. Production inputs, CLI, SDK/Broker dependencies and that fixture remained unchanged; the final Maven package regenerated the server JAR, whose new hash was recorded rather than called byte-identical. Those scenarios were not redundantly rerun locally after repackaging.

Mutation sensitivity

The gate itself was unchanged for each mutation. Every run executed 1 test and failed exactly its intended assertion (1 failure, 0 errors, 0 skipped), rather than failing startup or reaching a generic timeout.

Removed/broken guard Observed failure
Public Last-Event-ID precedence Resumed IDs [1,2] instead of [2]
WebShell afterSequence Resumed IDs [1,2] instead of [2]
Public replay SSE id 2 changed to 20 SSE ID 20 differs from payload sequence 2
WebShell replay SSE id 2 changed to 20 SSE ID 20 differs from payload sequence 2
SQL exclusive > cursor changed to >= Duplicate resumed prefix [1,2] instead of [2]

A separate intentional failure waited for real Broker status and the SQL EXECUTING/no-result checks, then threw before allocating the FIFO writer. The exact failure marker and exit 1 were retained, and the observed driver, Harness and real worker were all gone at Maven return. This is failure-cleanup evidence, not a normal gate pass.

All mutated production sources were restored and recompiled; the injected driver was restored byte-for-byte. The final normal verification ran afterward. All 935 frozen inputs stayed unchanged, and all 944 final source/artifact entries matched the recorded manifest. Six audit rounds completed, combining open-ended review and reverse verification. The final two consecutive rounds found zero Critical issues; after round 5 the review scope is Critical-only. Online review found no correctness blockers. Its request for comments explaining the 60-second polling window and executor cleanup was implemented. Its nonblocking suggestion to make fixture state atomic was not adopted: the HTTP dispatcher is serial, prompt identity is set before async task submission, execution identity stays on that dispatcher, and source keys are read after Future.get establishes visibility. Additional speculative synchronization is unnecessary for this fixture.

Linux CI

Latest-head SDK Java CI passed on Linux/MySQL 8.4/JDK 21. Downloaded XML contains 8 Hosted integration tests across three classes (5 Workspace Tool Turn, 1 Process Crash covering six scenarios, 2 other Hosted tests), with zero failures/errors/skips; FG6e actually ran in 3.647 seconds. The job log independently confirms all 29 Runtime Broker fault gates passed. The checkout was PR merge commit 2b71e528013f143d871aecef259038a64c3e3677, merging source head 769f48bf4f80905faa7ac9acc46d0bf065ad3280 into then-main bd45b95f826b3873e25ae84d78ae35f1599c141a. Java 11/17/21 SDK checks, macOS/Windows Java 21, MariaDB integration and real daemon E2E all passed. Main CI also completed successfully, including lint/static, the full Linux unit-test job, no-AK integration, desktop checks and WebShell browser smoke. The earlier cc0e2e3 SDK run also passed; the figures here are from the latest head, not carried over.

The latest head also received an independent Critical-only APPROVE. The separate automatic review-pr workflow is still running; this report does not claim that review workflow has completed.

Scope

A test-only relay reads actual private Harness frames, uses the production projector and SQL append path, and holds only the real terminal projection until both streams catch up. Public Workspace Turn admission, coordinator batching/Turn transitions, browser UI, pagination, retention/resync and slow-consumer overflow are not claimed by this gate. No production behavior, protocol, dependency or migration changes.

中文摘要

FG6e 本地验证、170项Java单测、5个Hosted集成方法、43项CLI测试及FG6c六种崩溃场景均通过;20项D3测试属于170项单测的子集。五种突变都命中明确行为断言,提前分配writer前的失败没有遗留进程。源码恢复后的最终正常门禁再次通过,SQL、资源哈希、迁移历史和原执行身份独立核对通过。完成六轮审计,最终连续两轮Critical为0,第5轮之后仅处理Critical。已接受线上审阅的两条说明注释建议;原子字段建议因当前串行派发及异步完成可见性已满足要求而不采用。范围仅测试与双语设计文档。最新提交的Linux/MySQL CI已确认8项Hosted集成测试和29项Broker故障门禁全部通过,FG6e实际运行3.647秒;SDK平台矩阵、MariaDB集成和真实daemon E2E也全部通过。主CI也已全部通过,包含静态检查、全量Linux单测、no-AK集成、桌面检查和WebShell浏览器smoke。

@wenshao
wenshao marked this pull request as ready for review September 28, 2026 13:12
@wenshao

wenshao commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator Author

Independent real-stack verification — FG6e @ 769f48bf

Most of this ran at cc0e2e3a. The new head 769f48bf adds only the two IT comments from follow-up 1 below. I re-ran HostedWorkspaceToolTurnIT there on MySQL 8.4.7 with checkstyle:check: 5/5, 0 violations.

Verdict: ready to merge, with no blocking findings. The gate passes on the CI database family (MySQL 8.4) and on MariaDB, and it stayed stable across repeats. It detects every in-scope regression I injected. It also closes the two items the triage review left unverified:

  • All five of the author's mutants reproduce here with the stated failures.
  • The Linux run is green in CI at cc0e2e3a (job 108942860455, MySQL 8.4.6, HOSTED_SSE_GAP_OK). CI for 769f48bf was still running when I posted.

The most useful new fact is that FG6e is the only test that catches regressions on the WebShell resume path. Three WebShell-stream mutants (A2, A4 and N2 below) pass all 170 existing unit tests and fail FG6e.

One follow-up is already addressed in 769f48bf, and one optional wording tweak remains (see the end).

Setup. macOS arm64, Zulu JDK 21.0.12, Maven 3.9.16 and Node 22.23.2. I ran npm run build && npm run bundle from the PR head. The databases were a native MySQL 8.4.7 and a mariadb:10.11.18 container (the image CI uses). A private Maven repo held the PR's SDK and Broker. The stack was real: Spring, the Broker and JDBC, the packaged Hosted Harness and Workspace worker processes, and a deterministic fake model.

1. Gate runs

FG6e gate matrix

Run Result
CI-equivalent -Phosted-harness-mysql clean verify checkstyle:check + check-failsafe-reports.js hosted, MySQL 8.4.7 ✅ 170 unit tests, 8 Hosted IT methods (including FG6c), 0 Checkstyle violations, 168 s
HostedWorkspaceToolTurnIT, MariaDB 10.11.18 ✅ 5/5 in 53 s
FG6e method alone, unmodified, repeated ✅ MySQL 9/9 (4.9–5.6 s), MariaDB 5/5 (6.4–6.8 s); both surfaces [1,2,3] on every run
PR ⊕ #12848 (the only open PR that touches this IT), CI-equivalent ✅ clean merge; 172 unit tests + 8 IT methods, 0 Checkstyle violations
PR ⊕ main bd45b95f clean merge; main's two new commits touch only packages/core and web-shell

The load average was 17–33 throughout. No Harness, worker or driver process was left behind, including after the 10 failing mutant runs.

Independent SQL check. I read the database directly after a MySQL run instead of going through the driver:

  • Events 1–3 are session.created, item.output_text.delta (source key …:12) and turn.completed (source key …:14).
  • There is one qwen_tool_execution row: SETTLED / success / dispatch generation 1.
  • The runtime session is RELEASED and the lease holder is NULL.

This matches the author's report. The same query also shows 0 managed_agent_turn rows and harness_last_event_id = 0 (see follow-up 2).

2. Mutation sensitivity

I applied 13 production mutants, one at a time, with the gate unchanged.

FG6e mutants vs unit suite

  • The author's five mutants (A1–A5) reproduce exactly.
    • Ignoring the header or afterSequence, or changing > to >=, makes the resumed stream return [1,2].
    • Corrupting a replayed SSE id fails SSE id must equal payload sequence.
  • New mutants on the replay→live hand-off and the live path:
    • N1/N2: the stream does not advance its cursor after replay. This produces a duplicate loop, [1,2,2,3,2,3…].
    • N3: a live hub frame carries the wrong id. This fails the id check.
    • N4: the hub never wakes subscribers. N8: the relay's append skips publishAfterCommit. In both cases the terminal is never delivered and the 10 s catch-up wait fails.
  • Unit-suite cross-check. I ran the same mutants against the 170 unit tests. A2, A4 and N2 survive and every other mutant is killed. So today FG6e is the only guard on the WebShell afterSequence / hand-off path.
  • Three mutants are out of reach by construction but covered elsewhere.
    • N5 (the hub re-delivers the cursor event) cannot be reached because the held terminal keeps replay and hub from overlapping.
    • N6/N7 are on the Harness batch path (recordHarnessEvents / appendEvents), which the relay does not use.
    • The unit suite kills all three, for example via commitsHarnessEventsAsOneReplayableBatch and SessionEventHubTest. Together the two suites kill 13/13.

3. Probes

These use a copy of the driver and IT only; production code is unmodified.

FG6e probes

  • Hub lifecycle (read via reflection). After the clients abort, the server keeps both subscriptions. It drops them only when it writes event 2 into the dead sockets. On resume, event 2 comes from SQL replay and event 3 arrives live through the hub. The gate therefore exercises both halves of the hand-off.
  • Timing variants, all exact on both surfaces:
    • No barrier: releasing the terminal 0–60 ms into the reconnect gave [2,3] in 10/10 runs.
    • Late: committing the terminal before the reconnect gave [2,3] from replay in 3/3 runs.
    • Default intervals (5 s poll / 15 s heartbeat): 2/2 pass.
  • The triage review's note 1, measured:
    • With the default 5 s poll, N4 (dead hub) survives because the SQL reconcile hides it. The PR's 60 s override is what kills it.
    • Without shutdownNow() in the IT's finally block, the FG6e method takes 67.3 s instead of 6.4 s, because parked stream tasks block ExecutorService.close() for about 60 s.

Follow-ups

  1. ✅ Comment the two coupled lines in the IT: done in 769f48bf. The two new comments match the measurements above. The 60s intervals make the gate sensitive to the after-commit hub, and shutdownNow() keeps them from adding about a minute to every hosted run.
  2. Optional: tighten one sentence in both design docs. The docs say "The regular SQL sequence allocation, identity derivation, after-commit event hub … remain in the exercised path". That is true of the single-event appendEvent that the relay calls. Production Harness events take a different route: HarnessCoordinator → recordHarnessEvents → appendEvents, which is batched, lease-fenced and advances harness_last_event_id. FG6e never reaches that path (0 turn rows, cursor 0). This is not a coverage hole, because the unit suite kills N6/N7. Suggested wording: "the single-event SQL append path; the batched Harness write path stays covered by ManagedAgentServerIntegrationTest".

Evidence (figures, rig scripts, raw results): wenshao/qwen-code@227e5fd5 · pr12942/

中文版

独立真实环境验证 — FG6e @ 769f48bf

大部分验证在 cc0e2e3a 上完成。新 head 769f48bf 只增加了下文后续建议 1 提到的两行 IT 注释。我在新 head 上用 MySQL 8.4.7 带 checkstyle:check 重跑了 HostedWorkspaceToolTurnIT:5/5,违规 0。

结论:可以合并,没有阻断问题。 门禁在 CI 所用的数据库系列(MySQL 8.4)和 MariaDB 上都通过,重复运行结果稳定。我注入的范围内回归全部被它检出。它还补上了 triage 评审留下的两项「未验证」:

  • 作者的五个突变在本地全部复现,失败原因与描述一致。
  • Linux 运行已在 cc0e2e3a 的 CI 中通过(job 108942860455,MySQL 8.4.6,HOSTED_SSE_GAP_OK)。发帖时 769f48bf 的 CI 仍在运行。

最有价值的新发现是:FG6e 是目前唯一能发现 WebShell 续传路径回归的测试。 三个 WebShell 流突变(下文的 A2、A4、N2)能通过现有全部 170 项单测,但都会让 FG6e 失败。

一条后续建议已在 769f48bf 中处理,另有一处可选的措辞调整(见文末)。

环境。 macOS arm64、Zulu JDK 21.0.12、Maven 3.9.16、Node 22.23.2。从 PR head 执行 npm run build && npm run bundle。数据库为本机 MySQL 8.4.7 和 mariadb:10.11.18 容器(与 CI 相同的镜像)。私有 Maven 仓库中安装了本 PR 的 SDK 与 Broker。整条链路都是真实进程:Spring、Broker 与 JDBC,打包后的 Hosted Harness 与 Workspace worker 进程,以及确定性假模型。

1. 门禁运行

运行 结果
与 CI 等价的 -Phosted-harness-mysql clean verify checkstyle:check + check-failsafe-reports.js hosted,MySQL 8.4.7 ✅ 170 项单测,8 个 Hosted IT 方法(含 FG6c),Checkstyle 违规 0,168 s
HostedWorkspaceToolTurnIT,MariaDB 10.11.18 ✅ 5/5,53 s
只跑 FG6e 方法(未修改),重复运行 ✅ MySQL 9/9(4.9–5.6 s),MariaDB 5/5(6.4–6.8 s);每次两端都是 [1,2,3]
PR ⊕ #12848(唯一改动同一 IT 的在飞 PR),与 CI 等价 ✅ 合并无冲突;172 项单测 + 8 个 IT 方法,Checkstyle 违规 0
PR ⊕ main bd45b95f 合并无冲突;main 新增的两个提交只改了 packages/core 和 web-shell

全程负载 17–33。没有遗留任何 Harness、worker 或 driver 进程,10 次失败的突变运行之后也没有。

独立 SQL 核对。 MySQL 运行结束后,我直接读数据库,不经过 driver:

  • 事件 1–3 依次是 session.created、item.output_text.delta(source key …:12)和 turn.completed(source key …:14)。
  • qwen_tool_execution 只有 1 行:SETTLED / success / 派发代数 1。
  • runtime session 为 RELEASED,租约持有者为 NULL。

与作者报告一致。同一组查询还显示 managed_agent_turn 为 0 行、harness_last_event_id = 0(见后续建议 2)。

2. 突变敏感性

我逐个应用了 13 个产品代码突变,门禁本身不做改动。

  • 作者的五个突变(A1–A5)完全复现。
    • 忽略 header 或 afterSequence,或把 > 改成 >=,续传结果都变成 [1,2]。
    • 篡改回放帧的 SSE id,会触发 SSE id must equal payload sequence。
  • 新增突变,针对回放→实时交接和实时路径:
    • N1/N2:流在回放后不推进游标,产生重复循环 [1,2,2,3,2,3…]。
    • N3:hub 实时帧带错误 id,id 检查失败。
    • N4:hub 从不唤醒订阅者。N8:转发器的追加路径跳过 publishAfterCommit。两种情况下终态都送不到,10 s 追赶等待超时失败。
  • 单测交叉核对。 同一批突变放到 170 项单测上跑:A2、A4、N2 存活,其余全部被杀。所以目前 WebShell afterSequence 和交接路径只有 FG6e 在守。
  • 有三个突变按设计够不到,但其他测试覆盖了。
    • N5(hub 重发游标所在事件)够不到,因为暂扣的终态让回放和 hub 不会重叠。
    • N6/N7 位于 Harness 批量写路径(recordHarnessEvents / appendEvents),转发器不走这条路径。
    • 这三个都被单测杀死,例如 commitsHarnessEventsAsOneReplayableBatch 和 SessionEventHubTest。两套测试合起来杀死 13/13。

3. 探针

只改了 driver 和 IT 的副本,产品代码未改动。

  • Hub 生命周期(反射读取)。客户端断开后,服务端仍持有两个订阅,直到把事件 2 写进已断开的连接时才释放。续传时,事件 2 来自 SQL 回放,事件 3 经 hub 实时送达。所以门禁确实覆盖了交接的两半。
  • 时序变体,两端结果全部精确:
    • 无屏障:在重连开始后 0–60 ms 放行终态,10/10 次得到 [2,3]。
    • 晚到:重连前先提交终态,3/3 次从回放得到 [2,3]。
    • 默认间隔(poll 5 s / heartbeat 15 s):2/2 通过。
  • 用实测回答 triage 评审的第 1 条:
    • 在默认 5 s poll 下,N4(hub 失效)存活,因为 SQL reconcile 把它掩盖了。是本 PR 的 60 s 覆盖才让它被杀死。
    • 去掉 IT finally 块里的 shutdownNow() 后,FG6e 方法耗时从 6.4 s 变为 67.3 s,因为停住的流任务让 ExecutorService.close() 阻塞了约 60 s。

后续建议

  1. ✅ 给 IT 里两处互相耦合的代码加注释:已在 769f48bf 完成。 新增的两行注释与上面的实测一致:60s 间隔让门禁对提交后 hub 敏感,shutdownNow() 则避免这两个间隔让每次 hosted 运行多花约 1 分钟。
  2. 可选:收紧两版设计文档里的一句话。 文档写的是「正常 SQL 序号分配、身份推导、提交后事件 hub……仍在被测路径上」。对转发器调用的单事件 appendEvent 来说,这没错。但产品中的 Harness 事件走的是另一条路:HarnessCoordinator → recordHarnessEvents → appendEvents,它是批量的、有租约隔离,并会推进 harness_last_event_id。FG6e 从未走到这条路径(turn 0 行,游标为 0)。这不构成覆盖空洞,因为单测会杀死 N6/N7。建议措辞:「单事件 SQL 追加路径;批量 Harness 写路径仍由 ManagedAgentServerIntegrationTest 覆盖」。

证据(图、装置脚本、原始结果):wenshao/qwen-code@227e5fd5 · pr12942/

@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 scan — APPROVE

Head reviewed: 769f48bf4f80905faa7ac9acc46d0bf065ad3280

Scope

Test and design-doc changes only. No production code is touched:

  • docs/design/hosted-sse-gap-fault-gates.md / .zh-CN.md (new)
  • integration-tests/helpers/hosted-sse-gap-driver.ts (new, 455 lines)
  • HostedSseGapProbe.java (new, 232 lines)
  • HostedWorkspaceToolTurnIT.java (modified, +30/-5)

Historical blocking issues

None. There are no reviews and no inline review comments on this PR. Triage stage 2 recorded "No correctness blockers", with both notes explicitly marked non-blocking, and stage 3 kept them non-blocking. The single commit after cc0e2e3 adds exactly the two explanatory comments that non-blocking note 1 asked for — gh api compare shows +2/-0, both // lines, no behavioural change.

What I verified independently

For a gate PR the dominant risk is a false green, so I checked that each new assertion can actually fail.

1. The gate ran on Linux/MySQL rather than being skipped. CI for the current head was still pending when I reviewed, so I used the prior head cc0e2e3, which is identical apart from the two comment lines. Its Hosted process fault gates / MySQL 8.4 / Java 21 job (job 108942860455) succeeded with:

FG6E_DATABASE …
FG6E_LEDGER 6b6f5b4c-8faa-4a4a-a431-9be1c7b66d25
HOSTED_SSE_GAP_OK
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 -- in HostedWorkspaceToolTurnIT

FG6E_LEDGER is printed only from HostedSseGapProbe.assertReport, so its presence proves the new @Test executed. The test also asserts -Dmysql.url / -Dmysql.user up front, so it fails loudly instead of silently skipping when unconfigured. The count moved from 4 to 5 tests in this class, matching one added method.

2. Off-thread assertion failures cannot be swallowed. The probe records handler throwables into an AtomicReference<Throwable> failure, re-asserts it in assertReport and in two in-path checks, and returns 500 from the handler — which independently trips the driver's assert.equal(response.status, 200, …). Either path fails the run.

3. Stream failures propagate on the main path. The SSE reader stores an unexpected error in this.failure only when the abort was not intentional, and rethrows it both inside through() (so it fails on first violation instead of being retried into a timeout) and in close(). All four observers are closed on the main path, not just in the finally, so a recorded failure surfaces even though the cleanup uses Promise.allSettled.

4. Existing coverage is not weakened. All five deleted lines are accounted for: the try (var spring = …) header was restructured into a builder so the FG6e initializer and properties can be added conditionally, and three lines were replaced by longer ternary chains that keep the FG6a/FG6b/FG6d branches intact. assertFaultLedger and cancellationProbe.assertReport are still called for their own drivers, and assertThat(workspace.resolve("proof.txt")).doesNotExist() still runs for every case. Probe cleanup sits in the outer finally, so the transport is restored and workers are reaped even when assertReport throws.

5. The assertions match the exit check in #12872. Both surfaces resume with Last-Event-ID / afterSequence and are then held to frames.map(id) deep-equal [1, 2, 3] ("no gaps or duplicates") and frames.map(data) deep-equal the SQL projection field-for-field. Frame ids are validated against payload sequence, modelCalls is pinned so a reconnect cannot restart inference, and the broker operation log is asserted to contain zero cancel calls. The 60s poll/heartbeat intervals keep SQL polling outside the 10s receive windows, so the frames can only arrive via live hub delivery — the property is genuinely under test rather than satisfied by the reconcile path.

CI

Green at the prior head, which differs from the reviewed head only by two comment lines; several jobs on the current head were still pending at review time and none showed a failure attributable to this PR.

@wenshao
wenshao enabled auto-merge September 28, 2026 14:16
@wenshao

wenshao commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@wenshao
wenshao added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit cd0b35c Sep 28, 2026
68 of 69 checks passed

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline.

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • integration-tests/helpers/hosted-sse-gap-driver.ts:129 — Observer.through() renders a clean server-side stream end as a generic 10s timeout — already reported (comment 5872181288, the sandboxed-verify lane's finding S2)

Not explored to full depth (tool budget reached): "agent 1a": typecheck/lint execution for integration-tests/helpers/hosted-sse-gap-driver.ts (no node_modules in the review worktree).; "agent 6a": none — every check I set out to run completed within the tool budget (19 calls used)..

中文说明

已审查。 建议见行内评论。

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未探索到全部深度(达到工具调用预算):"agent 1a":typecheck/lint execution for integration-tests/helpers/hosted-sse-gap-driver.ts (no node_modules in the review worktree).;"agent 6a":none — every check I set out to run completed within the tool budget (19 calls used).。

— qwen3.8-max via Qwen Code /review (v0.24.6)

'Content-Type': 'application/json',
};

async function privateJson(route: string, body?: unknown, expected = 200) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] R1-1: privateJson() never checks the proxyFailure latch that its twin evidence() checks as its first statement (line 69), and the top-level catch at line 446 never logs it, so a broker-proxy failure surfaces as an unrelated FIFO timeout with the real cause discarded.

The proxy's catch stores the cause in proxyFailure and destroys the response when the upstream status assert fires or the 30s upstream timeout trips. The CLI's broker call then fails, so the Edit tool never reaches its FIFO read — and control is sitting in the FIFO-open waitUntil at lines 288-296, which passes no timeout (30s default), carries no proxyFailure guard, and swallows ENXIO as it polls. It throws Timed out after 30000ms; the first guarded call, evidence('entered') at line 302, is never reached. The catch prints only cli.output and operations, so the broker's status and body never reach the log that HostedWorkspaceToolTurnIT.java:236 surfaces via as("Driver output: %s", Files.readString(log)), and 30s of the 130s driver budget is burned first. The same unguarded shape sits at lines 314-317 (the waitUntil on hasActivePrompt), which hosted-broker-reply-loss-driver.ts:203-206 does guard.

Witness:

broker-reply-loss (FG6a) HELPER L48  ASSERT L55  GUARD none-in-helper  GUARD L204 (inside the hasActivePrompt waitUntil)  catch L335 no proxyFailure
store-failure    (FG6b) HELPER L228 ASSERT L239 GUARD L229 + L240 (both sides)  end-of-try L414  catch L418 no proxyFailure
process-crash    (FG6c) HELPER L250 ASSERT L261 GUARD L251 + L262 (both sides) + L325 + L345  end-of-try L417  catch L424 no proxyFailure
cancellation     (FG6d) HELPER L55  ASSERT L63  GUARD L56 + L64 (both sides)  catch L408 no proxyFailure
sse-gap          (FG6e) HELPER L45  ASSERT L52  GUARD none; only guard is L69, inside evidence()  catch L446 no proxyFailure
3 of 4 siblings guard both sides of their JSON helper; the new file guards 0 of 2 and has no end-of-try guard.
Suggested change
async function privateJson(route: string, body?: unknown, expected = 200) {
async function privateJson(route: string, body?: unknown, expected = 200) {
if (proxyFailure) throw proxyFailure;

Mirror hosted-cancellation-driver.ts:55-64: the guard goes first in the helper and again after the status assert, and the two waitUntil loops get guarded the way hosted-broker-reply-loss-driver.ts:204 guards its own. One premise the fix must keep: no sibling driver logs proxyFailure in its catch (0 of 4 — hosted-cancellation-driver.ts:408, hosted-store-failure-driver.ts:418, hosted-process-crash-driver.ts:424, hosted-broker-reply-loss-driver.ts:335); the family instead guarantees that the thrown cause is proxyFailure, through helper guards or an end-of-try rethrow (hosted-store-failure-driver.ts:414, hosted-process-crash-driver.ts:417), so the guards are the family-conforming fix rather than a new catch-block log line.

中文说明

privateJson() 从不检查它的孪生函数 evidence() 在第一条语句就检查的 proxyFailure 闩锁(第 69 行),而第 446 行的顶层 catch 也从不打印它,因此一次 broker 代理失败会表现成一个与之无关的 FIFO 超时,真正的原因被丢弃。

代理的 catch 会把原因存进 proxyFailure 并销毁响应(上游状态断言触发,或 30 秒上游超时到期时)。此后 CLI 的 broker 调用失败,Edit 工具永远到不了它的 FIFO 读取——而控制流正停在第 288-296 行的 FIFO 打开 waitUntil 上:它没有传超时(默认 30 秒),没有 proxyFailure 保护,还会在轮询中吞掉 ENXIO。于是它抛出 Timed out after 30000ms;第一个带保护的调用(第 302 行的 evidence('entered'))永远到不了。catch 只打印 cli.output 和 operations,所以 broker 的状态码与响应体永远到不了 HostedWorkspaceToolTurnIT.java:236 通过 as("Driver output: %s", Files.readString(log)) 呈现的那份日志,而且先烧掉 130 秒驱动预算里的 30 秒。第 314-317 行(对 hasActivePrompt 的 waitUntil)有同样的无保护形态,而 hosted-broker-reply-loss-driver.ts:203-206 是加了保护的。

修法与 hosted-cancellation-driver.ts:55-64 一致——保护语句放在 helper 的第一行,并在状态断言之后再来一次——同时按 hosted-broker-reply-loss-driver.ts:204 的做法给两处 waitUntil 循环加保护。有一条前提必须保留:四个同族驱动都没有在 catch 里打印 proxyFailure(0/4——hosted-cancellation-driver.ts:408、hosted-store-failure-driver.ts:418、hosted-process-crash-driver.ts:424、hosted-broker-reply-loss-driver.ts:335);这一族的做法是通过 helper 保护或 try 末尾的重新抛出,保证被抛出的 cause 就是 proxyFailure(hosted-store-failure-driver.ts:414、hosted-process-crash-driver.ts:417),因此符合家族约定的修法是加保护,而不是在 catch 里新增一行日志。

— qwen3.8-max via Qwen Code /review (v0.24.6)

Comment on lines +156 to +158
// Keep SQL polling outside the 10s receive window to require live hub delivery.
arguments.add("--qwen.managed-agent.events.poll-interval=60s");
arguments.add("--qwen.managed-agent.events.heartbeat-interval=60s");

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] R1-2: The gate's central premise — that the resumed frames arrive by live after-commit hub delivery rather than SQL reconciliation — is held only by an unstated numeric relation between these two arguments and the driver's 10s receive window in another language (hosted-sse-gap-driver.ts:176), and the comment names only polling although the reconciliation cadence is min(heartbeat-interval, poll-interval).

ManagedEventStreamService.waitDuration returns min(untilHeartbeat, reconciliationInterval), and an empty delivery sets reconcile = true, so SQL is re-read on that combined cadence. Both knobs are 60s here, which is why Observer.through()'s waitUntil(..., 10_000) can today only be satisfied by hub delivery. Lower either one below 10s — the natural edit for anyone trying to shrink the teardown stall that the shutdownNow() at line 261 exists to work around, since that stall is the 60s cadence — and SQL reconciliation can deliver the frames inside the window. FG6e then keeps passing while no longer distinguishing live hub delivery from polling fallback, so a regression that breaks after-commit hub notification ships green through the very gate built to catch it. Nothing asserts the relation in either language, and the drift direction is the system default: ManagedAgentProperties.Events ships pollInterval = 5s, heartbeatInterval = 15s.

Witness:

Reflective invocation of the real private ManagedEventStreamService.waitDuration(long) on a real ManagedAgentProperties, classpath = the module's target/classes + Spring 6.2.19:
PR override poll=60s heartbeat=60s cadence= 59985ms OK > 10s window: only live hub can deliver
product DEFAULT poll=5s heartbeat=15s cadence= 5000ms LEAK <= 10s window: SQL reconcile can deliver
poll lowered poll=5s heartbeat=60s cadence= 5000ms LEAK <= 10s window: SQL reconcile can deliver
hb lowered poll=60s heartbeat=5s cadence= 4999ms LEAK <= 10s window: SQL reconcile can deliver
boundary poll=11s heartbeat=60s cadence= 11000ms OK > 10s window: only live hub can deliver
boundary poll=9s heartbeat=60s cadence= 9000ms LEAK <= 10s window: SQL reconcile can deliver
The boundary is the driver's 10s window, and lowering either knob breaches it.

Express the invariant once instead of in a comment: runDriver already writes a config object to the driver (lines 229-232), so the cadence can travel in it for the sse-gap mode and the driver can assert the relation at startup, or derive through()'s budget from the supplied interval.

// hosted-sse-gap-driver.ts, at startup, once the cadence travels in driver.json
assert(
  reconcileIntervalMs > RECEIVE_WINDOW_MS,
  `SQL reconciliation every ${reconcileIntervalMs}ms can satisfy the ${RECEIVE_WINDOW_MS}ms receive window; the gate would no longer prove live hub delivery`,
);

Two premises the fix rests on: the driver.json map at HostedWorkspaceToolTurnIT.java:229-232 is shared by all five drivers and destructured by each, so a new key must be optional or supplied for the other four modes, which do not set the events intervals; and waitUntil's default timeout is 30_000 (integration-tests/helpers/hosted-harness-process.ts:24) while through() passes 10_000 explicitly, so the assertion must key on the 10s window rather than the default. With that assertion in place, changing line 157 to --qwen.managed-agent.events.poll-interval=5s must make the driver fail immediately on it; remove the assertion and the same configuration passes silently, which is the hole being closed.

中文说明

这道门禁的核心前提——续传帧是经由提交后的实时 hub 投递、而不是 SQL 补偿到达的——只由一个没有写下来的数值关系维持:这两个参数,与另一种语言里驱动的 10 秒接收窗口(hosted-sse-gap-driver.ts:176)。而且注释只提到了轮询,实际的补偿节奏是 min(heartbeat-interval, poll-interval)。

ManagedEventStreamService.waitDuration 返回 min(untilHeartbeat, reconciliationInterval),而一次空投递会把 reconcile 置为 true,所以 SQL 是按这个合并节奏重读的。这里两个旋钮都是 60 秒,这正是 Observer.through() 的 waitUntil(..., 10_000) 今天只能由 hub 投递满足的原因。把其中任何一个降到 10 秒以下——对于想缩短第 261 行 shutdownNow() 所要绕开的那个拆解停顿的人来说,这是最自然的改动,而那个停顿本身就是 60 秒节奏造成的——SQL 补偿就能在窗口内投递这些帧。于是 FG6e 继续通过,却不再能区分实时 hub 投递与轮询回退:一个破坏提交后 hub 通知的回归,会绿着穿过专为捕获它而建的这道门禁。两种语言里都没有任何东西断言这个关系,而漂移方向正是系统默认值:ManagedAgentProperties.Events 出厂就是 pollInterval = 5s、heartbeatInterval = 15s。

把这个不变量表达一次,而不是写在注释里:runDriver 已经会给驱动写一个配置对象(第 229-232 行),所以节奏可以随它传给 sse-gap 模式,由驱动在启动时断言这个关系,或者由传入的间隔推导 through() 的预算。

修复依赖两条前提:HostedWorkspaceToolTurnIT.java:229-232 的 driver.json map 由五个驱动共享并各自解构,所以新增的键必须是可选的,或者为其余四个模式也提供——它们并不设置 events 间隔;另外 waitUntil 的默认超时是 30_000(integration-tests/helpers/hosted-harness-process.ts:24),而 through() 显式传的是 10_000,所以断言必须以 10 秒窗口为准,而不是默认值。有了这个启动断言之后,把第 157 行改成 --qwen.managed-agent.events.poll-interval=5s 必须让驱动立刻在该断言上失败;去掉断言,同样的配置就会静默通过——那正是要堵住的洞。

— qwen3.8-max via Qwen Code /review (v0.24.6)

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline.

3 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R1-1 privateJson proxyFailure guards — integration-tests/helpers/hosted-sse-gap-driver.ts:45 — already reported (inline comment 4124853134, /review round 1 ledger id R1-1)
  • R1-15 close() never reads the recorded relay failure — packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HostedSseGapProbe.java:230 — already reported (issue comment 5872181288, sandboxed-verify finding…
  • R1-16 server-side stream end rendered as a generic timeout — integration-tests/helpers/hosted-sse-gap-driver.ts:128 — already reported (issue comment 5872181288, sandboxed-verify finding S2)

Not explored to full depth (tool budget reached): "agent 1c": 未实测私有 Hosted 流在一次真实 Edit 期间是否只产出 agent_message_chunk (探针 containsExactly("session.created","item.output_text.delta") 依赖此点;若还产出 tool_call 会红,属响亮失败而非静默错误,但需真…; "agent 1c": 未量化 hosted-harness-mysql profile 的 forkedProcessTimeoutInSeconds=600 余量 —— 新增第 5 个 @Timeout(180) 用例后,该 fork 内所有 Hosted*IT 的典型总耗时是否仍留在 600s 内,我没有 CI 历史时长…; "agent 3a": 未验证 managed-agent-server 生产侧 SSE 写帧代码的实际换行符(在 src/main/java grep text/event-stream / "data:" 无命中,未能定位写帧处),因此 consumeFrames 的 CRLF 成本只能存疑、未作为 finding 提出。; "agent 1d": 无——所有计划核查均在预算内完成(约 12/41 次工具调用),没有因预算截断的检查项。; "agent 6c": 未读 AuthenticatedTenantActor 源码确认其为接口( MissingOverride 是否适用于这三个方法据此推断,间接证据是同类夹具对同名三方法使用了 @Override ), and 2 more.

中文说明

已审查。 建议见行内评论。

本轮确认的 3 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未探索到全部深度(达到工具调用预算):"agent 1c":未实测私有 Hosted 流在一次真实 Edit 期间是否只产出 agent_message_chunk (探针 containsExactly("session.created","item.output_text.delta") 依赖此点;若还产出 tool_call 会红,属响亮失败而非静默错误,但需真…;"agent 1c":未量化 hosted-harness-mysql profile 的 forkedProcessTimeoutInSeconds=600 余量 —— 新增第 5 个 @Timeout(180) 用例后,该 fork 内所有 Hosted*IT 的典型总耗时是否仍留在 600s 内,我没有 CI 历史时长…;"agent 3a":未验证 managed-agent-server 生产侧 SSE 写帧代码的实际换行符(在 src/main/java grep text/event-stream / "data:" 无命中,未能定位写帧处),因此 consumeFrames 的 CRLF 成本只能存疑、未作为 finding 提出。;"agent 1d":无——所有计划核查均在预算内完成(约 12/41 次工具调用),没有因预算截断的检查项。;"agent 6c":未读 AuthenticatedTenantActor 源码确认其为接口( MissingOverride 是否适用于这三个方法据此推断,间接证据是同类夹具对同名三方法使用了 @Override ),另有 2 条。

— qwen3.8-max via Qwen Code /review (v0.24.6)

const response = await fetch(
new URL(
surface === 'public'
? `/v1/agents/sessions/${sessionId}/events?stream=true&after=0`

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] R2-1: The public reconnect's ?after=0 is the very value PublicAgentController uses for "no cursor supplied" (@RequestParam(defaultValue = "0") long after), so this gate does not pin Last-Event-ID precedence as firmly as both design docs claim. The mutation the docs do name — ignoring a resume cursor — is caught. A precedence flip of the other shape is not: rewrite the call site as long cursor = after > 0 ? after : parseSequence(lastEventId, after); (or Math.max(after, parseSequence(...))) and FG6e stays green, because with after=0 both implementations fall back to the header and publicAfter.frames is still [2]. That is not only a fixture concern. A browser EventSource auto-reconnect resends the original URL, so a real client carries a non-zero after=N together with Last-Event-ID: M where M>N; under that mutation the cursor becomes N, the client re-receives N+1..M, and the guarantee this gate exists to protect — each durable event exactly once — is violated with nothing here to catch it. The hardcoded 0 also reads like a leftover constant next to the web branch's honest afterSequence: after ?? 0 two lines below, so a future maintainer "fixing" it to the real cursor would silently remove the coverage without reddening any test.

Witness — reflective call into the real compiled PublicAgentController.parseSequence (classpath = the module's own target/classes):

loaded real method: private static long ...PublicAgentController.parseSequence(java.lang.String,long)
Last-Event-ID=1 after=0 -> shipped=1 mutationTernary=1 mutationMax=1 | ternary indistinguishable=true max indistinguishable=true
Last-Event-ID=1 after=3 -> shipped=1 mutationTernary=3 mutationMax=3 | ternary indistinguishable=false max indistinguishable=false
header=-1 -> threw ApiException: Last-Event-ID is invalid.

javap -p -v on the same class file shows parameter 3 of events carrying RequestParam(defaultValue="0"), and javap -c shows 4: invokestatic parseSequence → 7: lstore 9, after which after is never read again.

Give Observer.start a query cursor separate from the header cursor (e.g. a queryAfter?: number parameter) and have the public reconnect pass an upward-conflicting value such as after=3, above the durable watermark at that moment. Correct code returns the header value 1 and ignores after, so the replay stays exactly [2] and the assertion still passes; under the mutation the cursor becomes 3 and publicAfter.through(2) times out red after 10s. The wording in both design docs ("a conflicting after=0" / 「冲突的 after=0」) is worth correcting at the same time, since after=0 is not a conflict, and the mutation list should name this shape alongside the three it already lists.

The conflict has to come from after rather than from an illegal header: PublicAgentController.parseSequence (PublicAgentController.java:222-235) throws ApiException(BAD_REQUEST, "invalid_event_cursor") on a negative or non-numeric Last-Event-ID — measured above — and the header at line 105 is attached only when after !== undefined, so the reconnect call must keep passing a cursor. The test that has to go red without this change is assert.deepEqual(publicAfter.frames.map((frame) => frame.id), [2], 'Public cursor replay') at hosted-sse-gap-driver.ts:327-331: once after=3 is in place, swapping the precedence to after > 0 ? after : ... must fail that assertion and the preceding publicAfter.through(2); with today's after=0 neither does.

中文说明

[建议] R2-1:公开流重连用的 ?after=0,正是 PublicAgentController 表示「未提供游标」的那个值(@RequestParam(defaultValue = "0") long after),因此本门禁对 Last-Event-ID 优先级的固定程度并不像两版设计文档声称的那样强。文档点名的那种突变(忽略续传游标)确实会被抓住;另一种形态的优先级翻转则不会:把调用点改成 long cursor = after > 0 ? after : parseSequence(lastEventId, after);(或 Math.max(after, parseSequence(...))),FG6e 依然全绿——因为在 after=0 下两种实现都回落到 header,publicAfter.frames 仍是 [2]。这不只是夹具层面的问题:浏览器 EventSource 自动重连会重发原始 URL,所以真实客户端带的是非零的 after=N 加上 Last-Event-ID: M(M>N);在该突变下游标变成 N,客户端会重复收到 N+1..M,而本门禁存在的意义——每个持久化事件恰好一次——就此被破坏,且这里没有任何断言能发现。另外,硬编码的 0 读起来像一个漏改的常量,而两行之下的 web 分支诚实地写成 afterSequence: after ?? 0;将来有人把它"修正"为真实游标,会在不让任何测试变红的情况下悄悄移除这项覆盖。

见证——对真实已编译的 PublicAgentController.parseSequence 做反射调用(classpath 用该模块自己的 target/classes):输出见上方英文部分代码块。after=0 时出厂实现与两种突变实现逐值相同(indistinguishable=true);把冲突值抬到 after=3 后立刻分叉(1 vs 3)。同一份 class 文件的 javap -p -v 显示 events 的第 3 个参数带 RequestParam(defaultValue="0"),javap -c 显示 4: invokestatic parseSequence → 7: lstore 9,此后 after 再未被读取。

建议给 Observer.start 增加一个与 header 游标分离的查询游标参数(例如 queryAfter?: number),并让公开流重连传一个向上冲突的值(如 after=3,高于当时的持久水位)。正确实现会返回 header 值 1 并忽略 after,重放仍恰为 [2],断言照旧通过;突变实现下游标变成 3,publicAfter.through(2) 会在 10s 后超时变红。两版设计文档中「冲突的 after=0」的措辞也值得一并修正,因为 after=0 并不构成冲突;突变清单也应把这一形态与已有的三项并列写出。

修复约束:冲突必须来自 after,不能来自非法 header——PublicAgentController.parseSequence(PublicAgentController.java:222-235)对负数或非数字的 Last-Event-ID 抛 ApiException(BAD_REQUEST, "invalid_event_cursor")(上方已实测),且第 105 行的 header 只在 after !== undefined 时才附加,所以重连调用必须继续传游标。验收标准:hosted-sse-gap-driver.ts:327-331 的 assert.deepEqual(publicAfter.frames.map((frame) => frame.id), [2], 'Public cursor replay') —— 在 after=3 就位后,把优先级换成 after > 0 ? after : ... 必须让该断言以及其前的 publicAfter.through(2) 变红;在今天的 after=0 下两者都不会红。

— qwen3.8-max via Qwen Code /review (v0.24.6)

]),
},
);
assert.equal(response.status, 200);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] R2-2: This is the only HTTP helper in the driver that throws away the server's response body when the status is wrong — privateJson passes text + cli.output (line 53), api passes text (65), evidence passes text (80) and the proxy passes text (203) — and it is also the call most likely to receive a server-side explanation. TenantContextFilter.doFilterInternal (managed-agent-server/src/main/java/.../api/TenantContextFilter.java:51-73) answers a missing or malformed X-Qwen-Tenant-Id with 400 plus an invalid_tenant JSON envelope, and an actor/tenant mismatch with 403 plus actor_scope_mismatch. So if FG6e ever fails at stream establishment — the fixture's AuthenticatedTenantActor not taking effect, or the managed_workspace_access grant changing — the driver reports only 403 !== 200, and the top-level catch prints cli.output and operations, neither of which contains that envelope. Every red then costs a rerun with added logging just to learn whether it was 400, 403 or 500.

Witness — three arms over the verbatim Observer class, the only variable being how this line is written:

=== server=forbidden assert=pr ===
start() threw: AssertionError
  message: "Expected values to be strictly equal:\n\n403 !== 200\n"
  message carries the server envelope? false
=== server=forbidden assert=fix ===
start() threw: AssertionError
  message: "{\"error\":{\"code\":\"actor_scope_mismatch\",\"message\":\"Authenticated actor scope is invalid.\",\"requestId\":\"probe\"}}\n\n403 !== 200\n"
  message carries the server envelope? true
=== server=stream assert=naive ===
start() STILL BLOCKED after 2010ms -- await response.text() on a live text/event-stream never resolves

Read the body only on the failure path, so the success path never touches the stream:

if (response.status !== 200) {
  assert.equal(response.status, 200, await response.text());
}

The if is load-bearing, not decorative: the 200 response body here IS the SSE stream consumed at lines 122-124 via response.body!.pipeThrough(new TextDecoderStream()).getReader(), and calling .text() on a live text/event-stream reads until the stream ends — the naive arm above was still blocked after 2010ms, which would hang Observer.start forever and leave the observer receiving no frames at all.

中文说明

[建议] R2-2:这是驱动里唯一一个在状态码不对时丢弃服务端响应体的 HTTP helper——privateJson 传 text + cli.output(第 53 行)、api 传 text(65)、evidence 传 text(80)、proxy 传 text(203)——而它恰恰是最可能收到服务端解释的那次调用。TenantContextFilter.doFilterInternal(managed-agent-server/src/main/java/.../api/TenantContextFilter.java:51-73)对缺失或非法的 X-Qwen-Tenant-Id 返回 400 加 invalid_tenant JSON 信封,对 actor 与 tenant 不匹配返回 403 加 actor_scope_mismatch。因此一旦 FG6e 在建流处失败——夹具的 AuthenticatedTenantActor 没生效,或 managed_workspace_access 授权被改动——驱动只会报 403 !== 200,而顶层 catch 打印的是 cli.output 与 operations,两者都不含那个信封。于是每次变红都要加日志重跑一遍,才能知道到底是 400、403 还是 500。

见证——对逐字复制的 Observer 类跑三臂,唯一变量是这一行的写法:输出见上方英文部分代码块。现状只剩 403 !== 200(carries the server envelope? false);建议写法把信封带了出来(true);而无条件 .text() 的第三臂在 2010ms 后仍然阻塞。

只在失败路径上读响应体,成功路径就永远不会碰到这条流。那个 if 是承重的、不是装饰:这里 200 的响应体就是随后在第 122-124 行经 response.body!.pipeThrough(new TextDecoderStream()).getReader() 消费的 SSE 流,对活的 text/event-stream 调 .text() 会一直读到流结束——上方第三臂在 2010ms 后仍未返回,那会让 Observer.start 永久挂住,观察者一帧也收不到。

— qwen3.8-max via Qwen Code /review (v0.24.6)

);
await waitUntil(async () => {
try {
writer = await open(proof, constants.O_WRONLY | constants.O_NONBLOCK);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] R2-3: These ten lines are the third byte-identical copy of the FIFO park protocol in this family — hosted-cancellation-driver.ts:83-92 and hosted-process-crash-driver.ts:89-98 are character-for-character the same, and the three differ only in what they do after the park. The block encodes a non-obvious OS-level contract (O_WRONLY|O_NONBLOCK returns ENXIO when no reader is present, so a successful open is itself the proof that the tool is parked inside read), and it writes the handle into a module-level writer that each driver's finally must remember to close. Any correction to it — an errno that differs between macOS and Linux, or closing the writer immediately after a park timeout so the handle cannot leak — has to be applied by hand in three places. Fixing one leaves FG6c, FG6d and FG6e holding three implementations of the same protocol that then fail differently, and debugging means diffing them against each other. The shared module this belongs in already exists and is already imported by all six hosted drivers: hosted-harness-process.ts exports waitUntil.

Witness — mechanical byte comparison, indentation normalised and nothing else:

### diff sse-gap(287-296) vs cancellation(83-92): BYTE-IDENTICAL (modulo indent)
### diff sse-gap(287-296) vs process-crash(89-98): BYTE-IDENTICAL (modulo indent)
### diff cancellation(83-92) vs process-crash(89-98): BYTE-IDENTICAL (modulo indent)
### who imports hosted-harness-process
hosted-broker-reply-loss-driver.ts hosted-cancellation-driver.ts
hosted-process-crash-driver.ts hosted-sse-gap-driver.ts
hosted-store-failure-driver.ts hosted-workspace-tool-turn-driver.ts

Add export async function parkOnFifo(fifo: string): Promise<FileHandle> to hosted-harness-process.ts doing the waitUntil + ENXIO retry + isFIFO() assertion and returning the writer handle; the three drivers then become writer = await parkOnFifo(proof); and keep their own evidence and status assertions.

The shared helper has to preserve if ((cause as NodeJS.ErrnoException).code !== 'ENXIO') throw cause; (hosted-cancellation-driver.ts:88) exactly — swallow only ENXIO and rethrow every other errno immediately, otherwise EACCES or ENOENT from a wrong or unreadable proof path degrades into a silent 30s timeout instead of an immediate error naming the real cause.

中文说明

[建议] R2-3:这十行是本门禁族里 FIFO 停车协议的第三份逐字节拷贝——hosted-cancellation-driver.ts:83-92 与 hosted-process-crash-driver.ts:89-98 与之逐字符相同,三者的差异只在停车之后各自做什么。这段代码编码的是一个不显而易见的操作系统级契约(无 reader 时 O_WRONLY|O_NONBLOCK 返回 ENXIO,因此打开成功本身就是"工具已停在 read 里"的证据),并且它把句柄写进模块级的 writer,依赖每个驱动自己的 finally 记得关闭。对它的任何修正——macOS 与 Linux 之间的 errno 差异,或停车超时后立刻关闭 writer 以免泄漏句柄——都必须手工同步三处。只改一处,就会让 FG6c、FG6d、FG6e 对同一协议持有三份实现,失败表现各不相同,排查时要逐份互相 diff。它该去的共享模块已经存在、且已被全部六个 hosted 驱动 import:hosted-harness-process.ts 已导出 waitUntil。

见证——机械字节比对(仅归一化缩进,其余未动):输出见上方英文部分代码块,三处两两比对均为 BYTE-IDENTICAL,且六个驱动全都 import 了 hosted-harness-process。

建议在 hosted-harness-process.ts 增加 export async function parkOnFifo(fifo: string): Promise<FileHandle>,内部完成 waitUntil + ENXIO 重试 + isFIFO() 断言并返回 writer 句柄;三个驱动改为 writer = await parkOnFifo(proof);,各自保留自己的 evidence 与状态断言。

修复约束:共享 helper 必须原样保留 if ((cause as NodeJS.ErrnoException).code !== 'ENXIO') throw cause;(hosted-cancellation-driver.ts:88)——只吞 ENXIO、其余 errno 立即重抛,否则 proof 路径写错或不可读时的 EACCES/ENOENT 会退化成一次 30 秒的静默超时,而不是立刻报出真因。

— qwen3.8-max via Qwen Code /review (v0.24.6)

@Override
public void close() throws Exception {
releaseTerminal.countDown();
Object provisioner = ReflectionTestUtils.getField(brokerService, "provisioner");

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] R2-4: This four-hop teardown chain (service → provisioner → delegate → owned[*] → process, then destroyForcibly + waitFor(5s)) is character-for-character identical to HostedCancellationProbe.java:240-249 apart from the receiver variable name, one unrelated setField on the cancellation side, and brace style — and a repo-wide grep for the literal "provisioner" returns exactly these two sites. The cost is that the chain has no compile-time contract at all: if LocalProcessRuntimeProvisioner ever renames delegate, owned or process, both probes fail during cleanup with an NPE or ClassCastException rather than at compile time, so whichever gate happens to be running gets fixed while the other one rots silently. The copy also dropped the original's .as("FIFO worker cleanup") description, so an FG6e worker that outlives 5s reports a bare expected: true but was: false with nothing naming what timed out. (The separate question of an assertion throwing from the IT's finally is already covered by the sandboxed-verify report's S3 on this PR, so this comment asks only for the shared helper and the restored description.)

Witness — mechanical diff of HostedSseGapProbe.java:223-229 against HostedCancellationProbe.java:240-249; the five middle lines are identical:

1c1,2
< Object provisioner = ReflectionTestUtils.getField(brokerService, "provisioner");
---
> ReflectionTestUtils.setField(service, "transport", transport);
> Object provisioner = ReflectionTestUtils.getField(service, "provisioner");
7c8,10
< for (Process process : processes) assertThat(process.waitFor(5, TimeUnit.SECONDS)).isTrue();
---
> for (Process process : processes) {
> assertThat(process.waitFor(5, TimeUnit.SECONDS)).as("FIFO worker cleanup").isTrue();
> }
### grep -rn '"provisioner"' --include=*.java (excluding target/)
HostedCancellationProbe.java:241
HostedSseGapProbe.java:223

Extract a package-private fixture helper — both probes are constructed by HostedWorkspaceToolTurnIT, so reachability is not an issue — e.g. HostedFixtureWorkers.destroyOwned(Object brokerService), called from both close() methods, and restore the .as("FIFO worker cleanup") description inside it.

releaseTerminal.countDown(); at line 223 must still run before the worker teardown: the relay thread is parked in assertThat(releaseTerminal.await(45, TimeUnit.SECONDS)).as("terminal release").isTrue(); (line 142) and close() ends with if (relay != null) relay.get(10, TimeUnit.SECONDS); (line 230), so a shared helper that absorbs or reorders the latch release turns cleanup from "done within 5s" into a 10s TimeoutException.

中文说明

[建议] R2-4:这条四跳回收链(service → provisioner → delegate → owned[*] → process,再 destroyForcibly + waitFor(5s))与 HostedCancellationProbe.java:240-249 逐字符相同,差别只有接收者变量名、cancellation 侧一行无关的 setField,以及花括号风格——而全仓对字面量 "provisioner" 的 grep 恰好只返回这两处。代价是这条链完全没有编译期契约:一旦 LocalProcessRuntimeProvisioner 重命名 delegate、owned 或 process,两个探针都会在清理阶段以 NPE 或 ClassCastException 失败而不是编译失败,于是当时正在跑的那个门禁被修好,另一份静默腐烂。这份拷贝还丢掉了原件的 .as("FIFO worker cleanup") 描述,所以一个超过 5 秒未退出的 FG6e worker 只会报一句裸的 expected: true but was: false,没有任何文字说明是什么超时了。(至于"断言从 IT 的 finally 里抛出"这个另一个问题,本 PR 上沙箱验证报告的 S3 已经覆盖,所以这条评论只要求抽共享 helper 与恢复描述。)

见证——HostedSseGapProbe.java:223-229 与 HostedCancellationProbe.java:240-249 的机械 diff(中间五行完全相同),以及全仓 "provisioner" 只有两处命中:输出见上方英文部分代码块。

建议抽一个包私有夹具工具——两个探针都由 HostedWorkspaceToolTurnIT 构造,可达性没有问题——例如 HostedFixtureWorkers.destroyOwned(Object brokerService),由两处 close() 调用,并把 .as("FIFO worker cleanup") 描述恢复在其中。

修复约束:第 223 行的 releaseTerminal.countDown(); 必须仍先于 worker 回收执行——relay 线程正停在 assertThat(releaseTerminal.await(45, TimeUnit.SECONDS)).as("terminal release").isTrue();(第 142 行),而 close() 以 if (relay != null) relay.get(10, TimeUnit.SECONDS);(第 230 行)收尾,所以一个把闩锁释放吞进去或改变其顺序的共享 helper,会把清理从"5 秒内完成"变成 10 秒 TimeoutException。

— qwen3.8-max via Qwen Code /review (v0.24.6)

boolean faults = !driverName.equals("workspace-tool-turn");
boolean storeFaults = driverName.equals("store-failure");
boolean cancellations = driverName.equals("cancellation");
boolean sseGaps = driverName.equals("sse-gap");

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] R2-5: FG6e is the fourth driverName-derived boolean in runDriver (faults, storeFaults, cancellations, sseGaps), and adding this one gate meant editing seven places inside the same method: line 115 (the switch), 155-159 (arguments + application.initializers), 166 (the database-banner ternary), 212 (probe construction), 238 (the HOSTED_*_OK marker ternary), 246 (the report-assertion chain) and 261 (the shutdownNow cleanup). The part worth writing down is that two of those three ternary chains are prepend-only, and nothing in the file says so: because boolean faults = !driverName.equals("workspace-tool-turn") is true for every gate driver, an arm appended at the end of the chain at 238 or 246 is swallowed by the faults arm. This diff prepends correctly at all three sites, which is exactly why the rule is invisible — the next gate author has to rediscover it, and the two failure shapes are silent rather than loud.

Witness — the real expressions extracted verbatim from lines 112-116, 166, 238-240 and 246-249, compiled with javac -proc:none under JDK 21 and evaluated by a JVM for three routing variants across all six driver names:

== asShipped            driver=fg6f | banner=FG6A_DATABASE  | marker=HOSTED_REPLY_LOSS_OK | reportAssert=assertFaultLedger(...)
== fg6fAppendedAtEnd    driver=fg6f | banner=FG6F_DATABASE  | marker=HOSTED_REPLY_LOSS_OK | reportAssert=assertFaultLedger(...)
== fg6fPrependedAtFront driver=fg6f | banner=FG6F_DATABASE  | marker=HOSTED_FG6F_OK       | reportAssert=sseProbeF.assertReport

Read the two harm paths off that table. Omitting an arm entirely (asShipped) still runs green while the CI log names the wrong database — and that banner line is the only evidence of which database the gate ran on, printed immediately before the forensic assertThat(metadata...).containsAnyOf("mysql","mariadb") output. Appending instead of prepending leaves marker=HOSTED_REPLY_LOSS_OK, so the line-238 log assertion looks for a marker the new driver never prints (red, for the wrong reason), and line 246 falls through to FG6a's generic assertFaultLedger(...), meaning the new gate's own SQL assertions never execute and the generic one can pass green while proving nothing about it. All five existing drivers route identically across the three variants, so only a new arm is exposed.

Replace the four booleans with one exhaustive driver descriptor — an enum or record carrying name, okMarker and databaseBanner, with requiresMySql() derived from databaseBanner != null. Lines 166 and 238 then become field lookups and the chain at 246 becomes a switch expression over the enum, so a missing arm is a compile error instead of being silently masked by faults.

driverName doubles as the driver's filename token — "integration-tests/helpers/hosted-" + driverName + "-driver.ts" at line 232 — so the descriptor must keep handing the literal sse-gap (not SSE_GAP) to ProcessBuilder; and the marker strings are printed by each driver itself (console.log('HOSTED_SSE_GAP_OK', ...) at hosted-sse-gap-driver.ts:444), so they cannot be renamed.

中文说明

[建议] R2-5:FG6e 是 runDriver 里第四个由 driverName 派生的布尔量(faults、storeFaults、cancellations、sseGaps),而为新增这一个门禁,需要在同一个方法内改动七处:第 115 行(开关声明)、155-159(arguments + application.initializers)、166(数据库 banner 三元)、212(探针构造)、238(HOSTED_*_OK 标记三元)、246(报告断言链)、261(shutdownNow 清理)。真正值得写下来的是:这三条三元链里有两条是只能前插的,而文件里没有任何地方说明这一点——因为 boolean faults = !driverName.equals("workspace-tool-turn") 对每个门禁驱动都为真,所以把新臂追加到 238 或 246 那条链的末尾会被 faults 臂吞掉。本次 diff 在三处都正确前插了,这恰恰使该规则不可见:下一位门禁作者必须重新发现它,而两种失败形态都是静默的、不是响亮的。

见证——把第 112-116、166、238-240、246-249 行的真实表达式逐字抽出,用 JDK 21 下 javac -proc:none 编译,再由 JVM 对三种路由变体 × 全部六个驱动名求值:输出见上方英文部分代码块。

从那张表可以读出两条危害路径。整条臂漏加(asShipped)时门禁照样全绿,而 CI 日志里打印的是错误的数据库——那一行 banner 是"这次门禁跑在哪个数据库上"的唯一证据,紧跟其后才是 assertThat(metadata...).containsAnyOf("mysql","mariadb") 的取证输出。尾插而非前插时 marker=HOSTED_REPLY_LOSS_OK,于是第 238 行的日志断言会去查一个新驱动永远不会打印的标记(变红,但原因错位),而第 246 行会落到 FG6a 的通用 assertFaultLedger(...),意味着新门禁自己的 SQL 断言完全不执行,通用断言却可能绿着通过、对新门禁什么都不证明。五个既有驱动在三种变体下路由完全一致,所以暴露的只有新臂。

建议把四个布尔量换成一个穷尽的驱动描述符——枚举或 record,携带 name、okMarker 与 databaseBanner,并由 databaseBanner != null 派生 requiresMySql()。这样第 166 与 238 行变成字段查询,第 246 行的链变成对该枚举的 switch 表达式,漏掉一个臂就是编译错误,而不再被 faults 静默遮蔽。

修复约束:driverName 同时充当驱动的文件名 token——第 232 行的 "integration-tests/helpers/hosted-" + driverName + "-driver.ts"——所以描述符必须继续把字面量 sse-gap(而不是 SSE_GAP)交给 ProcessBuilder;且标记字符串由各驱动自己打印(hosted-sse-gap-driver.ts:444 的 console.log('HOSTED_SSE_GAP_OK', ...)),因此不可重命名。

— qwen3.8-max via Qwen Code /review (v0.24.6)

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants