Skip to content

test(managed-agent): gate R1-1 owner-release recovery across a Spring restart (FG12d) - #14990

Merged
wenshao merged 2 commits into
mainfrom
test/fg12d-r1-1-spring-restart
Oct 21, 2026
Merged

wenshao merged 2 commits into
mainfrom
test/fg12d-r1-1-spring-restart

Conversation

@wenshao

@wenshao wenshao commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

Adds the FG12d R1-1 gate cell: during a lifecycle v2 close, the owner release is refused before it reaches the Broker after every MCP configuration is drained, and then Spring, together with its embedded Broker, is restarted. The case proves that the next detach recovers. It resends exactly one owner release, with no acquire and no MCP control. The storage holder clears, the configuration becomes released, and the Runtime Session becomes RELEASED.

To make the restart possible, the class now seeds the storage registration and the Workspace rows only on its first Spring boot, and restarts Spring inside the JVM.

Why it's needed

#14880 item 3, the last open residual of #14447, was recorded as blocked: a whole-Spring restart "re-registers the storage" and WorkspaceStorageGuard refuses that while the holder is booked. That refusal came from the test rig, not from the product. The class's startSpring() called register and inserted the Workspace rows on every boot. Production never re-registers on start: register is the operator step (WorkspaceStorageRegistrationMain), the registration lives in ma_storage_owner, and the mount roots come from configuration.

R1-1 is the regression #13109's real-stack round found. After the drain receipt, a lost owner release followed by a Broker replacement left detach answering 503 forever with the holder kept, because the restarted Broker refused the release with runtime_reconciliation_required. The retry recommended in that review never landed, so #14447 asked whether R1-1 still reproduces and planned to land a fix with the cell if it did.

It does not reproduce on main. The replaced Broker holds no local context for the Session, so the release reaches releasedSession. The Session row is still READY, the binding is READY with the same generation, and the lifecycle v2 close has marked the Harness draining. #13673's managed-context branch in that path releases the saved Session directly. No product change is needed. The cell pins that branch.

The restart happens inside the JVM rather than by killing a separate JVM. The Broker's close() cancels in-memory work and writes nothing, and the durable local provisioner does not terminate its workers on close. The restarted Broker therefore sees the same rows and the same live worker that a killed Broker would leave behind, and that is the state R1-1 depends on.

Reviewer Test Plan

How to verify

cd packages/managed-agent-server
JAVA_HOME=<jdk21> mvn test-compile failsafe:integration-test failsafe:verify \
  -Dit.test='HostedMcpRestartFaultGatesIT#ownerReleaseRefusedThenSpringRestartReleasesTheHolderOnMySql' \
  -Dfailsafe.failIfNoSpecifiedTests=false \
  -Dmysql.url='jdbc:mysql://127.0.0.1:<port>/hosted_harness_test?allowPublicKeyRetrieval=true&useSSL=false' \
  -Dmysql.user=root -Dmysql.password=<password> \
  -Dnode.executable="$(command -v node)" -Dqwen.cli.entry=<repo>/dist/cli.js

Expected: one test, zero failures. In target/fg12-cases/fg12d/<tenant>/proxy.json, the owner release (POST …/tool-sessions/{id}:release) appears twice: first with 503 (refused before forwarding), then, after the restart, with 200 as the only call the proxy sees.

Evidence (Before & After)

N/A for UI. Mutation witness: in RuntimeBrokerService.releasedSession, disable #13673's branch (if (false && record.isActive() && … isManagedContext() …)), clean, and rerun the case. It went red 3 of 3 times at "detach recovers after the Spring restart". Every retry forwarded an owner release, the restarted Broker answered 503, and the holder stayed booked, which is the symptom #13109 recorded. The restored control, rebuilt clean, is green.

Tested on

OS Status
🍏 macOS ⚠️
🪟 Windows N/A
🐧 Linux ✅
  • Linux aarch64, JDK 21, MySQL 8.4.6, with dist/cli.js built from the base:
    • the new case passed 5 standalone runs in a row (about 20–21 s each);
    • the class passed 9/9 twice (103.4–103.8 s), with the new case at 11.4–11.9 s.
  • On this head, rebased on main at 7d27f475be with a fresh install, build and bundle: the class passes 9/9 in 102.6 s, with the new case at 11.1 s.
  • mvn -DskipTests test-compile checkstyle:check checkstyle:check@runtime-broker-checkstyle is clean on JDK 21. scripts/tests/hosted-process-ci.test.js passes 73/73, and Prettier and git diff --check are clean.
  • After merging main at e17380e6ef (ledger conflict with test(managed-agent): reuse the run on a same-key retry (FG11d) #14981, resolved by stacking this cell on its numbers) with a fresh install, build and bundle: the class passes 9/9 in 105.4 s, with the new case at 11.3 s; the meta test passes 73/73.

Environment (optional)

The packaged Harness, durable worker and real stdio fixture, as in the rest of the class. MySQL ran in a throwaway container with the binary log disabled.

Risk & Scope

  • Writer-lease retry. The case's Session writer lease is 3 s, and Spring is down for about 3 s. The first retry after the restart can therefore answer workspace_lifecycle_resources_unsettled because the Harness writer is not active yet. That attempt sends nothing to the Broker. The bounded loop tolerates only that code and hosted_turn_active, and the wire assertion after the restart is exact (a single owner release, 200).
  • One unexplained failure in the shared preamble. In one early run, before any restart, the shared FG12d close preamble (admitClose followed by the effects phase) answered workspace_lifecycle_claim_fenced. The existing FG12d release cells use the same preamble. It did not recur in about 13 later runs of the preamble, and I have not root-caused it.
  • Not validated / out of scope:
    • a Broker killed while it handles the owner release itself;
    • a Spring restart before drained (FG12c's split-out Spring cells; the seed-once change unblocks them too);
    • cross-host recovery;
    • macOS.
  • Breaking changes / migration notes: none; test, budget and docs only. The cell adds 24 s (2x its measured 11.9 s) to the shared Hosted fork ledger: on top of current main: healthy 10050 → 10074 s and fork 10650 → 10674 s. The 188-minute Verify step still wraps it (11274 s of fork plus wrap), and the job ceiling stays 291. Only 6 s of margin remain, so the step comment now says that the next cell must raise the step and the job ceiling in the same diff.

Design doc: English · 中文. Both gain the same "R1-1 across a Spring restart" section, which also corrects the earlier blocker record.

Linked Issues

Part of #14447 (FG12d, the R1-1 cell). Addresses item 3 of #14880, the last open item there.

中文说明

这个 PR 做了什么

新增 FG12d 的 R1-1 门禁格:在一次 lifecycle v2 关闭中,所有 MCP configuration 都已 drained 之后,owner 释放在到达 Broker 之前被拒;随后重启 Spring 及其内嵌 Broker。该格证明下一次 detach 能够恢复:它恰好重发一次 owner 释放,没有 acquire,也没有任何 MCP 控制请求。storage holder 被清空,configuration 变为 released,Runtime Session 变为 RELEASED。

为了能够重启,该类现在只在第一次启动 Spring 时种入 storage 登记与 Workspace 行,并在 JVM 内重启 Spring。

为什么需要

#14880 第 3 项是 #14447 最后一项未完成的残余,此前被记录为阻塞:整仓 Spring 重启会"重新登记 storage",而 WorkspaceStorageGuard 在 holder 仍被占用时拒绝登记。这次拒绝来自测试 rig,而不是产品。该类的 startSpring() 每次启动都会调用 register 并插入 Workspace 行。生产环境启动时从不重新登记:register 是运维步骤(WorkspaceStorageRegistrationMain),登记记录在 ma_storage_owner 中,挂载根来自配置。

R1-1 是 #13109 真实栈轮次发现的回归。drain 回执之后,如果 owner 释放丢失、随后 Broker 被替换,detach 会永远回答 503 并保留 holder,原因是重启后的 Broker 以 runtime_reconciliation_required 拒绝释放。当时评审建议的重试一直没有落地,所以 #14447 要求确认 R1-1 是否仍能复现;若仍复现,则修复与该格一起落地。

它在 main 上已不再复现。 被替换后的 Broker 没有该 Session 的本地上下文,所以释放请求走到 releasedSession。此时 Session 行仍为 READY,binding 仍为 READY 且 generation 相同,lifecycle v2 关闭也已把 Harness 标为 draining。#13673 在这条路径上加的 managed-context 分支会直接释放已保存的 Session。因此无需改动产品代码,该格钉住的正是这个分支。

重启在 JVM 内完成,而不是杀掉一个单独的 JVM。Broker 的 close() 只取消内存中的工作,不写库;durable local provisioner 关闭时也不会终止 worker。因此重启后的 Broker 看到的库状态和存活 worker,与被杀掉的 Broker 留下的完全相同,而 R1-1 依赖的正是这一状态。

评审测试计划

如何验证

cd packages/managed-agent-server
JAVA_HOME=<jdk21> mvn test-compile failsafe:integration-test failsafe:verify \
  -Dit.test='HostedMcpRestartFaultGatesIT#ownerReleaseRefusedThenSpringRestartReleasesTheHolderOnMySql' \
  -Dfailsafe.failIfNoSpecifiedTests=false \
  -Dmysql.url='jdbc:mysql://127.0.0.1:<port>/hosted_harness_test?allowPublicKeyRetrieval=true&useSSL=false' \
  -Dmysql.user=root -Dmysql.password=<password> \
  -Dnode.executable="$(command -v node)" -Dqwen.cli.entry=<repo>/dist/cli.js

预期:1 个测试,0 失败。在 target/fg12-cases/fg12d/<tenant>/proxy.json 中,owner 释放(POST …/tool-sessions/{id}:release)出现两次:第一次为 503(在转发前被拒);重启之后为 200,并且是代理看到的唯一一次调用。

证据(前后对比)

无 UI 变化。变异见证:在 RuntimeBrokerService.releasedSession 中关掉 #13673 的分支(if (false && record.isActive() && … isManagedContext() …)),clean 后重跑该格。3 次运行全部在"detach 在 Spring 重启后恢复"处变红:每次重试都会转发 owner 释放,重启后的 Broker 回答 503,holder 一直被占用,正是 #13109 记录的症状。恢复并 clean 重建后的对照为绿。

测试平台

OS 状态
🍏 macOS ⚠️
🪟 Windows N/A
🐧 Linux ✅
  • Linux aarch64、JDK 21、MySQL 8.4.6,dist/cli.js 基于 base 构建:
    • 新格单独连续运行 5 次均通过(每次约 20–21 s);
    • 整类两次 9/9 通过(103.4–103.8 s),新格耗时 11.4–11.9 s。
  • 本 head 已基于 main 的 7d27f475be,经全新 install、build、bundle 后:整类 9/9 通过,用时 102.6 s,新格 11.1 s。
  • JDK 21 上 mvn -DskipTests test-compile checkstyle:check checkstyle:check@runtime-broker-checkstyle 干净。scripts/tests/hosted-process-ci.test.js 73/73 通过,Prettier 与 git diff --check 干净。
  • 合并 main 的 e17380e6ef(与 test(managed-agent): reuse the run on a same-key retry (FG11d) #14981 的账本冲突,已把本格叠加在其数字之上)并全新 install、build、bundle 后:整类 9/9 通过,用时 105.4 s,新格 11.3 s;元测试 73/73。

环境(可选)

与该类其他格相同:打包 Harness、durable worker 与真实 stdio fixture。MySQL 跑在关闭 binlog 的一次性容器中。

风险与范围

  • 写者租约重试。 本格的 Session 写者租约为 3 s,而 Spring 停机约 3 s。因此重启后的第一次重试可能因 Harness 写者尚未激活而回答 workspace_lifecycle_resources_unsettled。那次尝试不会向 Broker 发送任何请求。有界循环只容忍这个错误码和 hosted_turn_active,重启后的线上断言是精确的(只有一次 owner 释放,200)。
  • 共享前置流程中一次未查明原因的失败。 在早期的一次运行中,尚未重启时,FG12d 共享的关闭前置(admitClose 之后的 effects 阶段)回答了 workspace_lifecycle_claim_fenced。现有的 FG12d release 格使用同一段前置。之后约 13 次包含该前置的运行都没有再出现,我尚未查明根因。
  • 未验证 / 不在范围:
    • Broker 恰好在处理 owner 释放时被杀;
    • drained 之前的 Spring 重启(FG12c 拆出的 Spring 格;"只种一次"的改动同样可以解开它们);
    • 跨主机恢复;
    • macOS。
  • 破坏性变更 / 迁移说明: 无;仅测试、预算与文档。该格按 2×(实测 11.9 s)向共享 Hosted fork 账本计入 24 s:在当前 main 的基础上:healthy 10050 → 10074 s,fork 10650 → 10674 s。188 分钟的 Verify step 仍能包住(fork 加包裹共 11274 s),job 上限保持 291。余量只剩 6 s,因此 step 注释现在写明:下一个格必须在同一 diff 中同时提高该 step 与 job 上限。

设计文档:English · 中文。两版都新增同一个"跨 Spring 重启的 R1-1"章节,并更正了此前关于阻塞点的记录。

关联 Issue

属于 #14447(FG12d 的 R1-1 那一格)。处理 #14880 第 3 项,也是该 issue 最后一项未完成的内容。

… restart (FG12d)

Cover #14880 item 3. During a lifecycle v2 close the Broker proxy refuses
the owner release after every configuration is drained, then the test
replaces Spring and its embedded Broker. Detach must recover with exactly
one forwarded owner release (no acquire, no MCP control), clear the
storage holder, and leave the Runtime Session RELEASED.

The recorded blocker was the rig, not WorkspaceStorageGuard:
startSpring() registered the storage and inserted the Workspace rows on
every boot, and the guard refuses a second registration while the holder
is booked. Production never re-registers on start, so seed only on the
first boot and restart in the JVM; the Broker's close writes nothing and
the durable provisioner keeps the worker alive, which is the state R1-1
needs.

R1-1 does not reproduce on main: the replaced Broker reaches the
persisted-Session release path, and #13673's managed-context branch
releases the saved Session while the Harness drains. Disabling that
branch turns the case red 3 of 3 times with the original symptom (every
retry answers 503 and the holder stays booked).

Budget the cell at 24 s (2x its measured 11.9 s): healthy 10039 -> 10063,
fork 10639 -> 10663, Verify stays 188 minutes.
@qwen-code-review-bot

qwen-code-review-bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

⚠️ Deferred approval not posted — the PR head moved (or the PR closed) after the review of eb86e6c; approving now would attest to unreviewed code. Re-run @qwen-code /triage on the new head. finalize run

⚠️ 延迟审批未提交 —— 审查 eb86e6c 之后 PR head 已变更(或 PR 已关闭),此时审批会为未审查的代码背书。请在新 head 上重新运行 @qwen-code /triage。查看 finalize 运行

@wenshao

wenshao commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

已评审提交 eb86e6cc63a2114da0ebba132ffac3dfa52476b4:未发现阻塞性问题(C=0)。发布前再次确认了 head;当前没有需要复核的既有未解决评审问题。

重点核实了这道门禁是否真正覆盖 R1-1:

  • owner release 的 503 在代理转发前注入;重启前已断言 configuration 为 drained、storage holder 仍占用、Runtime Session 为 READY,重启后继续断言原状态未被提前结算。
  • 首次启动才登记 storage 的改动符合现有持久化模型:挂载根由配置恢复,登记与 Workspace 行保存在数据库中。Broker 关闭及 durable local provisioner 的关闭路径也与这里保留原 worker 的测试前提一致。
  • 恢复断言同时检查线上请求和持久化结果:只重发一次 owner release,没有 acquire 或 MCP control,随后 holder 清空、configuration 为 released、Runtime Session 为 RELEASED。核查了 Broker 的持久化 Session 释放分支及其原 generation/worker 身份验证。
  • POM、CI 注释与预算 meta-test 的数值一致;双语设计新增章节的行为、约束与验证范围一致。

本轮实际验证(macOS arm64、JDK 21、Maven 3.9.11、Node 22.22.2、独立临时 MySQL 8.0.46,Harness 从上述提交重新构建):

  • 锁定依赖安装、Java SDK install、npm run build && npm run bundle:通过。
  • Java test-compile 与两套 Checkstyle:通过。
  • 新增用例独立运行:1/1 通过,9.174 秒。
  • HostedMcpRestartFaultGatesIT 整类:9/9 通过,43.933 秒,零失败、错误、跳过。
  • 独立运行的 proxy 记录中,同一 owner release 为 seq=9 / 503 → seq=10 / 200,之后无其他请求;最终 SQL 查询确认 Session RELEASED、剩余 holder 为 0。
  • hosted-process-ci、sdk-java-workflow、managed-agent-gate-workflow:合计 237/237 通过;变更文件的 Prettier 检查与 git diff --check 通过。

验证边界:本轮没有重跑 Linux/MySQL 8.4、完整 mvn verify 或 PR 描述中的 mutation witness;检查时 PR 的 Hosted/MySQL 8.4 故障测试 CI 为 skipped。因此上述结论以代码审查及本轮列出的实测为依据,不代表完整 CI 已通过。

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — every section present, Before/After correctly marked N/A for a non-UI change, and both design-doc languages linked.

Problem: observed, not theoretical — and the description keeps two distinct things straight. One is a rig defect: startSpring() registered the storage and inserted the Workspace rows on every boot, so WorkspaceStorageGuard refused a whole-Spring restart while the holder was booked. That refusal is what #14880 item 3 was recorded as blocked by, and it was never a product behaviour. The other is the missing FG12d R1-1 cell. The honest part is the conclusion that R1-1 no longer reproduces on main, so no product change is claimed — the cell pins #13673's managed-context release branch instead, and the mutation witness (red 3 of 3 with that branch disabled) is the evidence that it really pins it.

Direction: aligned. This closes the last open residual of #14447's FG12d row and follows the pattern the earlier cells (FG11d, FG12c, FG12d, FG13f) already established, budget ledger included. Correcting the design doc's blocker record — the rig, not the guard — is worth leaving behind on its own, and the same seeding change unblocks FG12c's split-out Spring cells.

Size: not applicable, no core paths are touched. Production logic lines: 0. The 231 additions / 10 deletions are one Java IT, two design docs, the failsafe budget, a workflow comment, and the meta-test that pins that budget.

Approach: the scope is the minimum the goal needs, and I checked the parts most likely to leak. The seed-once guard sits after jdbc and store are re-read from the new context, so a restarted boot still gets fresh beans and skips only the durable-row seeding. Because the class carries no @TestInstance, that flag is per test method, so the eight existing cells seed exactly as before. The ledger arithmetic holds in all three places that pin it (10039 + 24 = 10063 healthy, + 600 = 10663 fork, + 600 = 11263 wrap, inside the unchanged 188-minute step), and no stale 10639 or 10039 survives anywhere in the tree. sdk-java.yml changed comment-only, and hosted-process-ci.test.js — the meta-test that pins the literal — was updated in the same PR, which is what AGENTS.md asks for.

Risk: no elevated risk signals; none of the changed files match the revert-correlated paths.

One thing worth stating up front, because it changes what a green CI means here: this PR's own run selected tier=fast, so hosted-harness-mysql ("Hosted process fault gates / MySQL 8.4") is skipped by its if: inputs.tier != 'fast'. Nothing in the pre-approval CI executes the new cell. That is the intended design — a maintainer's approval is what buys the full tier — but it does mean the cell's first automated run lands after approval rather than before. Carrying that into the review rather than treating it as a defect.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 各节齐备,非 UI 改动的 Before/After 正确标注 N/A,设计中英文档均已附上链接。

问题: 是已观测到的,不是理论性加固——而且描述把两件事分得很清楚。一是测试 rig 的缺陷:startSpring() 在每次启动时都登记 storage 并插入 Workspace 行,因此 holder 仍被占用时 WorkspaceStorageGuard 会拒绝整仓 Spring 重启。#14880 第 3 项记录的阻塞点正是这次拒绝,而它从来不是产品行为。二是缺失的 FG12d R1-1 格。最诚实的一点是结论:R1-1 在 main 上已不再复现,所以并未声称改了产品代码——该格改为钉住 #13673 的 managed-context 释放分支,而变异见证(关掉该分支后 3 次全红)就是它确实钉住了的证据。

方向: 对齐。它收掉了 #14447 FG12d 行的最后一项残余,并沿用了既有格(FG11d、FG12c、FG12d、FG13f)已建立的模式,包括预算台账。把设计文档里的阻塞记录更正为"在 rig 而不在守卫"本身就有价值,而且同一个"只种一次"的改动也解开了 FG12c 拆出的 Spring 格。

规模: 不适用,未触及核心路径。生产逻辑行数为 0。231 增 / 10 删分布在一个 Java IT、两份设计文档、failsafe 预算、一处 workflow 注释,以及钉住该预算的 meta-test。

方案: 范围就是达成目标所需的最小集合,我重点看了最容易外溢的部分。"只种一次"的提前返回位于从新上下文重新取回 jdbc 与 store 之后,所以重启后的启动仍能拿到新 bean,只跳过持久行的种入。由于该类没有 @TestInstance,该标志是按测试方法隔离的,因此既有的八格照旧种入。台账算术在三处钉住它的地方都成立(10039 + 24 = 10063 healthy,+ 600 = 10663 fork,+ 600 = 11263 wrap,仍在未改动的 188 分钟 step 之内),且整棵树里再没有残留的 10639 或 10039。sdk-java.yml 只改了注释,而钉住该字面量的 meta-test hosted-process-ci.test.js 在同一个 PR 里一并更新,这正是 AGENTS.md 的要求。

风险: 无升级风险信号;改动文件均未命中与 revert 相关的路径。

有一点需要先说明,因为它改变了"CI 绿"在这里的含义:本 PR 自己的运行选中了 tier=fast,因此 hosted-harness-mysql("Hosted process fault gates / MySQL 8.4")被其 if: inputs.tier != 'fast' 跳过。批准前的 CI 中没有任何一项会执行这个新格。这是既定设计——需要 maintainer 的批准才会换取 full tier——但确实意味着该格的第一次自动化运行发生在批准之后而非之前。我会把这一点带进审查,而不是当作缺陷。

进入代码审查 🔍

— Qwen Code · qwen3.8-max-2026-09-02

Reviewed at eb86e6cc63a2114da0ebba132ffac3dfa52476b4 · re-run with @qwen-code /triage

@qwen-code-review-bot

qwen-code-review-bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Code review

No critical blockers and no AGENTS.md violations. I tried to break the three things this diff actually depends on, and they hold:

The seed-once guard is correctly scoped and correctly placed. spring, jdbc and store are instance fields, and the class has no @TestInstance, so JUnit's default per-method lifecycle gives every cell a fresh springSeeded = false. The eight existing cells therefore seed exactly as they did before — the change is invisible to them. Within the new cell, restartSpring() skips the seeding and reuses the durable rows, which is the point. The placement is the subtle part and it is right: the early return sits after jdbc and store are re-read from the new context, so a restarted boot gets fresh beans and skips only guard.register and the two Workspace inserts. Had it gone in above those assignments the restarted cell would have queried a closed context. @AfterEach stop() closes whatever spring points at, which after a restart is the new context — no leak and no double-close, since restartSpring() already closed the old one.

The fault injection is precise, not broad. This was my main worry: the new proxy branch matches any POST whose path ends in :release, and MCP controls traverse the same proxy. It is safe. The Broker routes MCP controls to a /control suffix, and its other release route is :release-mount, which does not satisfy endsWith(":release") — so the filter selects the owner release alone. The arming order backs this up empirically: refuseNextOwnerRelease() is called after prepare_resources, and the cell asserts releaseState == "drained" immediately after the refusal, which proves the single mcp-release control had already been answered before the flag was armed. The branch itself mirrors the existing refuseArmed block structurally — same proxyCalls.set(index, call.complete(503)), same wire entry, same headers, body, close and return — and it consumes its flag with compareAndSet, so it cannot leak into a second call or into another cell.

The assertions are not vacuous. storageHolders() counts ma_storage_owner.holder_key IS NOT NULL joined to the session's binding, which the schema confirms is the booked-holder predicate, and the cell asserts 1 before the restart and 0 after — so it can only pass if the holder genuinely clears. The post-restart wire assertion is the strong one: singleElement() over calls with seq() > restartSeq, requiring path :release, null kind and status 200. A stray call in the restart window, or a 502 from the relay's upstream-failure branch, fails it loudly rather than passing silently. The retry loop is also stricter than its neighbour — 30 attempts tolerating only workspace_lifecycle_resources_unsettled and hosted_turn_active, asserting every other code fails, against the existing release cell's looser 60-attempt loop. For a gate that is the right way round.

Three non-blocking notes:

  1. This addition spends nearly all the remaining step margin. The ledger moves from 11239 s of fork-plus-wrap against the 11280 s step (41 s spare) to 11263 s (17 s spare). The arithmetic is right and the step still wraps, but the next cell added to this fork has to raise timeout-minutes: 188 alongside the fork budget — otherwise it lands as an undiagnosable step timeout, which is the Main CI failed: SDK Java — HostedHarnessMySqlIT.lifecycleOperationsCloseThePackagedHarnessSession #13780 failure mode the ledger comments exist to prevent. Worth a line in the comment block so the next author inherits that rather than rediscovering it.
  2. The unroot-caused workspace_lifecycle_claim_fenced you flagged is pre-existing, not introduced here. I compared the preamble against firstReleaseRefusedBeforeForwardingResumesUnderTheSameId and it is identical — same tool_profile update, admitClose, effects, the manual DRAINING write, then prepare_resources. So the intermittent failure belongs to shared code that existing cells already run. It is not a reason to hold this PR, but it is currently disclosed only here while the other cells share the exposure — a standing follow-up rather than a blocker.
  3. The in-JVM restart rebinds two fixed ports (port and brokerPort) immediately after spring.close(). Fourteen-plus green runs is decent evidence and Tomcat sets SO_REUSEADDR, so I am not calling this a risk — just the first place to look if this cell ever flakes in CI.

I skipped the sequence diagram and the changed-files table: this is a test-only change with no product flow reshaped, and four of the six files are comment or budget updates, so a table would have said "comment updated" four times.

Test evidence

This is an unattended CI run, so I did not build or execute anything from this PR — the evidence below is the PR's own CI, read through the API for the reviewed commit. Zero checks have failed. Most lanes are green; two were still in flight at fetch time and are listed as pending rather than guessed at.

The load-bearing fact is which lane is skipped. Select the Managed Agent tier returned fast for this pull_request run, and hosted-harness-mysql ("Hosted process fault gates / MySQL 8.4") carries if: inputs.tier != 'fast'. That job is the only one that runs HostedMcpRestartFaultGatesIT, so no check on this PR executes the new cell — the green SDK Java (fast) results cover Flyway uniqueness, the Java 11 real-daemon E2E and the Java 21 unit lane, none of which touch it. This is by design: the workflow only escalates to tier=full on a maintainer's approval, specifically so the gate's own fast tier cannot buy the most expensive run in the repository with no human involved. The consequence is that the cell's first automated execution is triggered by the approval, not available before it.

Final CI results for eb86e6c (auto-updated by the triage finalize job after CI completed):

Check Conclusion
(needs.scope.outputs.tier == 'full' || needs.scope.outputs.touched != 'true' || (github.event_name != 'pull_request' && 🚫 cancelled
SDK Java (fast) / ubuntu-latest / Java 21 🚫 cancelled
Build and verify reference distribution ✅ success
Classify PR ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
OpenTUI no-flicker gate ✅ success
SDK Java (fast) / Flyway migration version uniqueness ✅ success
SDK Java (fast) / Real daemon E2E / Java 11 ✅ success
Select the Managed Agent tier ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
TUI parity snapshots (ink vs opentui) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

Bot orchestration checks (triage, review-pr, authorize, resolve-pr and friends) are omitted from the table; nothing in the full set of 55 checks reports a failure.

Not verified, and why:

  • That the new cell passes — not verified: the only lane that runs it is skipped on the fast tier, so no CI result speaks to it. The author reports 5 consecutive standalone runs, 9/9 twice on the class, and 9/9 on a head rebased onto 7d27f475be. That is the author's claim, not independently re-run here, and this run neither builds nor executes PR code.
  • That the cell is load-bearing — not verified: the mutation witness (disabling fix(managed-agent): recover original Hook sessions during Workspace retirement #13673's managed-context branch, red 3 of 3 at the recovery assertion) is likewise the author's claim. This matters more than the pass count, because a green cell that would also pass with that branch removed would be worthless — it is the whole justification for adding a cell for a bug that no longer reproduces.
  • macOS — not verified: declared N/A by the author, and the MySQL hosted lanes are Linux-only.

Sandboxed verification would settle the second gap: @qwen-code /verify — specifically an A/B that runs ownerReleaseRefusedThenSpringRestartReleasesTheHolderOnMySql with #13673's managed-context branch disabled and confirms it goes red at "detach recovers after the Spring restart", then restores it and confirms green. That is the one claim the diff cannot substantiate statically and the fast tier cannot substantiate at all. Worth knowing before sponsoring it: the lane needs JDK 21, a disposable MySQL 8.4 and a bundled dist/cli.js, none of which the fast tier provisions, so this is the expensive run rather than a cheap one. There is no TUI surface here, so @qwen-code /tmux has nothing to drive.

中文说明

代码审查

没有致命阻塞项,也没有违反 AGENTS.md 的地方。我针对这个 diff 真正依赖的三点做了破坏性检查,都成立:

"只种一次"的守卫作用域与位置都正确。 spring、jdbc、store 都是实例字段,且该类没有 @TestInstance,所以 JUnit 默认的按方法生命周期让每个格都拿到全新的 springSeeded = false。既有的八格因此照旧种入——这个改动对它们完全不可见。在新格里,restartSpring() 跳过种入、复用持久行,这正是目的所在。位置是最微妙的一点,而它是对的:提前返回位于从新上下文重新取回 jdbc 与 store 之后,所以重启后的启动拿到的是新 bean,只跳过 guard.register 和两条 Workspace 插入。如果放在这些赋值之上,重启后的格会去查询一个已关闭的上下文。@AfterEach stop() 关闭的是 spring 当前指向的对象,重启后即新上下文——不会泄漏,也不会重复关闭,因为旧上下文已由 restartSpring() 关掉。

故障注入是精确的,不是宽泛的。 这是我主要的顾虑:新的代理分支匹配任何路径以 :release 结尾的 POST,而 MCP 控制请求也走同一个代理。结论是安全的。Broker 把 MCP 控制请求路由到 /control 后缀,另一条释放路由是 :release-mount,它不满足 endsWith(":release")——所以该过滤器只会选中 owner 释放。装填顺序也从实证上印证了这点:refuseNextOwnerRelease() 在 prepare_resources 之后调用,而该格在拒绝之后立刻断言 releaseState == "drained",这证明唯一那次 mcp-release 控制在标志装填前就已经应答完毕。分支本身在结构上与既有的 refuseArmed 块一致——同样的 proxyCalls.set(index, call.complete(503))、同样的 wire 记录、同样的响应头、响应体、close 与 return——并用 compareAndSet 消费自己的标志,因此不会漏到第二次调用,也不会影响其他格。

断言不是空转的。 storageHolders() 统计的是 join 到该 Session binding 上的 ma_storage_owner.holder_key IS NOT NULL,schema 确认这就是"holder 被占用"的判定式;该格在重启前断言 1、重启后断言 0,所以只有 holder 真正清空才能通过。重启后的线上断言是最强的一条:对 seq() > restartSeq 的调用做 singleElement(),要求路径为 :release、kind 为 null、状态 200。重启窗口内若有多余调用,或 relay 的上游失败分支返回 502,都会响亮地失败,而不是静默通过。重试循环也比邻格更严——30 次尝试,只容忍 workspace_lifecycle_resources_unsettled 和 hosted_turn_active,其余错误码一律断言失败;而既有释放格是更宽松的 60 次循环。对一个门禁格来说,这个方向是对的。

三条非阻塞意见:

  1. 这次新增几乎花光了 step 的剩余余量。 台账从 11239 s(fork 加包裹,对 11280 s 的 step,剩 41 s)变成 11263 s(剩 17 s)。算术正确、step 仍能包住,但下一个加进这个 fork 的格必须连同 fork 预算一起上调 timeout-minutes: 188——否则会表现为一次无法诊断的 step 超时,正是台账注释要防的 Main CI failed: SDK Java — HostedHarnessMySqlIT.lifecycleOperationsCloseThePackagedHarnessSession #13780 失败形态。建议在注释块里补一句,让下一位作者直接继承这个信息,而不是重新踩一遍。
  2. 你标出的那次未定位根因的 workspace_lifecycle_claim_fenced 是既有问题,不是本 PR 引入的。我把该前导段与 firstReleaseRefusedBeforeForwardingResumesUnderTheSameId 对比过,完全一致——同样的 tool_profile 更新、admitClose、effects、手写 DRAINING,然后 prepare_resources。所以这个偶发失败属于既有格也在跑的共享代码。它不构成暂缓本 PR 的理由,但目前只在这里披露,而其他格同样暴露在其中——属于长期跟进项,不是阻塞项。
  3. JVM 内重启会在 spring.close() 之后立刻重新绑定两个固定端口(port 与 brokerPort)。十四次以上全绿是不错的证据,Tomcat 也会设置 SO_REUSEADDR,所以我不把它算作风险——只是如果这个格将来在 CI 里抖动,这是第一个该看的地方。

我省略了时序图和改动文件表:这是一个纯测试改动,没有重塑任何产品流程,而且六个文件里有四个是注释或预算更新,做成表格只会把"注释已更新"写四遍。

测试证据

这是无人值守的 CI 运行,所以我没有构建或执行本 PR 的任何代码——下面的证据来自通过 API 读取的、该 PR 自己在被审 commit 上的 CI 结果。没有任何检查失败。 多数通道为绿;有两项在取数时仍在运行,如实列为 pending,不做猜测。

关键在于哪条通道被跳过。Select the Managed Agent tier 在本次 pull_request 运行中返回了 fast,而 hosted-harness-mysql("Hosted process fault gates / MySQL 8.4")带有 if: inputs.tier != 'fast'。那个 job 是唯一会运行 HostedMcpRestartFaultGatesIT 的,所以本 PR 上没有任何检查会执行这个新格——已绿的 SDK Java (fast) 结果覆盖的是 Flyway 唯一性、Java 11 真实 daemon E2E 和 Java 21 单测通道,都不触及它。这是设计如此:该 workflow 只在 maintainer 批准时升级到 tier=full,正是为了避免门禁自己的 fast tier 在无人参与的情况下换取仓库里最贵的一次运行。其结果是,该格的第一次自动化执行是由批准触发的,而不是在批准之前就已具备。

(CI 表格见上方标记区域;机器人编排类检查已省略,全部 55 项检查中没有任何一项报失败。)

未验证的部分及原因:

  • 新格是否通过 —— 未验证:唯一会运行它的通道在 fast tier 下被跳过,因此没有任何 CI 结果能说明。作者报告单独连跑 5 次通过、整类两次 9/9、以及 rebase 到 7d27f475be 的头 9/9。这是作者的陈述,本次未独立复跑,且本运行不构建也不执行 PR 代码。
  • 该格是否真正有承重作用 —— 未验证:变异见证(关掉 fix(managed-agent): recover original Hook sessions during Workspace retirement #13673 的 managed-context 分支,3 次全部在恢复断言处变红)同样是作者的陈述。这一点比通过次数更重要,因为一个"即使移除该分支也照样变绿"的格是没有价值的——而这正是为一个已不再复现的 bug 新增格的全部理由。
  • macOS —— 未验证:作者标注 N/A,且 MySQL hosted 通道仅支持 Linux。

沙箱验证可以补上第二个缺口:@qwen-code /verify —— 具体是做一次 A/B:在关掉 #13673 managed-context 分支的情况下运行 ownerReleaseRefusedThenSpringRestartReleasesTheHolderOnMySql,确认它在 "detach recovers after the Spring restart" 处变红;再恢复该分支,确认变绿。这是 diff 无法静态证明、fast tier 也完全无法证明的那一条主张。赞助之前值得知道:该通道需要 JDK 21、一个可丢弃的 MySQL 8.4 以及打包好的 dist/cli.js,这些 fast tier 都不提供,所以这是昂贵的那次运行,不是便宜的那次。这里没有 TUI 界面,因此 @qwen-code /tmux 无可驱动。

— Qwen Code · qwen3.8-max-2026-09-02

Reviewed at eb86e6cc63a2114da0ebba132ffac3dfa52476b4 · re-run with @qwen-code /triage

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — the static review is clean and I could check every load-bearing part of it; the one thing I cannot verify is structural to this repo's CI, not a defect in the PR.

Stepping back: this is a well-behaved PR. It came in claiming a product regression, investigated it, found the regression does not reproduce on main, and said so plainly instead of shipping a fix nobody needed. It then correctly diagnosed that the thing which had blocked this cell for months was the test rig re-registering storage on every boot — not WorkspaceStorageGuard doing its job — and fixed the rig with a two-line guard rather than weakening the guard. It also went back and corrected the design doc's blocker record in both languages, which is the part that stops the next person from re-attempting the same dead end. That is the opposite of the volume-driven PR the gate exists to filter.

My independent proposal before reading the diff was: refuse the owner release at the proxy before forwarding, replace the Broker, then assert recovery with an exact wire assertion. The PR does that and goes further — I would not have thought to look at the rig's seeding as the actual blocker, and that is the contribution here over my baseline.

What I verified rather than assumed: the seed-once flag is per test method (no @TestInstance, instance fields), so the eight existing cells are untouched; the early return sits after the beans are re-read, so the restarted context is actually usable; the :release path filter cannot catch an MCP control, because controls route to /control and the sibling :release-mount route does not match; the holder assertion is a real schema predicate asserted at 1 before and 0 after, not a tautology; and the budget ledger is consistent across all three places that pin it with no stale literal left in the tree.

What I could not verify, and the reason matters: this PR's run selected the fast tier, so the only job that executes HostedMcpRestartFaultGatesIT is skipped. The cell has therefore never run in CI on this PR, and its load-bearing property — that it goes red when #13673's managed-context branch is removed — rests on the author's mutation witness. I read the workflow: that is deliberate, so the gate's own fast tier cannot buy the most expensive run in the repository without a human. The practical consequence is that approving is what triggers the full tier that finally runs the cell. I am comfortable with that ordering here, because the failure mode is caught immediately afterwards by the very run the approval unlocks, and because every prior cell in this series landed the same way. But a maintainer who wants the evidence before the vote should sponsor @qwen-code /verify first — the A/B I described in the review is the only thing that closes that gap, and it needs JDK 21 plus a disposable MySQL 8.4.

The reservations I have are all non-blocking and named in the review: this addition leaves only 17 s of margin under the 188-minute step, so the next cell must raise that timeout too; the intermittent workspace_lifecycle_claim_fenced in the shared preamble is pre-existing but currently disclosed only here; and the in-JVM restart rebinds two fixed ports, which is the first thing to look at if this cell ever flakes.

Two of the PR's own workflow runs are still in flight, so I am not posting an approval in this run. Approval is deferred until CI lands green on eb86e6cc63a2114da0ebba132ffac3dfa52476b4; the finalize workflow posts the commit-pinned approval once every check on that commit completes green, and withholds it if anything lands red or the head moves.

中文说明

Confidence: 4/5 —— 静态审查干净,其中每一个承重环节我都能核实;唯一无法验证的那一点源于本仓库 CI 的结构性安排,而不是这个 PR 的缺陷。

退一步看:这是一个规矩的 PR。它进来时声称有一个产品回归,调查之后发现该回归在 main 上并不复现,于是直说了,而没有顺手发一个没人需要的修复。它接着正确判断出:把这格阻塞了几个月的原因是测试 rig 每次启动都重新登记 storage,而不是 WorkspaceStorageGuard 在尽职——并且用两行守卫修了 rig,而不是去削弱那个守卫。它还回头把设计文档里的阻塞记录在中英两份里都更正了,这部分能阻止下一个人再走一遍同样的死路。这与门禁要过滤的那类"堆量 PR"恰好相反。

在读 diff 之前,我的独立方案是:在代理转发前拒绝 owner 释放,替换 Broker,然后用一条精确的线上断言验证恢复。这个 PR 做到了,而且走得更远——我不会想到去查 rig 的种入逻辑才是真正的阻塞点,而这正是它相对我的基线做出的贡献。

我核实而非假设的部分:那个"只种一次"的标志是按测试方法隔离的(没有 @TestInstance,字段为实例字段),所以既有的八格完全没被碰到;提前返回位于 bean 重新取回之后,所以重启后的上下文确实可用;:release 路径过滤器不会误伤 MCP 控制请求,因为控制请求路由到 /control,而同族的 :release-mount 路由不满足匹配;holder 断言是真实的 schema 判定式,且在重启前断言 1、重启后断言 0,不是同义反复;预算台账在钉住它的三处都一致,树里没有残留的旧字面量。

我无法核实的部分,以及原因之所以重要:本 PR 的运行选中了 fast tier,因此唯一会执行 HostedMcpRestartFaultGatesIT 的 job 被跳过了。所以这个格在本 PR 上从未在 CI 中运行过,而它的承重性质——移除 #13673 的 managed-context 分支后它会变红——依赖作者的变异见证。我读过该 workflow:这是有意为之,目的是不让门禁自己的 fast tier 在无人参与的情况下换取仓库里最贵的一次运行。实际后果是,批准这个动作本身才会触发最终运行该格的 full tier。我对这个顺序在这里是可以接受的,因为失败模式会紧接着被批准所解锁的那次运行捕获,也因为这一系列之前的每一格都是这样落地的。但如果 maintainer 希望在投票之前就拿到证据,应当先赞助 @qwen-code /verify——我在审查中描述的那次 A/B 是唯一能补上这个缺口的手段,而它需要 JDK 21 和一个可丢弃的 MySQL 8.4。

我保留的意见都是非阻塞的,并已在审查中点名:这次新增让 188 分钟的 step 只剩 17 s 余量,所以下一个格必须同时上调那个超时;共享前导段里偶发的 workspace_lifecycle_claim_fenced 是既有问题,但目前只在这里披露;JVM 内重启会重新绑定两个固定端口,如果这个格将来抖动,那是第一个该看的地方。

该 PR 自己的 workflow 运行中还有两项在进行,所以本次不提交批准。批准推迟到 CI 在 eb86e6cc63a2114da0ebba132ffac3dfa52476b4 上全绿之后;finalize workflow 会在该 commit 上的每项检查都完成后提交绑定该 commit 的批准,若有检查变红或 head 移动则不予提交。

— Qwen Code · qwen3.8-max-2026-09-02

Reviewed at eb86e6cc63a2114da0ebba132ffac3dfa52476b4 · re-run with @qwen-code /triage

wenshao added a commit to wenshao/qwen-code that referenced this pull request Oct 21, 2026
wenshao added a commit to wenshao/qwen-code that referenced this pull request Oct 21, 2026
wenshao added a commit to wenshao/qwen-code that referenced this pull request Oct 21, 2026
@wenshao

wenshao commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

🔍 Local verification — PR #14990

Verdict: merge-ready, with one follow-up on a pre-existing class-level race (details below).

5/5 standalone green cell runs · 3/3 mutant kills · 73/73 budget assertions · 0 checkstyle violations · 4/4 green base-class runs

Verified head eb86e6cc63a2114da0ebba132ffac3dfa52476b4 (base 7d27f475be9f4feb6f0911a2464a748154d92e98, which is also the current main tip — no rebase needed). Diff: 6 files, +231/−10, no production code (git diff --name-only | grep src/main is empty).

🇨🇳 中文摘要

结论:可以合并,但建议另开 issue 跟进一个在本机高频复现的既有类级竞态(不是本 PR 引入的)。

我在 macOS + 真实 MySQL 8.4.13 + 打包 Harness/durable worker 上完整跑通了这条新门禁:

  • 新格单独运行 5 次全绿(冷启动 18.78 s,稳定后 9.38–10.29 s);
  • proxy.json 线上证据与描述完全一致:owner release 先被 503 拒(未转发),重启 Spring 后仅有一次 200,中间没有 acquire、没有 MCP 控制;
  • 变异见证:关掉 fix(managed-agent): recover original Hook sessions during Workspace retirement #13673 的 managed-context 分支后 3/3 红在 detach recovers after the Spring restart(第 1021 行),恢复后 1/1 绿;
  • checkstyle 0 violation、Prettier 通过、hosted-process-ci 73/73、git diff --check 干净;
  • 预算算术核对无误:healthy 10063、fork 10663(=+600)、188 分钟步骤(11280 s)仍能包住 11263 s,job 上限 291 不变。

一个需要注意的发现: 整类(9 格)在本机 7 次运行中红了 5 次,失败全部发生在共享的关闭前置(admitClose 之后、driveLifecycle("effects") 处)——workspace_lifecycle_claim_fenced 或 workspace_lifecycle_resources_unsettled,都在重启之前。它同样打中了本 PR 未改动的既有格 firstReleaseRefusedBeforeForwardingResumesUnderTheSameId;把新格的 @Test 注释掉、只跑 8 格时仍出现 1/3 红;base 测试文件 8 格 4/4 全绿。所以这是测试 rig 的类级时序竞态,新格只是又增加了一个消费者,不是新格断言错误,也不是产品路径问题。

CI 提示: 因为本 PR 是 test-only,SDK Java (fast) / Hosted process fault gates 作业处于 skipping,本门禁在 CI 里没有跑过——它的首次真实 CI 运行会在合并之后。

What I ran

Gate Result Evidence
Install + build (corepack pnpm install --frozen-lockfile, npm run bundle) ✅ PASS 4m59s, exit 0; dist/cli.js reports 0.25.3
New cell, standalone (-Dit.test=HostedMcpRestartFaultGatesIT#ownerReleaseRefusedThenSpringRestartReleasesTheHolderOnMySql) ✅ 5/5 PASS 18.78 s cold, then 10.29 / 10.06 / 9.377 s; restored control 9.507 s
Wire assertion (proxy.json) ✅ PASS owner release 503 (seq 9) → 200 (seq 10); no acquire, no MCP control in between
Mutation witness — disable #13673's arm in RuntimeBrokerService.releasedSession ✅ 3/3 KILLED red at line 1021 [detach recovers after the Spring restart] in all three runs; restored control green
Whole class, 9 cells ⚠️ 2/7 green red runs are the pre-existing preamble race — see below
Class with the new cell present but not executed (8 cells) ⚠️ 2/3 green 1 red — the race survives without the new cell
Base test file (8 cells, no new cell) ✅ 4/4 PASS each run 8/8
scripts/tests/hosted-process-ci.test.js ✅ 73/73 PASS budget literals are pinned and enforced
checkstyle:check + checkstyle:check@runtime-broker-checkstyle ✅ PASS You have 0 Checkstyle violations. twice
Prettier (md / js / yml) and git diff --check ✅ PASS Java/XML have no Prettier parser, which is expected

The gate does exactly what the description claims

  • Before the restart the owner release is refused at the proxy: 503, never forwarded; the case records releaseState: drained, one storage holder, and binding + Runtime Session READY.
  • After the restart the Harness's bounded retry sends exactly one request — the owner release — and it is answered 200. The holder clears, releaseState becomes released, the Runtime Session is RELEASED, and there is still one mcp-release, one mcp.start, one mcp.exit. The proxy log confirms no acquire and no MCP control crossed the restart.
  • The seed-once change is sound. @BeforeEach boots a fresh Spring per test, so springSeeded is false at every test's first boot and only suppresses re-registration inside restartSpring(). The other eight cells were green in every 8/8 run I got.
  • Budget arithmetic checks out. 10063 + 600 = 10663 ≤ 188 × 60 = 11280, job ceiling 291 unchanged; pom.xml, the workflow comment and hostedForkHealthySeconds/forkSeconds all carry the same numbers.

Finding — the shared close preamble races, and it is not rare on this host

Every red whole-class run failed in the same place: the shared admitClose → driveLifecycle("effects") preamble, before any restart.

Run Failing cell Signature
class run 1 new cell workspace_lifecycle_claim_fenced at effects
headclass-1 pre-existing firstReleaseRefusedBeforeForwardingResumesUnderTheSameId workspace_lifecycle_claim_fenced at effects
headclass-2 new cell workspace_lifecycle_claim_fenced at effects
headclass-4 pre-existing cells (two) [settle must complete], awaitility timeout on workspace_lifecycle_resources_unsettled
headclass-5 one cell same family

It is not caused by this diff: the failing set includes a cell the PR does not touch, and it still appears (1 of 3 runs) with the new cell's @Test removed. The base 8-cell file was 4/4 green, so the rate is not uniform, but the 8-cell head file is not clean either. On this host the class is simply timing-sensitive around the 3 s Session writer lease. This is the "one unexplained failure in the shared preamble" the description already discloses — here it reproduced in 5 of 7 class runs, not 1 in 13, so it is worth a follow-up issue rather than leaving it to chance.

CI caveat: for this test-only PR the hosted lane is skipping, so the new gate has no CI evidence yet; its first real lane run will be post-merge.

Not covered

  • Linux / CI (where the description's timings come from) — this round is macOS 15 arm64, JDK 21.0.12, MySQL 8.4.13, Node v22.23.1.
  • A Broker killed while it handles the owner release; a restart before drained; cross-host recovery — the description's own non-goals.
  • Root cause of the preamble race; I quantified it and proved it is not introduced here, but did not fix it.

Methodology

  • Fresh worktree at the PR head on the current main tip; corepack pnpm install --frozen-lockfile and npm run bundle in that tree; private hardlinked Maven repo (-Dmaven.repo.local), qwencode-sdk installed from the same tree to avoid ~/.m2 cross-tree staleness.
  • Disposable mysqld --initialize-insecure datadir on port 33306, binlog off; nothing touched the system MySQL.
  • Failsafe invoked directly (maven-failsafe-plugin:3.5.6:integration-test + :verify) under -Phosted-harness-mysql, env -i so no QWEN_* variable leaks into the spawned CLI; target/failsafe-reports wiped before every run.
  • Red/green mutation applied to RuntimeBrokerService.releasedSession with a cp backup, a unique-anchor count assertion, and git status clean after restore; mutant rows rebuilt before each run.
  • Class-rate arms each run serially in one tree: head (9 cells), head with the new cell not executed (8 cells), and the base test file (8 cells).

Evidence captures

New gate cell, standalone runs

Broker wire evidence

Mutation witness and class runs

Verification artifacts: ~/.qwen-verify/pr14990/ (logs, wire.json, screenshots). Run scoped from @wenshao's PR description; evidence images on wenshao/qwen-code@assets/pr14990-verify.

…g-restart

# Conflicts:
#	.github/workflows/sdk-java.yml
#	packages/managed-agent-server/pom.xml
#	scripts/tests/hosted-process-ci.test.js
@wenshao

wenshao commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

I merged main at e17380e6ef (10a1494fc2). The only conflict was the shared fork ledger with #14981 (+11 s), and I resolved it by stacking this cell on #14981's numbers: healthy 10074 s and fork 10674 s. The 188-minute Verify step still wraps the fork (11274 s), and the job ceiling stays 291. After a fresh install, build and bundle on that tree, the class passes 9/9 in 105.4 s, with the new case at 11.3 s, and the meta test passes 73/73.

On the stage-2 notes:

  1. Margin. Addressed in this push. Only 6 s remain now, so the step comment above timeout-minutes: 188 says the next cell must raise the step and the job ceiling in the same diff. The budget paragraph in both design docs says the same.
  2. workspace_lifecycle_claim_fenced in the shared preamble. Agreed that it predates this PR and that the existing FG12d release cells are exposed to it too. It needs its own investigation, which I have not started, so it stays a follow-up outside this PR.
  3. Fixed ports after the in-JVM restart. Noted. If this cell ever flakes in CI, that is where I'll look first.
中文说明

我合并了 main 的 e17380e6ef(10a1494fc2)。唯一的冲突是与 #14981(+11 s)共享的 fork 账本,我把本格叠加在 #14981 的数字之上解决了:healthy 10074 s,fork 10674 s。188 分钟的 Verify step 仍能包住 fork(11274 s),job 上限保持 291。在该树上全新 install、build、bundle 后,整类 9/9 通过,用时 105.4 s,新格 11.3 s;元测试 73/73。

关于 stage 2 的几点:

  1. 余量。 已在本次推送中处理。现在只剩 6 s,所以 timeout-minutes: 188 上方的 step 注释写明:下一个格必须在同一 diff 中同时提高该 step 与 job 上限。两版设计文档的预算段也写了同样的话。
  2. 共享前置流程中的 workspace_lifecycle_claim_fenced。 同意它早于本 PR 就存在,现有的 FG12d release 格也同样会遇到。它需要单独排查,我还没有开始,所以作为本 PR 之外的后续项保留。
  3. JVM 内重启后复用固定端口。 已记下。如果这一格在 CI 中出现偶发失败,我会先从这里查。

@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 10a1494fc2.

The R1-1 cell proves the invariant rather than the happy path: the refusal is armed once through ownerReleaseRefuseArmed (CAS), the restart replaces Spring and with it the embedded Broker, and recovery is pinned by singleElement() over the proxy calls with seq > restartSeq - so "no acquire and no MCP control" is an exclusivity claim, not an absence of evidence. ownerReleaseCalls() statuses containsExactly(503, 200) supply the accumulation axis, controls("mcp-release") hasSize(1) keeps the dispatch counted once, and releaseState moving drained -> released with the storage holder going to 0 is the durable-side witness.

Two details I checked because they are the usual failure modes here:

  • the injected refusal writes the same evidence fields as the pre-existing refusal branch (status + answer), so the JSONL contract between the proxy and the Java assertions is unchanged;
  • springSeeded is what makes the restart legal - the storage registration and the Workspace rows are durable, so re-registering on the new context would be refused by the guard while the holder is booked. Re-reading jdbc/store before that early return is the right order.

Budget: fork 10650 -> 10674 and healthy 10050 -> 10074 (+24 = 2x the measured 11.9 s), offset 600 which is the floor, 600 + 10074 = 10674 exactly. Verify correctly stays 188 (ceil(11274/60) = 188), so ledger ['hosted-harness-mysql', 278, 291] and job 291 are right to be untouched, and the guard's "6 s left before the step must move" is accurate (188x60 - 11274 = 6). sdk-java.yml grows to 110580 bytes against the 109610 baseline - 970, inside the 4096 allowance - so not bumping .size-baseline is correct. The @Timeout count (9) matches the case-name list (9).

Merge-order note, not a finding: #14990 (+24 s) and the stacked #14991 -> #14992 -> #14993 (+150 s each) all bump the same two constants from the same base. If all four land, the terminal state is healthy 10524 / fork 11124 / Verify 196 / ledger ['hosted-harness-mysql', 286, 299] / job 299, and whoever merges last has to re-pin all five faces. The conflict is textual and fail-closed, but resolving it by keeping one side leaves every guard literal green while silently dropping the sibling's budget.

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Released in v0.26.0.

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.

4 participants